Files
yetone 0ae7330242 plugin: a pi package's sign-in screen on pi's terminal UI is asked in magpie, and an empty reply a package makes of a vendor's error says the error (all pi plugins: Kiro's login hung, pi-zcode's quota ran out as an empty answer) — pi-provider-kiro asks how to sign in through ctx.ui.custom, a component of pi-tui (a SelectList, then Input fields for a start URL and region), and magpie gave pi's runner no UI, so custom came back undefined and the sign-in waited forever. pi's runner now has a UI for the sign-in's questions: select, confirm, input and editor become magpie's sign-in prompts, and custom draws the package's component and asks its list or field the same way, answering through the component's own onSelect/onSubmit, until the package calls done. 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 the parts are found by what drawing walks: every container goes through its children, so the arrays iterated while it renders are what it shows, in order; the text above the list is its question. Outside a sign-in the UI answers as pi's no-UI context does. session_start now reaches every package at startup, which Kiro needs to keep the context it asks through. Separately, pi-zcode turns ZCode's 1005 quota error into a reply with no content; magpie keeps the small JSON or text bodies a package's fetch gets while it streams, and a stream that ends empty answers that body's message and code (429 for quota or rate limits, else 502) instead of an empty message. TestPiPackage drives a fake package that asks on real pi-tui classes (a list, then a start URL field) and fakes ZCode's empty quota reply; it fails on the old code (no first prompt) and passes. With real accounts in a sandbox HOME: Kiro's "Kiro Login" (API Key, Web Login, Device Code) came as a select, Web Login gave the app.kiro.dev sign-in, and after signing in kiro-plugin/claude-sonnet-4-5 called get_weather and answered with its result. pi-cursor (1.4.38, latest) answers plain turns but its models say no get_weather tool exists, though the package sends it (toolCount 1, its MCP schema) and Cursor's stream carries fields the package doesn't know; that is between pi-cursor and Cursor. The ZCode quota path wasn't tried with a real account. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
2026-10-03 23:47:15 +08:00

208 lines
9.4 KiB
TypeScript

