pi-optchat 0.8.9 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/connected-windows.md +2 -1
- package/docs/storage.md +1 -1
- package/package.json +1 -1
- package/src/agents.ts +21 -5
- package/src/checkpoint.ts +13 -1
- package/src/connected-window.ts +34 -8
- package/src/index.ts +3 -2
- package/src/window-bridge.ts +36 -19
package/README.md
CHANGED
|
@@ -176,7 +176,7 @@ Source paths, selection keys, exactly what is left out, and duplicate rules: [do
|
|
|
176
176
|
|
|
177
177
|
Each profile is locked to one Pi process. Open the same profile in a second terminal and Pi offers to connect it to the original window as a subagent.
|
|
178
178
|
|
|
179
|
-
- Your first message starts a subagent in the second window's working directory; later messages continue it. The window looks like a normal Pi chat.
|
|
179
|
+
- Your first message starts a subagent in the second window's working directory; later messages continue it. The window looks like a normal Pi chat: messages you send while it works queue under the spinner, Esc interrupts, Alt+Up takes the last queued one back.
|
|
180
180
|
- The subagent runs inside the original process, which stays the only writer of memory.
|
|
181
181
|
- `/tell-main <message>` messages the main agent. `/complete` ends the conversation and the compactor writes a handoff for the main agent; closing the window also produces one, marked **interrupted**.
|
|
182
182
|
- Text only. The connection is a local socket restricted to your OS user (on Windows a named pipe, without that restriction; see [Windows](#windows)); no daemon or server.
|
|
@@ -8,6 +8,7 @@ If you open the same profile in a second terminal, Pi offers to connect it to th
|
|
|
8
8
|
|
|
9
9
|
- Your first message starts a subagent in the second window's working directory. Later messages continue the same conversation.
|
|
10
10
|
- The window looks like a normal Pi chat: replies, tool calls with their output and running time, the working spinner, and reports from the subagent's own agents in the dark box. Ctrl+O expands tool output.
|
|
11
|
+
- Messages you send while the agent works show under the spinner until it reads them, as in Pi. At the editor, Esc interrupts the agent (it goes on with any queued messages, or waits for your next one) and Alt+Up (Alt+Q on Windows) takes the newest queued message back to edit. Both follow your Pi keybindings.
|
|
11
12
|
- The subagent runs inside the original process, which stays the only writer of memory. It appears in the original window's inspector and takes one of the profile's active agent slots (Max active agents, 8 by default). While it is open, the original window can't switch profile or import.
|
|
12
13
|
- The main agent is told when the conversation starts. Use `/tell-main <message>` to message it yourself; the subagent can use `tell_parent`, and the main agent replies with `tell`. Routine turns don't wake the main agent.
|
|
13
14
|
- Text only. For images, give the agent a file path.
|
|
@@ -35,7 +36,7 @@ pi --optchat-profile work --optchat-agent 8ab62e89
|
|
|
35
36
|
This opens that **same agent's session** as a normal Pi chat: its task, previous replies and tool results, then live output. Messages you type are guidance to that agent; a paused agent continues when you send one. `/tell-main` still contacts the main agent.
|
|
36
37
|
|
|
37
38
|
- The original Pi window stays the only owner: it runs the agent, and reports still go to the agent's parent. Opening the window starts no new agent or model call.
|
|
38
|
-
- **Closing the terminal or `/complete` only detaches.** The agent keeps running.
|
|
39
|
+
- **Closing the terminal or `/complete` only detaches.** The agent keeps running. Esc interrupts it here as in a connected window; stop it from the main window's **Agents** page, which can open the same agent at the same time, as can other terminals.
|
|
39
40
|
- A finished agent opens for reading, without being restarted.
|
|
40
41
|
- This needs interactive Pi and a running owner window. An unknown ID, or a conversation owned by another connected window, is refused rather than starting a new one.
|
|
41
42
|
- The attached terminal saves no session to resume; run the command again instead. The agent ID is not a Pi session ID.
|
package/docs/storage.md
CHANGED
|
@@ -4,7 +4,7 @@ Profile data lives in `~/.optchat/profiles/<name>/` (override the root with `OPT
|
|
|
4
4
|
|
|
5
5
|
## Git checkpoints
|
|
6
6
|
|
|
7
|
-
Each profile folder is a local Git repository, committed after each turn
|
|
7
|
+
Each profile folder is a local Git repository, committed after each turn (memory and config; not runs, HTML, or usage). It has no remote, so it is not a backup. To back up, copy the folder while Pi is closed.
|
|
8
8
|
|
|
9
9
|
## Import generations
|
|
10
10
|
|
package/package.json
CHANGED
package/src/agents.ts
CHANGED
|
@@ -112,6 +112,16 @@ const answering = (session: AgentSession) => new Promise<void>(resolve => {
|
|
|
112
112
|
if (event.type === 'message_update' || event.type === 'agent_end' || event.type === 'message_end' && event.message.role === 'assistant') { off(); resolve(); }
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
|
+
|
|
116
|
+
/** What a run interrupted with messages queued continues with: the user's interrupt, so windows show it as theirs. */
|
|
117
|
+
export const INTERRUPTED = 'Interrupted by the user:\n\n';
|
|
118
|
+
const interruptedWith = (texts: string[]) => `${INTERRUPTED}${texts.join('\n\n')}`;
|
|
119
|
+
|
|
120
|
+
/** Pi reads AGENTS.md/CLAUDE.md only at session start and /reload. Re-reading the files Pi loaded picks up edits each run and keeps
|
|
121
|
+
* the session's own choices (`-nc`, its agent dir); a file that can't be read is skipped, as Pi does. A new file still needs /reload. */
|
|
122
|
+
export function refreshContextFiles(options: { contextFiles: Array<{ path: string; content: string }> }) {
|
|
123
|
+
options.contextFiles = options.contextFiles.flatMap(({ path }) => { try { return [{ path, content: readFileSync(path, 'utf8') }]; } catch { return []; } });
|
|
124
|
+
}
|
|
115
125
|
export class Children {
|
|
116
126
|
private readonly running = new Map<string, LiveRun>();
|
|
117
127
|
readonly history: RunHistory;
|
|
@@ -298,7 +308,6 @@ export class Children {
|
|
|
298
308
|
thinking?: ModelChoice['thinking']; sessionManager: SessionManager }) {
|
|
299
309
|
const { id, directory, depth, parentId, connected } = o;
|
|
300
310
|
const { subagentLevels, maxAgents, memorySearch } = this.settings, delegates = depth < subagentLevels;
|
|
301
|
-
const instructions = [this.instructions(), delegates ? delegation(maxAgents, this.choice()) : NO_DELEGATION, STEERABLE, connected ? CONNECTED : NOT_CONNECTED].filter(Boolean).join('\n\n');
|
|
302
311
|
// The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
|
|
303
312
|
const settingsManager = SettingsManager.inMemory({ ...SettingsManager.create(directory, getAgentDir()).getSettings(),
|
|
304
313
|
compaction: subagentCompaction(o.model.contextWindow, refreshFraction(this.settings.refreshAt)), cacheWarming: 'off' });
|
|
@@ -309,9 +318,11 @@ export class Children {
|
|
|
309
318
|
const provider = this.registry.getRegisteredProviderConfig(o.model.provider);
|
|
310
319
|
if (provider) pi.registerProvider(o.model.provider, provider);
|
|
311
320
|
// Same prompt as the main agent (AGENTS.md files, skills, cwd); only the OptChat preamble differs.
|
|
321
|
+
// Read every run, so edits reach a live child's next run (after a pause, or a connected window's next message); Pi sends only the changed sections.
|
|
312
322
|
pi.on('before_agent_start', event => {
|
|
313
323
|
event.systemPromptOptions.customPrompt = allowSearch(`${SUBAGENT}\n\n${VIEW_DOC}${memorySearch ? SEARCH_DOC : ''}`, memorySearch);
|
|
314
|
-
event.systemPromptOptions.sections.instructions = instructions;
|
|
324
|
+
event.systemPromptOptions.sections.instructions = [this.instructions(), delegates ? delegation(maxAgents, this.choice()) : NO_DELEGATION, STEERABLE, connected ? CONNECTED : NOT_CONNECTED].filter(Boolean).join('\n\n');
|
|
325
|
+
refreshContextFiles(event.systemPromptOptions);
|
|
315
326
|
});
|
|
316
327
|
pi.on('before_provider_request', async (event, ctx) => {
|
|
317
328
|
// Set by launch for a sibling's first request; a stop or an interrupt aborts the request, which ends the wait.
|
|
@@ -385,7 +396,9 @@ export class Children {
|
|
|
385
396
|
await session.prompt(prompt);
|
|
386
397
|
while (info.state !== 'stopping') {
|
|
387
398
|
if (live.interruption) {
|
|
388
|
-
|
|
399
|
+
// A message Pi took during the abort (a rebuild of its queue can still be landing) went into a request the abort
|
|
400
|
+
// cancelled, so it got no reply: it goes in this prompt too. Only a stop's undelivered ones stay out.
|
|
401
|
+
const guidance = live.interruption.filter(g => g.state !== 'undelivered');
|
|
389
402
|
// Read Pi's queue only once a rebuild has landed, and take the interrupt's messages off it so they arrive once.
|
|
390
403
|
// Until this is done, tell() holds new messages in pendingGuidance, where the turn below or a pause picks them up.
|
|
391
404
|
await live.requeueing;
|
|
@@ -407,7 +420,7 @@ export class Children {
|
|
|
407
420
|
if (!transition(info, 'running')) continue; // A stop during the cleanup wins.
|
|
408
421
|
for (const g of guidance) g.state = 'delivered';
|
|
409
422
|
this.save(info);
|
|
410
|
-
await session.prompt(
|
|
423
|
+
await session.prompt(interruptedWith(guidance.map(g => g.text)));
|
|
411
424
|
continue;
|
|
412
425
|
}
|
|
413
426
|
const paused = info.state === 'paused';
|
|
@@ -526,9 +539,12 @@ export class Children {
|
|
|
526
539
|
if (!live || !['running', 'waiting'].includes(live.info.state)) throw new Error(`No running subagent ${id}.`);
|
|
527
540
|
const guidance = live.info.guidance.filter(g => g.state === 'queued');
|
|
528
541
|
// Nothing yields before the abort starts, so the run cannot finish in between and drop the messages.
|
|
529
|
-
// They leave pendingGuidance
|
|
542
|
+
// They leave pendingGuidance and Pi's steering queue here, and execute() sends them in one prompt. Pi's queue must be empty
|
|
543
|
+
// before the abort: after an aborted tool call Pi hands it to a request the abort cancels.
|
|
530
544
|
const texts = new Set(guidance.map(g => g.text));
|
|
531
545
|
live.pendingGuidance = live.pendingGuidance.filter(text => !texts.has(text));
|
|
546
|
+
// The rest of the queue (a child's tell_parent message) follows the interrupt's prompt, as reports to a paused run do.
|
|
547
|
+
live.pendingReports.push(...live.session.clearQueue().steering.filter(text => !texts.has(text)));
|
|
532
548
|
live.interruption = guidance;
|
|
533
549
|
live.wake?.();
|
|
534
550
|
await live.session.abort();
|
package/src/checkpoint.ts
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
3
|
+
import { existsSync, rmSync, statSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { atomicWrite } from './store.ts';
|
|
6
6
|
const exec = promisify(execFile);
|
|
7
7
|
// Each checkpoint stores the whole day log again as a loose object. Git's own auto gc counts loose objects, not bytes, and waits for about 6,700 of them (some 1,000 checkpoints), so the checkpoint packs them itself once they pass this size.
|
|
8
8
|
const LOOSE_LIMIT_KIB = 16_384;
|
|
9
|
+
// A host that stops Pi with SIGTERM and SIGKILL a second later can kill git mid-checkpoint, and git never removes the lock it leaves (#220).
|
|
10
|
+
// Index and ref updates take seconds at most, so no live git plausibly holds a lock this old. A younger one may still belong to a git that a
|
|
11
|
+
// killed Pi left running (a big `git add` or gc), so that checkpoint is skipped and the next one, after the next turn, looks again.
|
|
12
|
+
const STALE_LOCK_MS = 10 * 60_000;
|
|
13
|
+
const LOCKS = ['index.lock', 'HEAD.lock', 'refs/heads/main.lock'];
|
|
9
14
|
|
|
10
15
|
export async function checkpoint(directory: string) {
|
|
11
16
|
const git = (args: string[]) => exec('git', ['-C', directory, ...args], { maxBuffer: 1_000_000 });
|
|
12
17
|
if (!existsSync(join(directory, '.git'))) await git(['init', '-q', '-b', 'main']);
|
|
13
18
|
if (!existsSync(join(directory, '.gitignore'))) atomicWrite(join(directory, '.gitignore'), '/runs/\n/memory.html\n/usage.jsonl\n*.tmp\n');
|
|
19
|
+
const locks = LOCKS.flatMap(name => {
|
|
20
|
+
const path = join(directory, '.git', name), stat = statSync(path, { throwIfNoEntry: false });
|
|
21
|
+
return stat ? [{ path, age: Date.now() - stat.mtimeMs }] : [];
|
|
22
|
+
});
|
|
23
|
+
if (locks.some(lock => lock.age < STALE_LOCK_MS)) return;
|
|
24
|
+
// A lock that cannot go (Windows EBUSY) makes git fail below, and the next turn tries again.
|
|
25
|
+
for (const { path } of locks) try { rmSync(path, { force: true }); } catch { /* next turn retries */ }
|
|
14
26
|
await git(['add', '-A', '--', '.']);
|
|
15
27
|
const status = await git(['diff', '--cached', '--name-only']);
|
|
16
28
|
if (!status.stdout.trim()) return;
|
package/src/connected-window.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { getMarkdownTheme, UserMessageComponent, type ExtensionAPI, type ExtensionContext, type Theme } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import { Container, Loader, Markdown, type Component, type TUI } from '@earendil-works/pi-tui';
|
|
1
|
+
import { getMarkdownTheme, keyText, UserMessageComponent, type ExtensionAPI, type ExtensionContext, type Theme } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { Container, Editor, getKeybindings, Loader, Markdown, Spacer, TruncatedText, TuiAltScreen, TuiMainScreen, type Component, type TUI } from '@earendil-works/pi-tui';
|
|
3
3
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
4
4
|
import { connectWindow, type LiveState, type WindowEvent } from './window-bridge.ts';
|
|
5
5
|
import { profilePath } from './profiles.ts';
|
|
@@ -43,14 +43,15 @@ export function registerConnectedRenderer(pi: ExtensionAPI) {
|
|
|
43
43
|
|
|
44
44
|
const toolCalls = (message: AgentMessage) => message.role === 'assistant' ? message.content.flatMap(part => part.type === 'toolCall' ? [part.id] : []) : [];
|
|
45
45
|
|
|
46
|
-
/** What normally sits at the bottom of Pi's chat while it works: the streaming reply, running tools
|
|
46
|
+
/** What normally sits at the bottom of Pi's chat while it works: the streaming reply, running tools, the working spinner and your queued messages. */
|
|
47
47
|
class LiveTurn implements Component {
|
|
48
48
|
private readonly loader: Loader;
|
|
49
49
|
private readonly body = new Container();
|
|
50
|
+
private readonly queued = new Container();
|
|
50
51
|
private spinning = false;
|
|
51
52
|
turn: AgentMessage[] = [];
|
|
52
53
|
live?: LiveState;
|
|
53
|
-
constructor(
|
|
54
|
+
constructor(readonly tui: TUI, private readonly theme: Theme) {
|
|
54
55
|
this.loader = new Loader(tui, text => theme.fg('accent', text), text => theme.fg('muted', text), 'Working');
|
|
55
56
|
// Running tools show their elapsed time, which only moves when they are redrawn.
|
|
56
57
|
this.timer = setInterval(() => { if (this.live?.tools.length) this.update(); }, 1000); this.timer.unref();
|
|
@@ -65,13 +66,21 @@ class LiveTurn implements Component {
|
|
|
65
66
|
// Waiting with no agents of its own means it is waiting for you, so nothing spins, like an idle Pi.
|
|
66
67
|
const agents = this.live?.agents ?? 0;
|
|
67
68
|
const spin = !!state && statusState(state, agents) === 'working';
|
|
68
|
-
|
|
69
|
+
// Owners that send `queued` also take Esc; older ones would drop the connection on it.
|
|
70
|
+
const interrupt = this.live?.queued ? ` (${keyText('app.interrupt')} to interrupt)` : '';
|
|
71
|
+
this.loader.setMessage(state === 'waiting' ? `Waiting for ${agents === 1 ? '1 agent' : `${agents} agents`}${interrupt}` : state === 'stopping' ? 'Stopping' : `Working${interrupt}`);
|
|
69
72
|
if (spin && !this.spinning) this.loader.start(); else if (!spin && this.spinning) this.loader.stop();
|
|
70
73
|
this.spinning = spin;
|
|
74
|
+
// Like Pi's own queue: what you sent shows at once, and leaves this list when the agent reads it into the chat.
|
|
75
|
+
this.queued.clear();
|
|
76
|
+
const queued = this.live?.queued ?? [];
|
|
77
|
+
if (queued.length) this.queued.addChild(new Spacer(1));
|
|
78
|
+
for (const text of queued) this.queued.addChild(new TruncatedText(this.theme.fg('dim', `Steering: ${text.replace(/\s+/g, ' ')}`), 1, 0));
|
|
79
|
+
if (queued.length) this.queued.addChild(new TruncatedText(this.theme.fg('dim', `↳ ${keyText('app.message.dequeue')} to edit the last one`), 1, 0));
|
|
71
80
|
this.tui.requestRender();
|
|
72
81
|
}
|
|
73
|
-
render(width: number) { return [...this.body.render(width), ...(this.spinning ? this.loader.render(width) : [])]; }
|
|
74
|
-
invalidate() { this.body.invalidate(); this.loader.invalidate(); }
|
|
82
|
+
render(width: number) { return [...this.body.render(width), ...(this.spinning ? this.loader.render(width) : []), ...this.queued.render(width)]; }
|
|
83
|
+
invalidate() { this.body.invalidate(); this.loader.invalidate(); this.queued.invalidate(); }
|
|
75
84
|
dispose() { clearInterval(this.timer); this.loader.stop(); }
|
|
76
85
|
}
|
|
77
86
|
|
|
@@ -135,6 +144,23 @@ export async function openConnectedWindow(pi: ExtensionAPI, ctx: ExtensionContex
|
|
|
135
144
|
ctx.ui.setStatus('optchat', `OptChat: ${profile} · connected window · send a task to start`); title('waiting');
|
|
136
145
|
display(`Connected to ${profile}'s original window. Messages here go to one subagent using the profile's subagent model. /tell-main sends a message to the main agent; /complete ends this conversation and sends a handoff. Closing this window interrupts it.`);
|
|
137
146
|
}
|
|
147
|
+
const failed = (what: string) => (error: unknown) => ctx.ui.notify(`Could not ${what}: ${error instanceof Error ? error.message : String(error)}`, 'error');
|
|
148
|
+
/** The agent runs in the original window, so Esc and Alt+Up go there; only at the chat's editor, so dialogs and autocomplete keep them. */
|
|
149
|
+
const unlisten = ctx.ui.onTerminalInput(data => {
|
|
150
|
+
const tui = liveTurn?.tui, focused = tui instanceof TuiMainScreen || tui instanceof TuiAltScreen ? tui.getFocusedComponent() : undefined;
|
|
151
|
+
if (ended || !live?.queued || !(focused instanceof Editor) || focused.isShowingAutocomplete() || tui?.hasOverlay()) return;
|
|
152
|
+
const keys = getKeybindings();
|
|
153
|
+
if (keys.matches(data, 'app.interrupt') && live.state !== 'stopping' && statusState(live.state, live.agents) === 'working') {
|
|
154
|
+
connection.request('interrupt').catch(failed('interrupt'));
|
|
155
|
+
return { consume: true };
|
|
156
|
+
}
|
|
157
|
+
if (keys.matches(data, 'app.message.dequeue') && live.queued.length) {
|
|
158
|
+
connection.request('withdraw').then(text => {
|
|
159
|
+
if (text !== undefined) ctx.ui.setEditorText([text, ctx.ui.getEditorText()].filter(part => part.trim()).join('\n\n'));
|
|
160
|
+
}, failed('take the message back'));
|
|
161
|
+
return { consume: true };
|
|
162
|
+
}
|
|
163
|
+
});
|
|
138
164
|
return {
|
|
139
165
|
/** Ended by /complete, by the owner, or by losing the owner: nothing left here to protect from a session switch. */
|
|
140
166
|
get ended() { return ended; },
|
|
@@ -150,6 +176,6 @@ export async function openConnectedWindow(pi: ExtensionAPI, ctx: ExtensionContex
|
|
|
150
176
|
ended = true; connection.close(); ctx.shutdown();
|
|
151
177
|
} finally { ctx.ui.setWorkingMessage(); }
|
|
152
178
|
},
|
|
153
|
-
close() { ended = true; connection.close(); },
|
|
179
|
+
close() { ended = true; unlisten(); connection.close(); },
|
|
154
180
|
};
|
|
155
181
|
}
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@ 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';
|
|
17
17
|
import { registerReportRenderer, type ReportDetails } from './report-message.ts';
|
|
18
18
|
import { memoryTools, result, searchTool } from './tools.ts';
|
|
19
|
-
import { Children, loadedBuiltins, modelsTool, TASKS } from './agents.ts';
|
|
19
|
+
import { Children, loadedBuiltins, modelsTool, refreshContextFiles, TASKS } from './agents.ts';
|
|
20
20
|
import { isActiveRun, type RunInfo } from './runs.ts';
|
|
21
21
|
import { exportBrowser } from './browser.ts';
|
|
22
22
|
import { Inbox } from './inbox.ts';
|
|
@@ -168,8 +168,8 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
168
168
|
await closeWindows?.(); closeWindows = undefined;
|
|
169
169
|
await recovery;
|
|
170
170
|
await old.children.close(); flush(); if (!pendingImport(old.dir)) old.inbox.recover(old.memory);
|
|
171
|
+
// No checkpoint here: a host may kill Pi a second after asking it to stop (#220). The next turn's checkpoint commits what this wrote.
|
|
171
172
|
await old.memory.close(); await checkpoints;
|
|
172
|
-
await checkpoint(old.dir);
|
|
173
173
|
} finally {
|
|
174
174
|
await old.memory.close(); await old.unlock(); active = undefined;
|
|
175
175
|
run = []; previous = []; logged = 0; keep = 0; view = undefined; runStarted = false; receipts.clear(); prompt = ''; idle = () => true;
|
|
@@ -387,6 +387,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
387
387
|
syncSearch(a.config);
|
|
388
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
|
+
refreshContextFiles(event.systemPromptOptions);
|
|
390
391
|
prompt = event.systemPrompt;
|
|
391
392
|
});
|
|
392
393
|
const DUPLICATE_REPORT = '(duplicate report, already delivered)';
|
package/src/window-bridge.ts
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import { chmodSync } from 'node:fs';
|
|
2
2
|
import { createConnection, createServer, type Socket } from 'node:net';
|
|
3
|
-
import type
|
|
3
|
+
import { INTERRUPTED, type Children } from './agents.ts';
|
|
4
4
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
5
5
|
import { record } from './cache.ts';
|
|
6
6
|
import { checkSocketPath, isWindows, profileSocket, removeStaleSocket } from './profiles.ts';
|
|
7
7
|
import { textContent } from './transcript.ts';
|
|
8
|
-
import { isActiveRun } from './runs.ts';
|
|
8
|
+
import { isActiveRun, type RunInfo } from './runs.ts';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
const ACTIONS = ['list-agents', 'attach', 'start', 'say', 'tell-main', 'answer', 'interrupt', 'withdraw', 'complete'] as const;
|
|
11
|
+
type Action = typeof ACTIONS[number];
|
|
12
|
+
const isAction = (value: unknown): value is Action => ACTIONS.some(action => action === value);
|
|
11
13
|
interface Request { kind: 'request'; id: number; action: Action; text?: string; cwd?: string; activeOnly?: boolean }
|
|
12
14
|
interface Reply { kind: 'reply'; id: number; error?: string; text?: string }
|
|
13
15
|
/** A tool the subagent is running right now, with its streamed output so far. */
|
|
14
16
|
export interface LiveTool { id: string; name: string; args: unknown; output?: unknown; started: number }
|
|
15
|
-
/** What the subagent is doing between finished messages: its streaming reply and
|
|
16
|
-
export interface LiveState { state: string; model: string; streaming?: AgentMessage; tools: LiveTool[]; agents?: number }
|
|
17
|
+
/** What the subagent is doing between finished messages: its streaming reply, running tools, and your messages it has not read yet. */
|
|
18
|
+
export interface LiveState { state: string; model: string; streaming?: AgentMessage; tools: LiveTool[]; agents?: number; queued?: string[] }
|
|
17
19
|
/**
|
|
18
20
|
* `from` marks the conversation itself (your messages and the agent's replies); other messages have no `from`.
|
|
19
21
|
* `message` carries the agent's own messages (replies with tool calls, tool results) so the window can draw them with Pi's components.
|
|
@@ -28,7 +30,8 @@ const isMessage = (value: unknown): value is AgentMessage => record(value) && (v
|
|
|
28
30
|
const isLive = (value: unknown): value is LiveState => record(value) && typeof value.state === 'string' && typeof value.model === 'string'
|
|
29
31
|
&& (value.streaming === undefined || isMessage(value.streaming))
|
|
30
32
|
&& Array.isArray(value.tools) && value.tools.every(t => record(t) && typeof t.id === 'string' && typeof t.name === 'string' && typeof t.started === 'number')
|
|
31
|
-
&& (value.agents === undefined || typeof value.agents === 'number')
|
|
33
|
+
&& (value.agents === undefined || typeof value.agents === 'number')
|
|
34
|
+
&& (value.queued === undefined || Array.isArray(value.queued) && value.queued.every(text => typeof text === 'string'));
|
|
32
35
|
function parse(value: unknown): Frame {
|
|
33
36
|
if (!record(value)) throw new Error('Invalid window message');
|
|
34
37
|
const name = value.name, action = value.action, from = value.from;
|
|
@@ -38,7 +41,7 @@ function parse(value: unknown): Frame {
|
|
|
38
41
|
if (typeof value.id !== 'number' || !Number.isSafeInteger(value.id)) throw new Error('Invalid request ID');
|
|
39
42
|
if (value.kind === 'reply' && (value.error === undefined || typeof value.error === 'string') && (value.text === undefined || typeof value.text === 'string'))
|
|
40
43
|
return { kind: 'reply', id: value.id, error: value.error, text: value.text };
|
|
41
|
-
if (value.kind === 'request' && (action
|
|
44
|
+
if (value.kind === 'request' && isAction(action)
|
|
42
45
|
&& (value.text === undefined || typeof value.text === 'string') && (value.cwd === undefined || typeof value.cwd === 'string')
|
|
43
46
|
&& (value.activeOnly === undefined || typeof value.activeOnly === 'boolean'))
|
|
44
47
|
return { kind: 'request', id: value.id, action, text: value.text, cwd: value.cwd, activeOnly: value.activeOnly as boolean | undefined };
|
|
@@ -97,7 +100,6 @@ export async function serveWindows(directory: string, children: Children, availa
|
|
|
97
100
|
connections.add(socket);
|
|
98
101
|
let child: string | undefined, attached = false, cursor = 0, firstUser = true, lastStatus = '', ending = false;
|
|
99
102
|
let finalMessages: AgentMessage[] | undefined;
|
|
100
|
-
const said = new Set<string>(); // Main-agent guidance and child reports reach the session as user messages too.
|
|
101
103
|
const controller = new AbortController();
|
|
102
104
|
let queue = Promise.resolve();
|
|
103
105
|
const track = (promise: Promise<void>) => { work.add(promise); void promise.finally(() => work.delete(promise)).catch(() => {}); };
|
|
@@ -124,13 +126,11 @@ export async function serveWindows(directory: string, children: Children, availa
|
|
|
124
126
|
throw new Error(`Subagent ${id} is no longer available in the menu. Refresh and choose a live agent.`);
|
|
125
127
|
const live = children.live(id);
|
|
126
128
|
const messages = children.retained(id);
|
|
127
|
-
said.add(info.task.trim());
|
|
128
129
|
// This socket borrows the existing session, never its lifetime, slot or report destination.
|
|
129
130
|
// Other views (the main window's Agents page, other terminals) stay open alongside it.
|
|
130
131
|
attached = true; child = id; finalMessages = live ? undefined : messages;
|
|
131
|
-
for (const guidance of info.guidance) if (guidance.from === 'user') said.add(guidance.text.trim());
|
|
132
132
|
send({ kind: 'event', name: 'started', text: child });
|
|
133
|
-
drainMessages(messages, info
|
|
133
|
+
drainMessages(messages, info);
|
|
134
134
|
} else if (frame.action === 'start') {
|
|
135
135
|
if (child) throw new Error('This window already has a conversation');
|
|
136
136
|
if (!available()) throw new Error('Profile is importing or shutting down; try again when it is ready');
|
|
@@ -146,16 +146,25 @@ export async function serveWindows(directory: string, children: Children, availa
|
|
|
146
146
|
if (text.length > ANSWER_LIMIT) throw new Error(`The reply is over ${ANSWER_LIMIT.toLocaleString('en')} characters; the original Pi window has it in full`);
|
|
147
147
|
send({ kind: 'reply', id: frame.id, text }); return;
|
|
148
148
|
}
|
|
149
|
+
// Esc and Alt+Up in the window, as agent-view.ts does them in the main one.
|
|
150
|
+
if (frame.action === 'interrupt') {
|
|
151
|
+
// Reply once the abort has started, not when it ends, so a tool that ignores it can't hold up this window.
|
|
152
|
+
// interrupt() throws before it starts the abort, so a refusal is already settled and wins the race.
|
|
153
|
+
const interrupting = children.interrupt(child);
|
|
154
|
+
track(interrupting.then(() => {}));
|
|
155
|
+
send({ kind: 'reply', id: frame.id, text: await Promise.race([interrupting, undefined]) }); return;
|
|
156
|
+
}
|
|
157
|
+
if (frame.action === 'withdraw') { send({ kind: 'reply', id: frame.id, text: children.withdraw(child) }); return; }
|
|
149
158
|
if (frame.action === 'complete') {
|
|
150
159
|
const live = children.live(child);
|
|
151
|
-
if (live) drainMessages(children.retained(child), live.info
|
|
160
|
+
if (live) drainMessages(children.retained(child), live.info);
|
|
152
161
|
ending = true;
|
|
153
162
|
if (!attached) track(children.finish(child, 'complete').then(() => {}));
|
|
154
163
|
send({ kind: 'event', name: 'finished', text: attached ? 'Detached. The background agent remains with its original parent.'
|
|
155
164
|
: 'Conversation ended by you. The original window is stopping remaining work and preparing the handoff.' });
|
|
156
165
|
} else {
|
|
157
166
|
if (!frame.text?.trim()) throw new Error('Message is empty');
|
|
158
|
-
if (frame.action === 'say')
|
|
167
|
+
if (frame.action === 'say') await children.tell(child, frame.text, 'user');
|
|
159
168
|
else await report(`[${child}] User message from connected window: ${frame.text}`);
|
|
160
169
|
}
|
|
161
170
|
}
|
|
@@ -164,7 +173,8 @@ export async function serveWindows(directory: string, children: Children, availa
|
|
|
164
173
|
});
|
|
165
174
|
track(queue);
|
|
166
175
|
});
|
|
167
|
-
|
|
176
|
+
/** Main-agent guidance and child reports reach the session as user messages too; only what the user sent, from any view, shows as theirs. */
|
|
177
|
+
const drainMessages = (messages: AgentMessage[], info: RunInfo) => {
|
|
168
178
|
const displayable = messages.filter(message => message.role === 'user' || message.role === 'assistant' || message.role === 'toolResult');
|
|
169
179
|
// An attachment may replay hours of tool output. Pace the history instead of overflowing the bounded wire
|
|
170
180
|
// or starving the owner's event loop. The next status tick continues from the same cursor, which stays valid because
|
|
@@ -174,8 +184,14 @@ export async function serveWindows(directory: string, children: Children, availa
|
|
|
174
184
|
if (batch++ >= 32 || socket.writableLength > 1_000_000) return false;
|
|
175
185
|
const message = displayable[cursor++];
|
|
176
186
|
let text = message.role === 'toolResult' ? '' : textContent(message.content);
|
|
177
|
-
|
|
178
|
-
if (
|
|
187
|
+
const task = message.role === 'user' && firstUser;
|
|
188
|
+
if (task) { text = info.task; firstUser = false; }
|
|
189
|
+
if (message.role === 'user') {
|
|
190
|
+
// The task, and an interrupt, which continues with your queued messages, are yours too.
|
|
191
|
+
const yours = task || text.startsWith(INTERRUPTED) || info.guidance.some(g => g.from === 'user' && g.text === text.trim());
|
|
192
|
+
if (text) send({ kind: 'event', name: 'message', from: yours ? 'user' : undefined, text: shorten(text, 200_000) });
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
179
195
|
const clipped = clip(message);
|
|
180
196
|
if (fits(clipped)) send({ kind: 'event', name: 'message', from: message.role === 'assistant' ? 'agent' : undefined, text: shorten(text, 200_000), message: clipped });
|
|
181
197
|
else if (text) send({ kind: 'event', name: 'message', from: 'agent', text: shorten(text, 200_000) });
|
|
@@ -191,19 +207,20 @@ export async function serveWindows(directory: string, children: Children, availa
|
|
|
191
207
|
send({ kind: 'event', name: 'finished', text: 'This conversation was deleted from the Agents page.' });
|
|
192
208
|
return;
|
|
193
209
|
}
|
|
194
|
-
if (live) drainMessages(children.retained(child), info
|
|
210
|
+
if (live) drainMessages(children.retained(child), info);
|
|
195
211
|
const preview = live?.streaming && 'content' in live.streaming ? textContent(live.streaming.content).slice(-2000) : '';
|
|
196
212
|
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}`;
|
|
197
213
|
let state: LiveState = { state: info.state, model: info.model,
|
|
198
214
|
streaming: live?.streaming?.role === 'assistant' ? clip(live.streaming) : undefined,
|
|
199
215
|
tools: live ? [...live.tools].map(([id, t]) => ({ id, name: t.name, args: t.args, output: clipOutput(t.output), started: t.started })) : [],
|
|
200
216
|
// A finished child counts until its report has reached this agent, so the agent never looks idle in between.
|
|
201
|
-
agents: [...children.history.records.values()].filter(run => run.parentId === child && (isActiveRun(run) || children.live(run.id))).length
|
|
217
|
+
agents: [...children.history.records.values()].filter(run => run.parentId === child && (isActiveRun(run) || children.live(run.id))).length,
|
|
218
|
+
queued: info.guidance.flatMap(g => g.state === 'queued' && g.from === 'user' ? [g.text.slice(0, 1000)] : []) };
|
|
202
219
|
if (!fits(state)) state = { ...state, streaming: undefined, tools: state.tools.map(t => ({ ...t, args: {}, output: undefined })) };
|
|
203
220
|
const status = JSON.stringify(state);
|
|
204
221
|
if (status !== lastStatus) { lastStatus = status; send({ kind: 'event', name: 'status', text, live: state }); }
|
|
205
222
|
if (!live && (attached && !isActiveRun(info) || info.handoff?.delivered)) {
|
|
206
|
-
try { if (!drainMessages(finalMessages ??= children.retained(child), info
|
|
223
|
+
try { if (!drainMessages(finalMessages ??= children.retained(child), info)) return; }
|
|
207
224
|
catch (error) { send({ kind: 'event', name: 'message', text: `Could not read the final transcript: ${errorText(error)}` }); }
|
|
208
225
|
ending = true;
|
|
209
226
|
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.' });
|