@evandu/super-pi 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +79 -2
  2. package/agents/coder.md +10 -0
  3. package/agents/critic.md +20 -0
  4. package/agents/patcher.md +23 -0
  5. package/agents/planner.md +18 -0
  6. package/agents/reviewer.md +13 -0
  7. package/agents/scout.md +23 -0
  8. package/agents/super-pi-task/core.ts +485 -0
  9. package/agents/super-pi-task/index.ts +141 -0
  10. package/agents/verifier.md +21 -0
  11. package/bin/super-pi.js +17 -0
  12. package/dist/client/assets/index-BMQsEdio.js +24 -0
  13. package/dist/client/assets/index-CsbZzuMy.css +1 -0
  14. package/dist/client/index.html +14 -0
  15. package/dist/extension.d.ts +15 -0
  16. package/dist/extension.js +111 -0
  17. package/dist/server/agents.d.ts +177 -0
  18. package/dist/server/agents.js +484 -0
  19. package/dist/server/application/catalog-service.d.ts +44 -0
  20. package/dist/server/application/catalog-service.js +94 -0
  21. package/dist/server/application/conversation-service.d.ts +73 -0
  22. package/dist/server/application/conversation-service.js +202 -0
  23. package/dist/server/application/extension-ui-service.d.ts +28 -0
  24. package/dist/server/application/extension-ui-service.js +73 -0
  25. package/dist/server/application/run-service.d.ts +115 -0
  26. package/dist/server/application/run-service.js +341 -0
  27. package/dist/server/application/workspace-service.d.ts +45 -0
  28. package/dist/server/application/workspace-service.js +106 -0
  29. package/dist/server/command-handler.d.ts +22 -0
  30. package/dist/server/command-handler.js +137 -0
  31. package/dist/server/files.d.ts +202 -0
  32. package/dist/server/files.js +629 -0
  33. package/dist/server/http-server.d.ts +20 -0
  34. package/dist/server/http-server.js +313 -0
  35. package/dist/server/index.d.ts +1 -0
  36. package/dist/server/index.js +103 -0
  37. package/dist/server/pi-command.d.ts +65 -0
  38. package/dist/server/pi-command.js +183 -0
  39. package/dist/server/runtime.d.ts +819 -0
  40. package/dist/server/runtime.js +1470 -0
  41. package/dist/server/server-context.d.ts +46 -0
  42. package/dist/server/server-context.js +1 -0
  43. package/dist/server/store.d.ts +424 -0
  44. package/dist/server/store.js +1121 -0
  45. package/dist/server/subagent-runs.d.ts +142 -0
  46. package/dist/server/subagent-runs.js +315 -0
  47. package/dist/server/subagents.d.ts +194 -0
  48. package/dist/server/subagents.js +542 -0
  49. package/dist/server/task-tool.d.ts +196 -0
  50. package/dist/server/task-tool.js +285 -0
  51. package/dist/server/tool-calls.d.ts +62 -0
  52. package/dist/server/tool-calls.js +141 -0
  53. package/dist/server/types.d.ts +920 -0
  54. package/dist/server/types.js +11 -0
  55. package/dist/server/upload.d.ts +37 -0
  56. package/dist/server/upload.js +169 -0
  57. package/dist/server/ws-server.d.ts +45 -0
  58. package/dist/server/ws-server.js +116 -0
  59. package/package.json +115 -4
