@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.
- package/README.md +79 -2
- package/agents/coder.md +10 -0
- package/agents/critic.md +20 -0
- package/agents/patcher.md +23 -0
- package/agents/planner.md +18 -0
- package/agents/reviewer.md +13 -0
- package/agents/scout.md +23 -0
- package/agents/super-pi-task/core.ts +485 -0
- package/agents/super-pi-task/index.ts +141 -0
- package/agents/verifier.md +21 -0
- package/bin/super-pi.js +17 -0
- package/dist/client/assets/index-BMQsEdio.js +24 -0
- package/dist/client/assets/index-CsbZzuMy.css +1 -0
- package/dist/client/index.html +14 -0
- package/dist/extension.d.ts +15 -0
- package/dist/extension.js +111 -0
- package/dist/server/agents.d.ts +177 -0
- package/dist/server/agents.js +484 -0
- package/dist/server/application/catalog-service.d.ts +44 -0
- package/dist/server/application/catalog-service.js +94 -0
- package/dist/server/application/conversation-service.d.ts +73 -0
- package/dist/server/application/conversation-service.js +202 -0
- package/dist/server/application/extension-ui-service.d.ts +28 -0
- package/dist/server/application/extension-ui-service.js +73 -0
- package/dist/server/application/run-service.d.ts +115 -0
- package/dist/server/application/run-service.js +341 -0
- package/dist/server/application/workspace-service.d.ts +45 -0
- package/dist/server/application/workspace-service.js +106 -0
- package/dist/server/command-handler.d.ts +22 -0
- package/dist/server/command-handler.js +137 -0
- package/dist/server/files.d.ts +202 -0
- package/dist/server/files.js +629 -0
- package/dist/server/http-server.d.ts +20 -0
- package/dist/server/http-server.js +313 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +103 -0
- package/dist/server/pi-command.d.ts +65 -0
- package/dist/server/pi-command.js +183 -0
- package/dist/server/runtime.d.ts +819 -0
- package/dist/server/runtime.js +1470 -0
- package/dist/server/server-context.d.ts +46 -0
- package/dist/server/server-context.js +1 -0
- package/dist/server/store.d.ts +424 -0
- package/dist/server/store.js +1121 -0
- package/dist/server/subagent-runs.d.ts +142 -0
- package/dist/server/subagent-runs.js +315 -0
- package/dist/server/subagents.d.ts +194 -0
- package/dist/server/subagents.js +542 -0
- package/dist/server/task-tool.d.ts +196 -0
- package/dist/server/task-tool.js +285 -0
- package/dist/server/tool-calls.d.ts +62 -0
- package/dist/server/tool-calls.js +141 -0
- package/dist/server/types.d.ts +920 -0
- package/dist/server/types.js +11 -0
- package/dist/server/upload.d.ts +37 -0
- package/dist/server/upload.js +169 -0
- package/dist/server/ws-server.d.ts +45 -0
- package/dist/server/ws-server.js +116 -0
- 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;
|