pi-optchat 0.6.3 → 0.6.5

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
@@ -137,9 +137,10 @@ See OpenAI's guides on [exporting ChatGPT data](https://help.openai.com/en/artic
137
137
 
138
138
  ## Connected windows
139
139
 
140
- Each profile is locked to one Pi process. If you open the same profile in a second terminal, Pi offers to connect it to the original window as a subagent.
140
+ Each profile is locked to one Pi process. If you open the same profile in a second terminal, Pi offers to connect it to the original window as a subagent, or to go back to the profile picker.
141
141
 
142
142
  - Your first message starts a subagent in the second window's working directory. Later messages continue the same conversation.
143
+ - 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.
143
144
  - The subagent runs inside the original process, which stays the only writer of memory. It appears in the original window's inspector and uses one of the 8 agent slots. While it is open, the original window can't switch profile or import.
144
145
  - 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.
145
146
  - Run `/complete` when done. The window closes, remaining work stops, and the compactor writes a handoff for the main agent: decisions, changes, evidence, failures, unfinished work, and links to the transcripts.
@@ -174,12 +175,13 @@ To delete a profile, delete its folder. Your original Pi sessions are kept in Pi
174
175
  ## Good to know
175
176
 
176
177
  - **Profiles separate memory and instructions only.** Agents keep full filesystem access and share provider credentials.
178
+ - **Tab title**: the terminal tab shows the profile and what it is doing: `π personal` while waiting for you, `● π personal` while the agent works, plus `· 2 agents` while subagents run. A connected window shows `↳ personal`, `● ↳ personal`, then `↳ personal · done` or `↳ personal · disconnected`. OptChat replaces Pi's default title and puts its own back when Pi resets it (new session, reload, rename).
177
179
  - **Use worktrees** when parallel agents edit the same repository; they share the filesystem.
178
180
  - **Instructions**: the main agent and subagents get Pi's usual global and repository `AGENTS.md` files and your skills, followed by the profile's `AGENTS.md`, which comes last and wins. OptChat replaces only Pi's opening prompt. Prompt templates work in the main session only.
179
181
  - **Images** are available during the current run but stored in memory as text placeholders.
180
182
  - **Pi's auto-compaction is off.** A single very long run can still hit the model's context limit; stop it and continue in a new turn.
181
183
  - **Restarts**: unsent inputs are recovered into memory, and pending subagent reports are delivered. Interrupted subagents are not restarted.
182
- - **Skill and template inputs** can occasionally be recovered as an unanswered input after a crash, because Pi expands them after the input journal records them. Plain text chat is unaffected.
184
+ - **Prompt-template inputs** can be saved twice: expanded, and later in their original form as an unanswered input, because Pi expands them after the input journal records them. Skill commands (`/skill:name`) are matched back to their journaled input and don't have this problem. Plain text chat is unaffected.
183
185
 
184
186
  ## How it differs from the recipe
185
187
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
package/src/agent-view.ts CHANGED
@@ -60,7 +60,8 @@ export class TranscriptView {
60
60
  private streaming?: AssistantMessageComponent;
61
61
  private expanded = false;
62
62
  constructor(private readonly tui: TUI, private readonly cwd: string, private readonly definition: (name: string) => Renderers = () => undefined) {}
63
- toggleTools() { this.expanded = !this.expanded; for (const tool of this.tools.values()) tool.setExpanded(this.expanded); }
63
+ toggleTools() { this.setExpanded(!this.expanded); }
64
+ setExpanded(expanded: boolean) { if (expanded === this.expanded) return; this.expanded = expanded; for (const tool of this.tools.values()) tool.setExpanded(expanded); }
64
65
  private tool(id: string, name: string, args: unknown) {
65
66
  let tool = this.tools.get(id);
66
67
  if (!tool) {
package/src/agents.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { existsSync, readFileSync } from 'node:fs';
3
- import { join, dirname } from 'node:path';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { join, dirname, resolve } from 'node:path';
4
+ import { homedir } from 'node:os';
4
5
  import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, getAgentDir, type AgentSession, type AgentSessionEvent, type ModelRegistry } from '@earendil-works/pi-coding-agent';
5
6
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
6
7
  import { SUBAGENT, VIEW_DOC } from './prompts.ts';
@@ -28,10 +29,16 @@ const packageName = (path: string): string | undefined => {
28
29
  for (let dir = dirname(path); dir !== dirname(dir); dir = dirname(dir)) {
29
30
  const manifest = join(dir, 'package.json');
30
31
  if (!existsSync(manifest)) continue;
31
- const data: unknown = JSON.parse(readFileSync(manifest, 'utf8'));
32
- return data && typeof data === 'object' && 'name' in data && typeof data.name === 'string' ? data.name : undefined;
32
+ try {
33
+ const data: unknown = JSON.parse(readFileSync(manifest, 'utf8'));
34
+ return data && typeof data === 'object' && 'name' in data && typeof data.name === 'string' ? data.name : undefined;
35
+ } catch { return undefined; } // A broken manifest is not OptChat's and must not block every spawn.
33
36
  }
34
37
  };
38
+ /** A task's cwd may start with `~` and may be relative to the spawning agent's directory. */
39
+ export const taskDirectory = (cwd: string, path = '.', windows = process.platform === 'win32') =>
40
+ resolve(cwd, path.replace(windows ? /^~(?=$|[\\/])/ : /^~(?=$|\/)/, homedir()));
41
+ export const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
35
42
  const isOptchat = (path: string) => packageName(path) === 'pi-optchat';
36
43
  export class Children {
37
44
  private readonly running = new Map<string, LiveRun>();
@@ -92,6 +99,7 @@ export class Children {
92
99
  } catch (error) { this.warn(`Could not record subagent activity: ${String(error)}`); }
93
100
  }
94
101
  async spawn(tasks: { task: string; cwd?: string }[], cwd: string, signal?: AbortSignal, parentId?: string, connected = false) {
102
+ for (const directory of tasks.map(t => taskDirectory(cwd, t.cwd))) if (!existsSync(directory) || !statSync(directory).isDirectory()) throw new Error(`No such directory: ${directory}`);
95
103
  if (this.closing) throw new Error('Profile is closing.');
96
104
  this.settling++;
97
105
  try { await this.memory.settle(signal); } finally { this.settling--; }
@@ -113,7 +121,7 @@ export class Children {
113
121
  for (const task of tasks) {
114
122
  signal?.throwIfAborted();
115
123
  if (cancelled()) throw new Error('Parent or profile is stopping.');
116
- const id = randomUUID().slice(0, 8), directory = task.cwd ?? cwd;
124
+ const id = randomUUID().slice(0, 8), directory = taskDirectory(cwd, task.cwd);
117
125
  const delegation = depth < 3 ? 'You may delegate parts of your assigned task with spawn when useful. Child reports arrive automatically after your current run ends; the harness keeps you alive to receive them. Never poll, sleep, or wait in a tool for children. Finish your current work and return; you will be prompted with their results. The profile allows 8 active agents total.' : 'You are at the maximum delegation depth. Complete your task with your own tools.';
118
126
  const instructions = [this.instructions(), delegation, connected ? 'You are speaking directly with the user in a connected window. Continue this conversation across requests. Use tell_parent for questions or findings the main agent needs now. A handoff will be generated when the user completes or disconnects the window.'
119
127
  : 'Use tell_parent only when your parent needs something now (a blocking question, an important early finding, or when asked to). Your final answer is delivered automatically; do not repeat it with tell_parent.'].filter(Boolean).join('\n\n');
@@ -151,7 +159,7 @@ export class Children {
151
159
  }
152
160
  } catch (error) {
153
161
  for (const child of launched) {
154
- child.session.dispose(); this.running.delete(child.info.id);
162
+ this.dispose(child.session); this.running.delete(child.info.id);
155
163
  child.info.state = 'failed'; child.info.ended = Date.now(); child.info.report = `Launch failed: ${String(error)}`;
156
164
  if (child.info.connected) child.info.handoff = { reason: signal?.aborted ? 'disconnected' : 'failed' };
157
165
  this.save(child.info);
@@ -171,7 +179,7 @@ export class Children {
171
179
  }
172
180
  private delegationTools(parentId: string, cwd: string) {
173
181
  return [{ name: 'spawn', label: 'Delegate task', description: 'Delegate parts of your task. Results arrive automatically after this run; never poll or sleep waiting. Maximum depth 3 and 8 active agents per profile.',
174
- parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String()) }), { minItems: 1, maxItems: 8 }) }),
182
+ parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1, maxItems: 8 }) }),
175
183
  execute: async (_id: string, args: { tasks: { task: string; cwd?: string }[] }, signal?: AbortSignal) => result(`Started: ${(await this.spawn(args.tasks, cwd, signal, parentId)).join(', ')}. Results will arrive automatically.`),
