Files
tobitege 21702d01ff Add German translation; harden paths against unset env vars (#770)
* Add German translation and harden paths against unset env vars

Add a German locale to the GUI, tray menu, usage alerts and website home page, and refuse to start when the home folder is unset or relative so nothing is written under the working folder; the pi plugin no longer adopts the user's PI_CODING_AGENT_DIR, and an inherited APPIMAGE no longer selects portable mode. Scripts refuse to run from the wrong folder or empty a shared out folder, and a new internal/testenv sandboxes every test package from the user's real files, which also fixes the Claude picker and Termux installer tests on Windows.

* Makefile: know magpie's folder by its go.mod, so paths with spaces work

The check compared $(CURDIR) with $(dir $(abspath …)), which make splits on spaces, so a checkout under 'My Projects' or 'John Doe' refused every target. It now reads go.mod relative to the working folder and wants magpie's module there: make -C and a symlinked Makefile work, make -f from another folder still stops before any recipe runs.

* i18n: German for the strings main added since, with a parity test

75 strings main added (group order, patterns, slow-start wait, usage caps, RTK savings …) get their German, and two that main dropped go. gui-de.test.cjs checks, as gui-ja does, that de has every zh string with its placeholders and tags, and that the window reads in German when picked or on a German system.

* appdir: ignore relative folder variables when reading, keep them for children

CheckEnv no longer unsets anything. magpie reads the XDG folders, APPDATA/LOCALAPPDATA and every agent's folder variable through appdir.Getenv/LookupEnv, which pass over a relative value; the environment stays as it is, so the agents magpie starts get the variable as the user set it. That covers what proc.UserPath lends from the login shell too, which is set after main's check and reached the desktop app unchecked. On Windows a path rooted on the current drive (\Users\x, Git Bash's /c/x) counts as usable, and magpie version and help answer without a home, before run makes any folder.

* Sandbox the tests main added since

internal/wslrun gets testenv's TestMain, and four tests main added that set HOME alone set their home with testenv.SetHome, so on Windows they don't read the runner's profile through USERPROFILE.

* agentenv: PI_CONFIG_DIR and OPENCODE_DB take relative names on purpose

omp puts $PI_CONFIG_DIR under the home and OpenCode a relative $OPENCODE_DB in its data folder, so neither depends on the working folder: appdir no longer passes them over.

* i18n: German for the Library's multi-select and System One's decision API

The 19 strings main added since (#791's skill multi-select, the decision API snippets) get their German; the one main replaced (four APIs → five APIs) goes.

* appdir: tidy the import into magpie's group; say what Go makes of /c/x

The appdir import joins the group of magpie's own imports where a file has one. rooted's comment now says that Go takes a Git Bash path like /c/x as \c\x on the current drive, not C:\x.
2026-10-04 23:30:56 +08:00

1097 lines
44 KiB
JavaScript

// pi's extensions and packages (pi coding agent, @earendil-works) run as
// magpie's plugins: host.js loads this module when a plugin it is given is
// one, and turns each provider the extensions register into the hooks an
// OpenCode plugin gives.
//
// The extensions are loaded by pi's own loader (discoverAndLoadExtensions),
// so they import pi's packages as in pi, and their providers are composed
// by pi's ModelRuntime, as pi composes them: its sign-in (OAuth or a key),
// its renewal, its models and its requests are pi's. The runtime keeps
// its sign-ins in magpie's plugin-auth.json, each account as host.js keeps
// an OpenCode plugin's (an OAuth one as {type: "oauth", refresh, access,
// expires, …}, a key as {type: "api", key}), read in the scope of the
// request's account. What else an extension registers (commands, tools,
// renderers, event handlers) is for pi's own sessions and is left unused.
//
// A request reaches a pi provider as Anthropic's Messages (the models are
// said to be @ai-sdk/anthropic's): the provider's loader gives a fetch
// that reads it into pi's context, streams it with pi, and answers with
// Anthropic's events.
import { AsyncLocalStorage } from "node:async_hooks"
import fs from "node:fs"
import path from "node:path"
import { pathToFileURL } from "node:url"
const CODING_AGENT = "@earendil-works/pi-coding-agent"
const PI_AI = "@earendil-works/pi-ai"
// what pi's packages were called before @earendil-works
const OLD_SCOPE = "@mariozechner/pi-"
function readJSON(file) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"))
} catch {
return undefined
}
}
// isPi is whether the plugin at target is pi's rather than OpenCode's: a
// package with a pi manifest, the pi-package keyword or pi among what it
// depends on, or a file that imports pi.
export function isPi(target) {
const stat = fs.statSync(target, { throwIfNoEntry: false })
if (!stat) return false
if (stat.isDirectory()) {
const pkg = readJSON(path.join(target, "package.json"))
if (!pkg) return fs.existsSync(path.join(target, "extensions")) && !fs.existsSync(path.join(target, "index.ts")) && !fs.existsSync(path.join(target, "index.js"))
if (pkg.pi && typeof pkg.pi === "object") return true
if (Array.isArray(pkg.keywords) && pkg.keywords.includes("pi-package")) return true
const deps = { ...pkg.peerDependencies, ...pkg.dependencies }
return Object.keys(deps).some((d) => d === CODING_AGENT || d === OLD_SCOPE + "coding-agent")
}
if (!/\.[cm]?[jt]sx?$/.test(target)) return false
try {
const src = fs.readFileSync(target, "utf8")
return src.includes(`"${CODING_AGENT}"`) || src.includes(`'${CODING_AGENT}'`) || src.includes(`"${OLD_SCOPE}coding-agent"`) || src.includes(`'${OLD_SCOPE}coding-agent'`)
} catch {
return false
}
}
// resolve finds pi's package name from the first of dirs that has it.
function resolve(name, dirs) {
for (const d of dirs) {
try {
return Bun.resolveSync(name, d)
} catch {}
}
throw new Error(`${name} isn't installed: add it as a plugin's dependency, or reinstall the pi plugin`)
}
// ---- sign-ins ------------------------------------------------------------------
// toPi is an account as pi keeps its credential; fromPi the other way,
// over what the account had.
function toPi(a) {
if (!a || typeof a !== "object") return undefined
if (a.type === "oauth") return { ...a, type: "oauth" }
if (a.type === "api" && typeof a.key === "string") return { type: "api_key", key: a.key, ...(a.metadata?.env ? { env: a.metadata.env } : {}) }
return undefined
}
function fromPi(c, was) {
if (c.type === "api_key") return { type: "api", key: c.key, ...(c.env ? { metadata: { ...(was?.metadata ?? {}), env: c.env } } : was?.metadata ? { metadata: was.metadata } : {}) }
const { type: _t, ...rest } = c
return { type: "oauth", ...rest }
}
// ---- the context pi is asked with ------------------------------------------------
// the signatures Gemini gives its tool calls (thoughtSignature), which
// Anthropic's tool_use has no field for: kept here by the call's id, to be
// given back with the conversation.
const callSigs = new Map()
const SIGS_KEPT = 4000
function keepSig(id, sig) {
if (!id || !sig) return
callSigs.delete(id)
callSigs.set(id, sig)
if (callSigs.size > SIGS_KEPT) callSigs.delete(callSigs.keys().next().value)
}
const zeroUsage = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } })
async function imageOf(b) {
const s = b.source ?? {}
if (s.type === "base64") return { type: "image", data: s.data, mimeType: s.media_type ?? "image/png" }
if (s.type === "url" && typeof s.url === "string") {
const m = /^data:([^;,]+);base64,(.*)$/s.exec(s.url)
if (m) return { type: "image", data: m[2], mimeType: m[1] }
const r = await fetch(s.url)
if (!r.ok) throw new Error(`couldn't fetch the image ${s.url}: ${r.status}`)
return { type: "image", data: Buffer.from(await r.arrayBuffer()).toString("base64"), mimeType: r.headers.get("content-type")?.split(";")[0] || "image/png" }
}
return undefined
}
function textOf(b) {
if (b.type === "text") return b.text ?? ""
if (b.type === "document") {
const s = b.source ?? {}
if (s.type === "text") return s.data ?? ""
if (s.type === "content" && Array.isArray(s.content)) return s.content.map((x) => x.text ?? "").join("\n")
}
return undefined
}
async function contentOf(c) {
const blocks = typeof c === "string" ? [{ type: "text", text: c }] : Array.isArray(c) ? c : []
const out = []
for (const b of blocks) {
if (b?.type === "image") {
const im = await imageOf(b)
if (im) out.push(im)
continue
}
const t = textOf(b ?? {})
if (t !== undefined) out.push({ type: "text", text: t })
}
return out
}
// contextOf is an Anthropic Messages request as pi's context, its
// assistant turns said to be the model's own so their signatures go back.
export async function contextOf(body, model) {
const now = Date.now()
const system = typeof body.system === "string" ? body.system : Array.isArray(body.system) ? body.system.map((b) => b?.text ?? "").filter(Boolean).join("\n\n") : ""
const names = new Map()
const messages = []
for (const m of body.messages ?? []) {
const blocks = typeof m.content === "string" ? [{ type: "text", text: m.content }] : Array.isArray(m.content) ? m.content : []
if (m.role === "assistant") {
const content = []
for (const b of blocks) {
if (b.type === "text") content.push({ type: "text", text: b.text ?? "" })
else if (b.type === "thinking") content.push({ type: "thinking", thinking: b.thinking ?? "", ...(b.signature ? { thinkingSignature: b.signature } : {}) })
else if (b.type === "redacted_thinking") content.push({ type: "thinking", thinking: "", thinkingSignature: b.data, redacted: true })
else if (b.type === "tool_use") {
names.set(b.id, b.name)
const sig = callSigs.get(b.id)
content.push({ type: "toolCall", id: b.id, name: b.name, arguments: b.input ?? {}, ...(sig ? { thoughtSignature: sig } : {}) })
}
}
if (!content.length) continue
messages.push({
role: "assistant",
content,
api: model.api,
provider: model.provider,
model: model.id,
usage: zeroUsage(),
stopReason: content.some((c) => c.type === "toolCall") ? "toolUse" : "stop",
timestamp: now,
})
continue
}
let user = []
const flush = () => {
if (user.length) messages.push({ role: "user", content: user, timestamp: now })
user = []
}
for (const b of blocks) {
if (b.type === "tool_result") {
flush()
messages.push({
role: "toolResult",
toolCallId: b.tool_use_id,
toolName: names.get(b.tool_use_id) ?? "",
content: await contentOf(b.content),
isError: !!b.is_error,
timestamp: now,
})
} else if (b.type === "image") {
const im = await imageOf(b)
if (im) user.push(im)
} else {
const t = textOf(b)
if (t !== undefined) user.push({ type: "text", text: t })
}
}
flush()
}
const tools = (body.tools ?? [])
.filter((t) => t && t.name && (t.input_schema || !t.type || t.type === "custom"))
.map((t) => ({ name: t.name, description: t.description ?? "", parameters: t.input_schema ?? { type: "object", properties: {} } }))
return { ...(system ? { systemPrompt: system } : {}), messages, ...(tools.length ? { tools } : {}) }
}
// levelOf is how hard the request asks the model to think, as pi's
// levels say it: output_config.effort, else thinking's budget; none when
// thinking is off or the model doesn't think. A model that can't be told
// not to think (pi's thinkingLevelMap has off null: gpt-oss and Claude on
// Antigravity, whose vendor turns a request without a level away) thinks
// as little as it can, as pi asks it.
export function levelOf(body, model, clamp) {
if (!model.reasoning) return undefined
const th = body.thinking
if (!th || th.type === "disabled") {
if (model.thinkingLevelMap?.off !== null || !clamp) return undefined
const l = clamp(model, "minimal")
return l === "off" ? undefined : l
}
let level = body.output_config?.effort
if (level === "minimal" || level === "low" || level === "medium" || level === "high" || level === "xhigh" || level === "max") {
} else if (th.type === "enabled" && th.budget_tokens > 0) {
const n = th.budget_tokens
level = n <= 4096 ? "low" : n <= 12000 ? "medium" : n <= 24000 ? "high" : "xhigh"
} else {
level = "high"
}
const l = clamp ? clamp(model, level) : level
return l === "off" ? undefined : l
}
// ---- answering as Anthropic ----------------------------------------------------
const STOP = { stop: "end_turn", length: "max_tokens", toolUse: "tool_use" }
function usageOf(u) {
return {
input_tokens: u?.input ?? 0,
output_tokens: u?.output ?? 0,
cache_read_input_tokens: u?.cacheRead ?? 0,
cache_creation_input_tokens: u?.cacheWrite ?? 0,
}
}
function sse(type, data) {
return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`
}
// statusOf is the status a failure is answered with: the vendor's, else
// one its message names, else 502.
function statusOf(res, msg) {
if (res?.status >= 400) return res.status
if (/prompt is too long|context (length|window)|too many tokens|maximum context/i.test(msg)) return 400
const m = /\b(4\d\d|5\d\d)\b/.exec(msg)
if (m) return Number(m[1])
if (/not configured|no api key|unauthori[sz]ed|sign in|log ?in again|invalid_grant/i.test(msg)) return 401
if (/quota|rate.?limit|too many requests|usage limit/i.test(msg)) return 429
return 502
}
function errorType(status) {
return (
{ 400: "invalid_request_error", 401: "authentication_error", 403: "permission_error", 404: "not_found_error", 413: "request_too_large", 429: "rate_limit_error", 529: "overloaded_error" }[status] ??
"api_error"
)
}
function failure(status, message, headers = {}) {
return new Response(JSON.stringify({ type: "error", error: { type: errorType(status), message } }), {
status,
headers: { "content-type": "application/json", ...headers },
})
}
// kept are the vendor's headers worth passing on with a failure.
function kept(res) {
const out = {}
for (const [k, v] of Object.entries(res?.headers ?? {})) if (/^(retry-after|x-ratelimit-|anthropic-ratelimit-)/i.test(k)) out[k] = String(v)
return out
}
// emptyOf is whether a message says nothing at all.
function emptyOf(m) {
return !(m?.content ?? []).some((c) => (c.type === "text" && c.text) || (c.type === "thinking" && (c.thinking || c.thinkingSignature)) || c.type === "toolCall")
}
// said is what a request's own fetches were last answered with, when that
// wasn't a stream: a package that reads only streams (pi-zcode) passes a
// vendor's refusal sent as JSON over in silence, and its answer is empty.
const said = new AsyncLocalStorage()
let watching = false
function watchFetch() {
if (watching) return
watching = true
const was = globalThis.fetch
globalThis.fetch = Object.assign(function fetch(input, init) {
const s = said.getStore()
const p = was(input, init)
if (!s) return p
return p.then((res) => {
if (/json|text\/plain/i.test(res.headers.get("content-type") ?? "") && Number(res.headers.get("content-length") ?? 0) <= 65536 && res.body)
s.body = res
.clone()
.text()
.catch(() => "")
return res
})
}, was)
}
// whyOf is what a vendor's JSON body said went wrong, its code after it.
async function whyOf(s) {
if (!s?.body) return ""
const text = await Promise.race([s.body, new Promise((ok) => setTimeout(() => ok(""), 1000))])
let v
try {
v = JSON.parse(text)
} catch {
return ""
}
const e = v?.error ?? v
const msg = typeof e === "string" ? e : e?.message ?? e?.msg ?? v?.msg ?? v?.message ?? v?.detail
if (typeof msg !== "string" || !msg) return ""
const code = (typeof e === "object" ? e?.code : undefined) ?? v?.code
return code === undefined || code === null || code === "" ? msg : `${msg} (${code})`
}
// answer turns pi's events into Anthropic's: an error before anything was
// said as an HTTP failure, else a stream (or, unstreamed, one message).
async function answer(events, { model, stream, res, said }) {
const it = events[Symbol.asyncIterator]()
const first = []
// wait for the first thing said, so a failure can still be a status
for (;;) {
const r = await it.next()
if (r.done) break
first.push(r.value)
const t = r.value.type
if (t === "error") {
const msg = r.value.error?.errorMessage || "the request failed"
if (r.value.reason === "aborted") return failure(499, msg)
const st = statusOf(res(), msg)
return failure(st, st === 400 && !/prompt is too long/i.test(msg) && /context|too long|too many tokens/i.test(msg) ? "prompt is too long: " + msg : msg, kept(res()))
}
if (t !== "start") break
}
// nothing said, while the vendor said why in a body the package didn't
// read (pi-zcode's {"code":1005,"msg":"exceed quota limit"})
const last = first.at(-1)
if (last?.type === "done" && emptyOf(last.message)) {
const why = await whyOf(said)
if (why) return failure(statusOf(res(), why), why, kept(res()))
}
const id = "msg_pi_" + Math.random().toString(36).slice(2, 14)
async function* all() {
yield* first
for (;;) {
const r = await it.next()
if (r.done) return
yield r.value
}
}
if (!stream) {
const content = []
let stop = "end_turn"
let usage = usageOf()
for await (const e of all()) {
if (e.type === "error") {
const msg = e.error?.errorMessage || "the request failed"
return failure(statusOf(res(), msg), msg, kept(res()))
}
if (e.type !== "done") continue
for (const c of e.message.content ?? []) {
if (c.type === "text") c.text && content.push({ type: "text", text: c.text })
else if (c.type === "thinking") {
if (c.redacted) content.push({ type: "redacted_thinking", data: c.thinkingSignature ?? "" })
else if (c.thinking || c.thinkingSignature) content.push({ type: "thinking", thinking: c.thinking ?? "", signature: c.thinkingSignature ?? "" })
} else if (c.type === "toolCall") {
keepSig(c.id, c.thoughtSignature)
content.push({ type: "tool_use", id: c.id, name: c.name, input: c.arguments ?? {} })
}
}
stop = STOP[e.reason] ?? "end_turn"
usage = usageOf(e.message.usage)
}
return new Response(JSON.stringify({ id, type: "message", role: "assistant", model, content, stop_reason: stop, stop_sequence: null, usage }), {
status: 200,
headers: { "content-type": "application/json" },
})
}
const enc = new TextEncoder()
const body = new ReadableStream({
async start(ctl) {
const put = (type, data) => ctl.enqueue(enc.encode(sse(type, data)))
put("message_start", { message: { id, type: "message", role: "assistant", model, content: [], stop_reason: null, stop_sequence: null, usage: usageOf() } })
let index = -1
const open = new Map() // pi's content index → ours
const begin = (ci, block) => {
index++
open.set(ci, index)
put("content_block_start", { index, content_block: block })
return index
}
const end = (ci) => {
if (!open.has(ci)) return
put("content_block_stop", { index: open.get(ci) })
open.delete(ci)
}
try {
for await (const e of all()) {
switch (e.type) {
// a block is begun with what it says first: an empty one (a
// vendor's text with nothing in it) would be sent back, and
// Anthropic's Messages turn an empty text block away
case "text_start":
case "thinking_start":
break
case "text_delta":
if (!e.delta) break
if (!open.has(e.contentIndex)) begin(e.contentIndex, { type: "text", text: "" })
put("content_block_delta", { index: open.get(e.contentIndex), delta: { type: "text_delta", text: e.delta } })
break
case "text_end":
end(e.contentIndex)
break
case "thinking_delta":
if (!e.delta) break
if (!open.has(e.contentIndex)) begin(e.contentIndex, { type: "thinking", thinking: "" })
put("content_block_delta", { index: open.get(e.contentIndex), delta: { type: "thinking_delta", thinking: e.delta } })
break
case "thinking_end": {
const c = e.partial?.content?.[e.contentIndex]
if (c?.redacted) {
end(e.contentIndex)
if (c.thinkingSignature) begin(e.contentIndex, { type: "redacted_thinking", data: c.thinkingSignature })
end(e.contentIndex)
break
}
if (!open.has(e.contentIndex) && c?.thinkingSignature) begin(e.contentIndex, { type: "thinking", thinking: "" })
if (open.has(e.contentIndex) && c?.thinkingSignature) put("content_block_delta", { index: open.get(e.contentIndex), delta: { type: "signature_delta", signature: c.thinkingSignature } })
end(e.contentIndex)
break
}
case "toolcall_end": {
// a call is told whole, as its arguments are only JSON at its end
const c = e.toolCall
keepSig(c.id, c.thoughtSignature)
const i = begin(e.contentIndex, { type: "tool_use", id: c.id, name: c.name, input: {} })
put("content_block_delta", { index: i, delta: { type: "input_json_delta", partial_json: JSON.stringify(c.arguments ?? {}) } })
end(e.contentIndex)
break
}
case "done":
for (const ci of [...open.keys()]) end(ci)
put("message_delta", { delta: { stop_reason: STOP[e.reason] ?? "end_turn", stop_sequence: null }, usage: usageOf(e.message?.usage) })
put("message_stop", {})
break
case "error": {
for (const ci of [...open.keys()]) end(ci)
const msg = e.error?.errorMessage || "the request failed"
const st = statusOf(res(), msg)
put("error", { error: { type: errorType(st), message: msg } })
break
}
}
}
} catch (e) {
put("error", { error: { type: "api_error", message: String(e?.message ?? e) } })
}
ctl.close()
},
})
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } })
}
// ---- loading -------------------------------------------------------------------
// load loads the pi plugins in list ({spec, target}) and gives, for each,
// the hooks of the providers it registers, or why it couldn't load.
// h is what host.js lends: readAuth, changeAuth, keyFor, send, directory.
export async function load(h, list) {
const home = path.join(h.directory, "pi")
// pi keeps its settings and caches here, not in the user's ~/.pi. This
// overrides any PI_CODING_AGENT_DIR from the user's shell: with that one,
// the auth.json written below would be the user's own pi sign-ins, and
// magpie would delete every sign-in it doesn't hold itself.
process.env.PI_CODING_AGENT_DIR = home
// pi makes it on start; packages write their files into it (pi-antigravity
// its accounts) and fail with ENOENT when it isn't there
fs.mkdirSync(process.env.PI_CODING_AGENT_DIR, { recursive: true })
watchFetch()
const none = path.join(home, "none") // no project's or user's extensions come along
const dirs = [...list.map((p) => (fs.statSync(p.target, { throwIfNoEntry: false })?.isDirectory() ? p.target : path.dirname(p.target))), path.join(h.directory, "plugins")]
let pi, ai
try {
const entry = resolve(CODING_AGENT, dirs)
pi = await import(pathToFileURL(entry).href)
ai = await import(pathToFileURL(resolve(PI_AI, [path.dirname(entry), ...dirs])).href)
} catch (e) {
return list.map((p) => ({ spec: p.spec, error: String(e?.message ?? e) }))
}
// pi's runtime, its sign-ins those of the account a call is made for
const chains = new Map()
const credentials = {
async read(id) {
return toPi(h.readAuth()[h.keyFor(id)])
},
async list() {
return Object.entries(h.readAuth())
.filter(([k, a]) => !k.includes("#") && toPi(a))
.map(([k, a]) => ({ providerId: k, type: toPi(a).type }))
},
// one change at a time to an account, read afresh
modify(id, fn) {
const key = h.keyFor(id)
const prev = chains.get(key) ?? Promise.resolve()
const run = prev.catch(() => {}).then(async () => {
const was = h.readAuth()[key]
const next = await fn(toPi(was))
if (next === undefined) return toPi(was)
h.changeAuth((all) => {
all[key] = fromPi(next, all[key])
})
h.send({ event: "auth", provider: key.split("#")[0], account: key })
mirror()
return next
})
chains.set(key, run)
return run
},
async delete(id) {
const key = h.keyFor(id)
h.changeAuth((all) => {
if (!(key in all)) return false
delete all[key]
})
mirror()
},
}
const mr = await pi.ModelRuntime.create({ credentials, modelsPath: null, allowModelNetwork: false, refreshOnCreate: false })
// pi's auth.json, kept as pi keeps it: each provider's sign-in (its first
// account's). Packages read it back themselves rather than be given it
// (readStoredCredential: pi-zcode its plans, pi-cursor its token), and
// are told of it as pi tells them, with session_start: each as the host
// starts, and a provider again once newly signed in (pi-zcode registers
// its plans' models then).
const runners = new Map() // provider id → its package's runner
const mirrored = new Map()
const authFile = path.join(process.env.PI_CODING_AGENT_DIR, "auth.json")
let started = false
function mirror() {
const all = h.readAuth()
const fresh = new Set()
let file
for (const [id, runner] of runners) {
const c = toPi(all[id])
const now = c ? JSON.stringify(c) : ""
// a session starts for each package as pi starts, signed in or not
// (pi-provider-kiro keeps the UI its sign-in asks on from it)
if (!started && runner) fresh.add(runner)
if (mirrored.get(id) === now) continue
if (c && !mirrored.get(id) && runner) fresh.add(runner)
mirrored.set(id, now)
file ??= readJSON(authFile) ?? {}
if (c) file[id] = c
else delete file[id]
}
if (file) {
try {
const tmp = `${authFile}.${process.pid}.tmp`
fs.writeFileSync(tmp, JSON.stringify(file, null, 2), { mode: 0o600 })
fs.renameSync(tmp, authFile)
} catch (e) {
h.send({ event: "log", level: "error", message: `pi: auth.json: ${e?.message ?? e}` })
}
}
const reason = started ? "new" : "startup"
for (const runner of fresh) {
const before = JSON.stringify([...runners.keys()].map((id) => (mr.getModels(id) ?? []).map((m) => m.id)))
runner
.emit({ type: "session_start", reason })
.catch(() => {})
.then(() => {
// magpie asks the models again when they changed
if (JSON.stringify([...runners.keys()].map((id) => (mr.getModels(id) ?? []).map((m) => m.id))) !== before) h.send({ event: "auth" })
})
}
}
const hp = { ...h, mirror }
const out = []
for (const p of list) {
try {
const r = await pi.discoverAndLoadExtensions(await entriesOf(pi, p.target, none), none, none, pi.createEventBus())
const ids = [
...(r.runtime?.pendingProviderRegistrations ?? []).map((x) => x.name),
...(r.runtime?.pendingNativeProviderRegistrations ?? []).map((x) => x.provider.id),
]
if (r.errors?.length && !r.extensions?.length) throw new Error(r.errors.map((e) => e.error).join("; "))
for (const e of r.errors ?? []) h.send({ event: "log", level: "error", message: `${p.spec}: ${e.error}` })
if (!ids.length && !r.extensions?.length) throw new Error("has no pi extension")
const runner = bind(pi, r, mr, none, h, p.spec)
for (const id of ids) runners.set(id, runner)
const hooks = [...new Set(ids)].map((id) => ({ spec: p.spec, target: p.target, hooks: hooksOf(hp, mr, ai, id) }))
out.push({ spec: p.spec, hooks })
} catch (e) {
out.push({ spec: p.spec, error: String(e?.stack ?? e) })
}
}
mirror()
started = true
return out
}
// bind gives a package's extensions to the runtime as pi's session gives
// them: the providers they registered while loading are registered, one
// they register later (pi-zcode its plans, once signed in) at once, and
// their events (session_start) are given pi's context, with no session
// and no tools of its own, and a UI only for a sign-in's questions.
// Without pi's runner the providers are registered as they were loaded,
// and nothing later reaches them.
function bind(pi, r, mr, cwd, h, spec) {
const flush = () => {
for (const x of r.runtime?.pendingProviderRegistrations ?? []) mr.registerProvider(x.name, x.config)
for (const x of r.runtime?.pendingNativeProviderRegistrations ?? []) mr.registerNativeProvider(x.provider)
return null
}
if (!pi.ExtensionRunner || !pi.ModelRegistry || !pi.SessionManager?.inMemory || !r.runtime) return flush()
const nothing = () => {}
let runner
try {
runner = new pi.ExtensionRunner(r.extensions ?? [], r.runtime, cwd, pi.SessionManager.inMemory(cwd), new pi.ModelRegistry(mr))
runner.bindCore(
{
sendMessage: nothing,
sendUserMessage: nothing,
appendEntry: nothing,
setSessionName: nothing,
getSessionName: () => undefined,
setLabel: nothing,
getActiveTools: () => [],
getAllTools: () => [],
getSettings: () => ({}),
setActiveTools: nothing,
refreshTools: nothing,
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => "off",
setThinkingLevel: nothing,
},
{
getModel: () => undefined,
getScopedModels: () => [],
isIdle: () => true,
isProjectTrusted: () => false,
getSignal: () => undefined,
abort: nothing,
hasPendingMessages: () => false,
shutdown: nothing,
getContextUsage: () => undefined,
compact: nothing,
getSystemPrompt: () => "",
executeTool: async () => {
throw new Error("magpie runs no tools of pi's")
},
getCallableTools: () => [],
},
{
registerProvider: (name, config) => mr.registerProvider(name, config),
registerNativeProvider: (provider) => mr.registerNativeProvider(provider),
unregisterProvider: (name) => mr.unregisterProvider(name),
registerVirtualModel: (d) => mr.registerVirtualModel?.(d),
unregisterVirtualModel: (provider, id) => mr.unregisterVirtualModel?.(provider, id),
},
)
} catch (e) {
h.send({ event: "log", level: "error", message: `${spec}: pi's runner: ${e?.message ?? e}` })
return flush()
}
runner.onError((e) => h.send({ event: "log", level: "error", message: `${spec}: ${e.event}: ${e.error}` }))
try {
runner.setUIContext?.(uiOf(runner.getUIContext()), "rpc")
} catch (e) {
h.send({ event: "log", level: "error", message: `${spec}: pi's UI: ${e?.message ?? e}` })
}
return runner
}
// entriesOf is what pi loads of a package: the files its manifest names, a
// directory among them read as pi's package manager reads one, for its
// files (pi-xai-oauth's "./extensions") — pi's loader, given the package,
// would import the directory itself and fail.
async function entriesOf(pi, target, none) {
if (!fs.statSync(target, { throwIfNoEntry: false })?.isDirectory() || !pi.DefaultPackageManager || !pi.SettingsManager?.inMemory) return [target]
try {
const pm = new pi.DefaultPackageManager({ cwd: none, agentDir: none, settingsManager: pi.SettingsManager.inMemory() })
const r = await pm.resolveExtensionSources([target], { temporary: true })
const files = (r?.extensions ?? []).filter((e) => e.enabled).map((e) => e.path)
if (files.length) return files
} catch {}
return [target]
}
// ---- a package's own UI ----------------------------------------------------------
// asking is the sign-in a package's UI is shown in: pi's dialogs and its
// components (ctx.ui) are asked as the sign-in's questions, as magpie asks
// them, and outside one they answer as pi's do with no UI.
const asking = new AsyncLocalStorage()
// plain stands for pi's theme and keybindings: a colour or a style gives
// back the text it is given, and no key is bound.
const plain = new Proxy({}, { get: (_, k) => (k === "then" ? undefined : (...a) => [...a].reverse().find((x) => typeof x === "string") ?? "") })
function uiOf(base) {
const ask = (q) => asking.getStore().prompt(q)
return {
...base,
get theme() {
return plain
},
async select(title, options) {
if (!asking.getStore() || !options?.length) return undefined
return ask({ type: "select", message: title, options: options.map((o) => ({ id: o, label: o })) })
},
async confirm(title, message) {
if (!asking.getStore()) return false
const yes = await ask({ type: "select", message: [title, message].filter(Boolean).join("\n"), options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] })
return yes === "yes"
},
async input(title, placeholder) {
if (!asking.getStore()) return undefined
return ask({ type: "text", message: title, placeholder: placeholder ?? "" })
},
async editor(title, prefill) {
if (!asking.getStore()) return undefined
return ask({ type: "text", message: title, placeholder: prefill ?? "" })
},
notify(message) {
asking.getStore()?.notify({ type: "info", message: String(message) })
},
async custom(factory) {
const i = asking.getStore()
return i ? shown(i, factory) : undefined
},
}
}
// shown runs a component of pi's terminal UI (ctx.ui.custom: Kiro's choice
// of sign-in) with no terminal: the list it shows is asked as a choice and
// the field as text, each answer given to it as its keys would, until it
// is done; one with nothing to answer (a sign-in's waiting screen) is
// waited on.
async function shown(i, factory) {
let result
let over = false
let finish
const done = new Promise((ok) => (finish = ok))
const tui = new Proxy({ terminal: { columns: 100, rows: 40 } }, { get: (t, k) => (k in t ? t[k] : k === "then" ? undefined : () => {}) })
const c = await factory(tui, plain, plain, (v) => {
if (over) return
over = true
result = v
finish()
})
try {
while (!over) {
// what it does on an answer (its next screen) comes first
await new Promise((ok) => setTimeout(ok, 0))
if (over) break
const ctl = controlOf(c)
if (!ctl) {
await done
break
}
if (ctl.list) {
const items = ctl.list.filteredItems ?? ctl.list.items
const v = await i.prompt({ type: "select", message: ctl.message, options: items.map((x) => ({ id: String(x.value), label: x.label ?? String(x.value), description: x.description ?? "" })) })
const item = items.find((x) => String(x.value) === String(v))
if (item) ctl.list.onSelect?.(item)
else ctl.list.onCancel?.()
} else {
const v = String((await i.prompt({ type: "text", message: ctl.message, placeholder: "" })) ?? "")
ctl.input.setValue?.(v)
ctl.input.onSubmit?.(v)
}
}
} finally {
c?.dispose?.()
}
return result
}
const strip = (l) => String(l).replace(/\x1b\[[0-9;]*m/g, "").trim()
// controlOf is the first list or field a component shows, with the text
// shown above it, rules left out, as its question. A component may keep
// its parts to itself (Kiro's gives only render and handleInput), and each
// package loads its own copy of pi-tui, so its classes can't be told
// apart; but drawing walks the parts in order, each container going
// through its children, so the arrays walked while it draws are what it
// shows.
function controlOf(root) {
const seen = []
const walk = Array.prototype[Symbol.iterator]
Array.prototype[Symbol.iterator] = function () {
for (let i = 0; i < this.length; i++) {
const x = this[i]
if (x && typeof x === "object" && typeof x.render === "function" && !seen.includes(x)) seen.push(x)
}
return walk.call(this)
}
try {
root?.render?.(100)
} catch {
} finally {
Array.prototype[Symbol.iterator] = walk
}
const at = seen.findIndex((c) => (Array.isArray(c.items) && typeof c.onSelect === "function") || typeof c.onSubmit === "function")
if (at < 0) return undefined
const c = seen[at]
const above = seen
.slice(0, at)
.filter((t) => typeof t.text === "string")
.map((t) => strip(t.text))
.filter((l) => l && !/^[\s─━│┃┌┐└┘├┤┬┴┼╭╮╯╰═║-]*$/.test(l))
return { [typeof c.onSelect === "function" ? "list" : "input"]: c, message: above.join("\n") }
}
// ---- one provider's hooks --------------------------------------------------------
const LOGIN_WAIT = 10 * 60 * 1000
function loopbackRedirect(url) {
try {
const r = new URL(new URL(url).searchParams.get("redirect_uri") ?? "")
return ["localhost", "127.0.0.1", "[::1]"].includes(r.hostname)
} catch {
return false
}
}
// a sign-in under way: pi's login runs, and what it asks or shows comes
// out as events, one at a time
function startLogin(oauth) {
const ctl = new AbortController()
const timer = setTimeout(() => ctl.abort(new Error("the sign-in timed out")), LOGIN_WAIT)
const run = { ctl, n: 0, asked: null, url: null, shown: [], waiters: [], result: null, done: false }
const wake = () => {
for (const w of run.waiters.splice(0)) w()
}
run.next = () => new Promise((ok) => (run.waiters.push(ok), run.asked || run.url || run.done ? wake() : null))
const interaction = {
signal: ctl.signal,
prompt(q) {
return new Promise((ok, no) => {
run.asked = { q, ok, key: "pi" + run.n++ }
ctl.signal.addEventListener("abort", () => no(ctl.signal.reason ?? new Error("Login cancelled")), { once: true })
wake()
})
},
notify(e) {
if (e?.type === "auth_url" && e.url) run.url ??= { url: e.url, instructions: e.instructions ?? "" }
else if (e?.type === "device_code") run.url ??= { url: e.verificationUriComplete ?? e.verificationUri ?? e.verificationUrl ?? "", instructions: `Enter the code ${e.userCode}` }
else if (e?.type === "info" && e.message) run.shown.push(String(e.message))
wake()
},
}
run.promise = Promise.resolve()
.then(() => asking.run(interaction, () => oauth.login(interaction)))
.then(
(c) => (run.result = { ...c, type: "success" }),
(e) => (run.result = { type: "failed", error: String(e?.message ?? e) }),
)
.finally(() => {
clearTimeout(timer)
run.done = true
wake()
})
return run
}
function promptOf(run) {
const { q, key } = run.asked
const select = q.type === "select" && Array.isArray(q.options) && q.options.length
return {
type: select ? "select" : "text",
key,
message: q.message ?? "",
placeholder: q.placeholder ?? "",
// pi's options are { id, label, description } and the login is answered
// with the id (pi-zcode's "bigmodel"), never with what the reader saw
options: select ? q.options.map((o) => (typeof o === "string" ? { label: o, value: o, hint: "" } : { label: o.label ?? String(o.id ?? o.value), value: String(o.id ?? o.value ?? o.label), hint: o.hint ?? o.description ?? "" })) : undefined,
}
}
function hooksOf(h, mr, ai, id) {
const prov = () => mr.getProvider(id)
const runs = new Map() // method index → its sign-in under way
function methods() {
const a = prov()?.auth ?? {}
const out = []
if (a.oauth) {
const m = {
type: "oauth",
label: a.oauth.loginLabel ?? `Sign in with ${a.oauth.name ?? prov()?.name ?? id}`,
}
const index = out.length
// what the login asks before it shows its page, asked one at a time
// as magpie asks a method's prompts (magpie's own hook)
m.ask = async (inputs = {}) => {
let run = runs.get(index)
const answered = !!run?.asked && run.asked.key in inputs
// asked with nothing answered: a sign-in begun afresh
if (!run || run.done || (!answered && !Object.keys(inputs).length)) {
run?.ctl.abort(new Error("Login cancelled"))
run = startLogin(a.oauth)
runs.set(index, run)
}
if (answered) {
const { ok, key } = run.asked
run.asked = null
ok(String(inputs[key] ?? ""))
}
await run.next()
return run.asked && !run.url ? promptOf(run) : null
}
m.authorize = async () => {
let run = runs.get(index)
if (!run || (run.done && run.result?.type !== "success" && !run.url)) {
run = startLogin(a.oauth)
runs.set(index, run)
}
while (!run.url && !run.done) {
// a question left after the page (none expected): told nothing
if (run.asked) {
const { ok } = run.asked
run.asked = null
ok("")
}
await run.next()
}
if (run.done && run.result?.type !== "success" && !run.url) {
runs.delete(index)
throw new Error(run.result?.error ?? "the sign-in failed")
}
const url = run.url?.url ?? ""
// a page that comes back to a server the login runs here finishes
// by itself; one that doesn't asks for what it shows to be pasted
await Promise.race([run.next(), new Promise((ok) => setTimeout(ok, 300))])
const code = !!url && !loopbackRedirect(url) && !!run.asked && !run.done
const instructions = [run.url?.instructions, ...run.shown].filter(Boolean).join("\n")
return {
url,
instructions: code && !instructions ? run.asked.q.message ?? "" : instructions,
method: code ? "code" : "auto",
callback: async (pasted) => {
runs.delete(index)
if (pasted && run.asked) {
const { ok } = run.asked
run.asked = null
ok(String(pasted))
}
await run.promise
return run.result
},
}
}
out.push(m)
}
if (a.apiKey || !a.oauth) out.push({ type: "api", label: "API key" })
return out
}
const auth = {
provider: id,
get methods() {
return methods()
},
async loader() {
return { apiKey: "pi", baseURL: "http://pi.magpie/v1", fetch: (url, init) => piFetch(url, init) }
},
// renewed by magpie ahead of its expiry, as pi renews it
async refresh(stored) {
const oauth = prov()?.auth?.oauth
if (!oauth?.refresh) return undefined
const { type: _t, ...c } = await oauth.refresh({ ...stored, type: "oauth" })
return c
},
}
async function piFetch(url, init) {
let body
try {
body = JSON.parse(typeof init?.body === "string" ? init.body : new TextDecoder().decode(init?.body ?? new Uint8Array()))
} catch {
return failure(400, "pi's providers take Anthropic's Messages requests")
}
const model = mr.getModel(id, body.model)
if (!model) return failure(404, `${prov()?.name ?? id} has no model ${body.model}`)
let context
try {
context = await contextOf(body, model)
} catch (e) {
return failure(400, String(e?.message ?? e))
}
let res
const seen = {}
const max = Number(body.max_tokens) > 0 ? Number(body.max_tokens) : undefined
const opts = {
signal: init?.signal,
maxTokens: max && model.maxTokens > 0 ? Math.min(max, model.maxTokens) : max ?? (model.maxTokens || undefined),
reasoning: levelOf(body, model, ai.clampThinkingLevel),
...(typeof body.temperature === "number" ? { temperature: body.temperature } : {}),
...(body.tool_choice?.type === "none" ? { toolChoice: "none" } : body.tools?.length ? { toolChoice: "auto" } : {}),
onResponse: (r) => void (res = r),
}
let events
try {
events = said.run(seen, () => mr.streamSimple(model, context, opts))
} catch (e) {
const msg = String(e?.message ?? e)
return failure(statusOf(undefined, msg), msg)
}
return answer(events, { model: body.model, stream: body.stream !== false, res: () => res, said: seen })
}
return {
auth,
provider: {
id,
async models(_given, { auth: stored }) {
h.mirror()
if (stored && prov()?.refreshModels) {
const r = await mr.refresh({ providers: [id], allowNetwork: true })
const e = r?.errors?.get?.(id)
if (e) h.send({ event: "log", level: "error", message: `${id}: models: ${e.message ?? e}` })
}
let list = mr.getModels(id) ?? []
if (stored) {
try {
const av = await mr.getAvailable(id)
if (av?.length) list = av
} catch {}
}
const levels = (m) => {
try {
return ai.getSupportedThinkingLevels(m).filter((l) => l !== "off")
} catch {
return m.reasoning ? ["low", "medium", "high"] : []
}
}
return Object.fromEntries(
list
.filter((m) => !m.type || m.type === "chat")
.map((m) => [
m.id,
{
id: m.id,
providerID: id,
name: m.name ?? m.id,
api: { id: m.id, url: "", npm: "@ai-sdk/anthropic" },
status: "active",
headers: {},
options: {},
cost: { input: m.cost?.input ?? 0, output: m.cost?.output ?? 0, cache: { read: m.cost?.cacheRead ?? 0, write: m.cost?.cacheWrite ?? 0 } },
limit: { context: m.contextWindow ?? 0, output: m.maxTokens ?? 0 },
capabilities: {
temperature: true,
reasoning: !!m.reasoning,
attachment: (m.input ?? []).includes("image"),
toolcall: true,
input: { text: true, image: (m.input ?? []).includes("image"), audio: false, video: false, pdf: false },
output: { text: true, image: false, audio: false, video: false, pdf: false },
interleaved: false,
},
release_date: "",
variants: m.reasoning ? Object.fromEntries(levels(m).map((l) => [l, {}])) : {},
},
]),
)
},
},
config(c) {
c.provider ??= {}
c.provider[id] ??= {}
c.provider[id].name ??= prov()?.name ?? id
},
}
}