pi-optchat 0.6.2 → 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
@@ -69,6 +69,7 @@ Ask in plain words, for example: "Spawn an agent to investigate this repository
69
69
 
70
70
  - Children get the profile's memory view (frozen at launch), its instructions, read-only `zoom`/`date`, and normal coding tools plus your installed extensions.
71
71
  - Each child reports back on its own when it finishes. The parent stays alive to receive reports; it never polls.
72
+ - In the main chat, subagent messages and reports appear in a dark grey box labelled `↳ subagent <id> · still running` or `· report`, so they don't look like something you typed. The model still receives them as ordinary user messages. (One exception: reports recovered at startup, before your first message in the session, still show as plain user messages.)
72
73
  - The parent can send a running child guidance with `tell`, and the child can message its parent mid-run with `tell_parent` (a question, an early finding). It reaches the parent like a report, marked "still running": between tool calls if the parent is busy, or waking it if it's waiting.
73
74
  - Delegation goes up to three levels below the main agent (child, grandchild, great-grandchild).
74
75
  - At most 8 agents can be active per profile, including parents waiting on descendants. Going over a limit returns an error; there is no queue.
@@ -139,6 +140,7 @@ See OpenAI's guides on [exporting ChatGPT data](https://help.openai.com/en/artic
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
141
 
141
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.
142
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.
143
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.
144
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.
@@ -173,6 +175,7 @@ To delete a profile, delete its folder. Your original Pi sessions are kept in Pi
173
175
  ## Good to know
174
176
 
175
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).
176
179
  - **Use worktrees** when parallel agents edit the same repository; they share the filesystem.
177
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.
178
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.2",
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
@@ -11,7 +11,8 @@ 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 { boundedMessage, buildContext, logMessage, previousExchange, RUN_BOUNDARY, textContent } from './transcript.ts';
14
+ import { asUser, boundedMessage, buildContext, logMessage, previousExchange, REPORT_TYPE, RUN_BOUNDARY, textContent } from './transcript.ts';
15
+ import { registerReportRenderer } from './report-message.ts';
15
16
  import { memoryTools, result } from './tools.ts';
16
17
  import { Children } from './agents.ts';
17
18
  import { exportBrowser } from './browser.ts';
@@ -27,6 +28,7 @@ import { inspectorShortcut, mountNavigation } from './navigation.ts';
27
28
  import { serveWindows } from './window-bridge.ts';
28
29
  import { openConnectedWindow, registerConnectedRenderer } from './connected-window.ts';
29
30
  import { createHandoffSummarizer } from './handoff.ts';
31
+ import { mainTitle, TabTitle } from './title.ts';
30
32
 
31
33
  const binding = 'optchat.profile';
32
34
  interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
@@ -53,6 +55,13 @@ export default function optchat(pi: ExtensionAPI) {
53
55
  let unmountNavigation: (() => void) | undefined;
54
56
  let inspectorController: AbortController | undefined;
55
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
+ };
56
65
  const reportReceipt = (text: string) => 'report:' + createHash('sha256').update(text).digest('hex');
57
66
  const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
58
67
  pi.registerFlag('optchat-profile', { description: 'OptChat profile (required for noninteractive sessions without a saved binding)', type: 'string' });
@@ -76,10 +85,16 @@ export default function optchat(pi: ExtensionAPI) {
76
85
  }
77
86
  }
78
87
  };
88
+ // Shown as a dark background box, not as the user's own message. Before this profile has run once there is no
89
+ // built system prompt to reuse, so that rare case still goes through Pi's normal prompt path as a user message.
90
+ const sendReport = (text: string) => {
91
+ if (prompt) pi.sendMessage({ customType: REPORT_TYPE, content: text, display: true }, { triggerTurn: true, deliverAs: 'steer' });
92
+ else pi.sendUserMessage(text, { deliverAs: 'steer', expandPromptTemplates: false });
93
+ };
79
94
  const deliverReport = async (text: string, once = false) => {
80
95
  if (once && (active?.memory.root.some(e => e.receipt === reportReceipt(text)) || reports.includes(text))) return;
81
96
  reports.push(text); saveReports();
82
- if (!stopping) pi.sendUserMessage(text, { deliverAs: 'steer', expandPromptTemplates: false });
97
+ if (!stopping) sendReport(text);
83
98
  };
84
99
  const stop = async () => {
85
100
  remote?.close(); remote = undefined;
@@ -95,7 +110,8 @@ export default function optchat(pi: ExtensionAPI) {
95
110
  await checkpoint(old.dir);
96
111
  } finally {
97
112
  await old.memory.close(); await old.unlock(); active = undefined;
98
- run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear();
113
+ run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear(); prompt = '';
114
+ untitle?.(); untitle = undefined; title.clear(); working = false;
99
115
  }
100
116
  };