@@ -0,0 +1,485 @@
1
+ /**
2
+ * The `task` and `todowrite` tools' whole behaviour, with no Pi import anywhere in this file.
3
+ *
4
+ * WHY IT IS SPLIT OUT. `index.ts` is the Pi adapter: TypeBox schemas and `registerTool` calls.
5
+ * Everything with a decision in it lives here instead, because `index.ts` can only be loaded by Pi -
6
+ * Pi hands extensions `@sinclair/typebox` as a VIRTUAL MODULE (extensions/virtual-modules.ts), so the
7
+ * adapter is unimportable from an ordinary Node process and therefore untestable from one. A file that
8
+ * can only be exercised by starting a real Pi with a real model behind it does not get tested; this one
9
+ * is driven end to end against a real server from `scripts/task-tool.test.mjs` and
10
+ * `scripts/todo-tool.test.mjs`.
11
+ *
12
+ * WHAT IS BORROWED FROM OPENCODE, and where:
13
+ *
14
+ * - `tool/task.txt` is the basis of `TASK_DESCRIPTION`, INCLUDING its "When NOT to use" list. That list
15
+ * is the part that matters: a delegation tool with no stated cost model gets used for a one-line
16
+ * search, and each such use is a whole second Pi process. The guidance is kept nearly verbatim and in
17
+ * its original order because it was written by someone who had watched that happen.
18
+ * - `tool/task.ts:64-79` `renderOutput` is `renderTaskResult`, including the `task_result` /
19
+ * `task_error` tag switch, so the model sees the same envelope shape it would from OpenCode.
20
+ * - `tool/registry.ts:265-278` `describeTask()` is `describeTask()`: the tool's own description is where
21
+ * the roster goes, rather than the system prompt, so the list is refreshed with the tools and cannot
22
+ * outlive a roster that has changed.
23
+ * - `tool/todowrite.txt` is the basis of `TODO_DESCRIPTION`, INCLUDING its "Use proactively when" list -
24
+ * see the long note above `TODO_DESCRIPTION` for why that list is the mechanism and is not paraphrased.
25
+ * - `tool/todo.ts` is the shape of `executeTodo`: write the list, echo it back as JSON.
26
+ *
27
+ * THE ONE DELIBERATE DEVIATION on `task`: OpenCode puts `description` to work as a session title. Super
28
+ * Pi's sub-agent record has no title field, and adding one is a persisted-shape change this step does not
29
+ * need, so `description` is carried into the `<summary>` line of the envelope instead. It is still
30
+ * shown to the model, still 3-5 words, and the parameter still means what its schema says.
31
+ */
32
+
33
+ /**
34
+ * The environment contract with the server. The SAME four names are declared in
35
+ * `src/server/task-tool.ts`, which is the other end; the two are written out rather than imported
36
+ * because this file is loaded by Pi's module loader and that one by tsc, and a relative import across
37
+ * the two would resolve differently under each. `scripts/task-tool.test.mjs` asserts they agree.
38
+ */
39
+ export const TASK_TOOL_NAME = "task";
40
+ export const TASK_ENDPOINT_ENV = "SUPER_PI_TASK_ENDPOINT";
41
+ export const TASK_ROSTER_ENV = "SUPER_PI_TASK_ROSTER";
42
+ export const TASK_CONVERSATION_ENV = "SUPER_PI_TASK_CONVERSATION";
43
+ export const TASK_DEPTH_ENV = "SUPER_PI_TASK_DEPTH";
44
+
45
+ /**
46
+ * The `todowrite` extension's half of the same environment contract, declared here for the reason the
47
+ * four above are. `src/server/task-tool.ts` is the other end; `scripts/todo-tool.test.mjs` asserts they
48
+ * agree, because a rename on one side fails at call time with a message nothing reads.
49
+ */
50
+ export const TODO_ENDPOINT_ENV = "SUPER_PI_TODO_ENDPOINT";
51
+
52
+ /** The name the model calls, and the key Pi's tool allowlist matches on. OpenCode's name, kept. */
53
+ export const TODO_TOOL_NAME = "todowrite";
54
+
55
+ /** The parameters, OpenCode's `tool/todo.ts:6-8` minus its schema wrapper. */
56
+ export type TodoParams = { todos: TodoEntry[] };
57
+
58
+ /** One entry as the model writes it. `id` is OURS (see `normalizeTodos`), so it is absent here. */
59
+ export type TodoEntry = { content: string; status: "pending" | "in_progress" | "completed"; priority?: "high" | "medium" | "low" };
60
+
61
+ /** The three statuses Super Pi renders, as the model writes them. */
62
+ const TODO_STATUSES = new Set(["pending", "in_progress", "completed"]);
63
+
64
+ /** The parameters, exactly OpenCode's (`tool/task.ts:43-52`) minus the ones this step does not do. */
65
+ export type TaskParams = {
66
+ description: string;
67
+ prompt: string;
68
+ subagent_type: string;
69
+ task_id?: string;
70
+ background?: boolean;
71
+ };
72
+
73
+ /** One dispatchable sub-agent, as the server read it from `agents/*.md` at spawn time. */
74
+ export type RosterEntry = { id: string; name: string; description: string };
75
+
76
+ /**
77
+ * Reads a roster back out of an environment value.
78
+ *
79
+ * Anything unreadable is an EMPTY roster rather than a throw. An extension that throws while loading
80
+ * takes Pi's whole extension host down with it, and a malformed environment variable is a far smaller
81
+ * fault than "the primary agent cannot delegate at all".
82
+ */
83
+ export function parseRoster(raw: string | undefined): RosterEntry[] {
84
+ if (!raw) return [];
85
+ let parsed: unknown;
86
+ try { parsed = JSON.parse(raw); } catch { return []; }
87
+ if (!Array.isArray(parsed)) return [];
88
+ return parsed.filter((entry): entry is RosterEntry =>
89
+ typeof entry === "object" && entry !== null &&
90
+ typeof (entry as RosterEntry).id === "string" && (entry as RosterEntry).id !== "" &&
91
+ typeof (entry as RosterEntry).description === "string");
92
+ }
93
+
94
+ /**
95
+ * The model's half of the contract, and the part that decides whether the tool is used well.
96
+ *
97
+ * The first paragraph says what the tool IS in one sentence, because a tool description is read once
98
+ * and acted on. The "When NOT to use" list is OpenCode's, kept in its order and for its stated reason.
99
+ * The usage notes are OpenCode's too, with `background` dropped - there is no background mode, every
100
+ * dispatch is foreground and blocking, and offering a note about a mode that does not exist would be a
101
+ * lie that costs a retry.
102
+ */
103
+ export const TASK_DESCRIPTION = `Launch a subagent to handle a complex, multistep task autonomously.
104
+
105
+ You must specify a subagent_type to select which subagent to use.
106
+
107
+ When NOT to use this tool:
108
+ - If you want to read a specific file path, use the Read or Glob tool instead, to find the match more quickly
109
+ - If you are searching for a specific class definition like "class Foo", use the Grep tool instead, to find the match more quickly
110
+ - If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead, to find the match more quickly
111
+ - If no available subagent is a good fit for the task, use other tools directly
112
+
113
+ Usage notes:
114
+ 1. Every dispatch starts a SEPARATE agent process that runs until it finishes, so the cost of a delegation is minutes rather than seconds. Delegate work that is genuinely substantial; do not delegate a lookup you could do in one tool call.
115
+ 2. Once you have delegated work to a subagent, do not duplicate that work yourself. Continue with non-overlapping tasks, or wait for the result.
116
+ 3. When the subagent is done it returns ONE message back to you, wrapped in a <task> element. That result is not visible to the user: if it matters to the user, say so in your own reply.
117
+ 4. Each dispatch starts with a FRESH context. Your prompt is all the subagent will know, so it must contain the full task, and it must say exactly what you want back in its final message.
118
+ 5. Tell the subagent whether you expect it to WRITE CODE or only to RESEARCH (search, file reads, web fetches), and how to verify its work if it writes. It is not aware of the user's intent.
119
+ 6. If a subagent's description says it should be used proactively, use it without being asked.`;
120
+
121
+ /**
122
+ * The roster block, OpenCode's `describeTask()` reduced to its two jobs: the ids that are legal, and
123
+ * when to pick each one.
124
+ *
125
+ * `id` is printed, not `name`. The model has to type `subagent_type` exactly, and requiring it to
126
+ * reproduce a Chinese label as a machine token would be a transcription exercise in front of any actual
127
+ * work. The human-readable `name` is printed alongside because the ids in this repository are English
128
+ * file names while the roles are described in Chinese, and a model reading only `scout` learns less
129
+ * from it than from both.
130
+ *
131
+ * THE EMPTY CASE IS SPOKEN, NOT SILENT. With no sub-agents the tool still exists - Pi has already
132
+ * declared it - so a description that just ended after the guidance would leave the model to guess
133
+ * whether an empty roster is a bug or a fact about this install. It says so explicitly and tells the
134
+ * model to do the work itself, which is the only correct action in that state.
135
+ */
136
+ export function rosterSection(roster: readonly RosterEntry[]): string {
137
+ if (!roster.length) {
138
+ return "\n\nAvailable subagent types: NONE. This install has no agents/*.md file with `mode: subagent`, so there is nobody to delegate to. Do not call this tool - do the work yourself with your other tools, and mention that no sub-agents are configured if the user asked for delegation.";
139
+ }
140
+ const lines = roster.map((entry) => `- ${entry.id} (${entry.name}): ${entry.description || "No description was given for this subagent."}`);
141
+ return `\n\nAvailable subagent types and when to use each:\n${lines.join("\n")}`;
142
+ }
143
+
144
+ /** The full model-facing description: the guidance, then the live roster. OpenCode's `describeTask()`. */
145
+ export function describeTask(roster: readonly RosterEntry[]): string {
146
+ return `${TASK_DESCRIPTION}${rosterSection(roster)}`;
147
+ }
148
+
149
+ /**
150
+ * The envelope the model reads, OpenCode's `renderOutput` (`tool/task.ts:64-79`).
151
+ *
152
+ * `state` is one of three words and the tag follows from it, so a model can tell a finished answer from
153
+ * a failure by the tag alone without parsing prose. Super Pi's own run statuses (`stopped`, `failed`)
154
+ * are folded into `error` here rather than passed through: `stopped` and `failed` are distinctions
155
+ * Super Pi's UI needs and the model has no use for, and adding a fourth `state` value would break any
156
+ * consumer expecting OpenCode's three.
157
+ *
158
+ * The child's text goes in verbatim. Trimming or wrapping it would mean the model receives something the
159
+ * sub-agent did not say, which is the one transformation that cannot be undone by reading the record.
160
+ */
161
+ export function renderTaskResult(input: { id: string; state: "completed" | "error"; summary?: string; text: string }): string {
162
+ const tag = input.state === "error" ? "task_error" : "task_result";
163
+ return [
164
+ `<task id="${input.id}" state="${input.state}">`,
165
+ ...(input.summary ? [`<summary>${input.summary}</summary>`] : []),
166
+ `<${tag}>`,
167
+ input.text,
168
+ `</${tag}>`,
169
+ "</task>",
170
+ ].join("\n");
171
+ }
172
+
173
+ /** What the extension learns from its own environment before it can dispatch anything. */
174
+ export type TaskToolConfig = {
175
+ endpoint: string;
176
+ conversationId: string;
177
+ depth: number;
178
+ roster: RosterEntry[];
179
+ /** The server's bearer token, when one is configured. Absent means the server runs open on loopback. */
180
+ token?: string;
181
+ };
182
+
183
+ export function readTaskConfig(env: Record<string, string | undefined>): TaskToolConfig {
184
+ const depth = Number.parseInt(env[TASK_DEPTH_ENV] ?? "0", 10);
185
+ const token = env.SUPER_PI_ACCESS_TOKEN;
186
+ return {
187
+ endpoint: env[TASK_ENDPOINT_ENV] ?? "",
188
+ conversationId: env[TASK_CONVERSATION_ENV] ?? "",
189
+ // An unreadable depth is 0, the primary agent's own value, rather than a number that would claim to
190
+ // be nested and trip the server's refusal for a reason that has nothing to do with this call.
191
+ depth: Number.isFinite(depth) ? depth : 0,
192
+ roster: parseRoster(env[TASK_ROSTER_ENV]),
193
+ ...(token ? { token } : {}),
194
+ };
195
+ }
196
+
197
+ /** The context slice the tool needs, so this file does not depend on Pi's `ExtensionToolContext`. */
198
+ export type TaskToolContext = { cwd: string };
199
+
200
+ export type TaskToolResult = { content: { type: "text"; text: string }[]; details: unknown; isError?: boolean };
201
+
202
+ /** One HTTP seam, injected so a test can drive the tool without a socket and production uses global fetch. */
203
+ export type TaskFetch = (url: string, init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal }) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
204
+
205
+ const globalFetch: TaskFetch = (url, init) => fetch(url, init as RequestInit);
206
+
207
+ /**
208
+ * Refuses a call that cannot possibly work, in words the model can act on.
209
+ *
210
+ * The three cases are separated because the MODEL's next move differs in each: an empty roster means use
211
+ * your own tools, an unknown id means pick one of the listed ones, and a missing endpoint means this
212
+ * session was not started by Super Pi at all and no retry will change that. A single generic "dispatch
213
+ * failed" would leave a model that retries the same call three times.
214
+ */
215
+ export function refuseTask(config: TaskToolConfig, params: TaskParams): string | undefined {
216
+ if (!config.endpoint) return `The task tool is not available in this session: ${TASK_ENDPOINT_ENV} is not set, so there is no Super Pi server to dispatch to. Do the work yourself with your other tools.`;
217
+ if (!config.conversationId) return `The task tool is not available in this session: ${TASK_CONVERSATION_ENV} is not set, so this run cannot be attributed to a conversation. Do the work yourself with your other tools.`;
218
+ if (!config.roster.length) return "This install has no dispatchable sub-agents (no agents/*.md with `mode: subagent`), so there is nobody to delegate to. Do the work yourself with your other tools.";
219
+ if (!params.subagent_type.trim()) return `subagent_type is required. Available: ${config.roster.map((entry) => entry.id).join(", ")}.`;
220
+ if (!config.roster.some((entry) => entry.id === params.subagent_type)) return `${JSON.stringify(params.subagent_type)} is not a dispatchable subagent_type. Available: ${config.roster.map((entry) => entry.id).join(", ")}. Pick one of those, or do the work yourself.`;
221
+ if (!params.prompt.trim()) return "prompt is required and must not be empty: the sub-agent starts with a fresh context and your prompt is everything it will know.";
222
+ return undefined;
223
+ }
224
+
225
+ /**
226
+ * Runs one dispatch and returns the envelope the model reads.
227
+ *
228
+ * Returns rather than throws for EVERY outcome, including a malformed request. Pi treats a thrown tool
229
+ * error and an `isError` result almost identically, so this is not about Pi - it is that the model
230
+ * should see ONE shape for "the delegation did not work", with the reason inside it, instead of a stack
231
+ * trace for some failures and prose for others.
232
+ *
233
+ * The request carries a CONVERSATION, never a directory. The server resolves the workspace from its own
234
+ * stored state; a model that could name its own `cwd` would name a directory it can write, which is the
235
+ * entire boundary this endpoint exists to keep. That is why `context.cwd` is read here at all: only to
236
+ * be left out of the request.
237
+ */
238
+ export async function executeTask(
239
+ params: TaskParams,
240
+ context: TaskToolContext,
241
+ config: TaskToolConfig,
242
+ deps: { signal?: AbortSignal; fetch?: TaskFetch } = {},
243
+ ): Promise<TaskToolResult> {
244
+ const text = (body: string, isError?: boolean): TaskToolResult => ({ content: [{ type: "text", text: body }], details: undefined, ...(isError ? { isError: true } : {}) });
245
+ const refusal = refuseTask(config, params);
246
+ if (refusal) return text(renderTaskResult({ id: "refused", state: "error", text: refusal }), true);
247
+
248
+ const send = deps.fetch ?? globalFetch;
249
+ let response: Awaited<ReturnType<TaskFetch>>;
250
+ try {
251
+ response = await send(config.endpoint, {
252
+ method: "POST",
253
+ // The bearer token is the SAME check every other endpoint on this server applies, and the server
254
+ // enforces it BEFORE the loopback test, so an extension that forgot it would get a bare 401.
255
+ headers: { "content-type": "application/json", ...(config.token ? { authorization: `Bearer ${config.token}` } : {}) },
256
+ body: JSON.stringify({
257
+ conversationId: config.conversationId,
258
+ subagent_type: params.subagent_type,
259
+ prompt: params.prompt,
260
+ description: params.description,
261
+ depth: config.depth,
262
+ background: params.background === true,
263
+ }),
264
+ ...(deps.signal ? { signal: deps.signal } : {}),
265
+ });
266
+ } catch (error) {
267
+ // A failed CONNECTION is worth distinguishing from a refused dispatch: nothing ran, and no run
268
+ // record exists, so telling the model to look at the run would send it looking for something that
269
+ // was never created.
270
+ return text(renderTaskResult({ id: "unreachable", state: "error", text: `Could not reach the Super Pi server at ${config.endpoint}: ${error instanceof Error ? error.message : "unknown error"}` }), true);
271
+ }
272
+
273
+ /** The server answers JSON; a body that is not JSON means a proxy or an error page, not a verdict. */
274
+ let payload: { run?: { id?: string; status?: string; agentId?: string }; output?: string; error?: string; outputTruncated?: boolean };
275
+ try { payload = JSON.parse(await response.text()) as typeof payload; }
276
+ catch { return text(renderTaskResult({ id: "unreadable", state: "error", text: `The dispatch endpoint answered ${response.status} with something that is not JSON, so its result cannot be read.` }), true); }
277
+
278
+ // No run id means nothing ran: either the server refused before spawning, or the body was unreadable.
279
+ // "refused" says so, where "unknown" would imply a run exists somewhere whose output is merely absent.
280
+ const id = payload.run?.id ?? "refused";
281
+ if (params.background === true && payload.run?.status === "running") {
282
+ return text(`<task id="${id}" state="running">\n<summary>${params.description.trim() || params.subagent_type}</summary>\n<task_result>The task is working in the background. You will be notified automatically when it finishes. Do not sleep, poll for progress, ask the task for status, or duplicate this task's work. Continue with non-overlapping work.</task_result>\n</task>`);
283
+ }
284
+ const completed = payload.run?.status === "completed";
285
+ const body = completed
286
+ ? payload.output ?? ""
287
+ : payload.error ?? `The sub-agent run finished as ${JSON.stringify(payload.run?.status ?? "unknown")} without saying why.`;
288
+ return text(
289
+ renderTaskResult({
290
+ id,
291
+ state: completed ? "completed" : "error",
292
+ summary: params.description.trim() ? `${params.description.trim()} (${params.subagent_type})` : `${params.subagent_type}`,
293
+ text: payload.outputTruncated && completed ? `${body}\n\n[This answer was truncated at 50 KB by the server; the full run is recorded in Super Pi.]` : body,
294
+ }),
295
+ !completed,
296
+ );
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------------------------
300
+ // The `todowrite` tool: a task list the model maintains itself, and a person watches from the sidebar.
301
+ //
302
+ // THIS IS A PORT OF OPENCODE'S `tool/todo.ts` + `tool/todowrite.txt`, and the two files are 46 lines and
303
+ // 44 lines respectively - almost all of the first one Effect plumbing, and the whole of the second one
304
+ // prose the model reads. Only the prose ports intact, and that is the point of this file.
305
+ //
306
+ // THE PROACTIVE TRIGGER IS THE DESCRIPTION, and nothing else. This is the part of OpenCode's design that
307
+ // is easy to get wrong by looking for a mechanism that isn't there: OpenCode does NOT inject the todo
308
+ // list into any system prompt, does NOT have a scheduler, and does NOT nudge. `session/todo.ts` is a
309
+ // per-session table that a tool call writes, and the ONLY thing that makes a model call it on its own is
310
+ // the "Use proactively when" list in `todowrite.txt` - specifically the two entries that fire on work
311
+ // happening rather than on a question being asked:
312
+ //
313
+ // - "You start a task - mark it `in_progress` (only one at a time) before working"
314
+ // - "You finish a task - mark it `completed` and add any follow-ups discovered during the work"
315
+ //
316
+ // Those two lines ARE the design. Everything else here - the endpoint, the store, the sidebar - is
317
+ // plumbing that makes those two lines possible. So they are carried over in OpenCode's own order and
318
+ // wording, and the test asserts the sentences are PRESENT rather than paraphrased, because a reworded
319
+ // trigger is a trigger that fires less often and no test would notice except this one.
320
+ //
321
+ // WHAT CHANGES FROM OPENCODE, and why each change is forced rather than chosen:
322
+ //
323
+ // - `cancelled` is GONE from the States section. Super Pi's list is replaced whole on every call
324
+ // (`normalizeTodos` in src/server/task-tool.ts), so "no longer needed" is expressed by not sending the
325
+ // item. Keeping a fourth status would mean a state the model can set that nothing ever takes off the
326
+ // screen, which is a row that says it was cancelled forever.
327
+ // - The permission gate is gone. OpenCode asks `ctx.ask({ permission: "todowrite", patterns: ["*"] })`
328
+ // before every write (`tool/todo.ts:24-29`) and denies the tool to sub-agents that lack the permission
329
+ // (`agent/subagent-permissions.ts:19-24`). Super Pi's server has one permission story and it is
330
+ // bearer-token plus loopback, not a per-tool prompt, and a tool whose every call raises a modal is a
331
+ // tool a model stops calling - which would break the proactive trigger this file exists to preserve.
332
+ // The endpoint's loopback-and-token guards are what stands in that place; they are noted in
333
+ // `refuseTodo` so the model knows the rule rather than guessing at it.
334
+ // - The list is per-install, not per-session (`AppState.todos` is one array). Stated in the report rather
335
+ // than hidden: switching conversations shows the previous conversation's list until the model rewrites
336
+ // it. Making it per-conversation is a persisted-shape change and is out of this task's scope.
337
+ //
338
+ // THE OUTPUT IS THE SERVER'S LIST, NOT THE MODEL'S. OpenCode echoes its own input
339
+ // (`tool/todo.ts:38`). This tool echoes what the server actually STORED, which differs whenever the
340
+ // server dropped an empty item, truncated a long one or filled in a missing id - and echoing the stored
341
+ // copy is what stops the model from believing a row was saved when it was not.
342
+
343
+ /**
344
+ * The model-facing description. OpenCode's `todowrite.txt`, ported.
345
+ *
346
+ * Kept in English for the same reason `TASK_DESCRIPTION` above is: it is read by a model, and the rest
347
+ * of this extension's model-facing text is English, so a Chinese paragraph here would be the only Chinese
348
+ * instruction in the tool set. The paragraphs a person reads (the sidebar) are the Chinese ones.
349
+ *
350
+ * The one ADDED paragraph is the third one. OpenCode never tells the model that anyone is watching - its
351
+ * description is written entirely in the imperative voice of a task list. Super Pi's list is on screen in
352
+ * a panel a user is looking at while the agent works, and that is the reason to maintain it in real time
353
+ * rather than at the end, so the description says so. This is the piece Super Pi has that OpenCode does
354
+ * not, and it is stated to the model as a fact about the world rather than as a request.
355
+ */
356
+ export const TODO_DESCRIPTION = `Create and maintain a structured task list for the current coding session. Tracks progress, organizes multi-step work, and surfaces status to the user.
357
+
358
+ ## When to use
359
+ Use proactively when:
360
+ - The task requires 3+ distinct steps or actions (not just 3 tool calls for a single conceptual step)
361
+ - The work is non-trivial and benefits from planning
362
+ - The user provides multiple tasks (numbered or comma-separated) or explicitly asks for a todo list
363
+ - New instructions arrive - capture them as todos
364
+ - You start a task - mark it \`in_progress\` (only one at a time) before working
365
+ - You finish a task - mark it \`completed\` and add any follow-ups discovered during the work
366
+
367
+ ## When NOT to use
368
+ Skip when:
369
+ - The work is a single, straightforward task (or <3 trivial steps)
370
+ - The request is purely informational or conversational
371
+ - Tracking adds no organizational value
372
+
373
+ ## States
374
+ - \`pending\` - not started
375
+ - \`in_progress\` - actively working (exactly ONE at a time)
376
+ - \`completed\` - finished successfully
377
+
378
+ To drop an item that is no longer needed, leave it out of the next call entirely: this tool replaces the whole list, so an item you do not send is an item that disappears.
379
+
380
+ ## Rules
381
+ - Update status in real time; don't batch completions
382
+ - Mark \`completed\` only after the required work is actually done, including any required verification. Never based on intent.
383
+ - Keep exactly one \`in_progress\` while work remains
384
+ - If blocked or partial, keep it \`in_progress\` and add a follow-up todo describing the blocker
385
+ - Preserve user-provided commands verbatim (flags, args, order)
386
+ - Items should be specific and actionable; break large work into smaller steps
387
+
388
+ ## The user is watching this list
389
+ This list is rendered live in a panel beside the conversation, so a stale row is visible to the person you are working for while you are still working. That is why the triggers above are about PROGRESS and not about questions: update the list when a task starts, when it finishes, and when you discover a new step - not when the user asks where you are.
390
+
391
+ ## Examples
392
+
393
+ Use it:
394
+ - "Add a dark mode toggle and run the tests" -> multi-step feature + explicit verification
395
+ - "Rename getCwd -> getCurrentWorkingDirectory across the repo" -> grep reveals 15 occurrences in 8 files
396
+ - "Implement registration, catalog, cart, checkout" -> multiple complex features
397
+
398
+ Skip it:
399
+ - "How do I print Hello World in Python?" -> informational
400
+ - "Add a comment to calculateTotal" -> single edit
401
+ - "Run npm install and tell me what happened" -> one command
402
+
403
+ When in doubt, use it.`;
404
+
405
+ export type TodoToolConfig = { endpoint: string; conversationId: string; token?: string };
406
+
407
+ /** What the extension learns from its own environment before it can write anything. */
408
+ export function readTodoConfig(env: Record<string, string | undefined>): TodoToolConfig {
409
+ const token = env.SUPER_PI_ACCESS_TOKEN;
410
+ return { endpoint: env[TODO_ENDPOINT_ENV] ?? "", conversationId: env[TASK_CONVERSATION_ENV] ?? "", ...(token ? { token } : {}) };
411
+ }
412
+
413
+ /**
414
+ * Refuses a call that cannot work, in words the model can act on.
415
+ *
416
+ * The server is authoritative over what the list may contain (`normalizeTodos`), so this file does NOT
417
+ * re-check `content` or `status` and deliberately does not reject a list it dislikes: a list with one bad
418
+ * entry is better stored than refused, because a refusal loses the twenty good ones with it. What is
419
+ * checked here is only the things that make the call impossible - the same three-case split `refuseTask`
420
+ * uses, for the same reason.
421
+ */
422
+ export function refuseTodo(config: TodoToolConfig, params: TodoParams): string | undefined {
423
+ if (!config.endpoint) return `The todowrite tool is not available in this session: ${TODO_ENDPOINT_ENV} is not set, so there is no Super Pi server to write the list to. Keep working and describe your plan in prose instead.`;
424
+ if (!config.conversationId) return `The todowrite tool is not available in this session: ${TASK_CONVERSATION_ENV} is not set. Keep working and describe your plan in prose instead.`;
425
+ if (!Array.isArray(params.todos)) return "todowrite requires a `todos` array. Send the WHOLE list every time - this tool replaces the list rather than adding to it.";
426
+ return undefined;
427
+ }
428
+
429
+ /** One HTTP seam, injected so a test can drive the tool without a socket and production uses global fetch. */
430
+ export type TodoFetch = (url: string, init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal }) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
431
+
432
+ /**
433
+ * Runs one write and returns what the model reads.
434
+ *
435
+ * Returns rather than throws for every outcome, for the reason `executeTask` does: the model should see
436
+ * ONE shape for "the list was not written", with the reason inside it.
437
+ *
438
+ * THE RETURNED LIST IS THE SERVER'S, and this is the one place the port departs from OpenCode's behaviour
439
+ * on purpose. The server is the only party that has validated, truncated and id-stamped what was stored,
440
+ * so echoing its copy is what tells the model the truth; echoing the input instead (OpenCode,
441
+ * `tool/todo.ts:38`) would let it believe a 40,000-character row survived when it was cut to 400. The
442
+ * dropped count rides along in the sentence for the same reason - a silent truncation reads as success.
443
+ */
444
+ export async function executeTodo(
445
+ params: TodoParams,
446
+ config: TodoToolConfig,
447
+ deps: { signal?: AbortSignal; fetch?: TodoFetch } = {},
448
+ ): Promise<TaskToolResult> {
449
+ const text = (body: string, isError?: boolean): TaskToolResult => ({ content: [{ type: "text", text: body }], details: undefined, ...(isError ? { isError: true } : {}) });
450
+ const refusal = refuseTodo(config, params);
451
+ if (refusal) return text(refusal, true);
452
+
453
+ const send = deps.fetch ?? globalFetch;
454
+ let response: Awaited<ReturnType<TodoFetch>>;
455
+ try {
456
+ response = await send(config.endpoint, {
457
+ method: "POST",
458
+ headers: { "content-type": "application/json", ...(config.token ? { authorization: `Bearer ${config.token}` } : {}) },
459
+ // The CONVERSATION is named so the server can attribute the write, never a directory: the same
460
+ // boundary `executeTask` keeps, and for the same reason - a model that could name its own cwd could
461
+ // write outside the workspace this app exists to keep it inside.
462
+ body: JSON.stringify({ conversationId: config.conversationId, todos: params.todos }),
463
+ ...(deps.signal ? { signal: deps.signal } : {}),
464
+ });
465
+ } catch (error) {
466
+ return text(`Could not reach the Super Pi server at ${config.endpoint}: ${error instanceof Error ? error.message : "unknown error"}. The todo list was NOT saved - carry your plan in prose this turn and try again.`, true);
467
+ }
468
+
469
+ let payload: { todos?: TodoEntry[]; dropped?: number; error?: string };
470
+ try { payload = JSON.parse(await response.text()) as typeof payload; }
471
+ catch { return text(`The todo endpoint answered ${response.status} with something that is not JSON, so the list cannot be confirmed as saved. Treat it as unsaved and retry.`, true); }
472
+ if (!response.ok || !Array.isArray(payload.todos)) {
473
+ return text(payload.error ?? `The todo endpoint answered ${response.status} without saving the list. Treat it as unsaved and retry.`, true);
474
+ }
475
+
476
+ // Only statuses this file knows are echoed, and only because the server's validator already enforces
477
+ // them - this is a type narrowing for the renderer below, not a second validation pass.
478
+ const stored = payload.todos.filter((todo): todo is TodoEntry => Boolean(todo) && typeof todo.content === "string" && TODO_STATUSES.has(todo.status));
479
+ const open = stored.filter((todo) => todo.status !== "completed").length;
480
+ const summary = !stored.length ? "0 个待办任务" : !open ? `全部完成:${stored.length} 个待办任务` : `${open} 个待办未完成,共 ${stored.length} 个`;
481
+ const dropped = typeof payload.dropped === "number" && payload.dropped > 0
482
+ ? `\n\n[${payload.dropped} of the entries you sent were dropped or truncated by the server - they are NOT in the saved list. The saved list is the JSON below.]`
483
+ : "";
484
+ return text(`${summary}${dropped}\n\n${JSON.stringify(stored, null, 2)}`);
485
+ }
@@ -0,0 +1,141 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import {
3
+ TASK_TOOL_NAME,
4
+ describeTask,
5
+ executeTask,
6
+ readTaskConfig,
7
+ type TaskParams,
8
+ } from "./core.ts";
9
+ import {
10
+ TODO_DESCRIPTION,
11
+ TODO_TOOL_NAME,
12
+ executeTodo,
13
+ readTodoConfig,
14
+ type TodoParams,
15
+ } from "./core.ts";
16
+
17
+ /**
18
+ * The Pi adapter for the `task` and `todowrite` tools. Everything with a decision in it is in
19
+ * `./core.ts`; this file is the mounting hardware and nothing else.
20
+ *
21
+ * `@sinclair/typebox` is Pi's VIRTUAL MODULE (packages/coding-agent/src/core/extensions/virtual-modules.ts),
22
+ * which is why importing it works here and would not work anywhere else in this repository: it is not in
23
+ * `package.json` and is not installed in `node_modules`. Nothing outside Pi may import this file for that
24
+ * reason - `scripts/task-tool.test.mjs` drives `core.ts` instead, and reads this file as TEXT for the
25
+ * things that cannot be executed outside Pi.
26
+ *
27
+ * The parameter schema is OpenCode's `task.ts:43-52` verbatim, including `task_id`. `task_id` is
28
+ * accepted and ignored in this step: resuming a sub-agent's session would mean keeping its Pi process
29
+ * and its transcript alive between calls, and `runtime.ts` kills the primary's Pi after every turn, so
30
+ * there is nothing for a session id to point at yet. It is kept in the schema because removing it would
31
+ * make this tool incompatible with a model that has been told the OpenCode shape, and because the next
32
+ * step is precisely the thing that gives it meaning.
33
+ */
34
+ const Parameters = Type.Object({
35
+ description: Type.String({ description: "A short (3-5 words) description of the task" }),
36
+ prompt: Type.String({ description: "The task for the agent to perform" }),
37
+ subagent_type: Type.String({ description: "The type of specialized agent to use for this task" }),
38
+ task_id: Type.Optional(Type.String({ description: "Resume a previous task when supported" })),
39
+ background: Type.Optional(Type.Boolean({ description: "Run asynchronously and deliver the result as a follow-up message" })),
40
+ });
41
+
42
+ /**
43
+ * The `todowrite` parameter schema, OpenCode's `tool/todo.ts:6-8` in the TypeBox dialect this adapter
44
+ * speaks. `todos` is the WHOLE list every time - it replaces the list rather than adding to it - which is
45
+ * stated in the per-property description, because a model that assumes it appends will send one item and
46
+ * watch the other forty disappear.
47
+ *
48
+ * `id` is absent from the schema on purpose and this is the ONE place Super Pi's copy differs from
49
+ * OpenCode's: OpenCode's `Todo.Info` carries only content/status/priority. Super Pi keeps those three and
50
+ * ADDS a stable id server-side (`normalizeTodos`), which the schema does not mention so the model is not
51
+ * invited to invent ids it will not control.
52
+ */
53
+ const TodoParameters = Type.Object({
54
+ todos: Type.Array(Type.Object({
55
+ content: Type.String({ description: "The task text, specific and actionable" }),
56
+ status: Type.Union([Type.Literal("pending"), Type.Literal("in_progress"), Type.Literal("completed")], { description: "pending = not started, in_progress = being worked on now (exactly one at a time), completed = finished successfully" }),
57
+ priority: Type.Optional(Type.Union([Type.Literal("high"), Type.Literal("medium"), Type.Literal("low")], { description: "How urgent this item is" })),
58
+ }), { description: "The COMPLETE updated todo list - this replaces the previous list entirely, so include every item you still want on screen" }),
59
+ });
60
+
61
+ /**
62
+ * The only structural type this file needs, declared locally.
63
+ *
64
+ * It was `import type { ExtensionFactory } from "F:/code/open_agents_repo/pi/..."` before, which is an
65
+ * absolute path into one machine's checkout: fine there, unresolvable on any other, and it made this
66
+ * file impossible to open without that checkout present. Pi is a runtime, not a build dependency of
67
+ * Super Pi, so there is no package to import its types from - declaring the two shapes this file uses is
68
+ * the honest alternative, and `npm run check` would not have caught the old one because `agents/` is
69
+ * outside every tsconfig.
70
+ */
71
+ type ExtensionToolDefinition = {
72
+ name: string;
73
+ label: string;
74
+ description: string;
75
+ parameters: unknown;
76
+ execute(toolCallId: string, params: unknown, signal: AbortSignal | undefined, onUpdate: unknown, context: { cwd: string }): Promise<unknown>;
77
+ prepareLoadout?(loadout: unknown): { descriptions?: Record<string, string> } | undefined;
78
+ };
79
+ type ExtensionApi = { registerTool(tool: ExtensionToolDefinition): void };
80
+
81
+ const extension = (pi: ExtensionApi): void => {
82
+ // Read ONCE at load, not per call: the environment cannot change under a running Pi, and `prepareLoadout`
83
+ // is synchronous - it cannot await a fetch for a roster - so the roster has to already be here.
84
+ const config = readTaskConfig(process.env);
85
+ // Same rule for the todo tool, and for a stronger reason: the todo endpoint is derived from the same
86
+ // listen address as the task endpoint, so reading it per call would mean re-reading an environment
87
+ // that cannot have changed since the process started.
88
+ const todoConfig = readTodoConfig(process.env);
89
+
90
+ pi.registerTool({
91
+ name: TASK_TOOL_NAME,
92
+ label: "Task",
93
+ // The fallback for the seconds between registration and the first `prepareLoadout`: the guidance with
94
+ // no roster, which is still true text rather than a placeholder.
95
+ description: describeTask(config.roster),
96
+ promptSnippet: "delegate substantial work to a specialized subagent",
97
+ parameters: Parameters,
98
+ /**
99
+ * Injects the roster into the description the model is shown, on every loadout.
100
+ *
101
+ * Pi's own mechanism for a tool that lists other tools, and the reason it is used rather than
102
+ * editing the system prompt: `ToolDefinition.prepareLoadout` is documented for exactly this
103
+ * (types.ts:620-622 - "Tools that orchestrate other tools use it"), it is called whenever the active
104
+ * tools change, and a description that Pi receives is a description that cannot drift from the tool
105
+ * set it belongs to. `describeTask()` in OpenCode does the same thing one layer up, inside
106
+ * `registry.ts`.
107
+ *
108
+ * Read from the environment the server filled at spawn time, never from a list written here, so a
109
+ * sub-agent added to `agents/*.md` while the server runs is dispatchable on the next message.
110
+ */
111
+ prepareLoadout: () => ({ descriptions: { [TASK_TOOL_NAME]: describeTask(config.roster) } }),
112
+ execute: async (_toolCallId, params, signal, _onUpdate, context) =>
113
+ await executeTask(params as TaskParams, context, config, { signal: signal ?? undefined }),
114
+ });
115
+
116
+ /**
117
+ * `todowrite`, registered SECOND and for one reason: order in the tool list is the order a model reads
118
+ * them in, and a plan is worth more in front of the eye than a dispatcher is. Nothing depends on the
119
+ * order functionally - `runtime.ts` names both tools in its allowlist and Pi activates whichever of them
120
+ * the agent's own `tools:` map allows.
121
+ *
122
+ * NO `prepareLoadout`, and that is a decision rather than an omission. The task tool needs one because
123
+ * its description embeds the ROSTER, which changes on disk while the server runs; the todo description
124
+ * embeds nothing that changes, and the thing that does change - the current list - is the tool's own
125
+ * OUTPUT, which the model reads back from its own transcript on every subsequent turn. Re-injecting the
126
+ * list into the description would be a second source of truth for the same fact, and it would put a
127
+ * snapshot of the plan in front of the model that could disagree with the one its history already
128
+ * contains.
129
+ */
130
+ pi.registerTool({
131
+ name: TODO_TOOL_NAME,
132
+ label: "Todo",
133
+ description: TODO_DESCRIPTION,
134
+ promptSnippet: "track multi-step work as a live task list the user watches",
135
+ parameters: TodoParameters,
136
+ execute: async (_toolCallId, params, signal) =>
137
+ await executeTodo(params as TodoParams, todoConfig, { signal: signal ?? undefined }),
138
+ });
139
+ };
140
+
141
+ export default extension;
@@ -0,0 +1,21 @@
1
+ ---
2
+ name: 验证员
3
+ mode: subagent
4
+ description: 跑命令做验证:执行测试、构建或检查入口,如实贴出命令原文、退出码和失败行,不改任何文件。改完代码要确认通没通、或者需要亲眼看一次真实输出时用它。
5
+ color: "#5AC8FA"
6
+ tools:
7
+ "*": false
8
+ read: true
9
+ ls: true
10
+ bash: true
11
+ ---
12
+
13
+ 你是一个只负责验证的子 agent。你能跑命令,但不能修改任何文件——发现问题也不要自己动手修。
14
+
15
+ 行为约束:
16
+
17
+ - 只跑派活里说的命令,或者这个仓库里已经存在的检查入口(比如 `npm run check`)。不要为了「顺手看看」跑一堆别的。
18
+ - 把真实输出贴出来:命令原文、退出码、失败那几行原文。不要写「应该没问题」「看起来正常」这种话——你没资格说这种话,你只看得见输出。
19
+ - 测试失败时定位到具体是哪个测试、哪一行断言、期望值和实际值各是什么,然后停下。不要修。
20
+ - 命令跑不起来时要分清是环境问题(缺依赖、端口占用、可执行文件找不到)还是测试失败,两者的结论完全不同,不要混着说。
21
+ - 最后一行给结论:`通过`、`失败`、`无法验证`,三选一,不要含糊。