pi-optchat 0.8.8 → 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
 
@@ -12,7 +12,7 @@ The source remains upstream rather than duplicating the full article here. Its p
12
12
 
13
13
  - `src/memory.ts`: append-only log, binary summary tree, compression scheduling (§4 of the 2026-10-08 revision: a message's summary starts once fewer than 8 lines before it are unbuilt, a merge once both halves are built, kept in a queue so the tree is never scanned for work), the compactions' own view (§4: the view merged further to 16,000-32,000 bytes, ending at the node; unlike the recipe, a line not built yet shows as a placeholder instead of ending it, so a summary still sees the built lines after it), the view (§3.2: the most due pair, measured from its last message, merges in one batch from 128,000 down to 64,000 bytes, and the view is saved to `view.json` rather than refolded at start), zoom/date, and the opt-in text search over original messages (`src/tools.ts` has the tool; not in the recipe, off by default).
14
14
  - `src/compactor.ts`: contextual compression and size retries, with the 2026-10-08 revision's task (a 512-dash ruler for the size) and its "Too long" retry, verbatim. The compactions' view shows each line under its `id+n|` head, as the task's line ids need, so a reply that copies a head has it removed.
15
- - `src/cache.ts`: Anthropic cache marks: the view in blocks of 4 lines, one mark on the last whole block and one at the request's end, as in recipe §3.3, plus two marks 20 and 40 blocks before the last. Anthropic looks back only 20 blocks from a mark, so with the recipe's one view mark a turn that added more than 80 lines, such as 40 tool calls and their results, made the next turn rewrite the whole view. OpenAI requests get no marks: GPT-5.6 and GPT-6 Luna reject the recipe's `prompt_cache_breakpoint` with a 400 (found by @aaaxn), so they rely on implicit prefix caching. OptChat doesn't set `reasoning.context` either: GPT-5.6 already defaults to the recipe's `"all_turns"`, and OpenAI documents it only for GPT-5.6 and GPT-6.1 Sol.
15
+ - `src/cache.ts`: Anthropic cache marks: the view in blocks of 4 lines, one mark on the last whole block and one at the request's end, as in recipe §3.3 (a compaction leaves its end unmarked, as Unii does: its task is never sent again), plus two marks 20 and 40 blocks before the last. Anthropic looks back only 20 blocks from a mark, so with the recipe's one view mark a turn that added more than 80 lines, such as 40 tool calls and their results, made the next turn rewrite the whole view. OpenAI requests get no marks: GPT-5.6 and GPT-6 Luna reject the recipe's `prompt_cache_breakpoint` with a 400 (found by @aaaxn), so they rely on implicit prefix caching. OptChat doesn't set `reasoning.context` either: GPT-5.6 already defaults to the recipe's `"all_turns"`, and OpenAI documents it only for GPT-5.6 and GPT-6.1 Sol.
16
16
  - `src/transcript.ts`: fresh context per parent run, current-run tool loop retained. The previous completed exchange is also retained in full text (left out if over 16,000 bytes by default), an intentional addition to the summary-only recipe for conversational continuity.
17
17
  - `src/agents.ts`: asynchronous Pi SDK children and automatic completion reports.
18
18
  - `src/settings.ts`: per-profile settings for the departures from the recipe. Defaults are the recipe's (no memory search), except the previous exchange (on), the summary size tolerance (640 bytes, against the recipe's strict 512) and the turn refresh threshold (30%, where the recipe has none). Grouped subagent reports are off by default, as in the revised recipe, which reports each subagent on its own.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.8.8",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