// A pi extension as pi's own examples write one (custom-provider-anthropic):
// a provider with its own stream and an OAuth sign-in that asks as it goes,
// first on pi's terminal UI as pi-provider-kiro does, another signed in to
// with a key, a command and a tool, which magpie leaves unused, and a
// session_start that adds a model once it is signed in. Its stream tells
// what pi handed it.
import { spawn } from "node:child_process"
import { writeFileSync } from "node:fs"
import { join } from "node:path"
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"
import { readStoredCredential, type ExtensionAPI } from "@earendil-works/pi-coding-agent"
import { Container, Input, SelectList, Text } from "@earendil-works/pi-tui"
function stream(model, context, options) {
const s = createAssistantMessageEventStream()
const out = {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: { input: 11, output: 7, cacheRead: 3, cacheWrite: 2, totalTokens: 23, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "stop",
timestamp: Date.now(),
}
const msgs = context.messages
const last = msgs[msgs.length - 1]
const said = typeof last.content === "string" ? last.content : (last.content ?? []).map((c) => c.text ?? "").join("")
const system = msgs.filter((m) => m.role === "system")
queueMicrotask(() => {
s.push({ type: "start", partial: out })
if (said.includes("quota")) {
// as pi-zcode: a refusal sent as JSON, which it reads as nothing said
fetch('data:application/json,{"code":1005,"msg":"exceed quota limit"}').then(() => {
s.push({ type: "done", reason: "stop", message: out })
s.end()
})
return
}
if (said.includes("fail429")) {
options?.onResponse?.({ status: 429, headers: { "retry-after": "7" } })
out.stopReason = "error"
out.errorMessage = "429 Too Many Requests: slow down"
s.push({ type: "error", reason: "error", error: out })
s.end()
return
}
if (said.includes("think")) {
out.content.push({ type: "thinking", thinking: "", thinkingSignature: "" })
s.push({ type: "thinking_start", contentIndex: 0, partial: out })
out.content[0].thinking = "hmm"
s.push({ type: "thinking_delta", contentIndex: 0, delta: "hmm", partial: out })
out.content[0].thinkingSignature = "sig-1"
s.push({ type: "thinking_end", contentIndex: 0, content: "hmm", partial: out })
}
if (said.includes("tool")) {
const call = { type: "toolCall", id: "call_1", name: "lookup", arguments: { q: "x" }, thoughtSignature: "ts-1" }
out.content.push(call)
const i = out.content.length - 1
s.push({ type: "toolcall_start", contentIndex: i, partial: out })
s.push({ type: "toolcall_end", contentIndex: i, toolCall: call, partial: out })
out.stopReason = "toolUse"
s.push({ type: "done", reason: "toolUse", message: out })
s.end()
return
}
// an empty text first, as some vendors send one
out.content.push({ type: "text", text: "" })
s.push({ type: "text_start", contentIndex: out.content.length - 1, partial: out })
s.push({ type: "text_end", contentIndex: out.content.length - 1, content: "", partial: out })
const seen = {
apiKey: options?.apiKey,
reasoning: options?.reasoning ?? null,
maxTokens: options?.maxTokens,
system: system.map((m) => (typeof m.content === "string" ? m.content : m.content.map((c) => c.text).join(""))).join("|"),
tools: system.flatMap((m) => (m.toolsAdded ?? []).map((t) => t.name)),
roles: msgs.filter((m) => m.role !== "system").map((m) => m.role),
sigs: msgs.flatMap((m) => (m.role === "assistant" ? m.content.map((c) => c.thinkingSignature ?? c.thoughtSignature ?? "") : [])),
results: msgs.filter((m) => m.role === "toolResult").map((m) => m.toolName + ":" + m.content.map((c) => c.text ?? c.mimeType).join("")),
images: msgs.flatMap((m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === "image").map((c) => c.mimeType) : [])),
}
const text = JSON.stringify(seen)
out.content.push({ type: "text", text: "" })
const i = out.content.length - 1
s.push({ type: "text_start", contentIndex: i, partial: out })
for (const part of [text.slice(0, 10), text.slice(10)]) {
out.content[i].text += part
s.push({ type: "text_delta", contentIndex: i, delta: part, partial: out })
}
s.push({ type: "text_end", contentIndex: i, content: text, partial: out })
s.push({ type: "done", reason: "stop", message: out })
s.end()
})
return s
}
const models = [
{
id: "fp-think",
name: "FP Think",
reasoning: true,
thinkingLevelMap: { off: null, minimal: null, low: "low", medium: null, high: "high", xhigh: null, max: null },
input: ["text", "image"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1.25 },
contextWindow: 100000,
maxTokens: 8000,
},
{ id: "fp-plain", name: "FP Plain", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 500 },
]
// how is the sign-in's first choice, made as pi-provider-kiro makes it: a
// component of pi's terminal UI, a list and then a field, which it keeps
// to itself
function how(ui) {
return ui.custom((tui, theme, _kb, done) => {
const box = new Container()
const list = new SelectList(
[
{ value: "web", label: "Web Login", description: "in the browser" },
{ value: "org", label: "Your organization", description: "its start URL" },
],
2,
{ selectedPrefix: (t) => theme.fg("accent", t), selectedText: (t) => theme.fg("accent", t), description: (t) => theme.fg("muted", t), scrollInfo: (t) => t, noMatch: (t) => t },
)
const field = new Input()
list.onSelect = (item) => {
if (item.value === "web") return done({ method: "web" })
box.clear()
box.addChild(new Text(theme.fg("accent", "Start URL (https://…)"), 1, 0))
box.addChild(field)
box.addChild(new Text("enter submit • esc back", 1, 0))
tui.requestRender()
}
list.onCancel = () => done(null)
field.onSubmit = (v) => v.startsWith("https://") && done({ method: "org", url: v })
box.addChild(new Text(theme.fg("accent", theme.bold("FakePi Login")), 1, 0))
box.addChild(list)
box.addChild(new Text("↑↓ navigate • enter select", 1, 0))
return {
render: (w) => box.render(w),
invalidate: () => box.invalidate(),
handleInput: (d) => (d === "\x1b" ? done(null) : undefined),
}
})
}
export default function (pi: ExtensionAPI) {
let ui
pi.registerCommand("fakepi-hello", { description: "says hello", handler: async () => {} })
pi.registerTool({
name: "fakepi_tool",
label: "Fake",
description: "a tool for pi's own sessions",
parameters: { type: "object", properties: {} },
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
})
const fakepi = {
name: "FakePi",
baseUrl: "https://fakepi.invalid",
api: "fakepi-api",
models,
oauth: {
name: "FakePi Account",
async login(cb) {
// as pi-provider-kiro: with pi's UI given, a sign-in it can't ask
// there is cancelled
const chose = ui ? await how(ui) : undefined
if (!chose) throw new Error("Login cancelled")
// as pi-devin-plus runs `devin auth login`: on the terminal, which
// reads a line and writes its own; it keeps what it read
spawn("sh", ["-c", 'read -r l && printf %s "$l" >> "$PI_CODING_AGENT_DIR/stolen"; echo "Login canceled"'], { stdio: "inherit" })
// as pi-zcode asks its region: answered with the option's id
const region = await cb.onSelect({ message: "Region?", options: [{ id: "cn", label: "China" }, { id: "intl", label: "Global" }] })
if (region !== "cn" && region !== "intl") throw new Error("Login cancelled")
const team = await cb.onPrompt({ message: "Team?", placeholder: "blue" })
cb.onAuth({ url: "https://fakepi.invalid/auth?team=" + team, instructions: "Sign in as " + team })
const code = await cb.onPrompt({ message: "Paste the code:" })
if (code !== "good") throw new Error("that code isn't right")
// as pi-antigravity keeps its accounts, in pi's directory, made by pi
writeFileSync(join(process.env.PI_CODING_AGENT_DIR!, "fakepi-accounts.json"), team)
return { refresh: "r-" + team, access: "a-" + team, expires: Date.now() + 3600e3, team, region, org: chose.url }
},
async refreshToken(c) {
return { ...c, access: "renewed-" + c.refresh, expires: Date.now() + 3600e3 }
},
getApiKey: (c) => c.access,
},
streamSimple: stream,
}
pi.registerProvider("fakepi", fakepi)
// as pi-zcode registers its plans' models: once signed in, read back from
// pi's auth.json when the session starts
pi.on("session_start", (_e, ctx) => {
ui = ctx.ui
const c = readStoredCredential("fakepi")
if (c?.type === "oauth") pi.registerProvider("fakepi", { ...fakepi, models: [...models, { ...models[1], id: "fp-" + c.team, name: "FP " + c.team }] })
})
pi.registerProvider("fakepi-key", {
name: "FakePi Key",
baseUrl: "https://fakepi.invalid",
apiKey: "FAKEPI_KEY",
api: "fakepi-key-api",
models: [{ ...models[1], id: "fk-1", name: "FK 1" }],
streamSimple: stream,
})
}