pi-optchat 0.6.8 → 0.7.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 +30 -20
- package/docs/victor-recipe.md +4 -4
- package/package.json +2 -2
- package/src/agent-view.ts +18 -5
- package/src/agents.ts +138 -30
- package/src/compactor.ts +16 -6
- package/src/handoff.ts +2 -2
- package/src/import/job.ts +26 -11
- package/src/import/sources.ts +45 -6
- package/src/import/ui.ts +1 -1
- package/src/index.ts +62 -34
- package/src/inspector.ts +33 -15
- package/src/memory.ts +34 -11
- package/src/navigation.ts +9 -8
- package/src/prompts.ts +1 -1
- package/src/report-message.ts +8 -4
- package/src/runs.ts +8 -4
- package/src/settings-page.ts +11 -7
- package/src/settings.ts +6 -0
- package/src/tools.ts +28 -1
package/README.md
CHANGED
|
@@ -48,6 +48,7 @@ For headless use, pass `--optchat-profile work`.
|
|
|
48
48
|
| `/optchat agents` | Live agent tree and saved run history. |
|
|
49
49
|
| `/optchat agents model` | Subagent model and effort for this profile. |
|
|
50
50
|
| `/optchat usage` | Token usage and cost estimates. |
|
|
51
|
+
| `/optchat activity` | Memory gauge: view size, summaries catching up, running agents. |
|
|
51
52
|
| `/optchat instructions` | Edit this profile's `AGENTS.md`. |
|
|
52
53
|
| `/optchat browse` | Open a readable snapshot of memory: the shape of what the model sees, summaries you can open down to the original messages, and search that shows where each message is folded. Run again to refresh. |
|
|
53
54
|
| `/optchat import` | Import history, or resume/discard a paused import. |
|
|
@@ -76,8 +77,10 @@ Compression and subagents make extra model requests with your provider credentia
|
|
|
76
77
|
| Subagent model | Opus 5.5, high | Same as `/optchat agents model`. Applies to new subagents. |
|
|
77
78
|
| Subagent levels | 1 | 1: only the main agent starts subagents (the recipe). 2 or more: subagents may start their own, that many levels deep. Applies to subagents started or resumed after the change. |
|
|
78
79
|
| Max active agents | 8 | Subagents running at once in the profile, all levels together, so it also caps how deep a chain can go. |
|
|
80
|
+
| Group subagent reports | on | The subagents started by one spawn report together, in one message once the last of them finishes (the recipe). Off: each reports as soon as it finishes. Applies to the next spawn. |
|
|
79
81
|
| Previous exchange | on | Replays your last request and answer in full with the next turn (see below). Off is the recipe. |
|
|
80
82
|
| Previous exchange limit | 16 KB | A larger last exchange is left out. |
|
|
83
|
+
| Memory search | off | Gives the agent, and subagents started or resumed after the change, a `search` tool over your original messages (see below). Off is the recipe: zoom and date only. Applies from the next turn. |
|
|
81
84
|
| Summary size tolerance | 640 bytes | The compactor is always asked for 512-byte lines; a longer line up to this size is kept instead of retried. 512 is the recipe's strict rule. |
|
|
82
85
|
|
|
83
86
|
Numbers must be whole numbers of at least 1 (512 for the summary size tolerance). Missing keys in an older `config.json` take their defaults.
|
|
@@ -92,7 +95,7 @@ Ask in plain words, for example: "Spawn an agent to investigate this repository
|
|
|
92
95
|
|
|
93
96
|
- Children get the profile's memory view (frozen at launch), its instructions, read-only `zoom`/`date`, and normal coding tools plus your installed extensions.
|
|
94
97
|
- Children also get the Pi built-in extensions the main session loaded: MCP, codemode and tool search. Your MCP servers (`~/.pi/agent/mcp.json`, the project's `.pi/mcp.json`) work in subagents, with the sign-ins you made in Pi. If MCP is off in the main session (`--no-mcp`, `-builtin:mcp` in settings, or an extension that replaces `/mcp`), it is off in subagents too. Each subagent opens its own server connections (stdio servers start once per subagent) and closes them when it ends.
|
|
95
|
-
-
|
|
98
|
+
- The children of one spawn report together: when the last of them finishes, their reports reach the parent as one message, `[id] report` each, in spawn order. A stopped or failed child counts as finished, with its stop or error text as its report. Each finished report is journaled right away, so if Pi dies before the rest finish, the reports it already has are delivered at the next start. Turn **Group subagent reports** off to get each report as soon as its child finishes. Messages sent with `tell_parent`, connected windows and their handoffs are never held back, and a child resumed with `tell` reports on its own. The parent stays alive to receive reports; it never polls.
|
|
96
99
|
- In the main chat, subagent messages and reports appear in a dark grey box labelled `↳ subagent <id> · still running` or `· report`, so they don't look like something you typed. The model still receives them as ordinary user messages. (One exception: reports recovered at startup, before your first message in the session, still show as plain user messages.)
|
|
97
100
|
- The parent can send a running child guidance with `tell`, and the child can message its parent mid-run with `tell_parent` (a question, an early finding). It reaches the parent like a report, marked "still running": between tool calls if the parent is busy, or waking it if it's waiting.
|
|
98
101
|
- `tell` to a finished child resumes it: the same agent (ID, parent, model, directory) reopens its saved transcript, gets the message as a new prompt, and sends a new report. This also works for children from earlier Pi sessions. Only the agent that started the child can resume it, and the resumed child takes one active slot. Connected windows can't be resumed, and a child whose transcript is missing must be spawned fresh.
|
|
@@ -101,9 +104,9 @@ Ask in plain words, for example: "Spawn an agent to investigate this repository
|
|
|
101
104
|
- Stopping an agent stops its whole subtree. A failed parent stops its descendants.
|
|
102
105
|
- Agents run inside the Pi process. Closing Pi stops them; there is no detached mode.
|
|
103
106
|
|
|
104
|
-
## Agents and
|
|
107
|
+
## Agents, usage and activity inspector
|
|
105
108
|
|
|
106
|
-
An **Agents | Usage** bar sits below the input.
|
|
109
|
+
An **Agents | Usage | Activity** bar sits below the input.
|
|
107
110
|
|
|
108
111
|
| Key | Action |
|
|
109
112
|
| --- | --- |
|
|
@@ -111,7 +114,7 @@ An **Agents | Usage** bar sits below the input.
|
|
|
111
114
|
| **Left/Right**, **Enter** | Pick and open a section |
|
|
112
115
|
| **Escape**, **Up**, or typing | Back to the editor |
|
|
113
116
|
| **F6** | Open Agents directly, keeping your draft |
|
|
114
|
-
| **Tab** |
|
|
117
|
+
| **Tab** | Cycle Agents, Usage and Activity |
|
|
115
118
|
|
|
116
119
|
Set a different shortcut with `OPTCHAT_INSPECT_KEY=ctrl+shift+a pi`. If another extension supplies a custom editor, OptChat leaves its Down key alone; use the shortcut or commands instead.
|
|
117
120
|
|
|
@@ -121,17 +124,23 @@ Set a different shortcut with `OPTCHAT_INSPECT_KEY=ctrl+shift+a pi`. If another
|
|
|
121
124
|
|
|
122
125
|
| Key | Action |
|
|
123
126
|
| --- | --- |
|
|
124
|
-
| **Escape**
|
|
125
|
-
| **Ctrl+
|
|
127
|
+
| **Escape** | Clear a draft, else back to the main chat |
|
|
128
|
+
| **Ctrl+C** | Clear a draft, else interrupt the agent's current step. Never ends the agent: queued messages go to it at once and it carries on; with none queued it waits for you (**interrupted · waiting for you**) until your next message, or a `tell` from the main agent, resumes it |
|
|
129
|
+
| **Up** (empty input) | Take your newest queued message back to edit; send it again, or clear it to drop it |
|
|
130
|
+
| **Ctrl+X** twice | Stop this agent and the agents it started (the only key that ends it) |
|
|
126
131
|
| **Page Up/Down**, mouse wheel | Scroll; back at the bottom it follows again. The wheel needs Pi's default fullscreen mode |
|
|
127
132
|
| **Ctrl+O** | Expand tool output (Pi's own toggle) |
|
|
128
133
|
|
|
129
|
-
Guidance shows as queued until delivered, or undelivered if the child stops first. Guidance you send is also saved in main memory. Reasoning is not shown. Transcripts stay browsable after restart, and browsing them makes no model calls.
|
|
134
|
+
Guidance shows as queued until delivered, or undelivered if the child stops first. When you interrupt an agent with nothing queued, the agent that started it gets a one-line note instead of a report, so it isn't left waiting. Guidance you send is also saved in main memory. Reasoning is not shown. Transcripts stay browsable after restart, and browsing them makes no model calls.
|
|
130
135
|
|
|
131
136
|
**Usage** shows this session, last hour, today, last 7 days, or all time (**Left/Right**): one row per role and model (main agent, subagents, compactor, imports) with estimated cost, share of the total, output tokens, and how much input came from the cache. Costs are API prices, not your subscription bill.
|
|
132
137
|
|
|
133
138
|

|
|
134
139
|
|
|
140
|
+
**Activity** is a memory gauge: how many messages the profile holds and how much of the 128 KB view they fill, then either **Settled** or **Catching up · 12 of 40 summaries** with a progress bar counted from when the backlog last grew from empty. If summarizing keeps failing, the last error and the retry countdown show under it. It also counts running agents, and interrupted ones waiting for you; their list is on Agents. While summaries or agents are at work, the bar's Activity item gets a **●**.
|
|
141
|
+
|
|
142
|
+

|
|
143
|
+
|
|
135
144
|
- Costs are API-rate estimates, not your subscription bill. Unknown rates show zero.
|
|
136
145
|
- Record counts are not request counts; retries and tool overhead can add records.
|
|
137
146
|
- Main-agent tracking starts with v0.3.0 (resumed sessions are backfilled). Older records without a parent session are left out of **This session**.
|
|
@@ -146,13 +155,13 @@ Pick the destination profile, then run `/optchat import`.
|
|
|
146
155
|
3. **Mode** (only if the profile already has history):
|
|
147
156
|
- **Append**: keep existing summaries and add the import. Faster and cheaper.
|
|
148
157
|
- **Rebuild by conversation start date**: regenerate the whole tree, ordered by conversation start.
|
|
149
|
-
4. **Preview**: destination, new and duplicate counts, text size, rough token estimate, and compactor. This is not a price quote.
|
|
158
|
+
4. **Preview**: destination, new and duplicate counts, text size, rough token estimate, and compactor. This is not a price quote: a big import costs about 3x that estimate in compactor input, because every message is summarized and then re-read in merges, each with the memory view as (mostly cached) context.
|
|
150
159
|
|
|
151
|
-
**What gets imported**: user messages and final assistant replies, with original dates and source labels, as in Victor's recipe. Tool calls and results, intermediate commentary, reasoning, subagent transcripts, replayed context, and image/audio/file bytes are left out. ChatGPT alternate branches are labelled as alternatives. Imported records are marked as historical so old requests are not treated as new instructions.
|
|
160
|
+
**What gets imported**: user messages and final assistant replies, with original dates and source labels, as in Victor's recipe. Tool calls and results, intermediate commentary, reasoning, subagent transcripts, replayed context, and image/audio/file bytes are left out. So is the output Claude Code logs for slash and shell commands; the command itself stays as typed (`/name args` or `!command`). So are the context messages Codex injects, such as the AGENTS.md instructions and the environment context. Dropped text never becomes a conversation's title. ChatGPT alternate branches are labelled as alternatives. Imported records are marked as historical so old requests are not treated as new instructions.
|
|
152
161
|
|
|
153
162
|
**Claude Code memories**: the auto-memory topic files in `~/.claude/projects/*/memory/` (not `MEMORY.md`, which only indexes them), picked by project. Each file becomes one dated note in the memory tree, not part of the prompt. An edited file comes in again as a newer note.
|
|
154
163
|
|
|
155
|
-
**Duplicates**: re-importing skips messages already present, even if titles or paths changed. Changed source messages can appear as a separate historical version.
|
|
164
|
+
**Duplicates**: re-importing skips messages already present, even if titles or paths changed. A resumed Claude Code session copies earlier messages into its own file; those copies are matched by message id and text, so they come in once, also against messages an earlier import stored. Changed source messages can appear as a separate historical version.
|
|
156
165
|
|
|
157
166
|
**Pausing**: **Pause import** (or Escape) saves progress, and so does restarting Pi. `/optchat import` then offers **Resume** or **Discard staged import**. While an import is pending, chat in that profile is blocked; other profiles still work. Imports need the main agent and its subagents to be idle.
|
|
158
167
|
|
|
@@ -212,14 +221,15 @@ To delete a profile, delete its folder. Your original Pi sessions are kept in Pi
|
|
|
212
221
|
|
|
213
222
|
## How it differs from the recipe
|
|
214
223
|
|
|
215
|
-
The recipe's four prompts are kept verbatim in `src/prompts.ts
|
|
224
|
+
The recipe's four prompts are kept verbatim in `src/prompts.ts` (the view doc adds one sentence: you can zoom out too, from a message to the summaries above it), along with its numbers: 512-byte summary nodes (by default summaries up to 640 bytes are accepted without a retry, as long as they are smaller than what they replace; see Summary size tolerance), a 128,000-byte memory view, binary merges, 8 compression workers, fixed retry delays, 5 shortening attempts, and a 30,000-character tool output cap. Each compactor request shows a 512-byte example line for scale. It is a true line about OptChat itself, labelled as not from the chat and fenced off in `<example>` tags, with the text to summarize in `<input>` tags: shown bare, a made-up example was sometimes summarized as if it were chat and spread up the tree. Anthropic requests get stable cache breakpoints on the view, and when that view is not cached yet, one compactor call goes first and the others wait until it starts answering, so they read the cache instead of each writing it. See `docs/victor-recipe.md` for notes.
|
|
216
225
|
|
|
217
226
|
Each run's context is the memory view, the previous exchange, and your new message. Deliberate additions (the ones that change the recipe's behaviour are settings, see [Settings](#settings)):
|
|
218
227
|
|
|
219
228
|
1. **Previous exchange kept verbatim.** Your last request (with any steering) and the final answer are included in full, so "why is that?" refers to what you actually read. Tool calls and reasoning are not carried over. It comes on top of the 128,000-byte view. If it is over 16,000 bytes (about 4,000 tokens, usually a big paste; Previous exchange limit) it is left out entirely, and the model relies on the view and zoom as in Victor's recipe. A new Pi session starts with the memory view only.
|
|
220
|
-
2. **Subagents** are built in with Pi's SDK rather than a separate package.
|
|
221
|
-
3. **
|
|
222
|
-
4. **
|
|
229
|
+
2. **Subagents** are built in with Pi's SDK rather than a separate package. With Subagent levels above 1 they can delegate further.
|
|
230
|
+
3. **Memory search** (off by default). With the setting on, the agent also gets `search(text, before?)`: plain, case-insensitive text matching over the original messages, never the summaries (a summary can be wrong, and one fact repeats at every level of the tree), skipping logged zoom and search results. It returns 20 hits at a time, newest first, each with its id, the view line that holds it when that is a summary (`1234 (in 1024+256)`), its date and a snippet; `before: id` pages back, and `zoom(id, 1)` reads a hit. One line about it is added to the system prompt. Turning it on or off changes the cached prompt once.
|
|
231
|
+
4. **Profiles**, the **inspector**, the **usage ledger**, **import**, and **connected windows** are additions. Import adds historical-record guidance to the prompts.
|
|
232
|
+
5. **Not done**: computer use and hosting on an always-on machine.
|
|
223
233
|
|
|
224
234
|
## Development
|
|
225
235
|
|
|
@@ -242,12 +252,12 @@ npm run test:live # paid Anthropic calls on synthetic data in a disposable prof
|
|
|
242
252
|
|
|
243
253
|
Pi's [package directory](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) lists npm packages with the `pi-package` keyword, which the manifest already has. GitHub alone is not enough.
|
|
244
254
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
255
|
+
Publishing a GitHub release publishes to npm. The [Publish workflow](.github/workflows/publish.yml) uses npm trusted publishing, so no token or `npm login` is involved:
|
|
256
|
+
|
|
257
|
+
1. Merge a PR that bumps `version` in `package.json`.
|
|
258
|
+
2. `gh release create vX.Y.Z --target main --generate-notes`
|
|
259
|
+
|
|
260
|
+
The workflow checks that the tag matches `package.json`, runs the type check and tests, and publishes with provenance. It skips versions already on npm. To retry a tag, run it by hand: `gh workflow run publish.yml -f tag=vX.Y.Z`.
|
|
251
261
|
|
|
252
262
|
## Credits and license
|
|
253
263
|
|
package/docs/victor-recipe.md
CHANGED
|
@@ -10,12 +10,12 @@ The source remains upstream rather than duplicating the full article here. Its f
|
|
|
10
10
|
|
|
11
11
|
## Implementation mapping
|
|
12
12
|
|
|
13
|
-
- `src/memory.ts`: append-only log, binary summary tree, compression scheduling, bounded view, zoom/date.
|
|
13
|
+
- `src/memory.ts`: append-only log, binary summary tree, compression scheduling, bounded view, zoom/date, and the opt-in text search over original messages (`src/tools.ts` has the tool; not in the recipe, off by default).
|
|
14
14
|
- `src/compactor.ts`: contextual compression and size retries.
|
|
15
|
-
- `src/cache.ts`: stable Anthropic cache boundaries.
|
|
15
|
+
- `src/cache.ts`: stable Anthropic cache boundaries. OpenAI requests get no marks: GPT-5.6 rejects the recipe's `prompt_cache_breakpoint` with a 400 (found by @aaaxn), so they rely on implicit prefix caching. OptChat doesn't set `reasoning.context` either: GPT-5.6 already defaults to the recipe's `"all_turns"`, and OpenAI documents it only for GPT-5.6 and GPT-6.1 Sol.
|
|
16
16
|
- `src/transcript.ts`: fresh context per parent run, current-run tool loop retained. The previous completed exchange is also retained in full text (left out if over 16,000 bytes by default), an intentional addition to the summary-only recipe for conversational continuity.
|
|
17
17
|
- `src/agents.ts`: asynchronous Pi SDK children and automatic completion reports.
|
|
18
|
-
- `src/settings.ts`: per-profile settings for the departures from the recipe. Defaults are the recipe's (one subagent level), except the previous exchange (on) and the summary size tolerance (640 bytes, against the recipe's strict 512).
|
|
19
|
-
- `src/import/`: profile-scoped historical imports retain user messages and final assistant replies, following the lighter history described in recipe section 10. Source adapters, final-reply detection, replay filtering, and ChatGPT branch labels are integration choices. Live-chat tool logging remains unchanged.
|
|
18
|
+
- `src/settings.ts`: per-profile settings for the departures from the recipe. Defaults are the recipe's (one subagent level, no memory search), except the previous exchange (on) and the summary size tolerance (640 bytes, against the recipe's strict 512).
|
|
19
|
+
- `src/import/`: profile-scoped historical imports retain user messages and final assistant replies, following the lighter history described in recipe section 10. An import hands `Memory` one message at a time, each once the one before it is summarized, so the compactor sees the view a live chat would have shown it. Source adapters, final-reply detection, replay filtering, and ChatGPT branch labels are integration choices. Live-chat tool logging remains unchanged.
|
|
20
20
|
|
|
21
21
|
Profiles, native Pi UI, conversation import, and local Git checkpoints are integration choices described in the README.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-optchat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"pi": {
|
|
6
6
|
"extensions": [
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"check": "tsc --noEmit",
|
|
13
|
-
"test": "tsx --test test/*.test.ts",
|
|
13
|
+
"test": "tsx --import ./test/support.ts --test test/*.test.ts",
|
|
14
14
|
"test:live": "tsx test/live.ts"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
package/src/agent-view.ts
CHANGED
|
@@ -167,6 +167,7 @@ const statusOf = (run: RunInfo, children: Children) => {
|
|
|
167
167
|
if (!live) return run.state;
|
|
168
168
|
if (run.state === 'stopping') return 'stopping';
|
|
169
169
|
if (run.state === 'waiting') return 'waiting for its agents';
|
|
170
|
+
if (run.state === 'paused') return 'interrupted · waiting for you';
|
|
170
171
|
const tools = [...live.tools.values()].map(t => t.name);
|
|
171
172
|
return tools.length ? `running ${tools.join(', ')}` : live.streaming ? 'writing' : 'working';
|
|
172
173
|
};
|
|
@@ -207,7 +208,9 @@ export class AgentView implements Component, Focusable {
|
|
|
207
208
|
private readonly close = () => { if (!this.ended) { this.dispose(); this.options.done(); } };
|
|
208
209
|
dispose() { this.ended = true; clearInterval(this.timer); this.unsubscribe(); this.options.signal?.removeEventListener('abort', this.close); }
|
|
209
210
|
invalidate() { this.editor.invalidate(); }
|
|
210
|
-
private get
|
|
211
|
+
private get state() { return this.options.children.live(this.options.id)?.info.state ?? ''; }
|
|
212
|
+
private get canMessage() { return ['running', 'waiting', 'paused'].includes(this.state); }
|
|
213
|
+
private get canInterrupt() { return ['running', 'waiting'].includes(this.state); }
|
|
211
214
|
private scroll(lines: number) { this.back = Math.max(0, Math.min(this.overflow, this.back + lines)); }
|
|
212
215
|
/** Wheel scrolling, in Pi's default fullscreen mode; in "regular" mode the terminal keeps the wheel. */
|
|
213
216
|
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
@@ -226,12 +229,19 @@ export class AgentView implements Component, Focusable {
|
|
|
226
229
|
if (this.ended) return;
|
|
227
230
|
const { children, id } = this.options;
|
|
228
231
|
const stopping = this.confirmStop; this.confirmStop = false; this.notice = '';
|
|
229
|
-
|
|
230
|
-
|
|
232
|
+
const escape = matchesKey(data, 'escape') && !this.editor.isShowingAutocomplete(), interrupt = matchesKey(data, 'ctrl+c');
|
|
233
|
+
if ((escape || interrupt) && this.editor.getText()) {
|
|
231
234
|
this.editor.setText(''); // First press clears a draft, as in the main editor.
|
|
232
235
|
return this.options.redraw();
|
|
233
236
|
}
|
|
234
|
-
if (
|
|
237
|
+
if (escape || interrupt && !this.canInterrupt) return this.close();
|
|
238
|
+
// Up on an empty input pulls the newest queued message back to edit: sending it queues the new text, clearing it drops it.
|
|
239
|
+
const pulled = matchesKey(data, 'up') && !this.editor.getText() && this.canMessage ? children.withdraw(id) : undefined;
|
|
240
|
+
if (pulled !== undefined) this.editor.setText(pulled);
|
|
241
|
+
else if (interrupt) {
|
|
242
|
+
children.interrupt(id).catch((error: unknown) => { this.notice = `Could not interrupt: ${error instanceof Error ? error.message : String(error)}`; })
|
|
243
|
+
.finally(() => { if (!this.ended) this.options.redraw(); });
|
|
244
|
+
} else if (matchesKey(data, 'ctrl+x')) {
|
|
235
245
|
if (!children.live(id)) this.notice = 'This agent has already finished.';
|
|
236
246
|
else if (stopping) children.stop(id).catch((error: unknown) => { this.notice = `Could not stop: ${String(error)}`; this.options.redraw(); });
|
|
237
247
|
else this.confirmStop = true;
|
|
@@ -287,9 +297,12 @@ export class AgentView implements Component, Focusable {
|
|
|
287
297
|
const end = lines.length - this.back;
|
|
288
298
|
const body = lines.slice(Math.max(0, end - this.bodyHeight), end);
|
|
289
299
|
while (body.length < this.bodyHeight) body.push('');
|
|
300
|
+
const editable = this.canMessage && run?.guidance.some(g => g.state === 'queued' && g.from === 'user');
|
|
301
|
+
const keys = [...this.editor.getText() ? ['Esc clear'] : ['Esc back to main', ...this.canInterrupt ? ['Ctrl+C interrupt'] : [], ...editable ? ['↑ edit queued'] : []],
|
|
302
|
+
...live ? ['Ctrl+X stop'] : [], ...this.overflow ? [this.back ? 'PgDn newer' : 'PgUp older'] : []];
|
|
290
303
|
const hint = this.confirmStop ? color('warning', ' Press Ctrl+X again to stop this agent and the agents it started')
|
|
291
304
|
: this.notice ? color('error', ` ${this.notice}`)
|
|
292
|
-
: color('dim', ` ${
|
|
305
|
+
: color('dim', ` ${keys.join(' · ')}`);
|
|
293
306
|
const scrolled = this.back ? color('warning', `↑ ${this.back} lines up `) : '';
|
|
294
307
|
const footer = [visibleWidth(hint) + visibleWidth(scrolled) < width ? `${hint}${' '.repeat(width - visibleWidth(hint) - visibleWidth(scrolled))}${scrolled}` : hint];
|
|
295
308
|
// On a terminal too short for everything, the header goes first; the input and footer stay.
|
package/src/agents.ts
CHANGED
|
@@ -7,7 +7,7 @@ import * as sdk from '@earendil-works/pi-coding-agent';
|
|
|
7
7
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
8
8
|
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
9
9
|
import { SUBAGENT, VIEW_DOC } from './prompts.ts';
|
|
10
|
-
import { memoryTools } from './tools.ts';
|
|
10
|
+
import { memoryTools, SEARCH_DOC, searchTool } from './tools.ts';
|
|
11
11
|
import { type Memory } from './memory.ts';
|
|
12
12
|
import type { ModelChoice } from './compactor.ts';
|
|
13
13
|
import { cachePayload } from './cache.ts';
|
|
@@ -23,12 +23,20 @@ export interface LiveRun {
|
|
|
23
23
|
session: AgentSession; info: RunInfo; updated: number; streaming?: AgentMessage;
|
|
24
24
|
tools: Map<string, { name: string; args: unknown; output?: unknown; started: number }>;
|
|
25
25
|
pendingReports: string[]; pendingGuidance: string[]; wake?: () => void; completion?: Promise<void>;
|
|
26
|
+
/** Set by the user's interrupt: the queued guidance the run continues with once its current step is aborted; with none, it pauses. */
|
|
27
|
+
interruption?: RunInfo['guidance'];
|
|
28
|
+
/** Messages being steered back into Pi's queue after it was cleared; other queue changes wait for it. */
|
|
29
|
+
requeueing?: Promise<void>;
|
|
30
|
+
/** Shared by the children of one spawn whose reports are delivered together, in spawn order. */
|
|
31
|
+
batch?: { ids: string[]; reports: Map<string, string> };
|
|
26
32
|
}
|
|
27
33
|
interface Options { parentSession?: string; usage?: UsageLedger; createSession?: typeof createAgentSession;
|
|
28
34
|
/** Names of the built-in extensions the main session loaded (see `loadedBuiltins`). */
|
|
29
35
|
builtins?: () => Iterable<string>;
|
|
30
|
-
/**
|
|
31
|
-
|
|
36
|
+
/** Journals the reports a main-agent spawn has so far while its other children still run (none: the batch was delivered), so a crash cannot lose them. */
|
|
37
|
+
hold?: (batch: string, texts: string[]) => void;
|
|
38
|
+
/** Read on every spawn, so a changed profile setting applies to the next one. Missing settings take their defaults. */
|
|
39
|
+
settings?: () => Partial<Settings>;
|
|
32
40
|
summarizeHandoff?: (run: RunInfo, messages: AgentMessage[], descendants?: HandoffEvidence[]) => Promise<string> }
|
|
33
41
|
|
|
34
42
|
// Subagents load the user's installed extensions, except any copy of OptChat itself: they get memory tools directly and must not open a profile.
|
|
@@ -47,6 +55,8 @@ export const taskDirectory = (cwd: string, path = '.', windows = process.platfor
|
|
|
47
55
|
resolve(cwd, path.replace(windows ? /^~(?=$|[\\/])/ : /^~(?=$|\/)/, homedir()));
|
|
48
56
|
export const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
|
|
49
57
|
const isOptchat = (path: string) => packageName(path) === 'pi-optchat';
|
|
58
|
+
/** Pi hands steering to a run only between tool calls, so one long command keeps the parent and the user from reaching it. */
|
|
59
|
+
export const STEERABLE = 'Never block in a single command for more than about 60 seconds. To wait for something, poll in short separate tool calls (for example one `sleep 30` per call), so messages from your parent or the user can reach you between calls.';
|
|
50
60
|
|
|
51
61
|
// Pi's CLI adds its built-in extensions (MCP, codemode, tool search) to its own session; SDK sessions such as
|
|
52
62
|
// subagents must add them. Pi versions that do not export a factory simply do not get that extension.
|
|
@@ -72,7 +82,7 @@ export class Children {
|
|
|
72
82
|
private readonly completions = new Set<Promise<void>>();
|
|
73
83
|
constructor(private readonly memory: Memory, private readonly registry: ModelRegistry,
|
|
74
84
|
private readonly choice: () => ModelChoice, private readonly instructions: () => string,
|
|
75
|
-
private readonly report: (text: string, once?: boolean) => Promise<void>, private readonly warn: (text: string) => void,
|
|
85
|
+
private readonly report: (text: string, options?: { once?: boolean; count?: number }) => Promise<void>, private readonly warn: (text: string) => void,
|
|
76
86
|
private readonly profileDirectory = memory.directory, private readonly options: Options = {}) {
|
|
77
87
|
this.history = new RunHistory(profileDirectory);
|
|
78
88
|
for (const warning of this.history.warnings) warn(warning);
|
|
@@ -84,7 +94,7 @@ export class Children {
|
|
|
84
94
|
} catch (error) { warn(`Could not backfill child usage for ${run.id}: ${String(error)}`); }
|
|
85
95
|
}
|
|
86
96
|
}
|
|
87
|
-
private get settings() { return this.options.settings?.()
|
|
97
|
+
private get settings(): Settings { return { ...DEFAULT_SETTINGS, ...this.options.settings?.() }; }
|
|
88
98
|
private full(extra: number) { return this.running.size + this.launching + extra > this.settings.maxAgents; }
|
|
89
99
|
get ids() { return [...this.running.keys()]; }
|
|
90
100
|
get active() { return this.completions.size > 0 || this.launching > 0 || this.settling > 0; }
|
|
@@ -123,6 +133,14 @@ export class Children {
|
|
|
123
133
|
} catch (error) { this.warn(`Could not record subagent activity: ${String(error)}`); }
|
|
124
134
|
}
|
|
125
135
|
async spawn(tasks: { task: string; cwd?: string }[], cwd: string, signal?: AbortSignal, parentId?: string, connected = false) {
|
|
136
|
+
return (await this.launch(tasks, cwd, signal, parentId, connected)).ids;
|
|
137
|
+
}
|
|
138
|
+
/** The spawn tools' answer, saying how this spawn's reports will come back. */
|
|
139
|
+
async start(tasks: { task: string; cwd?: string }[], cwd: string, signal?: AbortSignal, parentId?: string) {
|
|
140
|
+
const { ids, grouped } = await this.launch(tasks, cwd, signal, parentId);
|
|
141
|
+
return `Started: ${ids.join(', ')}. ${grouped && ids.length > 1 ? 'Their reports will arrive together, as one message, once all of them have finished.' : 'Reports will arrive automatically.'}`;
|
|
142
|
+
}
|
|
143
|
+
private async launch(tasks: { task: string; cwd?: string }[], cwd: string, signal: AbortSignal | undefined, parentId: string | undefined, connected = false) {
|
|
126
144
|
for (const directory of tasks.map(t => taskDirectory(cwd, t.cwd))) if (!existsSync(directory) || !statSync(directory).isDirectory()) throw new Error(`No such directory: ${directory}`);
|
|
127
145
|
if (this.closing) throw new Error('Profile is closing.');
|
|
128
146
|
this.settling++;
|
|
@@ -131,7 +149,7 @@ export class Children {
|
|
|
131
149
|
const cancelled = () => this.closing || (parentId !== undefined && this.running.get(parentId)?.info.state !== 'running');
|
|
132
150
|
if (parentId && (!parent || parent.info.state !== 'running')) throw new Error('The parent is no longer running.');
|
|
133
151
|
const depth = parent ? parent.info.depth + 1 : 1;
|
|
134
|
-
const { subagentLevels, maxAgents } = this.settings;
|
|
152
|
+
const { subagentLevels, maxAgents, groupReports } = this.settings;
|
|
135
153
|
if (depth > subagentLevels) throw new Error(`Delegation depth limit reached: this profile allows ${subagentLevels} level${subagentLevels === 1 ? '' : 's'} of subagents.`);
|
|
136
154
|
if (this.closing) throw new Error('Profile is closing.');
|
|
137
155
|
if (this.full(tasks.length)) throw new Error(`Profile limit: at most ${maxAgents} active agents, including parents and descendants. Reduce the batch or continue without delegating.`);
|
|
@@ -140,15 +158,17 @@ export class Children {
|
|
|
140
158
|
const model = this.registry.find(selected.provider, selected.model);
|
|
141
159
|
if (!model) throw new Error(`Subagent model unavailable: ${selected.provider}/${selected.model}`);
|
|
142
160
|
const launched = await this.track(this.launchBatch({ tasks, cwd, depth, parentId, connected, selected, model, signal, cancelled }));
|
|
143
|
-
//
|
|
161
|
+
// Grouped, the last child of this spawn to finish delivers every report; otherwise each reports on its own.
|
|
162
|
+
const batch = groupReports && !connected ? { ids: launched.map(c => c.info.id), reports: new Map<string, string>() } : undefined;
|
|
144
163
|
for (const live of launched) {
|
|
164
|
+
live.batch = batch;
|
|
145
165
|
const work = this.execute(live, `${view}\n\nYour task:\n${live.info.task}`).catch(error => this.warn(`Subagent completion failed: ${String(error)}`))
|
|
146
166
|
.finally(() => { this.completions.delete(work); this.changed(); });
|
|
147
167
|
this.completions.add(work);
|
|
148
168
|
live.completion = work;
|
|
149
169
|
}
|
|
150
170
|
this.changed();
|
|
151
|
-
return launched.map(c => c.info.id);
|
|
171
|
+
return { ids: launched.map(c => c.info.id), grouped: batch !== undefined };
|
|
152
172
|
}
|
|
153
173
|
/** close() waits for launches, so shutdown never unlocks the profile under a child that is still opening. */
|
|
154
174
|
private track<T>(launch: Promise<T>) {
|
|
@@ -195,9 +215,9 @@ export class Children {
|
|
|
195
215
|
private async open(o: { id: string; directory: string; depth: number; parentId?: string; connected: boolean; provider: string; model: Model<Api>;
|
|
196
216
|
thinking?: ModelChoice['thinking']; sessionManager: SessionManager }) {
|
|
197
217
|
const { id, directory, depth, parentId, connected } = o;
|
|
198
|
-
const { subagentLevels, maxAgents } = this.settings, delegates = depth < subagentLevels;
|
|
218
|
+
const { subagentLevels, maxAgents, memorySearch } = this.settings, delegates = depth < subagentLevels;
|
|
199
219
|
const delegation = delegates ? `You may delegate parts of your assigned task with spawn when useful. Child reports arrive automatically after your current run ends; the harness keeps you alive to receive them. Never poll, sleep, or wait in a tool for children. Finish your current work and return; you will be prompted with their results. The profile allows ${maxAgents} active agents total.` : 'You are at the maximum delegation depth. Complete your task with your own tools.';
|
|
200
|
-
const instructions = [this.instructions(), delegation, connected ? 'You are speaking directly with the user in a connected window. Continue this conversation across requests. Use tell_parent for questions or findings the main agent needs now. A handoff will be generated when the user completes or disconnects the window.'
|
|
220
|
+
const instructions = [this.instructions(), delegation, STEERABLE, connected ? 'You are speaking directly with the user in a connected window. Continue this conversation across requests. Use tell_parent for questions or findings the main agent needs now. A handoff will be generated when the user completes or disconnects the window.'
|
|
201
221
|
: 'Use tell_parent only when your parent needs something now (a blocking question, an important early finding, or when asked to). Your final answer is delivered automatically; do not repeat it with tell_parent.'].filter(Boolean).join('\n\n');
|
|
202
222
|
// The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
|
|
203
223
|
const settingsManager = SettingsManager.inMemory({ ...SettingsManager.create(directory, getAgentDir()).getSettings(), compaction: { enabled: false }, cacheWarming: 'off' });
|
|
@@ -209,7 +229,7 @@ export class Children {
|
|
|
209
229
|
if (provider) pi.registerProvider(o.provider, provider);
|
|
210
230
|
// Same prompt as the main agent (AGENTS.md files, skills, cwd); only the OptChat preamble differs.
|
|
211
231
|
pi.on('before_agent_start', event => {
|
|
212
|
-
event.systemPromptOptions.customPrompt = `${SUBAGENT}\n\n${VIEW_DOC}`;
|
|
232
|
+
event.systemPromptOptions.customPrompt = `${SUBAGENT}\n\n${VIEW_DOC}${memorySearch ? SEARCH_DOC : ''}`;
|
|
213
233
|
event.systemPromptOptions.sections.instructions = instructions;
|
|
214
234
|
});
|
|
215
235
|
pi.on('before_provider_request', (event, ctx) => ctx.model?.api === 'anthropic-messages' ? cachePayload(event.payload) : event.payload);
|
|
@@ -218,7 +238,7 @@ export class Children {
|
|
|
218
238
|
await loader.reload();
|
|
219
239
|
const { session } = await (this.options.createSession ?? createAgentSession)({ cwd: directory, resourceLoader: loader, settingsManager,
|
|
220
240
|
model: o.model, thinkingLevel: o.thinking, sessionManager: o.sessionManager,
|
|
221
|
-
customTools: [...memoryTools(() => this.memory), ...(delegates ? this.delegationTools(id, directory, subagentLevels, maxAgents) : []), this.parentTool(id, parentId, connected)],
|
|
241
|
+
customTools: [...memoryTools(() => this.memory), ...(memorySearch ? [searchTool(() => this.memory)] : []), ...(delegates ? this.delegationTools(id, directory, subagentLevels, maxAgents) : []), this.parentTool(id, parentId, connected)],
|
|
222
242
|
excludeTools: delegates ? [] : ['spawn', 'tell'],
|
|
223
243
|
});
|
|
224
244
|
// Callers track the session only after this returns: clean up here if its extensions fail to start.
|
|
@@ -229,7 +249,7 @@ export class Children {
|
|
|
229
249
|
private delegationTools(parentId: string, cwd: string, levels: number, maxAgents: number) {
|
|
230
250
|
return [{ name: 'spawn', label: 'Delegate task', description: `Delegate parts of your task. Results arrive automatically after this run; never poll or sleep waiting. Maximum depth ${levels} and ${maxAgents} active agents per profile.`,
|
|
231
251
|
parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1 }) }),
|
|
232
|
-
execute: async (_id: string, args: { tasks: { task: string; cwd?: string }[] }, signal?: AbortSignal) => result(
|
|
252
|
+
execute: async (_id: string, args: { tasks: { task: string; cwd?: string }[] }, signal?: AbortSignal) => result(await this.start(args.tasks, cwd, signal, parentId)),
|
|
233
253
|
}, { name: 'tell', label: 'Guide child', description: 'Send guidance to one of your direct children. A finished child is resumed with its earlier conversation, and its new report arrives automatically.',
|
|
234
254
|
parameters: Type.Object({ id: Type.String(), message: Type.String() }),
|
|
235
255
|
execute: async (_id: string, args: { id: string; message: string }) => {
|
|
@@ -246,16 +266,21 @@ export class Children {
|
|
|
246
266
|
const message = args.message.trim();
|
|
247
267
|
if (!message) throw new Error('Message is empty.');
|
|
248
268
|
const text = `[${id}] ${connected ? 'Connected agent message' : 'Message from subagent (still running)'}: ${message}`;
|
|
249
|
-
if (!
|
|
250
|
-
|
|
251
|
-
if (!parent || !['running', 'waiting'].includes(parent.info.state)) throw new Error('Your parent is no longer running.');
|
|
252
|
-
if (parent.info.state === 'waiting') { parent.pendingReports.push(text); parent.wake?.(); }
|
|
253
|
-
else await parent.session.steer(text);
|
|
254
|
-
this.changed();
|
|
255
|
-
return result(`Message sent to parent ${parentId}.`);
|
|
269
|
+
if (!await this.toParent(parentId, text)) throw new Error('Your parent is no longer running.');
|
|
270
|
+
return result(parentId ? `Message sent to parent ${parentId}.` : 'Message sent to the main agent.');
|
|
256
271
|
},
|
|
257
272
|
};
|
|
258
273
|
}
|
|
274
|
+
/** A mid-run message to a child's parent: false if that parent is no longer running. */
|
|
275
|
+
private async toParent(parentId: string | undefined, text: string) {
|
|
276
|
+
if (!parentId) { await this.report(text); return true; }
|
|
277
|
+
const parent = this.running.get(parentId);
|
|
278
|
+
if (!parent || !['running', 'waiting', 'paused'].includes(parent.info.state)) return false;
|
|
279
|
+
if (parent.info.state === 'running') await parent.session.steer(text);
|
|
280
|
+
else { parent.pendingReports.push(text); parent.wake?.(); } // A paused parent keeps it until the user resumes it.
|
|
281
|
+
this.changed();
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
259
284
|
private async shutdown(session: AgentSession) {
|
|
260
285
|
try { await session.extensionRunner.emit({ type: 'session_shutdown', reason: 'quit' }); }
|
|
261
286
|
catch (error) { this.warn(`Subagent cleanup failed: ${String(error)}`); }
|
|
@@ -273,8 +298,36 @@ export class Children {
|
|
|
273
298
|
if (this.closing || info.handoff || state === 'stopping') throw new Error('Stopped before its first request.');
|
|
274
299
|
await session.prompt(prompt);
|
|
275
300
|
while (info.state !== 'stopping') {
|
|
301
|
+
if (live.interruption) {
|
|
302
|
+
const guidance = live.interruption.filter(g => g.state === 'queued');
|
|
303
|
+
// Read Pi's queue only once a rebuild has landed, and take the interrupt's messages off it so they arrive once.
|
|
304
|
+
// Until this is done, tell() holds new messages in pendingGuidance, where the turn below or a pause picks them up.
|
|
305
|
+
await live.requeueing;
|
|
306
|
+
const texts = new Set(guidance.map(g => g.text));
|
|
307
|
+
await this.requeue(live, session.clearQueue().steering.filter(text => !texts.has(text)));
|
|
308
|
+
live.interruption = undefined;
|
|
309
|
+
if (!guidance.length) {
|
|
310
|
+
// Nothing to hand over: the run waits for its next message instead of ending, and whoever waits on its report hears why.
|
|
311
|
+
if (transition(info, 'paused')) {
|
|
312
|
+
this.save(info);
|
|
313
|
+
if (!info.connected) await this.toParent(info.parentId, `[${info.id}] Interrupted by the user; it waits for their next message, so no report until then.`)
|
|
314
|
+
.catch(error => this.warn(`Could not tell the parent about the interrupt: ${String(error)}`));
|
|
315
|
+
}
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (!transition(info, 'running')) continue; // A stop during the cleanup wins.
|
|
319
|
+
for (const g of guidance) g.state = 'delivered';
|
|
320
|
+
this.save(info);
|
|
321
|
+
await session.prompt(`Interrupted by the user:\n\n${guidance.map(g => g.text).join('\n\n')}`);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const paused = info.state === 'paused';
|
|
325
|
+
if (paused && !live.pendingGuidance.length) {
|
|
326
|
+
await new Promise<void>(resolve => { live.wake = resolve; }); live.wake = undefined;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
276
329
|
const last = session.messages.findLast(m => m.role === 'assistant');
|
|
277
|
-
if (last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted')) break;
|
|
330
|
+
if (!paused && last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted')) break;
|
|
278
331
|
if (live.pendingGuidance.length) {
|
|
279
332
|
transition(info, 'running'); this.save(info);
|
|
280
333
|
await session.prompt(live.pendingGuidance.shift()!);
|
|
@@ -317,37 +370,92 @@ export class Children {
|
|
|
317
370
|
}
|
|
318
371
|
// A metadata failure must not suppress delivery of the actual result.
|
|
319
372
|
try { this.save(info); } catch (error) { this.warn(`Could not save run metadata: ${String(error)}`); }
|
|
320
|
-
|
|
373
|
+
let text = `[${info.id}] ${info.report}`;
|
|
374
|
+
const batch = live.batch;
|
|
375
|
+
let count: number | undefined;
|
|
376
|
+
if (batch) {
|
|
377
|
+
batch.reports.set(info.id, text);
|
|
378
|
+
const texts = batch.ids.flatMap(id => batch.reports.get(id) ?? []);
|
|
379
|
+
if (texts.length < batch.ids.length) { if (!info.parentId) this.options.hold?.(batch.ids[0], texts); return; }
|
|
380
|
+
text = texts.join('\n\n'); count = texts.length;
|
|
381
|
+
}
|
|
321
382
|
if (info.parentId) {
|
|
322
383
|
const parent = this.running.get(info.parentId);
|
|
323
384
|
if (parent && parent.info.state !== 'stopping') { parent.pendingReports.push(text); this.changed(); }
|
|
324
385
|
return;
|
|
325
386
|
}
|
|
326
|
-
if (this.closing)
|
|
327
|
-
try { await this.report(text); }
|
|
387
|
+
if (this.closing) this.memory.append('user', text);
|
|
388
|
+
else try { await this.report(text, { count }); }
|
|
328
389
|
catch (error) {
|
|
329
390
|
this.memory.append('user', text);
|
|
330
391
|
this.warn(`Subagent report saved but could not wake the parent: ${String(error)}`);
|
|
331
392
|
}
|
|
393
|
+
if (batch && batch.ids.length > 1) this.options.hold?.(batch.ids[0], []);
|
|
332
394
|
}
|
|
333
395
|
/** `caller` is the agent sending a manager message: undefined for the main agent, else the parent subagent's ID. */
|
|
334
396
|
async tell(id: string, message: string, source: 'manager' | 'user' = 'manager', caller?: string) {
|
|
335
397
|
const live = this.running.get(id);
|
|
336
398
|
if (!live && source === 'manager' && this.history.records.has(id)) return this.track(this.resume(id, message, caller));
|
|
337
|
-
if (live && !['running', 'waiting'].includes(live.info.state)) throw new Error(`${id} is finishing. Its report will arrive on its own${live.info.connected || source === 'user' ? '' : '; tell it again after that to resume it'}.`);
|
|
399
|
+
if (live && !['running', 'waiting', 'paused'].includes(live.info.state)) throw new Error(`${id} is finishing. Its report will arrive on its own${live.info.connected || source === 'user' ? '' : '; tell it again after that to resume it'}.`);
|
|
338
400
|
if (!live) throw new Error(`No running subagent ${id}.`);
|
|
339
401
|
const text = live.info.connected && source === 'manager' ? `[Main agent guidance]\n${message.trim()}` : message.trim(); if (!message.trim()) throw new Error('Message is empty.');
|
|
340
402
|
if (source === 'user') this.memory.append('user', `Direct guidance to subagent [${id}]: ${text}`);
|
|
341
403
|
const guidance: RunInfo['guidance'][number] = { text, date: Date.now(), state: 'queued', from: source };
|
|
342
404
|
live.info.guidance.push(guidance); this.save(live.info);
|
|
343
405
|
try {
|
|
344
|
-
|
|
345
|
-
|
|
406
|
+
await live.requeueing; // Keeps the queue in the order messages were sent.
|
|
407
|
+
if (live.info.state === 'running' && !live.interruption) await live.session.steer(text);
|
|
408
|
+
else { live.pendingGuidance.push(text); live.wake?.(); } // Waiting, paused or being interrupted: this message starts its next turn.
|
|
346
409
|
}
|
|
347
410
|
catch (error) { guidance.state = 'undelivered'; this.save(live.info); throw error; }
|
|
348
411
|
if (guidance.state === 'undelivered') throw new Error(`${id} finished before it read the message. Tell it again to resume it.`);
|
|
349
412
|
return 'Message queued for the next tool boundary.';
|
|
350
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* The user's Ctrl+C, never destructive: aborts the current step. With guidance queued, the run continues at once with it;
|
|
416
|
+
* with none, it pauses until the next message (from the user or a tell). Only stop() ends a run.
|
|
417
|
+
*/
|
|
418
|
+
async interrupt(id: string): Promise<'continued' | 'paused'> {
|
|
419
|
+
const live = this.running.get(id);
|
|
420
|
+
if (live?.info.state === 'paused') return 'paused';
|
|
421
|
+
if (!live || !['running', 'waiting'].includes(live.info.state)) throw new Error(`No running subagent ${id}.`);
|
|
422
|
+
const guidance = live.info.guidance.filter(g => g.state === 'queued');
|
|
423
|
+
// Nothing yields before the abort starts, so the run cannot finish in between and drop the messages.
|
|
424
|
+
// They leave pendingGuidance here and Pi's steering queue in execute(), which then sends them in one prompt.
|
|
425
|
+
const texts = new Set(guidance.map(g => g.text));
|
|
426
|
+
live.pendingGuidance = live.pendingGuidance.filter(text => !texts.has(text));
|
|
427
|
+
live.interruption = guidance;
|
|
428
|
+
live.wake?.();
|
|
429
|
+
await live.session.abort();
|
|
430
|
+
return guidance.length ? 'continued' : 'paused';
|
|
431
|
+
}
|
|
432
|
+
/** Takes the user's newest undelivered message back off the queue, to edit it; undefined when there is none left to take. */
|
|
433
|
+
withdraw(id: string) {
|
|
434
|
+
const live = this.running.get(id);
|
|
435
|
+
const text = live?.info.guidance.findLast(g => g.state === 'queued' && g.from === 'user')?.text;
|
|
436
|
+
if (!live || text === undefined || live.requeueing || live.interruption) return undefined;
|
|
437
|
+
// The queue holds only texts, so of equal ones the last is taken back, record and queue entry alike.
|
|
438
|
+
const guidance = live.info.guidance.findLast(g => g.state === 'queued' && g.text === text)!;
|
|
439
|
+
const pending = live.pendingGuidance.lastIndexOf(guidance.text);
|
|
440
|
+
if (pending >= 0) live.pendingGuidance.splice(pending, 1);
|
|
441
|
+
else {
|
|
442
|
+
const steering = live.session.getSteeringMessages();
|
|
443
|
+
const at = steering.lastIndexOf(guidance.text);
|
|
444
|
+
if (at < 0) return undefined; // Already on its way to the agent.
|
|
445
|
+
// Pi cannot drop a single queued message: clear the queue and steer the rest back in order.
|
|
446
|
+
void this.requeue(live, live.session.clearQueue().steering.filter((_, i) => i !== at));
|
|
447
|
+
}
|
|
448
|
+
live.info.guidance.splice(live.info.guidance.indexOf(guidance), 1); this.save(live.info);
|
|
449
|
+
return guidance.text;
|
|
450
|
+
}
|
|
451
|
+
private requeue(live: LiveRun, texts: string[]) {
|
|
452
|
+
const work: Promise<void> = (async () => {
|
|
453
|
+
try { for (const text of texts) await live.session.steer(text); }
|
|
454
|
+
catch (error) { this.warn(`Could not requeue a message for ${live.info.id}: ${String(error)}`); }
|
|
455
|
+
})().finally(() => { if (live.requeueing === work) live.requeueing = undefined; });
|
|
456
|
+
live.requeueing = work;
|
|
457
|
+
return work;
|
|
458
|
+
}
|
|
351
459
|
/** Reopens a finished child from its saved transcript, same ID, parent and model, and gives it a new message. */
|
|
352
460
|
private async resume(id: string, message: string, caller: string | undefined) {
|
|
353
461
|
const run = this.history.records.get(id)!, text = message.trim();
|
|
@@ -357,7 +465,7 @@ export class Children {
|
|
|
357
465
|
if (run.parentId !== caller) {
|
|
358
466
|
if (!run.parentId) throw new Error(`Only the main agent can resume ${id}.`);
|
|
359
467
|
const parent = this.running.get(run.parentId);
|
|
360
|
-
throw new Error(parent && ['running', 'waiting'].includes(parent.info.state) ? `Only ${id}'s parent ${run.parentId} can resume it. Ask ${run.parentId} with tell.`
|
|
468
|
+
throw new Error(parent && ['running', 'waiting', 'paused'].includes(parent.info.state) ? `Only ${id}'s parent ${run.parentId} can resume it. Ask ${run.parentId} with tell.`
|
|
361
469
|
: `Only ${id}'s parent ${run.parentId} can resume it, and that parent is no longer running. Resume ${run.parentId} instead, or spawn a new subagent.`);
|
|
362
470
|
}
|
|
363
471
|
if (this.closing) throw new Error('Profile is closing.');
|
|
@@ -376,7 +484,7 @@ export class Children {
|
|
|
376
484
|
const session = await this.open({ id, directory: run.cwd, depth: run.depth, parentId: run.parentId, connected: false, provider, model, sessionManager: manager });
|
|
377
485
|
// stop() cannot see this child until it is registered, so a parent stopped meanwhile must cancel it here.
|
|
378
486
|
const parent = run.parentId ? this.running.get(run.parentId) : undefined;
|
|
379
|
-
if (this.closing || run.parentId && (!parent || !['running', 'waiting'].includes(parent.info.state))) {
|
|
487
|
+
if (this.closing || run.parentId && (!parent || !['running', 'waiting', 'paused'].includes(parent.info.state))) {
|
|
380
488
|
await this.shutdown(session); this.dispose(session); throw new Error('Parent or profile is stopping.');
|
|
381
489
|
}
|
|
382
490
|
// The finished record stays untouched (and resumable) unless the new one is saved.
|
|
@@ -440,7 +548,7 @@ export class Children {
|
|
|
440
548
|
handoff.text = `[${run.id}] Connected conversation ${handoff.reason === 'complete' ? 'completed by user' : `interrupted (${handoff.reason})`}. This describes the conversation ending, not proof that every task succeeded.\n${summary}\n${source}`;
|
|
441
549
|
run.report = handoff.text; this.save(run);
|
|
442
550
|
}
|
|
443
|
-
await this.report(handoff.text, true);
|
|
551
|
+
await this.report(handoff.text, { once: true });
|
|
444
552
|
handoff.delivered = true; this.save(run);
|
|
445
553
|
}
|
|
446
554
|
async stop(id: string) {
|