101
117
  const chooseProfile = async (ctx: ExtensionContext): Promise<string | undefined> => {
@@ -139,13 +155,15 @@ export default function optchat(pi: ExtensionAPI) {
139
155
  active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined;
140
156
  if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, shortcut, page => { void inspect(ctx, page); });
141
157
  closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport);
158
+ untitle = children.subscribe(() => showTitle(ctx)); showTitle(ctx);
142
159
  status(ctx);
143
160
  ctx.ui.notify(`OptChat · ${name} · ${memory.root.length} messages\nCompactor: ${config.compactor.provider}/${config.compactor.model} (${config.compactor.thinking})`, 'info');
144
161
  const queuedReports = [...reports];
145
162
  if (!pendingImport(dir)) recovery = children.recoverHandoffs().catch(error => ctx.ui.notify(`Handoff recovery: ${errorText(error)}`, 'error'));
146
- setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const text of queuedReports) pi.sendUserMessage(text, { deliverAs: 'steer', expandPromptTemplates: false }); });
163
+ setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const text of queuedReports) sendReport(text); });
147
164
  } catch (error) {
148
165
  await closeWindows?.(); closeWindows = undefined;
166
+ untitle?.(); untitle = undefined; title.clear();
149
167
  if (active && active.memory === openingMemory) {
150
168
  unmountNavigation?.(); unmountNavigation = undefined;
151
169
  await active.children.close(); active = undefined;
@@ -168,14 +186,17 @@ export default function optchat(pi: ExtensionAPI) {
168
186
  catch (error) {
169
187
  if (!(error instanceof ProfileBusyError) || ctx.mode !== 'tui') throw error;
170
188
  if (!await ctx.ui.confirm('Profile open in another window', `${error.owner}\nStart a connected subagent conversation here?`)) throw error;
171
- 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;
172
190
  }
173
191
  if (!boundName) pi.appendEntry(binding, { name });
174
192
  } catch (error) {
175
193
  if (active) await stop().catch(() => {});
176
194
  fault = errorText(error); ctx.ui.notify(fault, 'error');
177
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();
178
198
  });
199
+ pi.on('session_info_changed', () => title.reapply()); // Pi retitles the tab on session renames, just before this.
179
200
  pi.on('session_shutdown', stop);
180
201
  pi.on('session_before_switch', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
181
202
  pi.on('session_before_fork', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
@@ -195,10 +216,18 @@ export default function optchat(pi: ExtensionAPI) {
195
216
  }
196
217
  return { action: 'continue' };
197
218
  });
198
- pi.on('before_agent_start', (event, ctx) => {
219
+ const startRun = (ctx: ExtensionContext) => {
199
220
  flush(); run = []; logged = 0; view = undefined; runStarted = true;
200
221
  previous = previousExchange(ctx.sessionManager.getBranch());
201
222
  pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
223
+ };
224
+ // A report sent while Pi is idle starts its run without before_agent_start; it reuses the last built prompt.
225
+ pi.on('agent_start', (_event, ctx) => {
226
+ working = true; showTitle(ctx);
227
+ if (active && !runStarted) startRun(ctx);
228
+ });
229
+ pi.on('before_agent_start', (event, ctx) => {
230
+ startRun(ctx);
202
231
  const a = required();
203
232
  // Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
204
233
  event.systemPromptOptions.customPrompt = `${MASTER}\n\n${VIEW_DOC}\n\nFor conversational continuity, the memory view may be followed by the immediately preceding completed exchange (its user requests and final answer, in full text), then the new input. Use that exact wording to understand follow-ups; older exchanges and previous tool output remain accessible through memory and zoom.`;
@@ -207,7 +236,8 @@ export default function optchat(pi: ExtensionAPI) {
207
236
  });
208
237
  pi.on('message_end', (event, ctx) => {
209
238
  if (!active || !runStarted) return;
210
- const message = boundedMessage(event.message);
239
+ const bounded = boundedMessage(event.message);
240
+ const message = asUser(bounded);
211
241
  if (message.role === 'user') {
212
242
  try {
213
243
  const text = textContent(message.content);
@@ -223,7 +253,7 @@ export default function optchat(pi: ExtensionAPI) {
223
253
  if (view !== undefined) {
224
254
  try { flush(); } catch (error) { ctx.abort(); fault = errorText(error); ctx.ui.notify(fault, 'error'); }
225
255
  }
226
- if (message !== event.message) return { message };
256
+ if (bounded !== event.message) return { message: bounded };
227
257
  });
228
258
  pi.on('context_with_system', async (event, ctx) => {
229
259
  try {
@@ -261,6 +291,7 @@ export default function optchat(pi: ExtensionAPI) {
261
291
  try { flush(); } catch (error) { fault = errorText(error); ctx.ui.notify(fault, 'error'); }
262
292
  if (runStarted) pi.appendEntry(RUN_BOUNDARY, { state: 'end' });
263
293
  runStarted = false; status(ctx);
294
+ working = false; showTitle(ctx);
264
295
  if (active) {
265
296
  const dir = active.dir;
266
297
  checkpoints = checkpoints.then(() => checkpoint(dir)).catch(error => ctx.ui.notify(`Local checkpoint failed: ${errorText(error)}`, 'error'));
@@ -268,6 +299,7 @@ export default function optchat(pi: ExtensionAPI) {
268
299
  }
269
300
  });
270
301
  registerConnectedRenderer(pi);
302
+ registerReportRenderer(pi);
271
303
  for (const tool of memoryTools(() => required().memory)) pi.registerTool(tool);
272
304
  pi.registerTool({ name: 'spawn', label: 'Spawn background agents',
273
305
  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.',
@@ -0,0 +1,34 @@
1
+ import { getMarkdownTheme, type ExtensionAPI, type Theme } from '@earendil-works/pi-coding-agent';
2
+ import { Box, Markdown, Text } from '@earendil-works/pi-tui';
3
+ import { REPORT_TYPE, textContent } from './transcript.ts';
4
+
5
+ const HEAD = /^\[([0-9a-f]{8})\] (?:(Message from subagent \(still running\)|Connected agent message): )?/;
6
+
7
+ /** Split "[id] Message from subagent (still running): body" into a short label and the body. */
8
+ export function reportParts(text: string) {
9
+ const match = HEAD.exec(text);
10
+ if (!match) return { label: 'subagent', body: text };
11
+ const kind = match[2] === undefined ? 'report' : match[2].startsWith('Message') ? 'still running' : 'connected window';
12
+ return { label: `subagent ${match[1]} · ${kind}`, body: text.slice(match[0].length) };
13
+ }
14
+
15
+ /** The theme's neutral grey, darkened on dark themes so the box recedes behind the conversation. */
16
+ function darkBackground(theme: Theme) {
17
+ const rgb = /^\x1b\[48;2;(\d+);(\d+);(\d+)m$/.exec(theme.getBgAnsi('toolPendingBg'))?.slice(1).map(Number);
18
+ if (!rgb || rgb[0] + rgb[1] + rgb[2] > 3 * 128) return (text: string) => theme.bg('toolPendingBg', text);
19
+ const open = `\x1b[48;2;${rgb.map(c => Math.round(c * 0.7)).join(';')}m`;
20
+ return (text: string) => `${open}${text}\x1b[49m`;
21
+ }
22
+
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);
32
+ export function registerReportRenderer(pi: ExtensionAPI) {
33
+ pi.registerMessageRenderer(REPORT_TYPE, (message, { outputPad }, theme) => reportBox(textContent(message.content), outputPad, theme));
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
@@ -5,6 +5,8 @@ import { cap, type Memory } from './memory.ts';
5
5
  import { record } from './cache.ts';
6
6
 
7
7
  export const RUN_BOUNDARY = 'optchat.run';
8
+ /** Subagent traffic to the main agent: a custom message on screen, a plain user message to the model and memory. */
9
+ export const REPORT_TYPE = 'optchat-report';
8
10
 
9
11
  export function textContent(content: unknown): string {
10
12
  if (typeof content === 'string') return content;
@@ -16,6 +18,11 @@ export function textContent(content: unknown): string {
16
18
  return '';
17
19
  }).filter(Boolean).join('\n');
18
20
  }
21
+ /** Reports reach the model, memory and the previous-exchange replay exactly as the user messages they used to be. */
22
+ export function asUser(message: AgentMessage): AgentMessage {
23
+ if (message.role !== 'custom' || message.customType !== REPORT_TYPE) return message;
24
+ return { role: 'user', content: textContent(message.content), timestamp: message.timestamp };
25
+ }
19
26
  export function logMessage(memory: Memory, message: AgentMessage, receipt?: string) {
20
27
  const date = new Date(message.timestamp).toISOString();
21
28
  if (message.role === 'user') memory.append('user', textContent(message.content), date, receipt);
@@ -54,7 +61,7 @@ function completedExchange(history: readonly AgentMessage[]) {
54
61
  export function previousExchange(branch: readonly SessionEntry[]) {
55
62
  let end = -1;
56
63
  let legacyEnd = branch.length;
57
- const messages = (entries: readonly SessionEntry[]) => entries.flatMap(entry => entry.type === 'message' ? [entry.message] : []);
64
+ const messages = (entries: readonly SessionEntry[]) => entries.flatMap(entry => entry.type === 'message' ? [asUser(entry.message)] : entry.type === 'custom_message' && entry.customType === REPORT_TYPE ? [asUser({ role: 'custom', customType: entry.customType, content: entry.content, display: entry.display, timestamp: Date.parse(entry.timestamp) })] : []);
58
65
  for (let i = branch.length - 1; i >= 0; i--) {
59
66
  const entry = branch[i];
60
67
  if (entry.type !== 'custom' || entry.customType !== RUN_BOUNDARY || !record(entry.data)) continue;
@@ -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)}` }); }