package/src/agents.ts CHANGED
@@ -23,7 +23,7 @@ import { THINKING } from './profiles.ts';
23
23
  export interface LiveRun {
24
24
  session: AgentSession; info: RunInfo; updated: number; streaming?: AgentMessage;
25
25
  tools: Map<string, { name: string; args: unknown; output?: unknown; started: number }>;
26
- pendingReports: string[]; pendingGuidance: string[]; wake?: () => void; completion?: Promise<void>;
26
+ pendingReports: string[]; pendingGuidance: string[]; wake?: () => void; completion?: Promise<void>; primed?: Promise<unknown>;
27
27
  /** Set by the user's interrupt: the queued guidance the run continues with once its current step is aborted; with none, it pauses. */
28
28
  interruption?: RunInfo['guidance'];
29
29
  /** Messages being steered back into Pi's queue after it was cleared; other queue changes wait for it. */
@@ -98,6 +98,30 @@ export function subagentCompaction(contextWindow: number, fraction: number) {
98
98
  if (!(fraction > 0)) return { enabled: true };
99
99
  return { enabled: true, reserveTokens: Math.max(16_384, Math.round(contextWindow * (1 - fraction) - VIEW / 4)) };
100
100
  }
101
+ /** Waits for `promise`, or until `signal` aborts. */
102
+ async function abortable(promise: Promise<unknown>, signal?: AbortSignal) {
103
+ let stop = () => {};
104
+ const aborted = new Promise<void>(resolve => { stop = resolve; });
105
+ signal?.addEventListener('abort', stop, { once: true });
106
+ if (signal?.aborted) stop();
107
+ try { await Promise.race([promise, aborted]); } finally { signal?.removeEventListener('abort', stop); }
108
+ }
109
+ /** Resolves once the session's model starts answering, or its request ends without an answer. */
110
+ const answering = (session: AgentSession) => new Promise<void>(resolve => {
111
+ const off = session.subscribe(event => {
112
+ if (event.type === 'message_update' || event.type === 'agent_end' || event.type === 'message_end' && event.message.role === 'assistant') { off(); resolve(); }
113
+ });
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
+ }
101
125
  export class Children {
102
126
  private readonly running = new Map<string, LiveRun>();
103
127
  readonly history: RunHistory;
@@ -224,10 +248,14 @@ export class Children {
224
248
  const launched = await this.track(this.launchBatch({ tasks: runs, cwd, depth, parentId, connected, signal, cancelled }));
225
249
  // Grouped, the last child of this spawn to finish delivers every report; otherwise each reports on its own.
226
250
  const batch = groupReports && !connected ? { ids: launched.map(c => c.info.id), reports: new Map<string, string>() } : undefined;
251
+ // Siblings on one model and directory send the same system prompt, tools and view. Anthropic can't read a cache entry
252
+ // still being written, so their first requests wait until the first sibling starts answering and read its entry instead.
253
+ const [first] = launched, answered = launched.length > 1 && first.session.model?.api === 'anthropic-messages' ? answering(first.session) : undefined;
227
254
  for (const live of launched) {
228
255
  live.batch = batch;
229
256
  const work = this.execute(live, `${view}\n\nYour task:\n${live.info.task}`).catch(error => this.warn(`Subagent completion failed: ${String(error)}`))
230
257
  .finally(() => { this.completions.delete(work); this.changed(); });
258
+ if (live === first && answered) for (const sibling of launched.slice(1)) if ((['model', 'thinking', 'cwd'] as const).every(k => sibling.info[k] === first.info[k])) sibling.primed = Promise.race([answered, work]);
231
259
  this.completions.add(work);
232
260
  live.completion = work;
233
261
  }
@@ -280,7 +308,6 @@ export class Children {
280
308
  thinking?: ModelChoice['thinking']; sessionManager: SessionManager }) {
281
309
  const { id, directory, depth, parentId, connected } = o;
282
310
  const { subagentLevels, maxAgents, memorySearch } = this.settings, delegates = depth < subagentLevels;
283
- const instructions = [this.instructions(), delegates ? delegation(maxAgents, this.choice()) : NO_DELEGATION, STEERABLE, connected ? CONNECTED : NOT_CONNECTED].filter(Boolean).join('\n\n');
284
311
  // The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
285
312
  const settingsManager = SettingsManager.inMemory({ ...SettingsManager.create(directory, getAgentDir()).getSettings(),
286
313
  compaction: subagentCompaction(o.model.contextWindow, refreshFraction(this.settings.refreshAt)), cacheWarming: 'off' });
@@ -291,11 +318,18 @@ export class Children {
291
318
  const provider = this.registry.getRegisteredProviderConfig(o.model.provider);
292
319
  if (provider) pi.registerProvider(o.model.provider, provider);
293
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.
294
322
  pi.on('before_agent_start', event => {
295
323
  event.systemPromptOptions.customPrompt = allowSearch(`${SUBAGENT}\n\n${VIEW_DOC}${memorySearch ? SEARCH_DOC : ''}`, memorySearch);
296
- 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);
326
+ });
327
+ pi.on('before_provider_request', async (event, ctx) => {
328
+ // Set by launch for a sibling's first request; a stop or an interrupt aborts the request, which ends the wait.
329
+ const live = this.running.get(id), primed = live?.primed;
330
+ if (live && primed) { live.primed = undefined; await abortable(primed, ctx.signal); }
331
+ return ctx.model?.api === 'anthropic-messages' ? cachePayload(event.payload) : event.payload;
297
332
  });
298
- pi.on('before_provider_request', (event, ctx) => ctx.model?.api === 'anthropic-messages' ? cachePayload(event.payload) : event.payload);
299
333
  }],
300
334
  });
