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 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. Stop or interrupt it from the main window's **Agents** page, which can open the same agent at the same time, as can other terminals.
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 and on clean shutdown (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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.8.9",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
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
- const guidance = live.interruption.filter(g => g.state === 'queued');
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(`Interrupted by the user:\n\n${guidance.map(g => g.text).join('\n\n')}`);
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 here and Pi's steering queue in execute(), which then sends them in one prompt.
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;
@@ -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 and the working spinner. */
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(private readonly tui: TUI, theme: Theme) {
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
- this.loader.setMessage(state === 'waiting' ? `Waiting for ${agents === 1 ? '1 agent' : `${agents} agents`}` : state === 'stopping' ? 'Stopping' : 'Working');
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)';
@@ -1,19 +1,21 @@
1
1
  import { chmodSync } from 'node:fs';
2
2
  import { createConnection, createServer, type Socket } from 'node:net';
3
- import type { Children } from './agents.ts';
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
- type Action = 'list-agents' | 'attach' | 'start' | 'say' | 'tell-main' | 'answer' | 'complete';
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 running tools. */
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 === 'list-agents' || action === 'attach' || action === 'start' || action === 'say' || action === 'tell-main' || action === 'answer' || action === 'complete')
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.task);
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.task);
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') { said.add(frame.text.trim()); await children.tell(child, frame.text, 'user'); }
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
- const drainMessages = (messages: AgentMessage[], task: string) => {
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
- if (message.role === 'user' && firstUser) { text = task; firstUser = false; said.add(task.trim()); }
178
- if (message.role === 'user') { if (text) send({ kind: 'event', name: 'message', from: said.has(text.trim()) ? 'user' : undefined, text: shorten(text, 200_000) }); continue; }
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.task);
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.task)) return; }
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.' });