176
184
  }, { name: 'tell', label: 'Guide child', description: 'Send guidance to one of your direct children.',
177
185
  parameters: Type.Object({ id: Type.String(), message: Type.String() }),
@@ -199,11 +207,14 @@ export class Children {
199
207
  },
200
208
  };
201
209
  }
210
+ private dispose(session: AgentSession) {
211
+ try { session.dispose(); } catch (error) { this.warn(`Subagent cleanup failed: ${String(error)}`); }
212
+ }
202
213
  private directChildren(id: string) { return [...this.running.values()].filter(c => c.info.parentId === id); }
203
214
  private async execute(live: LiveRun, view: string) {
204
215
  const { session, info } = live;
205
216
  try {
206
- if (info.connected) await this.report(`[${info.id}] User started a connected conversation in ${info.cwd}. Initial request: ${info.task}\nUse tell with this agent ID to coordinate. It stays open between replies and sends a final handoff on completion or disconnect.`);
217
+ if (info.connected) await this.report(`[${info.id}] User started a connected conversation in ${info.cwd}. That agent is handling this request with the user directly; don't do it yourself. Initial message: ${info.task}\n\nUse tell with this agent ID only if you know something it needs. It stays open between replies and sends a final handoff on completion or disconnect.`);
207
218
  if (this.closing || info.handoff) throw new Error('Conversation stopped before its first request.');
208
219
  await session.prompt(`${view}\n\nYour task:\n${info.task}`);
209
220
  while (info.state !== 'stopping') {
@@ -240,7 +251,8 @@ export class Children {
240
251
  for (const g of info.guidance) if (g.state === 'queued') g.state = 'undelivered';
241
252
  try { await session.extensionRunner.emit({ type: 'session_shutdown', reason: 'quit' }); }
242
253
  catch (error) { this.warn(`Subagent cleanup failed: ${String(error)}`); }
243
- finally { session.dispose(); this.running.delete(info.id); }
254
+ // A failed dispose must neither keep the slot taken nor drop the report below.
255
+ this.dispose(session); this.running.delete(info.id);
244
256
  }
245
257
  if (info.connected) {
246
258
  info.handoff ??= { reason: 'failed' };
@@ -1,42 +1,127 @@
1
- import { getMarkdownTheme, UserMessageComponent, type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
2
- import { Markdown } from '@earendil-works/pi-tui';
3
- import { connectWindow, type WindowEvent } from './window-bridge.ts';
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';
3
+ import type { AgentMessage } from '@earendil-works/pi-agent-core';
4
+ import { connectWindow, type LiveState, type WindowEvent } from './window-bridge.ts';
4
5
  import { profilePath } from './profiles.ts';
6
+ import { TranscriptView } from './agent-view.ts';
7
+ import { isReport, reportBox } from './report-message.ts';
8
+ import { statusState, windowTitle, type WindowState } from './title.ts';
5
9
 
6
- type Details = { from?: WindowEvent['from'] };
10
+ /** `turn` is one finished reply of the agent with the results of its tool calls, drawn like Pi draws its own turns. */
11
+ type Details = { from?: WindowEvent['from']; turn?: AgentMessage[] };
12
+
13
+ /**
14
+ * One transcript for the window, shared by the chat and the live widget, so tool calls keep their timings when they settle into the chat.
15
+ * Pi's built-in tools keep their own renderers. Other tools' definitions live in the owner's process, out of reach here, so they get the
16
+ * look Pi gives any registered tool without a renderer (`spawn tasks=[…]`) instead of the raw-JSON look of an unknown one.
17
+ */
18
+ let transcript: TranscriptView | undefined;
19
+ const transcriptOn = (tui: TUI) => new TranscriptView(tui, process.cwd(), () => ({}));
20
+ const view = (tui: TUI) => transcript ??= transcriptOn(tui);
21
+ /** Turns drawn before this window has a live view (after /reload) still get their tool boxes; there is just no live screen to redraw. */
22
+ let detached: TranscriptView | undefined;
23
+ const NO_SCREEN = { requestRender() {} } as unknown as TUI;
7
24
 
8
25
  /** The conversation itself renders like a normal chat; everything else keeps Pi's boxed custom-message look. */
9
26
  export function registerConnectedRenderer(pi: ExtensionAPI) {
10
- pi.registerMessageRenderer<Details>('optchat-connected', (message, { outputPad }) => {
27
+ pi.registerMessageRenderer<Details>('optchat-connected', (message, { expanded, outputPad }, theme) => {
11
28
  const text = typeof message.content === 'string' ? message.content : '';
12
29
  if (message.details?.from === 'user') return new UserMessageComponent(text, getMarkdownTheme(), outputPad);
13
- return message.details?.from === 'agent' ? new Markdown(text.trim(), outputPad, 0, getMarkdownTheme()) : undefined;
30
+ const turn = message.details?.turn;
31
+ if (turn) {
32
+ const shown = transcript ?? (detached ??= transcriptOn(NO_SCREEN));
33
+ shown.setExpanded(expanded);
34
+ const container = new Container();
35
+ for (const part of shown.build('', turn)) container.addChild(part);
36
+ return container;
37
+ }
38
+ if (message.details?.from === 'agent') return new Markdown(text.trim(), outputPad, 0, getMarkdownTheme());
39
+ // Reports from the agent's own subagents get the same dark box as in the main window.
40
+ return isReport(text) ? reportBox(text, outputPad, theme) : undefined;
14
41
  });
15
42
  }
16
43
 
17
- export async function openConnectedWindow(pi: ExtensionAPI, ctx: ExtensionContext, profile: string) {
18
- let started = false, ended = false;
19
- const display = (text: string, from?: WindowEvent['from']) => pi.sendMessage<Details>({ customType: 'optchat-connected', content: text, display: true, details: { from } }, { triggerTurn: false });
44
+ const toolCalls = (message: AgentMessage) => message.role === 'assistant' ? message.content.flatMap(part => part.type === 'toolCall' ? [part.id] : []) : [];
45
+
46
+ /** What normally sits at the bottom of Pi's chat while it works: the streaming reply, running tools and the working spinner. */
47
+ class LiveTurn implements Component {
48
+ private readonly loader: Loader;
49
+ private readonly body = new Container();
50
+ private spinning = false;
51
+ turn: AgentMessage[] = [];
52
+ live?: LiveState;
53
+ constructor(private readonly tui: TUI, theme: Theme) {
54
+ this.loader = new Loader(tui, text => theme.fg('accent', text), text => theme.fg('muted', text), 'Working');
55
+ // Running tools show their elapsed time, which only moves when they are redrawn.
56
+ this.timer = setInterval(() => { if (this.live?.tools.length) this.update(); }, 1000); this.timer.unref();
57
+ }
58
+ private readonly timer: ReturnType<typeof setInterval>;
59
+ update() {
60
+ const streaming = this.live?.streaming;
61
+ const running = new Map((this.live?.tools ?? []).map(t => [t.id, { output: t.output, started: t.started }] as const));
62
+ this.body.clear();
63
+ for (const part of view(this.tui).build('', streaming ? [...this.turn, streaming] : this.turn, streaming, running)) this.body.addChild(part);
64
+ const state = this.live?.state;
65
+ // Waiting with no agents of its own means it is waiting for you, so nothing spins, like an idle Pi.
66
+ const agents = this.live?.agents ?? 0;
67
+ 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
+ if (spin && !this.spinning) this.loader.start(); else if (!spin && this.spinning) this.loader.stop();
70
+ this.spinning = spin;
71
+ this.tui.requestRender();
72
+ }
73
+ render(width: number) { return [...this.body.render(width), ...(this.spinning ? this.loader.render(width) : [])]; }
74
+ invalidate() { this.body.invalidate(); this.loader.invalidate(); }
75
+ dispose() { clearInterval(this.timer); this.loader.stop(); }
76
+ }
77
+
78
+ export async function openConnectedWindow(pi: ExtensionAPI, ctx: ExtensionContext, profile: string, setTitle: (title: string) => void = title => ctx.ui.setTitle(title)) {
79
+ // Each window draws with its own transcript, so nothing from an earlier window carries over.
80
+ transcript = undefined;
81
+ let started = false, ended = false, liveTurn: LiveTurn | undefined, live: LiveState | undefined, turn: AgentMessage[] = [];
82
+ const title = (state: WindowState) => setTitle(windowTitle(profile, state));
83
+ const display = (text: string, details: Details = {}) => pi.sendMessage<Details>({ customType: 'optchat-connected', content: text, display: true, details }, { triggerTurn: false });
84
+ const refresh = () => { if (liveTurn) { liveTurn.turn = turn; liveTurn.live = live; liveTurn.update(); } };
85
+ /** A reply moves into the chat once all its tool calls have results, as Pi's own chat does when a tool finishes. */
86
+ const commit = () => {
87
+ if (!turn.length) return;
88
+ const [reply] = turn;
89
+ display(reply.role === 'assistant' ? reply.content.flatMap(p => p.type === 'text' ? [p.text] : []).join('\n') : '', { from: 'agent', turn });
90
+ turn = [];
91
+ };
92
+ const settled = () => { const done = new Set(turn.flatMap(m => m.role === 'toolResult' ? [m.toolCallId] : [])); return turn.length > 0 && toolCalls(turn[0]).every(id => done.has(id)); };
93
+ const showLive = () => ctx.ui.setWidget('optchat-connected', (tui, theme) => { view(tui); liveTurn = new LiveTurn(tui, theme); refresh(); return liveTurn; });
94
+ const hideLive = () => { live = undefined; liveTurn = undefined; ctx.ui.setWidget('optchat-connected', undefined); };
20
95
  const connection = await connectWindow(profilePath(profile), event => {
21
96
  if (event.name === 'started') {
22
- started = true;
97
+ started = true; showLive(); title('working');
23
98
  ctx.ui.setStatus('optchat', `OptChat: ${profile} · connected agent ${event.text} · /complete`);
24
99
  } else if (event.name === 'status') {
25
- ctx.ui.setWidget('optchat-connected', event.text.split('\n').slice(-8));
100
+ live = event.live; refresh();
101
+ // The tab says working exactly when the spinner spins.
102
+ if (!ended && live) title(statusState(live.state, live.agents));
103
+ } else if (event.message?.role === 'assistant') {
104
+ commit(); turn = [event.message];
105
+ if (settled()) commit();
106
+ refresh();
107
+ } else if (event.message?.role === 'toolResult') {
108
+ if (turn.length) { turn.push(event.message); if (settled()) commit(); }
109
+ refresh();
26
110
  } else {
27
- display(event.text, event.from);
111
+ commit();
112
+ display(event.text, { from: event.from });
28
113
  if (event.name === 'finished') {
29
- ended = true; ctx.ui.setWidget('optchat-connected', undefined);
114
+ ended = true; hideLive(); title('done');
30
115
  ctx.ui.setStatus('optchat', `OptChat: ${profile} · conversation ended · /complete to exit`);
31
- }
116
+ } else refresh();
32
117
  }
33
118
  }, () => {
34
119
  if (ended) return;
35
- ended = true; ctx.ui.setWidget('optchat-connected', undefined);
36
- ctx.ui.setStatus('optchat', `OptChat: ${profile} · disconnected`);
120
+ ended = true; commit(); hideLive();
121
+ ctx.ui.setStatus('optchat', `OptChat: ${profile} · disconnected`); title('disconnected');
37
122
  ctx.ui.notify('Connection closed. The owner saves the interrupted conversation and handoff; no local agent will run here.', 'info');
38
123
  });
39
- ctx.ui.setStatus('optchat', `OptChat: ${profile} · connected window · send a task to start`);
124
+ ctx.ui.setStatus('optchat', `OptChat: ${profile} · connected window · send a task to start`); title('waiting');
40
125
  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.`);
41
126
  return {
42
127
  async submit(text: string) {
package/src/inbox.ts CHANGED
@@ -23,8 +23,14 @@ export class Inbox {
23
23
  const item = { id: randomUUID(), text, date: new Date().toISOString() };
24
24
  this.items.push(item); this.save(); return item.id;
25
25
  }
26
- claim(text: string) {
27
- const item = this.items.find(i => !this.claimed.has(i.id) && i.text === text);
26
+ claim(text: string) { return this.claimWhere(i => i.text === text); }
27
+ /** Pi expands `/skill:name args` after the input is journaled; match the expansion back to that input. */
28
+ claimSkill(name: string, args = '') {
29
+ const command = `/skill:${name}`;
30
+ return this.claimWhere(i => i.text.startsWith(command) && /^(\s|$)/.test(i.text.slice(command.length)) && i.text.slice(command.length).trim() === args);
31
+ }
32
+ private claimWhere(match: (item: Arrival) => boolean) {
33
+ const item = this.items.find(i => !this.claimed.has(i.id) && match(i));
28
34
  if (!item) return undefined;
29
35
  this.claimed.add(item.id); return item.id;
30
36
  }
package/src/index.ts CHANGED
@@ -4,17 +4,17 @@ import { join } from 'node:path';
4
4
  import { hostname } from 'node:os';
5
5
  import { createHash } from 'node:crypto';
6
6
  import type { AgentMessage, ThinkingLevel } from '@earendil-works/pi-agent-core';
7
- import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
7
+ import { parseSkillBlock, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
8
8
  import { Type } from 'typebox';
9
9
  import { Memory } from './memory.ts';
10
10
  import { createCompressor } from './compactor.ts';
11
11
  import { atomicWrite, createProfile, instructions, lastProfile, listProfiles, loadConfig, lockProfile, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
12
12
  import { MASTER, VIEW_DOC } from './prompts.ts';
13
13
  import { cachePayload, record } from './cache.ts';
14
- import { asUser, boundedMessage, buildContext, logMessage, previousExchange, REPORT_TYPE, RUN_BOUNDARY, textContent } from './transcript.ts';
14
+ import { asUser, boundedMessage, buildContext, logMessage, previousExchange, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
15
15
  import { registerReportRenderer } from './report-message.ts';
16
16
  import { memoryTools, result } from './tools.ts';
17
- import { Children } from './agents.ts';
17
+ import { Children, CWD_DOC } from './agents.ts';
18
18
  import { exportBrowser } from './browser.ts';
19
19
  import { Inbox } from './inbox.ts';
20
20
  import { checkpoint } from './checkpoint.ts';
@@ -28,6 +28,7 @@ import { inspectorShortcut, mountNavigation } from './navigation.ts';
28
28
  import { serveWindows } from './window-bridge.ts';
29
29
  import { openConnectedWindow, registerConnectedRenderer } from './connected-window.ts';
30
30
  import { createHandoffSummarizer } from './handoff.ts';
31
+ import { mainTitle, TabTitle } from './title.ts';
31
32
 
32
33
  const binding = 'optchat.profile';
33
34
  interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
@@ -54,6 +55,13 @@ export default function optchat(pi: ExtensionAPI) {
54
55
  let unmountNavigation: (() => void) | undefined;
55
56
  let inspectorController: AbortController | undefined;
56
57
  const shortcut = inspectorShortcut();
58
+ const title = new TabTitle();
59
+ let working = false;
60
+ let untitle: (() => void) | undefined;
61
+ const showTitle = (ctx: ExtensionContext) => {
62
+ const a = active;
63
+ if (a) title.show(text => ctx.ui.setTitle(text), mainTitle(a.name, working, a.children.ids.length));
64
+ };
57
65
  const reportReceipt = (text: string) => 'report:' + createHash('sha256').update(text).digest('hex');
58
66
  const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
59
67
  pi.registerFlag('optchat-profile', { description: 'OptChat profile (required for noninteractive sessions without a saved binding)', type: 'string' });
@@ -103,8 +111,10 @@ export default function optchat(pi: ExtensionAPI) {
103
111
  } finally {
104
112
  await old.memory.close(); await old.unlock(); active = undefined;
105
113
  run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear(); prompt = '';
114
+ untitle?.(); untitle = undefined; title.clear(); working = false;
106
115
  }
107
116
  };
117
+ const CONNECT = 'Start a connected subagent conversation here', BACK = 'Back';
108
118
  const chooseProfile = async (ctx: ExtensionContext): Promise<string | undefined> => {
109
119
  if (!ctx.hasUI) return undefined;
110
120
  const names = listProfiles(), last = lastProfile();
@@ -146,6 +156,7 @@ export default function optchat(pi: ExtensionAPI) {
146
156
  active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined;
147
157
  if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, shortcut, page => { void inspect(ctx, page); });
148
158
  closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport);
159
+ untitle = children.subscribe(() => showTitle(ctx)); showTitle(ctx);
149
160
  status(ctx);
150
161
  ctx.ui.notify(`OptChat · ${name} · ${memory.root.length} messages\nCompactor: ${config.compactor.provider}/${config.compactor.model} (${config.compactor.thinking})`, 'info');
151
162
  const queuedReports = [...reports];
@@ -153,6 +164,7 @@ export default function optchat(pi: ExtensionAPI) {
153
164
  setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const text of queuedReports) sendReport(text); });
154
165
  } catch (error) {
155
166
  await closeWindows?.(); closeWindows = undefined;
167
+ untitle?.(); untitle = undefined; title.clear();
156
168
  if (active && active.memory === openingMemory) {
157
169
  unmountNavigation?.(); unmountNavigation = undefined;
158
170
  await active.children.close(); active = undefined;
@@ -164,25 +176,36 @@ export default function optchat(pi: ExtensionAPI) {
164
176
 
165
177
  pi.on('session_start', async (_event, ctx) => {
166
178
  stopping = false;
167
- const saved = ctx.sessionManager.getEntries().find(e => e.type === 'custom' && e.customType === binding);
179
+ const entries = ctx.sessionManager.getEntries();
180
+ const saved = entries.findLast(e => e.type === 'custom' && e.customType === binding);
168
181
  const boundName = saved?.type === 'custom' && record(saved.data) && typeof saved.data.name === 'string' ? saved.data.name : undefined;
182
+ // A session with conversation in it belongs to its profile. One that is only bound (a fresh `/optchat profile` session) may still pick another.
183
+ const settled = boundName !== undefined && entries.some(e => e.type === 'message' || e.type === 'custom_message');
169
184
  const flag = pi.getFlag('optchat-profile');
170
185
  try {
171
186
  if (boundName && typeof flag === 'string' && flag !== boundName) throw new Error(`Session belongs to ${boundName}; cannot resume it as ${flag}.`);
172
- const name = boundName ?? (typeof flag === 'string' ? flag : await chooseProfile(ctx));
173
- if (!name) { status(ctx); return; }
174
- try { await openProfile(name, ctx); }
175
- catch (error) {
176
- if (!(error instanceof ProfileBusyError) || ctx.mode !== 'tui') throw error;
177
- if (!await ctx.ui.confirm('Profile open in another window', `${error.owner}\nStart a connected subagent conversation here?`)) throw error;
178
- remote = await openConnectedWindow(pi, ctx, name); fault = undefined;
187
+ let name = boundName ?? (typeof flag === 'string' ? flag : await chooseProfile(ctx));
188
+ for (;;) {
189
+ if (!name) { status(ctx); return; }
190
+ try { await openProfile(name, ctx); break; }
191
+ catch (error) {
192
+ if (!(error instanceof ProfileBusyError) || ctx.mode !== 'tui') throw error;
193
+ // A resumed conversation already belongs to this profile, so another profile needs a new session (/optchat profile).
194
+ const choice = await ctx.ui.select(`${name} is open in another window\n${error.owner}`, settled ? [CONNECT] : [CONNECT, BACK]);
195
+ if (choice === CONNECT) { remote = await openConnectedWindow(pi, ctx, name, text => title.show(t => ctx.ui.setTitle(t), text)); fault = undefined; break; }
196
+ if (choice !== BACK) throw error;
197
+ name = await chooseProfile(ctx);
198
+ }
179
199
  }
180
- if (!boundName) pi.appendEntry(binding, { name });
200
+ if (name !== boundName) pi.appendEntry(binding, { name });
181
201
  } catch (error) {
182
202
  if (active) await stop().catch(() => {});
183
203
  fault = errorText(error); ctx.ui.notify(fault, 'error');
184
204
  }
205
+ // Pi sets its own title once every session_start handler has finished, so put ours back afterwards.
206
+ for (const ms of [0, 250, 1000]) setTimeout(() => title.reapply(), ms).unref();
185
207
  });
208
+ pi.on('session_info_changed', () => title.reapply()); // Pi retitles the tab on session renames, just before this.
186
209
  pi.on('session_shutdown', stop);
187
210
  pi.on('session_before_switch', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
188
211
  pi.on('session_before_fork', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
@@ -208,7 +231,10 @@ export default function optchat(pi: ExtensionAPI) {
208
231
  pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
209
232
  };
210
233
  // A report sent while Pi is idle starts its run without before_agent_start; it reuses the last built prompt.
211
- pi.on('agent_start', (_event, ctx) => { if (active && !runStarted) startRun(ctx); });
234
+ pi.on('agent_start', (_event, ctx) => {
235
+ working = true; showTitle(ctx);
236
+ if (active && !runStarted) startRun(ctx);
237
+ });
212
238
  pi.on('before_agent_start', (event, ctx) => {
213
239
  startRun(ctx);
214
240
  const a = required();
@@ -226,7 +252,10 @@ export default function optchat(pi: ExtensionAPI) {
226
252
  const text = textContent(message.content);
227
253
  if (reports.includes(text)) receipts.set(message, reportReceipt(text));
228
254
  else {
229
- let receipt = active.inbox.claim(text);
255
+ // The inbox journaled the typed input: match without image placeholders or Pi's image notes.
256
+ const typed = typedText(message.content), skill = parseSkillBlock(typed.bare);
257
+ let receipt = active.inbox.claim(typed.text) ?? active.inbox.claim(typed.bare)
258
+ ?? (skill ? active.inbox.claimSkill(skill.name, skill.userMessage) : undefined);
230
259
  if (!receipt) { active.inbox.record(text); receipt = active.inbox.claim(text); }
231
260
  if (receipt) receipts.set(message, receipt);
232
261
  }
@@ -274,6 +303,7 @@ export default function optchat(pi: ExtensionAPI) {
274
303
  try { flush(); } catch (error) { fault = errorText(error); ctx.ui.notify(fault, 'error'); }
275
304
  if (runStarted) pi.appendEntry(RUN_BOUNDARY, { state: 'end' });
276
305
  runStarted = false; status(ctx);
306
+ working = false; showTitle(ctx);
277
307
  if (active) {
278
308
  const dir = active.dir;
279
309
  checkpoints = checkpoints.then(() => checkpoint(dir)).catch(error => ctx.ui.notify(`Local checkpoint failed: ${errorText(error)}`, 'error'));
@@ -284,8 +314,8 @@ export default function optchat(pi: ExtensionAPI) {
284
314
  registerReportRenderer(pi);
285
315
  for (const tool of memoryTools(() => required().memory)) pi.registerTool(tool);
286
316
  pi.registerTool({ name: 'spawn', label: 'Spawn background agents',
287
- description: 'Start background subagents, returning IDs immediately. Use only when the user asks. Each receives the current memory view and read-only zoom/date. Children may delegate two more levels; the whole profile allows 8 active agents. Completion reports arrive automatically; never poll or sleep waiting for them.',
288
- parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String()) }), { minItems: 1, maxItems: 8 }) }),
317
+ description: 'Start background subagents, returning IDs immediately. Use only when the user asks. Give each task the cwd of the project it works on, so the subagent starts there with that project\'s AGENTS.md. Each receives the current memory view and read-only zoom/date. Children may delegate two more levels; the whole profile allows 8 active agents. Completion reports arrive automatically; never poll or sleep waiting for them.',
318
+ parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1, maxItems: 8 }) }),
289
319
  async execute(_id, args, signal, _update, ctx) {
290
320
  const ids = await required().children.spawn(args.tasks, ctx.cwd, signal); status(ctx);
291
321
  return result(`Started: ${ids.join(', ')}. Reports will arrive automatically.`);
package/src/memory.ts CHANGED
@@ -12,10 +12,12 @@ export interface Part { l: number; i: number }
12
12
  export interface Summary extends Part { text: string; size: number }
13
13
  export interface Compression { context: string; source: string; merge: boolean; historical?: boolean }
14
14
  export type Compressor = (input: Compression, signal: AbortSignal) => Promise<string>;
15
- const key = ({ l, i }: Part) => `${l}:${i}`;
15
+ const key = ({ l, i }: Part) => l * 2 ** 40 + i;
16
+ const UNBUILT = '(not summarized yet: zoom it)';
16
17
  export const start = ({ l, i }: Part) => i * 2 ** l;
17
18
  export const end = (part: Part) => start(part) + 2 ** part.l;
18
19
  export const bytes = (s: string) => Buffer.byteLength(s, 'utf8');
20
+ const UNBUILT_BYTES = bytes(UNBUILT);
19
21
  export const flat = (s: string) => s.replace(/[\r\n]+/g, ' ');
20
22
  export function cap(text: string, limit = CAP) {
21
23
  if (text.length <= limit) return text;
@@ -68,13 +70,17 @@ function isSummary(value: unknown): value is Summary {
68
70
  /** The log is authoritative. The tree and monotonically coarsening view follow recipe §§2–6. */
69
71
  export class Memory {
70
72
  readonly root: Entry[] = [];
71
- readonly tree = new Map<string, Summary>();
73
+ readonly tree = new Map<number, Summary>();
72
74
  readonly view: Part[] = [];
73
75
  private readonly events = new EventEmitter();
74
76
  private readonly controller = new AbortController();
75
- private readonly busy = new Map<string, Promise<void>>();
76
- private readonly retryAt = new Map<string, number>();
77
- private readonly reported = new Set<string>();
77
+ private readonly busy = new Map<number, Promise<void>>();
78
+ private readonly retryAt = new Map<number, number>();
79
+ private readonly reported = new Set<number>();
80
+ private viewBytes = 0;
81
+ private leaves = 0;
82
+ /** Per level, every node below this index is built. */
83
+ private readonly low: number[] = [];
78
84
  private retryTimer?: ReturnType<typeof setTimeout>;
79
85
  private scheduled = false;
80
86
  private stopped = false;
@@ -91,40 +97,53 @@ export class Memory {
91
97
  for (const value of records(join(directory, 'tree'), warn)) {
92
98
  if (!isSummary(value) || value.l < 0 || value.i < 0 || end(value) > this.root.length)
93
99
  throw new Error('Invalid OptChat summary record.');
100
+ if (value.l === 0 && !this.tree.has(key(value))) this.leaves++;
94
101
  this.tree.set(key(value), { ...value, size: bytes(value.text) });
95
102
  }
96
103
  // Fold history in order; do not retile the entire log on each turn.
97
- for (let i = 0; i < this.root.length; i++) { this.view.push({ l: 0, i }); this.fit(i + 1); }
104
+ for (let i = 0; i < this.root.length; i++) { this.push(i); this.fit(i + 1); }
98
105
  this.schedule();
99
106
  }
100
107
  append(kind: Kind, text: string, date = new Date().toISOString(), receipt?: string) {
101
108
  if (this.stopped) throw new Error('Memory is closed.');
102
109
  const entry: Entry = { i: this.root.length, kind, text, date, size: bytes(`${kind}: ${text}`), ...(receipt ? { receipt } : {}) };
103
110
  appendJson(join(this.directory, 'main', `${localDay()}.jsonl`), entry);
104
- this.root.push(entry); this.view.push({ l: 0, i: entry.i }); this.fit(); this.schedule();
111
+ this.root.push(entry); this.push(entry.i); this.fit(); this.schedule();
105
112
  return entry;
106
113
  }
107
114
  node(part: Part) { return this.tree.get(key(part)); }
108
- private text(part: Part) { return this.node(part)?.text ?? '(not summarized yet: zoom it)'; }
115
+ private text(part: Part) { return this.node(part)?.text ?? UNBUILT; }
116
+ private partBytes(part: Part) { return this.node(part)?.size ?? UNBUILT_BYTES; }
117
+ private push(i: number) { const part = { l: 0, i }; this.view.push(part); this.viewBytes += this.partBytes(part); }
109
118
  render() { return `<chat>\n${this.view.map(p => `${start(p)}+${2 ** p.l}|${flat(this.text(p))}`).join('\n')}\n</chat>`; }
110
119
  get ready() { return this.view.every(p => this.node(p)); }
111
- get pending() { return this.root.filter(e => !this.tree.has(key({ l: 0, i: e.i }))).length; }
120
+ get pending() { return this.root.length - this.leaves; }
112
121
  get active() { return this.busy.size; }
113
- get size() { return this.view.reduce((n, p) => n + bytes(this.text(p)), 0); }
122
+ get size() { return this.viewBytes; }
123
+ /** The view tiles the log in order, so a binary search finds the part covering a position. */
124
+ private visible(part: Part) {
125
+ const at = start(part);
126
+ for (let lo = 0, hi = this.view.length - 1; lo <= hi;) {
127
+ const mid = (lo + hi) >> 1, p = this.view[mid];
128
+ if (end(p) <= at) lo = mid + 1;
129
+ else if (start(p) > at) hi = mid - 1;
130
+ else return p.l === part.l && p.i === part.i;
131
+ }
132
+ return false;
133
+ }
114
134
  private fit(total = this.root.length) {
115
- let size = this.size;
116
- while (size > this.budget) {
135
+ while (this.viewBytes > this.budget) {
117
136
  let best = -1; let due = -Infinity;
118
137
  for (let j = 0; j + 1 < this.view.length; j++) {
119
138
  const a = this.view[j], b = this.view[j + 1];
120
- if (a.l !== b.l || a.i % 2 || b.i !== a.i + 1 || !this.node({ l: a.l + 1, i: a.i / 2 })) continue;
139
+ if (a.l !== b.l || a.i % 2 || b.i !== a.i + 1) continue;
121
140
  const age = (total - start(a)) / 2 ** (a.l + 2);
122
- if (age > due) { best = j; due = age; }
141
+ if (age > due && this.node({ l: a.l + 1, i: a.i / 2 })) { best = j; due = age; }
123
142
  }
124
143
  if (best < 0) break;
125
144
  const a = this.view[best], b = this.view[best + 1];
126
145
  const parent = { l: a.l + 1, i: a.i / 2 };
127
- size += bytes(this.text(parent)) - bytes(this.text(a)) - bytes(this.text(b));
146
+ this.viewBytes += this.partBytes(parent) - this.partBytes(a) - this.partBytes(b);
128
147
  this.view.splice(best, 2, parent);
129
148
  }
130
149
  this.events.emit('change');
@@ -140,11 +159,14 @@ export class Memory {
140
159
  const first = this.view.find(p => !this.node(p));
141
160
  const boundary = first ? start(first) : total;
142
161
  for (let l = 0; 2 ** l <= total; l++) {
143
- for (let i = 0; (i + 1) * 2 ** l <= total; i++) {
162
+ let low = this.low[l] ?? 0;
163
+ while (this.node({ l, i: low })) low++;
164
+ this.low[l] = low;
165
+ for (let i = low; (i + 1) * 2 ** l <= total; i++) {
144
166
  if (this.busy.size >= this.jobs) return;
145
167
  const part = { l, i }, id = key(part);
168
+ if ((l === 0 ? i : end(part)) > boundary) break;
146
169
  if (this.node(part) || this.busy.has(id) || (this.retryAt.get(id) ?? 0) > Date.now()) continue;
147
- if ((l === 0 ? i : end(part)) > boundary) continue;
148
170
  if (l && (!this.node({ l: l - 1, i: 2 * i }) || !this.node({ l: l - 1, i: 2 * i + 1 }))) continue;
149
171
  const promise = this.build(part).catch(error => {
150
172
  if (this.stopped) return;
@@ -173,6 +195,8 @@ export class Memory {
173
195
  const node = { ...part, text, size: bytes(text) };
174
196
  appendJson(join(this.directory, 'tree', `${localDay()}.jsonl`), node);
175
197
  this.tree.set(key(part), node); this.retryAt.delete(key(part));
198
+ // A leaf is usually still in the view when built, but after a damaged tree file a saved parent can already hide it.
199
+ if (part.l === 0) { this.leaves++; if (this.visible(part)) this.viewBytes += node.size - UNBUILT_BYTES; }
176
200
  if (!this.retryAt.size) this.lastError = undefined;
177
201
  this.fit();
178
202
  }
@@ -21,12 +21,14 @@ function darkBackground(theme: Theme) {
21
21
  }
22
22
 
23
23
  /** Background traffic: a dark neutral box with dim text, so it never looks like something the user typed. */
24
+ export function reportBox(text: string, outputPad: number, theme: Theme) {
25
+ const { label, body } = reportParts(text);
26
+ const box = new Box(outputPad, 1, darkBackground(theme));
27
+ box.addChild(new Text(theme.fg('dim', `↳ ${label}`), 0, 0));
28
+ box.addChild(new Markdown(body.trim(), 0, 0, getMarkdownTheme(), { color: text => theme.fg('muted', text) }));
29
+ return box;
30
+ }
31
+ export const isReport = (text: string) => HEAD.test(text);
24
32
  export function registerReportRenderer(pi: ExtensionAPI) {
25
- pi.registerMessageRenderer(REPORT_TYPE, (message, { outputPad }, theme) => {
26
- const { label, body } = reportParts(textContent(message.content));
27
- const box = new Box(outputPad, 1, darkBackground(theme));
28
- box.addChild(new Text(theme.fg('dim', `↳ ${label}`), 0, 0));
29
- box.addChild(new Markdown(body.trim(), 0, 0, getMarkdownTheme(), { color: text => theme.fg('muted', text) }));
30
- return box;
31
- });
33
+ pi.registerMessageRenderer(REPORT_TYPE, (message, { outputPad }, theme) => reportBox(textContent(message.content), outputPad, theme));
32
34
  }
package/src/title.ts ADDED
@@ -0,0 +1,31 @@
1
+ /** Terminal tab titles: `● π personal · 2 agents` in the main window, `● ↳ personal` in a connected window. */
2
+ export type StatusState = 'waiting' | 'working';
3
+ export type WindowState = StatusState | 'done' | 'disconnected';
4
+
5
+ const agents = (count: number) => count > 0 ? ` · ${count} ${count === 1 ? 'agent' : 'agents'}` : '';
6
+ export const mainTitle = (profile: string, working: boolean, running: number) => `${working ? '● ' : ''}π ${profile}${agents(running)}`;
7
+ export const windowTitle = (profile: string, state: WindowState) =>
8
+ `${state === 'working' ? '● ' : ''}↳ ${profile}${state === 'done' || state === 'disconnected' ? ` · ${state}` : ''}`;
9
+ /** A connected window's agent is working while it runs or stops, or waits on agents of its own; waiting with none means waiting for you. */
10
+ export const statusState = (state: string, agents = 0): StatusState =>
11
+ state === 'running' || state === 'stopping' || state === 'waiting' && agents > 0 ? 'working' : 'waiting';
12
+
13
+ /**
14
+ * Pi writes its own "π - <cwd>" title after binding a session (startup, reload, profile switch) and on
15
+ * session renames, so the last title is kept and re-applied at those points.
16
+ */
17
+ export class TabTitle {
18
+ private title: string | undefined;
19
+ private set: ((title: string) => void) | undefined;
20
+ show(set: (title: string) => void, title: string) {
21
+ this.set = set;
22
+ if (title === this.title) return;
23
+ this.title = title; this.apply();
24
+ }
25
+ reapply() { this.apply(); }
26
+ clear() { this.title = undefined; this.set = undefined; }
27
+ private apply() {
28
+ if (!this.title || !this.set) return;
29
+ try { this.set(this.title); } catch { /* A ctx captured before a session replacement is stale; the next session sets its own. */ }
30
+ }
31
+ }
package/src/transcript.ts CHANGED
@@ -8,16 +8,22 @@ export const RUN_BOUNDARY = 'optchat.run';
8
8
  /** Subagent traffic to the main agent: a custom message on screen, a plain user message to the model and memory. */
9
9
  export const REPORT_TYPE = 'optchat-report';
10
10
 
11
- export function textContent(content: unknown): string {
11
+ export function textContent(content: unknown, images = true): string {
12
12
  if (typeof content === 'string') return content;
13
13
  if (!Array.isArray(content)) return '';
14
14
  return content.map((part: unknown) => {
15
15
  if (typeof part !== 'object' || part === null) return '';
16
16
  if ('type' in part && part.type === 'text' && 'text' in part && typeof part.text === 'string') return part.text;
17
- if ('type' in part && part.type === 'image') return '[image attachment: available in Pi session; text memory does not preserve image bytes]';
17
+ if (images && 'type' in part && part.type === 'image') return '[image attachment: available in Pi session; text memory does not preserve image bytes]';
18
18
  return '';
19
19
  }).filter(Boolean).join('\n');
20
20
  }
21
+ /** What the user typed, as Pi's input event (and so the inbox) saw it: no image placeholders, and without the
22
+ * `[Image …]` notes Pi appends after the text when it resizes, converts or omits an attached image. */
23
+ export function typedText(content: unknown) {
24
+ const text = textContent(content, false);
25
+ return { text, bare: text.replace(/\n\n\[Image[ :][^\n]*\](?:\n\[Image[ :][^\n]*\])*$/, '') };
26
+ }
21
27
  /** Reports reach the model, memory and the previous-exchange replay exactly as the user messages they used to be. */
22
28
  export function asUser(message: AgentMessage): AgentMessage {
23
29
  if (message.role !== 'custom' || message.customType !== REPORT_TYPE) return message;
@@ -5,19 +5,36 @@ import type { AgentMessage } from '@earendil-works/pi-agent-core';
5
5
  import { record } from './cache.ts';
6
6
  import { profileSocket } from './profiles.ts';
7
7
  import { textContent } from './transcript.ts';
8
+ import { isActiveRun } from './runs.ts';
8
9
 
9
10
  type Action = 'start' | 'say' | 'tell-main' | 'complete';
10
11
  interface Request { kind: 'request'; id: number; action: Action; text?: string; cwd?: string }
11
12
  interface Reply { kind: 'reply'; id: number; error?: string }
12
- /** `from` marks the conversation itself (your messages and the agent's replies); other messages have no `from`. */
13
- export interface WindowEvent { kind: 'event'; name: 'started' | 'message' | 'status' | 'finished'; text: string; from?: 'user' | 'agent' }
13
+ /** A tool the subagent is running right now, with its streamed output so far. */
14
+ 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
+ /**
18
+ * `from` marks the conversation itself (your messages and the agent's replies); other messages have no `from`.
19
+ * `message` carries the agent's own messages (replies with tool calls, tool results) so the window can draw them with Pi's components.
20
+ */
21
+ export interface WindowEvent {
22
+ kind: 'event'; name: 'started' | 'message' | 'status' | 'finished'; text: string; from?: 'user' | 'agent';
23
+ message?: AgentMessage; live?: LiveState;
24
+ }
14
25
  type Frame = Request | Reply | WindowEvent;
15
26
  const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
27
+ const isMessage = (value: unknown): value is AgentMessage => record(value) && (value.role === 'assistant' || value.role === 'toolResult') && Array.isArray(value.content);
28
+ const isLive = (value: unknown): value is LiveState => record(value) && typeof value.state === 'string' && typeof value.model === 'string'
29
+ && (value.streaming === undefined || isMessage(value.streaming))
30
+ && 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');
16
32
  function parse(value: unknown): Frame {
17
33
  if (!record(value)) throw new Error('Invalid window message');
18
34
  const name = value.name, action = value.action, from = value.from;
19
35
  if (value.kind === 'event' && (name === 'started' || name === 'message' || name === 'status' || name === 'finished') && typeof value.text === 'string')
20
- return { kind: 'event', name, text: value.text, from: from === 'user' || from === 'agent' ? from : undefined };
36
+ return { kind: 'event', name, text: value.text, from: from === 'user' || from === 'agent' ? from : undefined,
37
+ message: isMessage(value.message) ? value.message : undefined, live: isLive(value.live) ? value.live : undefined };
21
38
  if (typeof value.id !== 'number' || !Number.isSafeInteger(value.id)) throw new Error('Invalid request ID');
22
39
  if (value.kind === 'reply' && (value.error === undefined || typeof value.error === 'string')) return { kind: 'reply', id: value.id, error: value.error };
23
40
  if (value.kind === 'request' && (action === 'start' || action === 'say' || action === 'tell-main' || action === 'complete')
@@ -25,6 +42,23 @@ function parse(value: unknown): Frame {
25
42
  return { kind: 'request', id: value.id, action, text: value.text, cwd: value.cwd };
26
43
  throw new Error('Invalid window message');
27
44
  }
45
+ const SHORTENED = '\n[Display shortened; full text is saved in the transcript.]';
46
+ const shorten = (text: string, limit: number) => text.length > limit ? `${text.slice(0, limit)}${SHORTENED}` : text;
47
+ /** Long text parts are cut and images dropped: the window only draws them, the owner keeps the full transcript. */
48
+ function clipContent(content: unknown): unknown {
49
+ if (!Array.isArray(content)) return content;
50
+ return content.map(part => !record(part) ? part : part.type === 'text' && typeof part.text === 'string' ? { ...part, text: shorten(part.text, 50_000) }
51
+ : part.type === 'image' ? { type: 'text', text: '[image]' } : part);
52
+ }
53
+ const clip = (message: AgentMessage) => ({ ...message, content: clipContent('content' in message ? message.content : []) }) as AgentMessage;
54
+ const clipOutput = (output: unknown) => record(output) ? { ...output, content: clipContent(output.content) } : output;
55
+ /**
56
+ * Huge tool arguments (a large file write) would choke the socket; the window then falls back to text.
57
+ * Partial tool output comes from any extension and may not serialize (a cycle, a BigInt); that falls back too
58
+ * instead of throwing inside the status timer, which would take the owner's Pi down with it.
59
+ */
60
+ const fits = (value: unknown) => { try { return JSON.stringify(value).length < 1_000_000; } catch { return false; } };
61
+
28
62
  /** Local JSONL protocol, bounded before parsing; the socket is accessible only by its OS user. */
29
63
  function wire(socket: Socket, receive: (frame: Frame) => void) {
30
64
  let buffer = '';
@@ -94,13 +128,15 @@ export async function serveWindows(directory: string, children: Children, availa
94
128
  track(queue);
95
129
  });
96
130
  const drainMessages = (messages: AgentMessage[], task: string) => {
97
- const displayable = messages.filter(message => message.role === 'user' || message.role === 'assistant');
131
+ const displayable = messages.filter(message => message.role === 'user' || message.role === 'assistant' || message.role === 'toolResult');
98
132
  while (cursor < displayable.length) {
99
133
  const message = displayable[cursor++];
100
- let text = textContent(message.content);
134
+ let text = message.role === 'toolResult' ? '' : textContent(message.content);
101
135
  if (message.role === 'user' && firstUser) { text = task; firstUser = false; said.add(task.trim()); }
102
- const from = message.role === 'assistant' ? 'agent' : said.has(text.trim()) ? 'user' : undefined;
103
- if (text) send({ kind: 'event', name: 'message', from, text: `${text.slice(0, 200_000)}${text.length > 200_000 ? '\n[Display shortened; full text is saved in the transcript.]' : ''}` });
136
+ if (message.role === 'user') { if (text) send({ kind: 'event', name: 'message', from: said.has(text.trim()) ? 'user' : undefined, text: shorten(text, 200_000) }); continue; }
137
+ const clipped = clip(message);
138
+ if (fits(clipped)) send({ kind: 'event', name: 'message', from: message.role === 'assistant' ? 'agent' : undefined, text: shorten(text, 200_000), message: clipped });
139
+ else if (text) send({ kind: 'event', name: 'message', from: 'agent', text: shorten(text, 200_000) });
104
140
  }
105
141
  };
106
142
  const timer = setInterval(() => {
@@ -109,8 +145,14 @@ export async function serveWindows(directory: string, children: Children, availa
109
145
  if (!info) return;
110
146
  if (live) drainMessages(live.session.messages, info.task);
111
147
  const preview = live?.streaming && 'content' in live.streaming ? textContent(live.streaming.content).slice(-2000) : '';
112
- const status = `${info.state === 'waiting' ? 'Awaiting user or child reports' : info.state} · ${info.model}\n${live ? [...live.tools.values()].map(t => t.name).join(', ') : ''}\n${preview}`;
113
- if (status !== lastStatus) { lastStatus = status; send({ kind: 'event', name: 'status', text: status }); }
148
+ 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}`;
149
+ let state: LiveState = { state: info.state, model: info.model,
150
+ streaming: live?.streaming?.role === 'assistant' ? clip(live.streaming) : undefined,
151
+ tools: live ? [...live.tools].map(([id, t]) => ({ id, name: t.name, args: t.args, output: clipOutput(t.output), started: t.started })) : [],
152
+ agents: [...children.history.records.values()].filter(run => run.parentId === child && isActiveRun(run)).length };
153
+ if (!fits(state)) state = { ...state, streaming: undefined, tools: state.tools.map(t => ({ ...t, args: {}, output: undefined })) };
154
+ const status = JSON.stringify(state);
155
+ if (status !== lastStatus) { lastStatus = status; send({ kind: 'event', name: 'status', text, live: state }); }
114
156
  if (!live && info.handoff?.delivered) {
115
157
  try { drainMessages(children.messages(child), info.task); }
116
158
  catch (error) { send({ kind: 'event', name: 'message', text: `Could not read the final transcript: ${errorText(error)}` }); }