301
335
  await loader.reload();
@@ -362,7 +396,9 @@ export class Children {
362
396
  await session.prompt(prompt);
363
397
  while (info.state !== 'stopping') {
364
398
  if (live.interruption) {
365
- 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');
366
402
  // Read Pi's queue only once a rebuild has landed, and take the interrupt's messages off it so they arrive once.
367
403
  // Until this is done, tell() holds new messages in pendingGuidance, where the turn below or a pause picks them up.
368
404
  await live.requeueing;
@@ -384,7 +420,7 @@ export class Children {
384
420
  if (!transition(info, 'running')) continue; // A stop during the cleanup wins.
385
421
  for (const g of guidance) g.state = 'delivered';
386
422
  this.save(info);
387
- 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)));
388
424
  continue;
389
425
  }
390
426
  const paused = info.state === 'paused';
@@ -503,9 +539,12 @@ export class Children {
503
539
  if (!live || !['running', 'waiting'].includes(live.info.state)) throw new Error(`No running subagent ${id}.`);
504
540
  const guidance = live.info.guidance.filter(g => g.state === 'queued');
505
541
  // Nothing yields before the abort starts, so the run cannot finish in between and drop the messages.
506
- // 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.
507
544
  const texts = new Set(guidance.map(g => g.text));
508
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)));
509
548
  live.interruption = guidance;
510
549
  live.wake?.();
511
550
  await live.session.abort();
