pi-optchat 0.6.3 → 0.6.4

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
@@ -140,6 +140,7 @@ See OpenAI's guides on [exporting ChatGPT data](https://help.openai.com/en/artic
140
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.
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,6 +175,7 @@ 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
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
@@ -203,7 +203,7 @@ export class Children {
203
203
  private async execute(live: LiveRun, view: string) {
204
204
  const { session, info } = live;
205
205
  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.`);
206
+ 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
207
  if (this.closing || info.handoff) throw new Error('Conversation stopped before its first request.');
208
208
  await session.prompt(`${view}\n\nYour task:\n${info.task}`);
209
209
  while (info.state !== 'stopping') {
@@ -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/index.ts CHANGED
@@ -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,6 +111,7 @@ 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
  };
108
117
  const chooseProfile = async (ctx: ExtensionContext): Promise<string | undefined> => {
@@ -146,6 +155,7 @@ export default function optchat(pi: ExtensionAPI) {
146
155
  active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined;
147
156
  if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, shortcut, page => { void inspect(ctx, page); });
148
157
  closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport);
158
+ untitle = children.subscribe(() => showTitle(ctx)); showTitle(ctx);
149
159
  status(ctx);
150
160
  ctx.ui.notify(`OptChat · ${name} · ${memory.root.length} messages\nCompactor: ${config.compactor.provider}/${config.compactor.model} (${config.compactor.thinking})`, 'info');
151
161
  const queuedReports = [...reports];
@@ -153,6 +163,7 @@ export default function optchat(pi: ExtensionAPI) {
153
163
  setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const text of queuedReports) sendReport(text); });
154
164
  } catch (error) {
155
165
  await closeWindows?.(); closeWindows = undefined;
166
+ untitle?.(); untitle = undefined; title.clear();
156
167
  if (active && active.memory === openingMemory) {
157
168
  unmountNavigation?.(); unmountNavigation = undefined;
158
169
  await active.children.close(); active = undefined;
@@ -175,14 +186,17 @@ export default function optchat(pi: ExtensionAPI) {
175
186
  catch (error) {
176
187
  if (!(error instanceof ProfileBusyError) || ctx.mode !== 'tui') throw error;
177
188
  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;
189
+ remote = await openConnectedWindow(pi, ctx, name, text => title.show(t => ctx.ui.setTitle(t), text)); fault = undefined;
179
190
  }
180
191
  if (!boundName) pi.appendEntry(binding, { name });
181
192
  } catch (error) {
182
193
  if (active) await stop().catch(() => {});
183
194
  fault = errorText(error); ctx.ui.notify(fault, 'error');
184
195
  }
196
+ // Pi sets its own title once every session_start handler has finished, so put ours back afterwards.
197
+ for (const ms of [0, 250, 1000]) setTimeout(() => title.reapply(), ms).unref();
185
198
  });
199
+ pi.on('session_info_changed', () => title.reapply()); // Pi retitles the tab on session renames, just before this.
186
200
  pi.on('session_shutdown', stop);
187
201
  pi.on('session_before_switch', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
188
202
  pi.on('session_before_fork', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
@@ -208,7 +222,10 @@ export default function optchat(pi: ExtensionAPI) {
208
222
  pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
209
223
  };
210
224
  // 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); });
225
+ pi.on('agent_start', (_event, ctx) => {
226
+ working = true; showTitle(ctx);
227
+ if (active && !runStarted) startRun(ctx);
228
+ });
212
229
  pi.on('before_agent_start', (event, ctx) => {
213
230
  startRun(ctx);
214
231
  const a = required();
@@ -274,6 +291,7 @@ export default function optchat(pi: ExtensionAPI) {
274
291
  try { flush(); } catch (error) { fault = errorText(error); ctx.ui.notify(fault, 'error'); }
275
292
  if (runStarted) pi.appendEntry(RUN_BOUNDARY, { state: 'end' });
276
293
  runStarted = false; status(ctx);
294
+ working = false; showTitle(ctx);
277
295
  if (active) {
278
296
  const dir = active.dir;
279
297
  checkpoints = checkpoints.then(() => checkpoint(dir)).catch(error => ctx.ui.notify(`Local checkpoint failed: ${errorText(error)}`, 'error'));
@@ -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
+ }
@@ -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)}` }); }