@evandu/super-pi 0.0.0-stage → 0.1.0

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,196 @@
1
+ import type { AgentDefinition, TodoItem } from "./types.js";
2
+ /**
3
+ * The `task` tool: how a primary agent hands work to a sub-agent on its own initiative.
4
+ *
5
+ * WHY THIS FILE EXISTS. The pipe was already built - `subagents.ts` spawns a child and streams it,
6
+ * `subagent-runs.ts` records and publishes it, and a human can already press a button to use it. What
7
+ * was missing is the mouth: a primary agent had no way to reach any of it, so the ONLY way to use a
8
+ * sub-agent was for a person to type into a form. This module is the policy half of that mouth - the
9
+ * rules about WHO may dispatch, HOW DEEP, and WITH WHICH TOOLS - and it holds no I/O at all, so every
10
+ * rule here can be proved without spawning a process.
11
+ *
12
+ * WHAT IS BORROWED FROM OPENCODE, and what is not:
13
+ *
14
+ * - `tool/task.ts:104-117` - the depth walk and `depth >= subagent_depth` refusal. OpenCode counts
15
+ * depth by walking session parents; Super Pi has one level of session, so depth is a number the
16
+ * caller states and the server checks (see `checkSubagentDepth`).
17
+ * - `tool/task.ts:64-79` - the `<task id state><task_result>` envelope. Rendered on the EXTENSION side
18
+ * (`agents/super-pi-task/core.ts`), because that is the tool's return value, not the server's.
19
+ * - `agent/subagent-permissions.ts:21-23` - a child inherits the parent's DENY rules. Note what that
20
+ * file does NOT do: it never intersects the parent's ALLOWLIST into the child, because "the parent
21
+ * agent's restrictions govern that agent" (its own header). Super Pi follows the same rule - see
22
+ * `parentDeniedTools`.
23
+ * - `tool/registry.ts:265-278` - the roster injected into the model's view of the tool. Built here
24
+ * from `readAgents`, injected through the environment, rendered by the extension.
25
+ */
26
+ /** The name the model calls. Also the key Pi's tool allowlist matches on, and the deny key in `tools:`. */
27
+ export declare const TASK_TOOL_NAME = "task";
28
+ /**
29
+ * How many task hops are allowed below a primary agent.
30
+ *
31
+ * `1` means: a primary agent may dispatch a sub-agent, and that sub-agent may not dispatch anything.
32
+ * OpenCode's default is the same number (`task.ts:111`, `cfg.subagent_depth ?? 1`).
33
+ *
34
+ * WHY THE NUMBER IS NOT A LOOP WALK. OpenCode counts session parents because it runs the child as a
35
+ * session inside the same process. Super Pi runs the child as a SEPARATE Pi process
36
+ * (`subagents.ts`, research note 3), so a child has no parent session to walk. It is enforced three
37
+ * other ways instead, and this constant is the belt to those braces:
38
+ *
39
+ * 1. STRUCTURAL - only `PiRpcRuntime` passes `--extension`, and only the primary agent runs through
40
+ * `PiRpcRuntime`. A child's argv never names the extension, so the child has no `task` tool at all.
41
+ * 2. ALLOWLIST - `toolFlags` adds `task` to the primary's `--tools` list and to nothing else's, so even
42
+ * an agent that loads the extension cannot be handed the tool by inheritance.
43
+ * 3. DECLARED - `checkSubagentDepth` refuses a request that says it is already nested.
44
+ *
45
+ * All three agree, and (1) is the one that actually holds. Raising this constant alone would change
46
+ * nothing, which is deliberate: the constant is the server's statement of intent, and the wiring is
47
+ * the enforcement.
48
+ */
49
+ export declare const DEFAULT_SUBAGENT_DEPTH = 1;
50
+ /**
51
+ * The environment contract between the server and the Pi child that carries the extension.
52
+ *
53
+ * These four names are a WIRE between two files that live in the same repository and are loaded by two
54
+ * different module systems, so they are written out on both sides rather than imported. The extension
55
+ * side is `agents/super-pi-task/core.ts`; `scripts/task-tool.test.mjs` asserts the two copies agree,
56
+ * because a rename on one side would otherwise fail at dispatch time with a message no one reads.
57
+ */
58
+ export declare const TASK_ENDPOINT_ENV = "SUPER_PI_TASK_ENDPOINT";
59
+ /** JSON roster, read from `readAgents(dir, { mode: "subagent" })` at SPAWN time - never hardcoded. */
60
+ export declare const TASK_ROSTER_ENV = "SUPER_PI_TASK_ROSTER";
61
+ /** The conversation the dispatch belongs to. The server resolves the workspace from this itself. */
62
+ export declare const TASK_CONVERSATION_ENV = "SUPER_PI_TASK_CONVERSATION";
63
+ /** How many task hops deep this process already is. A primary is `0`. See `DEFAULT_SUBAGENT_DEPTH`. */
64
+ export declare const TASK_DEPTH_ENV = "SUPER_PI_TASK_DEPTH";
65
+ /**
66
+ * One entry of the roster the model is shown: what it types into `subagent_type`, and when to pick it.
67
+ *
68
+ * `id` is the `subagent_type` value, NOT `name`. `name` is a label a human reads; requiring the model
69
+ * to reproduce a Chinese label exactly would be a transcription task before it could do any work.
70
+ */
71
+ export type SubagentRosterEntry = {
72
+ id: string;
73
+ name: string;
74
+ description: string;
75
+ };
76
+ /**
77
+ * The dispatchable roster, in the order it will be injected.
78
+ *
79
+ * `hidden` definitions are excluded, matching `readAgentCatalog`: a sub-agent marked hidden is
80
+ * documented as not for automatic dispatch, and the model's only channel for dispatching IS automatic.
81
+ * The sort is the one `readAgents` already applied, kept rather than re-sorted so the injected list
82
+ * reads the same way as the picker.
83
+ */
84
+ export declare function subagentRoster(agents: AgentDefinition[]): SubagentRosterEntry[];
85
+ /**
86
+ * The roster as one environment variable's worth of JSON.
87
+ *
88
+ * A CHILD PROCESS ENVIRONMENT IS NOT A CONFIG FILE. On Windows the whole environment block is capped at
89
+ * 32,768 characters, and a server that would spawn a primary Pi successfully and then fail to spawn
90
+ * one a moment later because a directory of agents grew would be a fault with no visible cause. So the
91
+ * payload is truncated at a budget that leaves the rest of the block alone, and the truncation is
92
+ * announced rather than silent: the injected list ends with a line saying it was cut short, because a
93
+ * roster that quietly omits an agent is a roster that makes the model pick wrongly with no way to know.
94
+ */
95
+ export declare const ROSTER_ENV_BUDGET = 12000;
96
+ export declare function rosterPayload(agents: AgentDefinition[]): string;
97
+ /** Reads a roster back out of an environment value. Anything unreadable is an empty roster, never a throw. */
98
+ export declare function parseRoster(raw: string | undefined): SubagentRosterEntry[];
99
+ /**
100
+ * Refuses a dispatch that claims to be nested deeper than this install allows.
101
+ *
102
+ * `depth` is how many task hops the CALLER already is: a primary agent's extension is `0`, and anything
103
+ * that dispatched from inside a sub-agent would be `1` or more. `>=` rather than `>` is OpenCode's
104
+ * comparison (`task.ts:111`) and it is what makes the default mean "one layer of children".
105
+ *
106
+ * The message is the deliverable as much as the refusal is. It arrives as the tool's error text and is
107
+ * therefore read by a MODEL that is about to retry, so it names the number, says what to do about it,
108
+ * and does not say "invalid request".
109
+ */
110
+ export declare function checkSubagentDepth(depth: number, maxDepth?: number): void;
111
+ /**
112
+ * The tools a parent agent's own definition DENIES, as concrete Pi tool names.
113
+ *
114
+ * ONLY denies, never an allowlist - the same rule as OpenCode's `deriveSubagentSessionPermission`, whose
115
+ * header states why: "Parent agent restrictions only govern that agent; the subagent's own permissions
116
+ * determine its capabilities." A parent that narrowed itself to `{read}` has not expressed "everything
117
+ * else is forbidden everywhere", and copying that into every child would silently strip a child of the
118
+ * write tools its own author gave it.
119
+ *
120
+ * Read out of `toolFlags` rather than a kept copy of the raw frontmatter because `toolFlags` IS the
121
+ * translated decision: `{"bash": false}` has already become `--exclude-tools bash` by the time a
122
+ * definition exists, and re-deriving it from the map would be a second implementation of the
123
+ * translation rather than a reader of the first.
124
+ */
125
+ export declare function parentDeniedTools(parent: AgentDefinition | undefined): string[];
126
+ /**
127
+ * The name the model calls. OpenCode's name, kept: a model trained on OpenCode's tool set reaches for
128
+ * `todowrite` and not `todo`, and there is nothing to gain from renaming a tool whose description has
129
+ * already been written to explain that name.
130
+ */
131
+ export declare const TODO_TOOL_NAME = "todowrite";
132
+ /**
133
+ * The endpoint the `todowrite` extension dials. A FIFTH name in the same wire as the four above, written
134
+ * out on both sides for the same reason: `agents/super-pi-task/core.ts` is loaded by Pi's module loader
135
+ * and `src/server/task-tool.ts` by tsc, so a relative import across the two would resolve differently
136
+ * under each. `scripts/todo-tool.test.mjs` asserts the two copies agree.
137
+ *
138
+ * It is a SEPARATE variable rather than a path derived from `TASK_ENDPOINT_ENV` because a Pi can
139
+ * legitimately be started with a task endpoint and no todo endpoint - a test harness, a checkout from
140
+ * before this tool existed - and a derived path could not tell that apart from a misconfiguration. The
141
+ * tool then refuses in words instead of posting somewhere that does not exist.
142
+ */
143
+ export declare const TODO_ENDPOINT_ENV = "SUPER_PI_TODO_ENDPOINT";
144
+ /**
145
+ * How many todos may be stored, and how long one may be.
146
+ *
147
+ * NOT A LIMIT OPENCODE HAS, and the reason it exists here is specific to this app: `state.json` is
148
+ * serialized on EVERY broadcast - every text delta of every turn - and `snapshot()` is a structuredClone
149
+ * of the whole state per connected browser. An unbounded list written by a model in a loop is therefore
150
+ * not just a large file, it is a large file re-serialized thousands of times within one turn. Fifty items
151
+ * is more than a plan of any real size and 400 characters is more than a sidebar row shows before it
152
+ * truncates, so neither bound is one a person would notice being reached.
153
+ *
154
+ * Over the cap is TRUNCATED, not refused. The model is mid-task and its call was correct in kind;
155
+ * refusing would produce an error envelope about a limit it cannot reason about, while a truncated list
156
+ * still shows the first fifty steps and the call still succeeds. `dropped` travels back to the caller so
157
+ * the model is told rather than left to notice.
158
+ */
159
+ export declare const TODO_MAX_ITEMS = 50;
160
+ export declare const TODO_MAX_CONTENT = 400;
161
+ /**
162
+ * What a `todowrite` body is allowed to become.
163
+ *
164
+ * Every field is checked because every field is rendered: `content` is the row a person reads, `status`
165
+ * chooses that row's colour and glyph, and `priority` is its badge. A list read straight out of
166
+ * `JSON.parse` would put `undefined` into all three, and a status of `"banana"` would fall through every
167
+ * colour rule in `styles.css` and render like a completed item - because "is this the completed style"
168
+ * is asked with `===`, and anything unknown is not it.
169
+ *
170
+ * THE FOUR RULES, and what each failure would cost:
171
+ *
172
+ * - A non-string or blank `content` is DROPPED. There is no safe default text: a row that says nothing is
173
+ * a row the user cannot act on, and it also consumes a slot of the cap below.
174
+ * - An unknown `status` becomes `pending`, neither dropped nor thrown on. Dropping loses work the model is
175
+ * about to do and throwing fails a call that was otherwise correct; `pending` is the state that claims
176
+ * the least, so the row is visible and slightly wrong rather than invisible.
177
+ * - An unknown `priority` DELETES the key. Absent is a real state here - the badge is simply not drawn -
178
+ * so there is nothing to repair, and inventing `"medium"` would put a badge on a row that never asked.
179
+ * - A missing `id` is FILLED IN, and an existing one is KEPT rather than regenerated, so the sidebar's
180
+ * React keys stay stable and a status change does not remount the row it changed. Two identical lines
181
+ * of text without ids would share one key, and React would reuse the first row's DOM for the second.
182
+ */
183
+ export declare function normalizeTodos(raw: unknown): {
184
+ todos: TodoItem[];
185
+ dropped: number;
186
+ };
187
+ /**
188
+ * The sentence the model reads back, which is also what the sidebar's header says.
189
+ *
190
+ * OpenCode's title for this tool is `${n} todos` where n counts what is NOT completed
191
+ * (`tool/todo.ts:37`), and that number is kept - but alone it lies at the end of a task: "3 todos" after
192
+ * the only three steps are done reads as three things still owed. So the completed count is in the
193
+ * sentence too, and derived HERE rather than in the sidebar, so the tool's own answer and the sidebar's
194
+ * header are computed from the same stored list and cannot disagree.
195
+ */
196
+ export declare function todoSummary(todos: readonly TodoItem[]): string;
@@ -0,0 +1,285 @@
1
+ /**
2
+ * The `task` tool: how a primary agent hands work to a sub-agent on its own initiative.
3
+ *
4
+ * WHY THIS FILE EXISTS. The pipe was already built - `subagents.ts` spawns a child and streams it,
5
+ * `subagent-runs.ts` records and publishes it, and a human can already press a button to use it. What
6
+ * was missing is the mouth: a primary agent had no way to reach any of it, so the ONLY way to use a
7
+ * sub-agent was for a person to type into a form. This module is the policy half of that mouth - the
8
+ * rules about WHO may dispatch, HOW DEEP, and WITH WHICH TOOLS - and it holds no I/O at all, so every
9
+ * rule here can be proved without spawning a process.
10
+ *
11
+ * WHAT IS BORROWED FROM OPENCODE, and what is not:
12
+ *
13
+ * - `tool/task.ts:104-117` - the depth walk and `depth >= subagent_depth` refusal. OpenCode counts
14
+ * depth by walking session parents; Super Pi has one level of session, so depth is a number the
15
+ * caller states and the server checks (see `checkSubagentDepth`).
16
+ * - `tool/task.ts:64-79` - the `<task id state><task_result>` envelope. Rendered on the EXTENSION side
17
+ * (`agents/super-pi-task/core.ts`), because that is the tool's return value, not the server's.
18
+ * - `agent/subagent-permissions.ts:21-23` - a child inherits the parent's DENY rules. Note what that
19
+ * file does NOT do: it never intersects the parent's ALLOWLIST into the child, because "the parent
20
+ * agent's restrictions govern that agent" (its own header). Super Pi follows the same rule - see
21
+ * `parentDeniedTools`.
22
+ * - `tool/registry.ts:265-278` - the roster injected into the model's view of the tool. Built here
23
+ * from `readAgents`, injected through the environment, rendered by the extension.
24
+ */
25
+ /** The name the model calls. Also the key Pi's tool allowlist matches on, and the deny key in `tools:`. */
26
+ export const TASK_TOOL_NAME = "task";
27
+ /**
28
+ * How many task hops are allowed below a primary agent.
29
+ *
30
+ * `1` means: a primary agent may dispatch a sub-agent, and that sub-agent may not dispatch anything.
31
+ * OpenCode's default is the same number (`task.ts:111`, `cfg.subagent_depth ?? 1`).
32
+ *
33
+ * WHY THE NUMBER IS NOT A LOOP WALK. OpenCode counts session parents because it runs the child as a
34
+ * session inside the same process. Super Pi runs the child as a SEPARATE Pi process
35
+ * (`subagents.ts`, research note 3), so a child has no parent session to walk. It is enforced three
36
+ * other ways instead, and this constant is the belt to those braces:
37
+ *
38
+ * 1. STRUCTURAL - only `PiRpcRuntime` passes `--extension`, and only the primary agent runs through
39
+ * `PiRpcRuntime`. A child's argv never names the extension, so the child has no `task` tool at all.
40
+ * 2. ALLOWLIST - `toolFlags` adds `task` to the primary's `--tools` list and to nothing else's, so even
41
+ * an agent that loads the extension cannot be handed the tool by inheritance.
42
+ * 3. DECLARED - `checkSubagentDepth` refuses a request that says it is already nested.
43
+ *
44
+ * All three agree, and (1) is the one that actually holds. Raising this constant alone would change
45
+ * nothing, which is deliberate: the constant is the server's statement of intent, and the wiring is
46
+ * the enforcement.
47
+ */
48
+ export const DEFAULT_SUBAGENT_DEPTH = 1;
49
+ /**
50
+ * The environment contract between the server and the Pi child that carries the extension.
51
+ *
52
+ * These four names are a WIRE between two files that live in the same repository and are loaded by two
53
+ * different module systems, so they are written out on both sides rather than imported. The extension
54
+ * side is `agents/super-pi-task/core.ts`; `scripts/task-tool.test.mjs` asserts the two copies agree,
55
+ * because a rename on one side would otherwise fail at dispatch time with a message no one reads.
56
+ */
57
+ export const TASK_ENDPOINT_ENV = "SUPER_PI_TASK_ENDPOINT";
58
+ /** JSON roster, read from `readAgents(dir, { mode: "subagent" })` at SPAWN time - never hardcoded. */
59
+ export const TASK_ROSTER_ENV = "SUPER_PI_TASK_ROSTER";
60
+ /** The conversation the dispatch belongs to. The server resolves the workspace from this itself. */
61
+ export const TASK_CONVERSATION_ENV = "SUPER_PI_TASK_CONVERSATION";
62
+ /** How many task hops deep this process already is. A primary is `0`. See `DEFAULT_SUBAGENT_DEPTH`. */
63
+ export const TASK_DEPTH_ENV = "SUPER_PI_TASK_DEPTH";
64
+ /**
65
+ * The dispatchable roster, in the order it will be injected.
66
+ *
67
+ * `hidden` definitions are excluded, matching `readAgentCatalog`: a sub-agent marked hidden is
68
+ * documented as not for automatic dispatch, and the model's only channel for dispatching IS automatic.
69
+ * The sort is the one `readAgents` already applied, kept rather than re-sorted so the injected list
70
+ * reads the same way as the picker.
71
+ */
72
+ export function subagentRoster(agents) {
73
+ return agents
74
+ .filter((agent) => agent.mode === "subagent" && !agent.hidden)
75
+ .map((agent) => ({ id: agent.id, name: agent.name, description: agent.description }));
76
+ }
77
+ /**
78
+ * The roster as one environment variable's worth of JSON.
79
+ *
80
+ * A CHILD PROCESS ENVIRONMENT IS NOT A CONFIG FILE. On Windows the whole environment block is capped at
81
+ * 32,768 characters, and a server that would spawn a primary Pi successfully and then fail to spawn
82
+ * one a moment later because a directory of agents grew would be a fault with no visible cause. So the
83
+ * payload is truncated at a budget that leaves the rest of the block alone, and the truncation is
84
+ * announced rather than silent: the injected list ends with a line saying it was cut short, because a
85
+ * roster that quietly omits an agent is a roster that makes the model pick wrongly with no way to know.
86
+ */
87
+ export const ROSTER_ENV_BUDGET = 12_000;
88
+ export function rosterPayload(agents) {
89
+ const roster = subagentRoster(agents);
90
+ const json = JSON.stringify(roster);
91
+ if (json.length <= ROSTER_ENV_BUDGET)
92
+ return json;
93
+ const kept = [];
94
+ let used = 2;
95
+ for (const entry of roster) {
96
+ const size = JSON.stringify(entry).length + 1;
97
+ if (used + size > ROSTER_ENV_BUDGET - 160)
98
+ break;
99
+ kept.push(entry);
100
+ used += size;
101
+ }
102
+ return JSON.stringify([...kept, { id: "__truncated__", name: "(名单过长已截断)", description: `There were ${roster.length} sub-agents and only ${kept.length} fit into this session's environment; the rest were dropped rather than risk failing to start Pi.` }]);
103
+ }
104
+ /** Reads a roster back out of an environment value. Anything unreadable is an empty roster, never a throw. */
105
+ export function parseRoster(raw) {
106
+ if (!raw)
107
+ return [];
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(raw);
111
+ }
112
+ catch {
113
+ return [];
114
+ }
115
+ if (!Array.isArray(parsed))
116
+ return [];
117
+ return parsed.filter((entry) => typeof entry === "object" && entry !== null &&
118
+ typeof entry.id === "string" && entry.id !== "" &&
119
+ typeof entry.description === "string");
120
+ }
121
+ /**
122
+ * Refuses a dispatch that claims to be nested deeper than this install allows.
123
+ *
124
+ * `depth` is how many task hops the CALLER already is: a primary agent's extension is `0`, and anything
125
+ * that dispatched from inside a sub-agent would be `1` or more. `>=` rather than `>` is OpenCode's
126
+ * comparison (`task.ts:111`) and it is what makes the default mean "one layer of children".
127
+ *
128
+ * The message is the deliverable as much as the refusal is. It arrives as the tool's error text and is
129
+ * therefore read by a MODEL that is about to retry, so it names the number, says what to do about it,
130
+ * and does not say "invalid request".
131
+ */
132
+ export function checkSubagentDepth(depth, maxDepth = DEFAULT_SUBAGENT_DEPTH) {
133
+ if (Number.isFinite(depth) && depth < maxDepth)
134
+ return;
135
+ throw new Error(`Subagent depth limit reached (${maxDepth}). This agent is already running as a sub-agent, and a sub-agent may not dispatch another one. Ask whoever is driving the primary agent to make the call instead, or raise the limit deliberately if this really needs to nest.`);
136
+ }
137
+ /**
138
+ * The tools a parent agent's own definition DENIES, as concrete Pi tool names.
139
+ *
140
+ * ONLY denies, never an allowlist - the same rule as OpenCode's `deriveSubagentSessionPermission`, whose
141
+ * header states why: "Parent agent restrictions only govern that agent; the subagent's own permissions
142
+ * determine its capabilities." A parent that narrowed itself to `{read}` has not expressed "everything
143
+ * else is forbidden everywhere", and copying that into every child would silently strip a child of the
144
+ * write tools its own author gave it.
145
+ *
146
+ * Read out of `toolFlags` rather than a kept copy of the raw frontmatter because `toolFlags` IS the
147
+ * translated decision: `{"bash": false}` has already become `--exclude-tools bash` by the time a
148
+ * definition exists, and re-deriving it from the map would be a second implementation of the
149
+ * translation rather than a reader of the first.
150
+ */
151
+ export function parentDeniedTools(parent) {
152
+ if (!parent)
153
+ return [];
154
+ const index = parent.toolFlags.indexOf("--exclude-tools");
155
+ if (index < 0)
156
+ return [];
157
+ const value = parent.toolFlags[index + 1];
158
+ return value ? value.split(",").map((name) => name.trim()).filter((name) => name !== "") : [];
159
+ }
160
+ // ---------------------------------------------------------------------------------------------
161
+ // The `todowrite` tool: the model's own task list, which the right sidebar renders.
162
+ //
163
+ // WHAT IS BORROWED FROM OPENCODE, and what is not:
164
+ //
165
+ // - `tool/todo.ts` is 46 lines and almost all of it is Effect plumbing: a schema, an `ask()` for the
166
+ // `todowrite` permission, and a call into a per-session store. What survives the port is the two
167
+ // decisions that file makes - the tool's OUTPUT is the whole list as JSON (so the model's next turn
168
+ // reads the current plan back out of its own history rather than out of a UI), and the tool is
169
+ // REGISTERED per session rather than globally.
170
+ // - `tool/todowrite.txt` is the description, and it is ported nearly verbatim (see
171
+ // `agents/super-pi-task/core.ts`). The "Use proactively when" list IS the mechanism: OpenCode never
172
+ // injects the todo list into a system prompt, so the trigger rules in the description are the only
173
+ // thing that makes a model update the list unprompted, and they are therefore not paraphrased here.
174
+ // - OpenCode's `session/todo.ts` keys the list by session. Super Pi has one list, not one per
175
+ // conversation, because `AppState.todos` is a single array - see the note on `TodoItem` for what that
176
+ // costs and why it is not solved in this file.
177
+ //
178
+ // WHAT IS ADDED, and why it is here rather than in the extension: TRUST BOUNDARY. Everything in this
179
+ // section runs on data that arrived over HTTP from a process the model controls the inputs of. The
180
+ // extension cannot import this file (Pi hands extensions a different module system - see
181
+ // `agents/super-pi-task/index.ts`), so if the rules lived there the server would be validating a list it
182
+ // never checked, and `state.json` would carry whatever the model happened to send.
183
+ /**
184
+ * The name the model calls. OpenCode's name, kept: a model trained on OpenCode's tool set reaches for
185
+ * `todowrite` and not `todo`, and there is nothing to gain from renaming a tool whose description has
186
+ * already been written to explain that name.
187
+ */
188
+ export const TODO_TOOL_NAME = "todowrite";
189
+ /**
190
+ * The endpoint the `todowrite` extension dials. A FIFTH name in the same wire as the four above, written
191
+ * out on both sides for the same reason: `agents/super-pi-task/core.ts` is loaded by Pi's module loader
192
+ * and `src/server/task-tool.ts` by tsc, so a relative import across the two would resolve differently
193
+ * under each. `scripts/todo-tool.test.mjs` asserts the two copies agree.
194
+ *
195
+ * It is a SEPARATE variable rather than a path derived from `TASK_ENDPOINT_ENV` because a Pi can
196
+ * legitimately be started with a task endpoint and no todo endpoint - a test harness, a checkout from
197
+ * before this tool existed - and a derived path could not tell that apart from a misconfiguration. The
198
+ * tool then refuses in words instead of posting somewhere that does not exist.
199
+ */
200
+ export const TODO_ENDPOINT_ENV = "SUPER_PI_TODO_ENDPOINT";
201
+ /**
202
+ * How many todos may be stored, and how long one may be.
203
+ *
204
+ * NOT A LIMIT OPENCODE HAS, and the reason it exists here is specific to this app: `state.json` is
205
+ * serialized on EVERY broadcast - every text delta of every turn - and `snapshot()` is a structuredClone
206
+ * of the whole state per connected browser. An unbounded list written by a model in a loop is therefore
207
+ * not just a large file, it is a large file re-serialized thousands of times within one turn. Fifty items
208
+ * is more than a plan of any real size and 400 characters is more than a sidebar row shows before it
209
+ * truncates, so neither bound is one a person would notice being reached.
210
+ *
211
+ * Over the cap is TRUNCATED, not refused. The model is mid-task and its call was correct in kind;
212
+ * refusing would produce an error envelope about a limit it cannot reason about, while a truncated list
213
+ * still shows the first fifty steps and the call still succeeds. `dropped` travels back to the caller so
214
+ * the model is told rather than left to notice.
215
+ */
216
+ export const TODO_MAX_ITEMS = 50;
217
+ export const TODO_MAX_CONTENT = 400;
218
+ /**
219
+ * What a `todowrite` body is allowed to become.
220
+ *
221
+ * Every field is checked because every field is rendered: `content` is the row a person reads, `status`
222
+ * chooses that row's colour and glyph, and `priority` is its badge. A list read straight out of
223
+ * `JSON.parse` would put `undefined` into all three, and a status of `"banana"` would fall through every
224
+ * colour rule in `styles.css` and render like a completed item - because "is this the completed style"
225
+ * is asked with `===`, and anything unknown is not it.
226
+ *
227
+ * THE FOUR RULES, and what each failure would cost:
228
+ *
229
+ * - A non-string or blank `content` is DROPPED. There is no safe default text: a row that says nothing is
230
+ * a row the user cannot act on, and it also consumes a slot of the cap below.
231
+ * - An unknown `status` becomes `pending`, neither dropped nor thrown on. Dropping loses work the model is
232
+ * about to do and throwing fails a call that was otherwise correct; `pending` is the state that claims
233
+ * the least, so the row is visible and slightly wrong rather than invisible.
234
+ * - An unknown `priority` DELETES the key. Absent is a real state here - the badge is simply not drawn -
235
+ * so there is nothing to repair, and inventing `"medium"` would put a badge on a row that never asked.
236
+ * - A missing `id` is FILLED IN, and an existing one is KEPT rather than regenerated, so the sidebar's
237
+ * React keys stay stable and a status change does not remount the row it changed. Two identical lines
238
+ * of text without ids would share one key, and React would reuse the first row's DOM for the second.
239
+ */
240
+ export function normalizeTodos(raw) {
241
+ if (!Array.isArray(raw))
242
+ return { todos: [], dropped: 0 };
243
+ const statuses = new Set(["pending", "in_progress", "completed"]);
244
+ const priorities = new Set(["high", "medium", "low"]);
245
+ const todos = [];
246
+ let dropped = 0;
247
+ for (const entry of raw) {
248
+ if (todos.length >= TODO_MAX_ITEMS) {
249
+ dropped++;
250
+ continue;
251
+ }
252
+ if (typeof entry !== "object" || entry === null) {
253
+ dropped++;
254
+ continue;
255
+ }
256
+ const item = entry;
257
+ const content = typeof item.content === "string" ? item.content.trim().slice(0, TODO_MAX_CONTENT) : "";
258
+ if (!content) {
259
+ dropped++;
260
+ continue;
261
+ }
262
+ const status = typeof item.status === "string" && statuses.has(item.status) ? item.status : "pending";
263
+ const priority = typeof item.priority === "string" && priorities.has(item.priority) ? item.priority : undefined;
264
+ const id = typeof item.id === "string" && item.id.trim() ? item.id.trim() : `todo_${crypto.randomUUID()}`;
265
+ todos.push({ id, content, status, ...(priority ? { priority } : {}) });
266
+ }
267
+ return { todos, dropped };
268
+ }
269
+ /**
270
+ * The sentence the model reads back, which is also what the sidebar's header says.
271
+ *
272
+ * OpenCode's title for this tool is `${n} todos` where n counts what is NOT completed
273
+ * (`tool/todo.ts:37`), and that number is kept - but alone it lies at the end of a task: "3 todos" after
274
+ * the only three steps are done reads as three things still owed. So the completed count is in the
275
+ * sentence too, and derived HERE rather than in the sidebar, so the tool's own answer and the sidebar's
276
+ * header are computed from the same stored list and cannot disagree.
277
+ */
278
+ export function todoSummary(todos) {
279
+ const open = todos.filter((todo) => todo.status !== "completed").length;
280
+ if (!todos.length)
281
+ return "0 个待办任务";
282
+ if (!open)
283
+ return `全部完成:${todos.length} 个待办任务`;
284
+ return `${open} 个待办未完成,共 ${todos.length} 个`;
285
+ }
@@ -0,0 +1,62 @@
1
+ import type { ToolCallArg } from "./types.js";
2
+ /**
3
+ * Turning one Pi tool call into something a human reads, and nothing more.
4
+ *
5
+ * WHY THIS IS ITS OWN MODULE. Two drain loops need exactly this derivation - the sub-agent supervisor
6
+ * (`subagents.ts`) and the PRIMARY agent's runtime (`runtime.ts`) - and `subagents.ts` already imports
7
+ * `runtime.ts` for `RpcChannel`, so the reverse import would be a cycle. One definition of "what does
8
+ * this call show" therefore has to live below both of them, or the two surfaces would drift: a `read`
9
+ * labelled by its path on a card and by its working directory on a call block is worse than either.
10
+ *
11
+ * The rule the whole module exists to enforce: DERIVED AND BOUNDED, NEVER COPIED. What Pi sends in
12
+ * `args` is an arbitrary object that may hold a file's contents, a multi-kilobyte patch or an entire
13
+ * shell script. What leaves here is short strings with a hard character cap, or a bare type word.
14
+ */
15
+ /**
16
+ * The longest label one tool call may contribute. See `SubagentToolCall.detail` and `ToolCallBlock.detail`
17
+ * for why the argument is summarised rather than carried: the cap is what makes the bound structural
18
+ * instead of aspirational, because nothing downstream can grow past it.
19
+ */
20
+ export declare const TOOL_DETAIL_CAP = 120;
21
+ /**
22
+ * How many parameters one call block may list.
23
+ *
24
+ * A bound on the COUNT as well as on each value, for the same reason `TOOL_DETAIL_CAP` exists: without
25
+ * it, a tool that Pi sends a dozen arguments to would put twelve rows - and twelve times the cap - into a
26
+ * field that is rewritten on every text delta of the message it belongs to. Six covers every call shape
27
+ * this project has seen (`read`'s path/offset/limit, `bash`'s command/description/timeout) and truncating
28
+ * past it is announced by the caller through the row count rather than silently.
29
+ */
30
+ export declare const TOOL_ARG_CAP = 6;
31
+ /**
32
+ * The one line that tells a reader what a tool call was FOR: the file it opened, the pattern it searched
33
+ * for, the command it ran.
34
+ *
35
+ * Returns `undefined` rather than a placeholder when nothing is recognised, so a row omits the column
36
+ * instead of rendering a column of emptiness.
37
+ *
38
+ * Exported so the mapping can be asserted without spawning a child, which is the only way a table this
39
+ * shape can be regression-tested at all.
40
+ */
41
+ export declare function describeToolCall(name: string, args: unknown): string | undefined;
42
+ /**
43
+ * Every parameter of one call, as a bounded list of `key`/`value` pairs a block can show when expanded.
44
+ *
45
+ * THIS IS THE SAME LINE `describeToolCall` DRAWS, APPLIED PER KEY. `describeToolCall` answers "what was
46
+ * this aimed at" with one label; this answers "what was this given" with the whole bounded list, which is
47
+ * the difference between a row that says `bash` and a block that says which command, plus its timeout
48
+ * and description. Both are derived: a string is whitespace-collapsed and capped at `TOOL_DETAIL_CAP`, and
49
+ * anything that is not a string or a number or a boolean becomes a type word (see `describeShape`).
50
+ *
51
+ * Order is Pi's own key order rather than this module's table order, because once every key is listed the
52
+ * per-tool table has said everything it knows - and a reader scanning a list wants the order the caller
53
+ * wrote it in, not one this file invented.
54
+ *
55
+ * Capped at `TOOL_ARG_CAP` ENTRIES, and the cap is applied by taking the first N rather than by sampling
56
+ * across the object: a truncated tail is a prefix of the real argument list, so what is shown is never
57
+ * a selection that implies the omitted keys did not exist.
58
+ *
59
+ * Returns `undefined` when there is nothing readable at all, so a call with no usable arguments renders
60
+ * no parameter section rather than an empty one.
61
+ */
62
+ export declare function describeToolCallArgs(args: unknown): ToolCallArg[] | undefined;