package/src/cache.ts CHANGED
@@ -33,8 +33,9 @@ function lastBlock(message: Record<string, unknown>) {
33
33
  /** Anthropic: marks on the view (see MARKS) plus a mark at the end of the request, so the next call finds the last one's entry
34
34
  * and pays only for the lines after it. The end mark sits on the last block itself rather than as top-level automatic caching:
35
35
  * a proxy or setting that gives block marks a 1h TTL makes Anthropic refuse a top-level mark, whose TTL defaults to 5m, on the
36
- * same block ("Top-level cache_control has ttl='5m' but the target block already has cache_control with ttl='1h'"). */
37
- export function cachePayload(payload: unknown): unknown {
36
+ * same block ("Top-level cache_control has ttl='5m' but the target block already has cache_control with ttl='1h'").
37
+ * `end: false` leaves the end unmarked: a compaction's task is never sent again, so marking it would only write it at 1.25× (#197). */
38
+ export function cachePayload(payload: unknown, { end: markEnd = true } = {}): unknown {
38
39
  if (!record(payload) || !Array.isArray(payload.messages)) return payload;
39
40
  const messages = payload.messages;
40
41
  const view = new Set<unknown>();
@@ -42,9 +43,12 @@ export function cachePayload(payload: unknown): unknown {
42
43
  if (!record(message) || message.role !== 'user' || !Array.isArray(message.content)) continue;
43
44
  const at = message.content.findIndex((block: unknown) => record(block) && block.type === 'text' && typeof block.text === 'string' && isView(block.text));
44
45
  if (at < 0) continue;
45
- const pieces = splitView(message.content[at].text), marked = new Set(MARKS.map(back => pieces.length - 2 - back));
46
+ // A subagent's task follows the view in the same text. Lines never hold a newline, so the first one before </chat> ends the view;
47
+ // the task goes in its own block, so the view's marks fall on lines its siblings share and they can read one another's entry.
48
+ const text: string = message.content[at].text, close = text.indexOf('\n</chat>'), end = close < 0 ? text.length : close + '\n</chat>'.length;
49
+ const pieces = splitView(text.slice(0, end)), marked = new Set(MARKS.map(back => pieces.length - 2 - back));
46
50
  const blocks = pieces.map((text, j) => ({ type: 'text', text, ...(marked.has(j) ? { cache_control: { type: 'ephemeral' } } : {}) }));
47
- message.content.splice(at, 1, ...blocks);
51
+ message.content.splice(at, 1, ...blocks, ...(end < text.length ? [{ type: 'text', text: text.slice(end) }] : []));
48
52
  for (const block of blocks) view.add(block);
49
53
  break;
50
54
  }
@@ -62,7 +66,7 @@ export function cachePayload(payload: unknown): unknown {
62
66
  // Pi may end the request with an empty system message carrying settings (effort); it holds nothing to cache.
63
67
  const [before, last] = messages.filter(message => !record(message) || message.role !== 'system').slice(-2);
64
68
  const turn = isAtWork(last) && record(before) && before.role === 'user' ? lastBlock(before) : undefined;
65
- const end = turn ?? endBlock(messages);
69
+ const end = markEnd ? turn ?? endBlock(messages) : undefined;
66
70
  if (end) end.cache_control = { type: 'ephemeral' };
67
71
  return payload;
68
72
  }
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/compactor.ts CHANGED
@@ -76,7 +76,7 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
76
76
  // A shared session id is the OpenAI prompt-cache key; SSE because over a websocket Codex would chain unrelated parallel calls on one cached connection.
77
77
  sessionId: 'optchat-compactor', transport: 'sse',
78
78
  reasoning: thinking, signal, cacheRetention: 'short',
79
- onPayload: payload => model.api === 'anthropic-messages' ? cachePayload(payload) : payload,
79
+ onPayload: payload => model.api === 'anthropic-messages' ? cachePayload(payload, { end: false }) : payload,
80
80
  });
81
81
  // The cache entry is usable once the model starts answering.
82
82
  for await (const event of stream) if (event.type !== 'start') { warmed(event.type !== 'error'); break; }
@@ -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)';
package/src/memory.ts CHANGED
@@ -279,14 +279,13 @@ export class Memory {
279
279
  }).finally(() => { this.busy.delete(id); this.schedule(); this.events.emit('change'); });
280
280
  this.busy.set(id, promise);
281
281
  };
282
- // A message's node starts once fewer than `jobs` lines before it are unbuilt; a merge, once both halves are built.
283
- let ahead = 0;
284
- for (const i of this.unbuilt) {
285
- if (ahead++ === this.jobs) break;
286
- if (this.busy.size >= this.jobs) return;
287
- run({ l: 0, i });
288
- }
289
- for (const part of this.merges.values()) {
282
+ // A message's node starts once fewer than `jobs` lines before it are unbuilt; a merge, once both halves are built. They start
283
+ // in the order of the context they end at, a merge first at a tie, as Unii's do (#194): messages first left the merges waiting
284
+ // through a backlog, so the views ran over budget and changed at every node built, and compactions read about 60% from the cache.
285
+ const ready: Part[] = [];
286
+ for (const i of this.unbuilt) { if (ready.length === this.jobs) break; ready.push({ l: 0, i }); }
287
+ const at = (part: Part) => part.l ? end(part) : part.i;
288
+ for (const part of [...ready, ...this.merges.values()].sort((a, b) => at(a) - at(b) || b.l - a.l)) {
290
289
  if (this.busy.size >= this.jobs) return;
291
290
  run(part);
292
291
  }
@@ -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.' });