pi-optchat 0.8.6 → 0.8.8
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 +1 -1
- package/docs/inspector.md +1 -0
- package/docs/recipe-differences.md +1 -1
- package/docs/storage.md +1 -1
- package/docs/subagents.md +1 -1
- package/package.json +1 -1
- package/src/agent-view.ts +2 -2
- package/src/agents.ts +12 -4
- package/src/compactor.ts +21 -15
- package/src/handoff.ts +4 -5
- package/src/index.ts +3 -3
- package/src/inspector.ts +4 -4
- package/src/memory.ts +56 -26
- package/src/navigation.ts +3 -3
- package/src/prompts.ts +6 -4
- package/src/runs.ts +2 -0
- package/src/window-bridge.ts +6 -5
package/README.md
CHANGED
|
@@ -245,7 +245,7 @@ OptChat keeps the recipe's numbers: 512-byte lines, binary merges, the 128,000
|
|
|
245
245
|
| Prompts | The original and 2026-10-08 prompts | The same, lightly adapted, all in `src/prompts.ts`: reports start `[id]`, a note on `[OptChat status]`, "don't poll slow things", the size limit as a ceiling rather than a target | Fit OptChat's tools; the size wording gave less filler with no recall loss (#129) |
|
|
246
246
|
| Compactor instructions | Gets your instructions | Doesn't | An A/B found they added nothing (#112) |
|
|
247
247
|
| Summary size | Strict 512 bytes | Asked for 512, keeps up to 640 without a retry | Saves retries for a little view space |
|
|
248
|
-
| Memory view | Sawtooth, 128,000 → 64,000 bytes | Same, saved to `view.json` and reloaded as
|
|
248
|
+
| Memory view | Sawtooth, 128,000 → 64,000 bytes | Same, saved to `view.json` with the compactions' view and reloaded as they were | A restart keeps both prompt prefixes cached |
|
|
249
249
|
| Compaction view | Ends at the first unsummarized line | Shows that line as a placeholder and keeps the lines after it | Cutting there hid short lines like an echo's own tool call; summaries scored lower (#101) |
|
|
250
250
|
| Tool-result summaries | Result only | Also sees the raw tool call | Recall 3.88 → 4.22 (#153) |
|
|
251
251
|
| Cache marks | View in 4-line blocks, one mark on the last whole block | Two more marks, 20 and 40 blocks before it | With one mark, a turn that added over 80 lines made the next turn rewrite the whole view; now up to 240 lines still read from cache |
|
package/docs/inspector.md
CHANGED
|
@@ -40,5 +40,6 @@ A memory gauge: how many messages the profile holds and how much of the 128 KB v
|
|
|
40
40
|
|
|
41
41
|
- If summarizing keeps failing, the last error and the retry countdown show under it. A turn waiting for summaries shows the same error next to its spinner, usually a summarizer model you aren't logged in to (`/optchat model`).
|
|
42
42
|
- Once every summary a turn waits for has failed, the turn goes on, with "(not summarized yet: zoom it)" in place of the missing lines, and the failed summaries keep retrying in the background.
|
|
43
|
+
- A summary the model itself won't write (a refusal, an empty reply, or 5 replies cut off at the token limit) isn't retried: that line keeps the start of what it summarizes, after "(not summarized: zoom it)", and a warning names it once. Errors such as a dropped connection, a rate limit or a missing login keep retrying.
|
|
43
44
|
- It also counts running agents, and interrupted ones waiting for you; their list is on Agents.
|
|
44
45
|
- While summaries or agents are at work, the bar's Activity item gets a **●**.
|
|
@@ -23,7 +23,7 @@ The README has the summary table. This page has the detail: what changed in each
|
|
|
23
23
|
|
|
24
24
|
- 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 (setting: Summary size tolerance).
|
|
25
25
|
- A memory view that grows a line per message and, past 128,000 bytes, merges in one batch down to 64,000. Between batches it only grows at its end, so each turn reads the last one's view from the cache. The view is saved and reloaded as it was.
|
|
26
|
-
- Binary merges, 8 compression workers by default (setting: Summaries at once), fixed retry delays, 5 shortening attempts, and a 30,000-character tool output cap.
|
|
26
|
+
- Binary merges, 8 compression workers by default (setting: Summaries at once), fixed retry delays, 5 shortening attempts, and a 30,000-character tool output cap. As in UniiChat, a node the model refuses keeps the first 512 bytes of its source instead (#193), and a summarizer is sent at most 100,000 characters of one message; zoom still pages through all of it.
|
|
27
27
|
|
|
28
28
|
## Compaction view and scheduling
|
|
29
29
|
|
package/docs/storage.md
CHANGED
|
@@ -12,7 +12,7 @@ An import builds a new memory generation (`memories/<id>/`) and switches `active
|
|
|
12
12
|
|
|
13
13
|
## The `Store` seam
|
|
14
14
|
|
|
15
|
-
Memory reaches disk through one seam, `Store` in `src/store.ts`: the log, summary nodes, saved view and images, which is what would travel between machines.
|
|
15
|
+
Memory reaches disk through one seam, `Store` in `src/store.ts`: the log, summary nodes, saved view and images, which is what would travel between machines. The saved view (`view.json`) holds the chat's view and the compactions' view, each with its batch state; an older one without the compactions' view refolds it from the chat's, once.
|
|
16
16
|
|
|
17
17
|
- Runs, recovery journals, imports and their pointer, usage, settings and `AGENTS.md` stay local files.
|
|
18
18
|
- `FileStore` (the layout in the README) is the only store today. Another, such as Postgres, would implement the same few operations.
|
package/docs/subagents.md
CHANGED
|
@@ -19,7 +19,7 @@ Your MCP servers (`~/.pi/agent/mcp.json`, the project's `.pi/mcp.json`) work in
|
|
|
19
19
|
Each task runs the profile's subagent model (`/optchat agents model`), unless the spawn sets that task's `model` (`provider/id`, as in `/model`) or `thinking`. The agent sets them only when you ask for them.
|
|
20
20
|
|
|
21
21
|
- What a task leaves out comes from the profile: a model alone runs at the profile's thinking level, or the nearest level that model takes.
|
|
22
|
-
- The `models` tool (`provider` optional) lists what a task may name, with the
|
|
22
|
+
- The agent's instructions name the profile's subagent model and level, so it leaves a task's `model` unset unless you ask for another one. The `models` tool (`provider` optional) lists what a task may name, with the default marked; the agent is told to take ids only from it, never from memory, since the newest ids may postdate its training ("Opus" without a version means the newest).
|
|
23
23
|
- A model Pi doesn't know refuses the whole spawn before any child starts, and the error lists the models you can use.
|
|
24
24
|
- A resumed child keeps the model and level it ran with.
|
|
25
25
|
|
package/package.json
CHANGED
package/src/agent-view.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
import { Editor, Spacer, compositeTuiLine, matchesKey, truncateToWidth, visibleWidth, type Component, type Focusable, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from '@earendil-works/pi-tui';
|
|
7
7
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
8
8
|
import type { Children } from './agents.ts';
|
|
9
|
-
import type
|
|
9
|
+
import { isHandingOff, type RunInfo } from './runs.ts';
|
|
10
10
|
import { textContent } from './transcript.ts';
|
|
11
11
|
import { record } from './cache.ts';
|
|
12
12
|
import { elapsed, fit, oneLine } from './inspector.ts';
|
|
@@ -164,7 +164,7 @@ export interface AgentViewOptions {
|
|
|
164
164
|
|
|
165
165
|
const statusOf = (run: RunInfo, children: Children) => {
|
|
166
166
|
const live = children.live(run.id);
|
|
167
|
-
if (!live) return run.state;
|
|
167
|
+
if (!live) return isHandingOff(run) ? 'handing off' : run.state;
|
|
168
168
|
if (run.state === 'stopping') return 'stopping';
|
|
169
169
|
if (run.state === 'waiting') return 'waiting for its agents';
|
|
170
170
|
if (run.state === 'paused') return 'interrupted · waiting for you';
|
package/src/agents.ts
CHANGED
|
@@ -68,8 +68,8 @@ export const taskDirectory = (cwd: string, path = '.', windows = process.platfor
|
|
|
68
68
|
const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
|
|
69
69
|
/** The spawn tools' tasks. What a task leaves out of model and thinking is the profile's subagent choice. */
|
|
70
70
|
export const TASKS = Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })),
|
|
71
|
-
model: Type.Optional(Type.String({ description: '
|
|
72
|
-
thinking: Type.Optional(StringEnum(THINKING, { description: '
|
|
71
|
+
model: Type.Optional(Type.String({ description: 'Leave unset for the profile\'s subagent model (named in your instructions). Set only when the user asks for a model, as provider/id from models().' })),
|
|
72
|
+
thinking: Type.Optional(StringEnum(THINKING, { description: 'Leave unset for the profile\'s subagent level. Set only when the user asks for one.' })) }), { minItems: 1 });
|
|
73
73
|
export type Task = Static<typeof TASKS>[number];
|
|
74
74
|
/** The tool beside spawn that lists what a task's `model` may be. */
|
|
75
75
|
export const modelsTool = (children: () => Children) => ({ name: 'models', label: 'List models',
|
|
@@ -147,13 +147,21 @@ export class Children {
|
|
|
147
147
|
const file = this.history.records.get(id)?.sessionFile;
|
|
148
148
|
return file ? sessionMessages(file) : [];
|
|
149
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Every message a run has finalized, in order and never rewritten: Pi's session entries, for a live run too.
|
|
152
|
+
* `session.messages` is only the model's projection, which compaction and retries replace with a shorter list.
|
|
153
|
+
*/
|
|
154
|
+
retained(id: string): AgentMessage[] {
|
|
155
|
+
const live = this.running.get(id);
|
|
156
|
+
return live ? live.session.sessionManager.getEntries().flatMap(e => e.type === 'message' ? [e.message] : []) : this.messages(id);
|
|
157
|
+
}
|
|
150
158
|
/** A run's messages for zoom, live or finished; undefined for no such run. A subagent `reader` sees only its own descendants, as with tell. */
|
|
151
159
|
transcript(id: string, reader?: string) {
|
|
152
160
|
const known = reader ? this.history.descendants(reader).some(run => run.id === id) : this.running.has(id) || this.history.records.has(id);
|
|
153
161
|
if (!known) return undefined;
|
|
154
162
|
const live = this.running.get(id);
|
|
155
163
|
if (!live) return this.messages(id);
|
|
156
|
-
const retained =
|
|
164
|
+
const retained = this.retained(id);
|
|
157
165
|
// Pi puts the finalized message in session.messages before message_end listeners run, then persists that same object.
|
|
158
166
|
const tail = live.streaming ?? live.session.messages.at(-1);
|
|
159
167
|
return tail && !retained.includes(tail) ? [...retained, tail] : retained;
|
|
@@ -272,7 +280,7 @@ export class Children {
|
|
|
272
280
|
thinking?: ModelChoice['thinking']; sessionManager: SessionManager }) {
|
|
273
281
|
const { id, directory, depth, parentId, connected } = o;
|
|
274
282
|
const { subagentLevels, maxAgents, memorySearch } = this.settings, delegates = depth < subagentLevels;
|
|
275
|
-
const instructions = [this.instructions(), delegates ? delegation(maxAgents) : NO_DELEGATION, STEERABLE, connected ? CONNECTED : NOT_CONNECTED].filter(Boolean).join('\n\n');
|
|
283
|
+
const instructions = [this.instructions(), delegates ? delegation(maxAgents, this.choice()) : NO_DELEGATION, STEERABLE, connected ? CONNECTED : NOT_CONNECTED].filter(Boolean).join('\n\n');
|
|
276
284
|
// The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
|
|
277
285
|
const settingsManager = SettingsManager.inMemory({ ...SettingsManager.create(directory, getAgentDir()).getSettings(),
|
|
278
286
|
compaction: subagentCompaction(o.model.contextWindow, refreshFraction(this.settings.refreshAt)), cacheWarming: 'off' });
|
package/src/compactor.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { clampThinkingLevel, isRetryableAssistantError, type Api, type Assistant
|
|
|
3
3
|
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
4
4
|
import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
5
5
|
import { COMPACT, compaction, IMPORT_GUIDANCE, tooLong } from './prompts.ts';
|
|
6
|
-
import { bytes,
|
|
6
|
+
import { bytes, cut, Refused, type Compressor } from './memory.ts';
|
|
7
7
|
import { cachePayload, splitView } from './cache.ts';
|
|
8
8
|
import { DEFAULT_SETTINGS } from './settings.ts';
|
|
9
9
|
|
|
@@ -42,10 +42,21 @@ function primeFirst() {
|
|
|
42
42
|
};
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
+
/** Pi's agent loop retries a dropped connection itself; streamSimple doesn't, so a transient error is asked again before it surfaces. */
|
|
46
|
+
export async function retryTransient(ask: () => Promise<AssistantMessage>, onReply: (reply: AssistantMessage) => void,
|
|
47
|
+
delays: readonly number[] = [1000, 2000], signal?: AbortSignal) {
|
|
48
|
+
for (let retry = 0; ; retry++) {
|
|
49
|
+
const reply = await ask();
|
|
50
|
+
onReply(reply);
|
|
51
|
+
if (retry >= delays.length || !isRetryableAssistantError(reply)) return reply;
|
|
52
|
+
await sleep(delays[retry], undefined, { signal });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
/** The model is asked for 512 bytes; `accepted` is the longest line kept without a retry (the profile's summary size tolerance). */
|
|
46
57
|
export function createCompressor(registry: ModelRegistry, choice: () => ModelChoice,
|
|
47
58
|
onUsage: (message: AssistantMessage) => void = () => {}, accepted = () => DEFAULT_SETTINGS.summaryAcceptBytes,
|
|
48
|
-
retryDelays
|
|
59
|
+
retryDelays?: readonly number[]): Compressor {
|
|
49
60
|
const gate = primeFirst();
|
|
50
61
|
return async (input, signal) => {
|
|
51
62
|
const selected = choice();
|
|
@@ -72,31 +83,26 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
|
|
|
72
83
|
return await stream.result();
|
|
73
84
|
} finally { warmed(false); }
|
|
74
85
|
};
|
|
75
|
-
|
|
76
|
-
const answer = async () => {
|
|
77
|
-
for (let retry = 0; ; retry++) {
|
|
78
|
-
const reply = await ask();
|
|
79
|
-
onUsage(reply);
|
|
80
|
-
if (retry >= retryDelays.length || !isRetryableAssistantError(reply)) return reply;
|
|
81
|
-
await sleep(retryDelays[retry], undefined, { signal });
|
|
82
|
-
}
|
|
83
|
-
};
|
|
86
|
+
const answer = () => retryTransient(ask, onUsage, retryDelays, signal);
|
|
84
87
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
85
88
|
const reply = await answer();
|
|
89
|
+
// A stop reason means the model answered: a refusal or a content filter, which asking again repeats.
|
|
90
|
+
// A provider's own 'error' stop (Mistral) is a failure on its side, not an answer: a plain error, tried again later.
|
|
91
|
+
if (reply.stopReason === 'error' && reply.rawStopReason !== undefined && reply.rawStopReason !== 'error' && !isRetryableAssistantError(reply))
|
|
92
|
+
throw new Refused(reply.errorMessage ?? `Compactor stopped: ${reply.rawStopReason}`);
|
|
86
93
|
if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? `Compactor ${reply.stopReason}`);
|
|
87
94
|
// A reply cut off at the token limit, or one that calls a tool, is no summary: ask again.
|
|
88
95
|
if (reply.stopReason === 'length' || reply.content.some(c => c.type === 'toolCall')) continue;
|
|
89
96
|
// The compactions' view shows each line under its id+n| head, which a line can copy.
|
|
90
97
|
const line = reply.content.filter(c => c.type === 'text').map(c => c.text).join('').trim().replace(/^\d+\+\d+\|\s*/, '');
|
|
91
|
-
if (!line) throw new
|
|
98
|
+
if (!line) throw new Refused('Compactor returned no text.');
|
|
92
99
|
tries.push(line);
|
|
93
100
|
// A merge of two short lines can come back nearly as big as both, so a line must also shrink what it replaces.
|
|
94
101
|
if (bytes(line) <= accepted() && bytes(line) < bytes(input.source)) break;
|
|
95
102
|
messages.push(reply);
|
|
96
|
-
|
|
97
|
-
messages.push({ role: 'user', content: tooLong(bytes(line), cut), timestamp: Date.now() });
|
|
103
|
+
messages.push({ role: 'user', content: tooLong(bytes(line), cut(line)), timestamp: Date.now() });
|
|
98
104
|
}
|
|
99
|
-
if (!tries.length) throw new
|
|
105
|
+
if (!tries.length) throw new Refused('Compactor returned no whole line in 5 tries.');
|
|
100
106
|
return tries.reduce((a, b) => bytes(a) <= bytes(b) ? a : b);
|
|
101
107
|
};
|
|
102
108
|
}
|
package/src/handoff.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
3
3
|
import type { AssistantMessage } from '@earendil-works/pi-ai';
|
|
4
|
-
import { reasoningFor, type ModelChoice } from './compactor.ts';
|
|
4
|
+
import { reasoningFor, retryTransient, type ModelChoice } from './compactor.ts';
|
|
5
5
|
import { HANDOFF } from './prompts.ts';
|
|
6
6
|
import type { RunInfo } from './runs.ts';
|
|
7
7
|
import { textContent } from './transcript.ts';
|
|
@@ -30,7 +30,7 @@ function formatEvidence({ run, messages, transcriptError }: HandoffEvidence) {
|
|
|
30
30
|
|
|
31
31
|
/** Summarize in one call when possible; otherwise fold chunks with the prior handoff included in the budget. */
|
|
32
32
|
export function createHandoffSummarizer(registry: ModelRegistry, choice: () => ModelChoice,
|
|
33
|
-
usage: (message: AssistantMessage) => void) {
|
|
33
|
+
usage: (message: AssistantMessage) => void, retryDelays?: readonly number[]) {
|
|
34
34
|
return async (run: RunInfo, messages: AgentMessage[], descendants: HandoffEvidence[] = []) => {
|
|
35
35
|
const selected = choice(), model = registry.find(selected.provider, selected.model);
|
|
36
36
|
if (!model) throw new Error('Profile compactor model unavailable');
|
|
@@ -50,11 +50,10 @@ export function createHandoffSummarizer(registry: ModelRegistry, choice: () => M
|
|
|
50
50
|
let end = Math.min(transcript.length, offset + available);
|
|
51
51
|
// Keep multibyte characters intact at chunk boundaries.
|
|
52
52
|
while (end < transcript.length && (transcript[end] & 0xc0) === 0x80) end--;
|
|
53
|
-
const reply = await registry.streamSimple(model, {
|
|
53
|
+
const reply = await retryTransient(() => registry.streamSimple(model, {
|
|
54
54
|
systemPrompt: HANDOFF,
|
|
55
55
|
messages: [{ role: 'user', timestamp: Date.now(), content: prefix + transcript.subarray(offset, end).toString('utf8') }],
|
|
56
|
-
}, { reasoning: reasoningFor(model, selected.thinking), maxTokens, signal: AbortSignal.timeout(300_000) }).result();
|
|
57
|
-
usage(reply);
|
|
56
|
+
}, { reasoning: reasoningFor(model, selected.thinking), maxTokens, signal: AbortSignal.timeout(300_000) }).result(), usage, retryDelays);
|
|
58
57
|
if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? reply.stopReason);
|
|
59
58
|
if (reply.stopReason === 'length') throw new Error('Handoff hit the model output limit before finishing');
|
|
60
59
|
summary = textContent(reply.content).trim();
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { Memory } from './memory.ts';
|
|
|
10
10
|
import { atomicWrite, storeFor } from './store.ts';
|
|
11
11
|
import { createCompressor } from './compactor.ts';
|
|
12
12
|
import { createProfile, holdProfile, instructions, isWindows, lastProfile, listProfiles, loadConfig, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
|
|
13
|
-
import { allowSearch, CONTINUITY, IMPORT_GUIDANCE, MASTER, SEARCH_DOC, TURN_REFRESHED, VIEW_DOC } from './prompts.ts';
|
|
13
|
+
import { allowSearch, CONTINUITY, IMPORT_GUIDANCE, MASTER, modelsHint, SEARCH_DOC, TURN_REFRESHED, VIEW_DOC } from './prompts.ts';
|
|
14
14
|
import { cachePayload, record } from './cache.ts';
|
|
15
15
|
import { saveImages } from './images.ts';
|
|
16
16
|
import { asUser, boundedMessage, atWork, buildContext, logMessage, previousExchange, refreshedRun, refreshBudget, refreshFraction, refreshPoint, reportReceipt, REPORT_RECEIPT, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
|
|
@@ -385,7 +385,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
385
385
|
const a = required();
|
|
386
386
|
// Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
|
|
387
387
|
syncSearch(a.config);
|
|
388
|
-
event.systemPromptOptions.customPrompt = allowSearch(`${MASTER}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}${a.config.memorySearch ? SEARCH_DOC : ''}`, a.config.memorySearch);
|
|
388
|
+
event.systemPromptOptions.customPrompt = allowSearch(`${MASTER}\n\n${modelsHint(a.config.subagent)}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}${a.config.memorySearch ? SEARCH_DOC : ''}`, a.config.memorySearch);
|
|
389
389
|
event.systemPromptOptions.sections.instructions = `${instructions(a.dir)}\n\n${IMPORT_GUIDANCE}`;
|
|
390
390
|
prompt = event.systemPrompt;
|
|
391
391
|
});
|
|
@@ -507,7 +507,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
507
507
|
if (tools.includes('search') !== memorySearch) pi.setActiveTools(memorySearch ? [...tools, 'search'] : tools.filter(name => name !== 'search'));
|
|
508
508
|
};
|
|
509
509
|
pi.registerTool({ name: 'spawn', label: 'Spawn background agents',
|
|
510
|
-
description: 'Start background subagents, returning IDs immediately. Use only when the user asks. Give each task the cwd of the project it works on, so the subagent starts there with that project\'s AGENTS.md.
|
|
510
|
+
description: 'Start background subagents, returning IDs immediately. Use only when the user asks. Give each task the cwd of the project it works on, so the subagent starts there with that project\'s AGENTS.md. Leave a task\'s model unset for the profile\'s default; name one only when the user asks, with an id from models(). Each receives the current memory view and read-only zoom/date. Whether children may delegate further, and how many agents may run at once, is set per profile. Completion reports arrive automatically; never poll or sleep waiting for them.',
|
|
511
511
|
parameters: Type.Object({ tasks: TASKS }),
|
|
512
512
|
async execute(_id, args, signal, _update, ctx) {
|
|
513
513
|
const text = await required().children.start(args.tasks, ctx.cwd, signal); status(ctx);
|
package/src/inspector.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithA
|
|
|
2
2
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
3
3
|
import type { Children } from './agents.ts';
|
|
4
4
|
import type { Memory } from './memory.ts';
|
|
5
|
-
import { isActiveRun, isRunning } from './runs.ts';
|
|
5
|
+
import { isActiveRun, isHandingOff, isRunning } from './runs.ts';
|
|
6
6
|
import type { Usage } from '@earendil-works/pi-ai';
|
|
7
7
|
import { ranges, summarizeUsage, type UsageLedger, type UsageRange, type UsageRole } from './usage.ts';
|
|
8
8
|
|
|
@@ -162,12 +162,12 @@ export class Inspector implements Component, Focusable {
|
|
|
162
162
|
private activityLines(width: number) {
|
|
163
163
|
const { memory, children, color } = this.options;
|
|
164
164
|
const { done, total, retryIn } = memory.progress();
|
|
165
|
-
const list = children.history.list(), agents = list.filter(isRunning).length, paused = list.filter(r => r.state === 'paused').length;
|
|
165
|
+
const list = children.history.list(), agents = list.filter(isRunning).length, paused = list.filter(r => r.state === 'paused').length, handingOff = list.filter(isHandingOff).length;
|
|
166
166
|
const filled = total ? Math.floor(10 * done / total) : 0;
|
|
167
167
|
const lines = [`${color('muted', 'Memory')} · ${count(memory.root.length)} messages · view ${Math.round(memory.size / 1000)} KB / ${Math.round(memory.budget / 1000)} KB`,
|
|
168
168
|
total ? `Catching up · ${count(done)} of ${count(total)} summaries ${color('accent', '█'.repeat(filled))}${color('dim', '░'.repeat(10 - filled))}` : color('dim', 'Settled')];
|
|
169
169
|
if (memory.lastError) lines.push(color('dim', `${oneLine(memory.lastError)}${retryIn === undefined ? '' : ` · retry in ${elapsed(retryIn)}`}`));
|
|
170
|
-
lines.push('', `${color('muted', 'Agents')} · ${agents} running${paused ? ` · ${paused} interrupted` : ''}`);
|
|
170
|
+
lines.push('', `${color('muted', 'Agents')} · ${agents} running${paused ? ` · ${paused} interrupted` : ''}${handingOff ? ` · ${handingOff} handing off` : ''}`);
|
|
171
171
|
return lines.flatMap(l => l ? wrapTextWithAnsi(l, width) : ['']);
|
|
172
172
|
}
|
|
173
173
|
render(width: number): string[] {
|
|
@@ -190,7 +190,7 @@ export class Inspector implements Component, Focusable {
|
|
|
190
190
|
if (cursor >= this.top + this.height) this.top = cursor - this.height + 1;
|
|
191
191
|
const rows = list.slice(this.top, this.top + this.height).map(run => {
|
|
192
192
|
const live = children.live(run.id), tools = live ? [...live.tools.values()].map(t => t.name).join(', ') : '';
|
|
193
|
-
const status = live ? `${run.state === 'stopping' ? 'stopping' : run.state === 'waiting' ? 'waiting for children' : run.state === 'paused' ? 'interrupted · waiting for you' : tools || (live.streaming ? 'responding' : 'working')} · ${elapsed(Date.now() - live.updated)} ago` : run.state;
|
|
193
|
+
const status = live ? `${run.state === 'stopping' ? 'stopping' : run.state === 'waiting' ? 'waiting for children' : run.state === 'paused' ? 'interrupted · waiting for you' : tools || (live.streaming ? 'responding' : 'working')} · ${elapsed(Date.now() - live.updated)} ago` : isHandingOff(run) ? 'handing off' : run.state;
|
|
194
194
|
return { run, task: `${' '.repeat(run.depth - 1)}${run.parentId ? '↳ ' : ''}${oneLine(run.task)}`, status, time: elapsed((run.ended ?? Date.now()) - run.started) };
|
|
195
195
|
});
|
|
196
196
|
// Columns: task (flexible) · status · duration (right-aligned); status yields first on narrow screens.
|
package/src/memory.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { FileStore, storeFor, type Saved, type Store } from './store.ts';
|
|
|
4
4
|
export const NODE = 512;
|
|
5
5
|
export const VIEW = 128_000;
|
|
6
6
|
export const CAP = 30_000;
|
|
7
|
+
/** The most characters of one message a leaf sends the compactor, so a huge paste can't overflow it; zoom still pages through all of it. */
|
|
8
|
+
export const SOURCE = 100_000;
|
|
7
9
|
/** The most characters one zoom into a message returns, so a page stays under the tool output CAP instead of losing its middle. */
|
|
8
10
|
export const PAGE = 25_000;
|
|
9
11
|
export const KINDS = ['user', 'talk', 'tool', 'echo', 'note', 'work'] as const;
|
|
@@ -16,13 +18,19 @@ export interface Summary extends Part { text: string; size: number }
|
|
|
16
18
|
* answers, raw, since its own line is often not built yet (#153). */
|
|
17
19
|
export interface Compression { context: string; source: string; part: Part; historical?: boolean; call?: { i: number; text: string } }
|
|
18
20
|
export type Compressor = (input: Compression, signal: AbortSignal) => Promise<string>;
|
|
21
|
+
/** A compressor throws this when the model itself gives no line for an input (a refusal, an empty reply, 5 cut-off replies):
|
|
22
|
+
* asked again it fails alike, so the node keeps the start of its source instead of being retried forever (#193). */
|
|
23
|
+
export class Refused extends Error {}
|
|
19
24
|
const key = ({ l, i }: Part) => l * 2 ** 40 + i;
|
|
20
25
|
const UNBUILT = '(not summarized yet: zoom it)';
|
|
26
|
+
const CLIPPED = '(not summarized: zoom it)';
|
|
21
27
|
export const start = ({ l, i }: Part) => i * 2 ** l;
|
|
22
28
|
export const end = (part: Part) => start(part) + 2 ** part.l;
|
|
23
29
|
export const bytes = (s: string) => Buffer.byteLength(s, 'utf8');
|
|
24
30
|
const UNBUILT_BYTES = bytes(UNBUILT);
|
|
25
31
|
export const flat = (s: string) => s.replace(/[\r\n]+/g, ' ');
|
|
32
|
+
/** The first NODE bytes, without a split character at the end. */
|
|
33
|
+
export const cut = (s: string) => Buffer.from(s).subarray(0, NODE).toString('utf8').replace(/\uFFFD+$/, '');
|
|
26
34
|
const lineBytes = (s: string) => bytes(flat(s));
|
|
27
35
|
const notice = (omitted: number) => `\n[${omitted} characters omitted; head and tail retained]\n`;
|
|
28
36
|
const VIEW_OPEN = '<chat>\n';
|
|
@@ -150,10 +158,8 @@ export class Memory {
|
|
|
150
158
|
}
|
|
151
159
|
for (const node of this.tree.values()) this.queueParent(node);
|
|
152
160
|
for (let i = 0; i < this.root.length; i++) if (!this.node({ l: 0, i })) this.unbuilt.add(i);
|
|
153
|
-
// Refolding the log gives
|
|
161
|
+
// Refolding the log gives different views than the live ones, and every cache entry would miss, so the views are saved.
|
|
154
162
|
const loaded = this.load(saved.view);
|
|
155
|
-
for (const part of this.view) this.compaction.push(part);
|
|
156
|
-
this.compaction.fit(loaded, true);
|
|
157
163
|
for (let i = loaded; i < this.root.length; i++) { this.push(i); this.fit(i + 1); }
|
|
158
164
|
this.fit(); this.save(); this.schedule();
|
|
159
165
|
}
|
|
@@ -182,33 +188,43 @@ export class Memory {
|
|
|
182
188
|
this.writing = tail;
|
|
183
189
|
return settled;
|
|
184
190
|
}
|
|
185
|
-
/** Takes the saved
|
|
191
|
+
/** Takes the saved views if they still tile the log from the start, and returns how many messages they cover. */
|
|
186
192
|
private load(view: string | undefined) {
|
|
187
193
|
if (view === undefined) return 0;
|
|
188
|
-
const rebuild = () => { this.warn('Rebuilt the memory view: the saved one does not match the log.'); return 0; };
|
|
189
194
|
let saved: unknown;
|
|
190
|
-
try { saved = JSON.parse(view); } catch {
|
|
195
|
+
try { saved = JSON.parse(view); } catch { saved = undefined; }
|
|
196
|
+
const chat = this.tiling(saved);
|
|
197
|
+
if (!chat) { this.warn('Rebuilt the memory view: the saved one does not match the log.'); return 0; }
|
|
198
|
+
for (const part of chat.parts) this.chat.push(part);
|
|
199
|
+
this.chat.merging = chat.merging;
|
|
200
|
+
// An older view.json has only the chat's view: the compactions' is refolded from it, a cold prefix once.
|
|
201
|
+
const compaction = object(saved) ? this.tiling(saved.compaction) : undefined;
|
|
202
|
+
if (compaction?.covered === chat.covered) { for (const part of compaction.parts) this.compaction.push(part); this.compaction.merging = compaction.merging; }
|
|
203
|
+
else { for (const part of chat.parts) this.compaction.push(part); this.compaction.fit(chat.covered, true); }
|
|
204
|
+
return chat.covered;
|
|
205
|
+
}
|
|
206
|
+
/** A saved view, `{ parts, merging }` or an older bare list of parts, if it tiles the log from the start. */
|
|
207
|
+
private tiling(saved: unknown) {
|
|
191
208
|
const merging = object(saved) && saved.merging === true;
|
|
192
209
|
if (object(saved) && typeof saved.merging === 'boolean') saved = saved.parts;
|
|
193
|
-
if (!Array.isArray(saved)) return
|
|
210
|
+
if (!Array.isArray(saved)) return;
|
|
194
211
|
const parts: Part[] = [];
|
|
195
212
|
let covered = 0;
|
|
196
213
|
for (const value of saved) {
|
|
197
214
|
const [l, i] = Array.isArray(value) ? value : [];
|
|
198
215
|
const part = { l, i };
|
|
199
216
|
if (!Number.isSafeInteger(l) || !Number.isSafeInteger(i) || l < 0 || start(part) !== covered || end(part) > this.root.length || (l > 0 && !this.node(part)))
|
|
200
|
-
return
|
|
217
|
+
return;
|
|
201
218
|
parts.push(part); covered = end(part);
|
|
202
219
|
}
|
|
203
|
-
|
|
204
|
-
this.chat.merging = merging;
|
|
205
|
-
return covered;
|
|
220
|
+
return { parts, merging, covered };
|
|
206
221
|
}
|
|
207
|
-
/** Saved when
|
|
208
|
-
* The log stays authoritative, so a failed save only warns. */
|
|
222
|
+
/** Saved when either view merges or changes batch state: a view that only grew is the saved one plus later messages, as `load`
|
|
223
|
+
* replays it. The log stays authoritative, so a failed save only warns. */
|
|
209
224
|
private save() {
|
|
210
225
|
const failed = (error: unknown) => { this.warn(`Could not save the memory view: ${error instanceof Error ? error.message : String(error)}`); };
|
|
211
|
-
|
|
226
|
+
const saved = ({ parts, merging }: Sawtooth) => ({ parts: parts.map(p => [p.l, p.i]), merging });
|
|
227
|
+
try { void this.enqueue(() => this.store.saveView(JSON.stringify({ ...saved(this.chat), compaction: saved(this.compaction) })), failed); }
|
|
212
228
|
catch (error) { failed(error); }
|
|
213
229
|
}
|
|
214
230
|
private push(i: number) {
|
|
@@ -231,15 +247,15 @@ export class Memory {
|
|
|
231
247
|
onChange(listener: () => void) { this.events.on('change', listener); return () => { this.events.off('change', listener); }; }
|
|
232
248
|
/** The view line covering message `at`. */
|
|
233
249
|
covering(at: number) { return this.chat.covering(at); }
|
|
234
|
-
/** Returns whether
|
|
250
|
+
/** Returns whether either view, or its batch state, changed. */
|
|
235
251
|
private fit(total = this.root.length) {
|
|
236
|
-
const merging = this.chat.merging;
|
|
252
|
+
const merging = [this.chat.merging, this.compaction.merging];
|
|
237
253
|
const merged = this.chat.fit(total);
|
|
238
|
-
this.compaction.fit(total, merged);
|
|
254
|
+
const compacted = this.compaction.fit(total, merged);
|
|
239
255
|
const owed = this.owed();
|
|
240
256
|
this.peak = owed > 0 ? Math.max(this.peak, owed) : 0;
|
|
241
257
|
this.events.emit('change');
|
|
242
|
-
return merged || merging !== this.chat.merging;
|
|
258
|
+
return merged || compacted || merging[0] !== this.chat.merging || merging[1] !== this.compaction.merging;
|
|
243
259
|
}
|
|
244
260
|
private schedule() {
|
|
245
261
|
if (this.scheduled || this.stopped) return;
|
|
@@ -286,11 +302,18 @@ export class Memory {
|
|
|
286
302
|
: [0, 1].map(offset => this.text({ l: part.l - 1, i: 2 * part.i + offset })).join('\n');
|
|
287
303
|
const mergeSource = part.l > 0 ? [0, 1].map(offset => flat(this.text({ l: part.l - 1, i: 2 * part.i + offset }))).join('\n') : source;
|
|
288
304
|
const call = part.l === 0 ? this.callOf(part.i) : undefined;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
305
|
+
let text = source;
|
|
306
|
+
if (bytes(source) > NODE) try {
|
|
307
|
+
text = (await this.compress({ context: this.compactionView(part), source: cap(mergeSource, SOURCE), part,
|
|
308
|
+
historical: this.root.slice(start(part), end(part)).some(entry => !!entry.origin),
|
|
309
|
+
...(call ? { call: { i: call.i, text: cap(`tool: ${call.text}`, 4000) } } : {}) }, this.controller.signal)).trim();
|
|
310
|
+
if (!text) throw new Refused('Compactor returned an empty summary.');
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (!(error instanceof Refused) || this.stopped) throw error;
|
|
313
|
+
text = cut(`${CLIPPED} ${flat(mergeSource)}`);
|
|
314
|
+
this.warn(`Compactor ${start(part)}+${2 ** part.l}: ${error.message} (kept its start instead)`);
|
|
315
|
+
}
|
|
292
316
|
if (this.stopped) return;
|
|
293
|
-
if (!text) throw new Error('Compactor returned an empty summary.');
|
|
294
317
|
const node = { ...part, text, size: lineBytes(text) };
|
|
295
318
|
// A failed node write fails the build, which is retried.
|
|
296
319
|
const written = this.enqueue(() => this.store.appendNode(node), error => { throw error; });
|
|
@@ -349,7 +372,7 @@ export class Memory {
|
|
|
349
372
|
* Paging a message that fits in one page is allowed, but the page says so. */
|
|
350
373
|
zoom(id: number, n: number, offset?: number, limit?: number) {
|
|
351
374
|
if (!Number.isSafeInteger(id) || id < 0 || !Number.isSafeInteger(n) || n < 1 || !Number.isInteger(Math.log2(n)) || id % n || id + n > this.root.length)
|
|
352
|
-
throw new Error(`No line ${id}+${n}.`);
|
|
375
|
+
throw new Error(`No line ${id}+${n}: n is a power of 2, id a multiple of n. The tree's roots: ${this.roots() || 'none, the chat is empty'}.`);
|
|
353
376
|
if (n === 1) {
|
|
354
377
|
const { kind, text } = this.root[id], page = Math.min(limit ?? PAGE, PAGE);
|
|
355
378
|
if (!Number.isSafeInteger(page) || page < 1) throw new Error('limit must be a positive integer.');
|
|
@@ -366,12 +389,19 @@ export class Memory {
|
|
|
366
389
|
}
|
|
367
390
|
if (offset !== undefined) throw new Error('offset and limit page one message: use them with n = 1.');
|
|
368
391
|
const l = Math.log2(n) - 1, i = 2 * id / n;
|
|
392
|
+
// A half not built yet shows as in the view, so the other half still comes back.
|
|
369
393
|
return [0, 1].map(offset => {
|
|
370
|
-
const part = { l, i: i + offset }
|
|
371
|
-
|
|
372
|
-
return `${start(part)}+${2 ** l}|${flat(node.text)}`;
|
|
394
|
+
const part = { l, i: i + offset };
|
|
395
|
+
return `${start(part)}+${2 ** l}|${flat(this.text(part))}`;
|
|
373
396
|
}).join('\n');
|
|
374
397
|
}
|
|
398
|
+
/** The largest whole lines covering the chat, oldest first: one per power of two in the message count. */
|
|
399
|
+
private roots() {
|
|
400
|
+
const lines: string[] = [];
|
|
401
|
+
for (let id = 0, n = 2 ** Math.floor(Math.log2(this.root.length || 1)); n >= 1; n /= 2)
|
|
402
|
+
if (id + n <= this.root.length) { lines.push(`${id}+${n}`); id += n; }
|
|
403
|
+
return lines.join(', ');
|
|
404
|
+
}
|
|
375
405
|
/** Original messages containing `text`, ignoring case, newest first, older than message `before`. Never summaries,
|
|
376
406
|
* nor the logged zoom/search calls and results, which only copy memory. */
|
|
377
407
|
search(text: string, before = this.root.length) {
|
package/src/navigation.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { matchesKey, truncateToWidth, visibleWidth, type KeyId } from '@earendil
|
|
|
3
3
|
import type { Children } from './agents.ts';
|
|
4
4
|
import type { Memory } from './memory.ts';
|
|
5
5
|
import { inspectorShowing, nextPage, type InspectorPage } from './inspector.ts';
|
|
6
|
-
import { isRunning } from './runs.ts';
|
|
6
|
+
import { isHandingOff, isRunning } from './runs.ts';
|
|
7
7
|
|
|
8
8
|
/** Configured before extension registration; this also works in legacy terminals with F6. */
|
|
9
9
|
export function inspectorShortcut(value = process.env.OPTCHAT_INSPECT_KEY ?? 'f6'): KeyId {
|
|
@@ -52,9 +52,9 @@ export function mountNavigation(ctx: ExtensionContext, children: Children, memor
|
|
|
52
52
|
invalidate() {},
|
|
53
53
|
render(width: number) {
|
|
54
54
|
if (inspectorShowing()) return []; // The open panel replaces the editor and this bar.
|
|
55
|
-
const list = children.history.list(), running = list.filter(isRunning).length, paused = list.filter(r => r.state === 'paused').length;
|
|
55
|
+
const list = children.history.list(), running = list.filter(isRunning).length, paused = list.filter(r => r.state === 'paused').length, handingOff = list.filter(isHandingOff).length;
|
|
56
56
|
const label = (page: InspectorPage, text: string) => navigation.selected === page ? theme.fg('accent', `› ${text}`) : theme.fg('muted', text);
|
|
57
|
-
const left = `${label('agents', `Agents: ${running} running${paused ? ` · ${paused} interrupted` : ''} · ${list.length - running - paused} saved`)} ${label('usage', 'Usage')} ${memory.progress().total || running ? theme.fg('accent', '● ') : ''}${label('activity', 'Activity')}`;
|
|
57
|
+
const left = `${label('agents', `Agents: ${running} running${paused ? ` · ${paused} interrupted` : ''}${handingOff ? ` · ${handingOff} handing off` : ''} · ${list.length - running - paused - handingOff} saved`)} ${label('usage', 'Usage')} ${memory.progress().total || running || handingOff ? theme.fg('accent', '● ') : ''}${label('activity', 'Activity')}`;
|
|
58
58
|
const hint = theme.fg('dim', navigation.selected ? '←→ select · Enter open · Esc input' : `${previous ? '' : '↓ select · '}${shortcut} inspect`);
|
|
59
59
|
const gap = width - visibleWidth(left) - visibleWidth(hint);
|
|
60
60
|
return [truncateToWidth(gap >= 3 ? `${left}${' '.repeat(gap)}${hint}` : `${left} ${hint}`, width)];
|
package/src/prompts.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ModelChoice } from './compactor.ts';
|
|
1
2
|
import { NODE, start, type Part } from './memory.ts';
|
|
2
3
|
|
|
3
4
|
// Prompts from Victor Taelin's OptChat recipe, lightly adapted; docs/recipe-differences.md lists the changes.
|
|
@@ -70,8 +71,10 @@ further along than it was, and never narrate the process of summarizing
|
|
|
70
71
|
("output unseen", "read-only", "unconfirmed") unless that uncertainty is itself
|
|
71
72
|
a fact OptChat must know. If told the line is too long, shorten it. Non-ASCII
|
|
72
73
|
characters cost 2-4 bytes.`;
|
|
73
|
-
/**
|
|
74
|
-
|
|
74
|
+
/** Names the profile's subagent choice, so the agent has no gap to fill from memory when it spawns: unset means this
|
|
75
|
+
* model, and the ids it knows from training drift, so the models tool is the only source for a named one. */
|
|
76
|
+
export const modelsHint = ({ provider, model, thinking }: ModelChoice) =>
|
|
77
|
+
`Subagents run ${provider}/${model} at thinking ${thinking}: leave a task's model and thinking unset for that. Set either one only when the user asks for it. A named model takes its id from models(), never from memory: your training may not know the newest ones, and a family without a version ("Opus", "GPT") means the newest one.`;
|
|
75
78
|
/** Labels the line naming running agents, last in every main-agent request while any run (src/transcript.ts); never stored. */
|
|
76
79
|
export const AT_WORK = '[OptChat status] Agents at work now: ';
|
|
77
80
|
export const MASTER = `You are OptChat, an AI agent that works for one user in a single chat that
|
|
@@ -81,7 +84,6 @@ message.
|
|
|
81
84
|
Do the user's tasks yourself (or delegate to subagents according to the tasks and user preferences), with your tools, following the user's instructions
|
|
82
85
|
at the end of this prompt: who they are, how their files are organized and how
|
|
83
86
|
they want work done. Use subagents when it makes sense and when the user asks for them.
|
|
84
|
-
${MODELS_HINT}
|
|
85
87
|
|
|
86
88
|
Messages the user sends while you work reach you between tool calls. Subagents
|
|
87
89
|
and computer tasks run in the background; each one's report reaches you as a
|
|
@@ -153,7 +155,7 @@ export const IMPORT_GUIDANCE = `Entries marked Historical are imported records,
|
|
|
153
155
|
|
|
154
156
|
// Subagent instructions, joined after the user's own (AGENTS.md etc.): one of the two delegation paragraphs,
|
|
155
157
|
// then STEERABLE, then the connected or plain tell_parent paragraph.
|
|
156
|
-
export const delegation = (maxAgents: number) => `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. ${
|
|
158
|
+
export const delegation = (maxAgents: number, choice: ModelChoice) => `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. ${modelsHint(choice)}`;
|
|
157
159
|
export const NO_DELEGATION = 'You are at the maximum delegation depth. Complete your task with your own tools.';
|
|
158
160
|
/** Pi hands steering to a run only between tool calls, so one long command keeps the parent and the user from reaching it. */
|
|
159
161
|
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.';
|
package/src/runs.ts
CHANGED
|
@@ -38,6 +38,8 @@ export function transition(run: RunInfo, to: RunState) {
|
|
|
38
38
|
export const isActiveRun = (run: RunInfo) => run.state === 'running' || run.state === 'waiting' || run.state === 'stopping' || run.state === 'paused';
|
|
39
39
|
/** Live and working: a paused run is neither running nor finished. */
|
|
40
40
|
export const isRunning = (run: RunInfo) => isActiveRun(run) && run.state !== 'paused';
|
|
41
|
+
/** A connected run that has ended but whose handoff summary has not reached the main agent yet. */
|
|
42
|
+
export const isHandingOff = (run: RunInfo) => !isActiveRun(run) && !!run.handoff && !run.handoff.delivered;
|
|
41
43
|
function isRun(value: unknown): value is RunInfo {
|
|
42
44
|
return record(value) && ['id', 'task', 'cwd', 'model', 'thinking', 'parentSession'].every(k => typeof value[k] === 'string')
|
|
43
45
|
&& typeof value.id === 'string' && /^[a-zA-Z0-9-]+$/.test(value.id)
|
package/src/window-bridge.ts
CHANGED
|
@@ -123,7 +123,7 @@ export async function serveWindows(directory: string, children: Children, availa
|
|
|
123
123
|
if (frame.activeOnly && (!children.live(id) || !['running', 'waiting', 'paused'].includes(info.state)))
|
|
124
124
|
throw new Error(`Subagent ${id} is no longer available in the menu. Refresh and choose a live agent.`);
|
|
125
125
|
const live = children.live(id);
|
|
126
|
-
const messages =
|
|
126
|
+
const messages = children.retained(id);
|
|
127
127
|
said.add(info.task.trim());
|
|
128
128
|
// This socket borrows the existing session, never its lifetime, slot or report destination.
|
|
129
129
|
// Other views (the main window's Agents page, other terminals) stay open alongside it.
|
|
@@ -148,7 +148,7 @@ export async function serveWindows(directory: string, children: Children, availa
|
|
|
148
148
|
}
|
|
149
149
|
if (frame.action === 'complete') {
|
|
150
150
|
const live = children.live(child);
|
|
151
|
-
if (live) drainMessages(
|
|
151
|
+
if (live) drainMessages(children.retained(child), live.info.task);
|
|
152
152
|
ending = true;
|
|
153
153
|
if (!attached) track(children.finish(child, 'complete').then(() => {}));
|
|
154
154
|
send({ kind: 'event', name: 'finished', text: attached ? 'Detached. The background agent remains with its original parent.'
|
|
@@ -167,7 +167,8 @@ export async function serveWindows(directory: string, children: Children, availa
|
|
|
167
167
|
const drainMessages = (messages: AgentMessage[], task: string) => {
|
|
168
168
|
const displayable = messages.filter(message => message.role === 'user' || message.role === 'assistant' || message.role === 'toolResult');
|
|
169
169
|
// An attachment may replay hours of tool output. Pace the history instead of overflowing the bounded wire
|
|
170
|
-
// or starving the owner's event loop. The next status tick continues from the same cursor
|
|
170
|
+
// or starving the owner's event loop. The next status tick continues from the same cursor, which stays valid because
|
|
171
|
+
// retained messages are only ever appended (unlike Pi's projection, which compaction and retries replace).
|
|
171
172
|
let batch = 0;
|
|
172
173
|
while (cursor < displayable.length) {
|
|
173
174
|
if (batch++ >= 32 || socket.writableLength > 1_000_000) return false;
|
|
@@ -190,7 +191,7 @@ export async function serveWindows(directory: string, children: Children, availa
|
|
|
190
191
|
send({ kind: 'event', name: 'finished', text: 'This conversation was deleted from the Agents page.' });
|
|
191
192
|
return;
|
|
192
193
|
}
|
|
193
|
-
if (live) drainMessages(
|
|
194
|
+
if (live) drainMessages(children.retained(child), info.task);
|
|
194
195
|
const preview = live?.streaming && 'content' in live.streaming ? textContent(live.streaming.content).slice(-2000) : '';
|
|
195
196
|
const text = `${info.state === 'waiting' ? 'Awaiting user or child reports' : info.state} · ${info.model}\n${live ? [...live.tools.values()].map(t => t.name).join(', ') : ''}\n${preview}`;
|
|
196
197
|
let state: LiveState = { state: info.state, model: info.model,
|
|
@@ -202,7 +203,7 @@ export async function serveWindows(directory: string, children: Children, availa
|
|
|
202
203
|
const status = JSON.stringify(state);
|
|
203
204
|
if (status !== lastStatus) { lastStatus = status; send({ kind: 'event', name: 'status', text, live: state }); }
|
|
204
205
|
if (!live && (attached && !isActiveRun(info) || info.handoff?.delivered)) {
|
|
205
|
-
try { if (!drainMessages(finalMessages ??= children.
|
|
206
|
+
try { if (!drainMessages(finalMessages ??= children.retained(child), info.task)) return; }
|
|
206
207
|
catch (error) { send({ kind: 'event', name: 'message', text: `Could not read the final transcript: ${errorText(error)}` }); }
|
|
207
208
|
ending = true;
|
|
208
209
|
send({ kind: 'event', name: 'finished', text: attached ? `Agent ${child} is ${info.state}. Its report goes to its original parent; /complete closes only this view.` : info.handoff?.text ?? 'Conversation ended.' });
|