pi-optchat 0.10.1 → 0.10.2

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/docs/subagents.md CHANGED
@@ -14,6 +14,10 @@ Your MCP servers (`~/.pi/agent/mcp.json`, the project's `.pi/mcp.json`) work in
14
14
  - If MCP is off in the main session (`--no-mcp`, `-builtin:mcp` in settings, or an extension that replaces `/mcp`), it is off in subagents too.
15
15
  - Each subagent opens its own server connections (stdio servers start once per subagent) and closes them when it ends.
16
16
 
17
+ ## Titles
18
+
19
+ A spawn task may carry a `title`, a short label the agent writes for you. The Agents page, the agent view, the agent picker of a second terminal and an attached window's tab show it instead of the task's first line, and the child's reports start `[id · title]`. A task without one shows its first line, as before. The title is saved with the run, so it survives `/reload`.
20
+
17
21
  ## Model and thinking level
18
22
 
19
23
  Each task runs the profile's subagent model (`/optchat agents model`), unless the spawn sets that task's `model` (`provider/id`, as in `/model`) or `thinking`. The agent sets them only when you ask for them.
@@ -25,7 +29,7 @@ Each task runs the profile's subagent model (`/optchat agents model`), unless th
25
29
 
26
30
  ## Reports
27
31
 
28
- Each child reports to its parent as soon as it finishes, as one message starting `[id]`. The parent stays alive to receive reports; it never polls.
32
+ Each child reports to its parent as soon as it finishes, as one message starting `[id]` (`[id · title]` with a title). The parent stays alive to receive reports; it never polls.
29
33
 
30
34
  - A failed child reports its error text.
31
35
  - A child whose request aborts with no stop (a provider abort) still reports `Task aborted: ...`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
package/src/agent-view.ts CHANGED
@@ -9,7 +9,8 @@ import type { Children } from './agents.ts';
9
9
  import { isHandingOff, type RunInfo } from './runs.ts';
10
10
  import { textContent } from './transcript.ts';
11
11
  import { record } from './cache.ts';
12
- import { elapsed, fit, oneLine } from './inspector.ts';
12
+ import { agentLabel, elapsed, fit, oneLine } from './inspector.ts';
13
+ import { isReport } from './report-message.ts';
13
14
 
14
15
  export type ViewTone = 'accent' | 'muted' | 'dim' | 'error' | 'warning' | 'success' | 'borderMuted';
15
16
  type ToolResult = Parameters<ToolExecutionComponent['updateResult']>[0];
@@ -23,14 +24,12 @@ const builtIns: Record<string, (cwd: string) => Renderers> = {
23
24
  grep: createGrepToolDefinition, find: createFindToolDefinition, ls: createLsToolDefinition,
24
25
  };
25
26
 
26
- /** Reports from the child's own agents start with their id, e.g. "[1a2b3c4d] ...". */
27
- const REPORT = /^\[[0-9a-f]{8}\] /;
28
27
  /** Connected windows prefix the main agent's guidance with this line. */
29
28
  const MAIN_PREFIX = '[Main agent guidance]\n';
30
29
 
31
30
  /** Who a user-role message in the child's conversation came from. Runs saved before senders were recorded count as the user's. */
32
31
  export function senderOf(text: string, guidance: Guidance = []): 'user' | 'manager' | 'report' {
33
- if (REPORT.test(text)) return 'report';
32
+ if (isReport(text)) return 'report';
34
33
  if (text.startsWith(MAIN_PREFIX)) return 'manager';
35
34
  return guidance.findLast(g => g.text === text)?.from === 'manager' ? 'manager' : 'user';
36
35
  }
@@ -270,7 +269,7 @@ export class AgentView implements Component, Focusable {
270
269
  const left = ` ${dot} ${color('accent', run.id)} `;
271
270
  const room = width - visibleWidth(left) - visibleWidth(right) - 3;
272
271
  if (room < 8) return truncateToWidth(`${left}${color('dim', right)}`, width);
273
- const task = fit(oneLine(run.task), room);
272
+ const task = fit(agentLabel(run), room);
274
273
  return `${left}${task}${' '.repeat(Math.max(2, width - visibleWidth(left) - visibleWidth(task) - visibleWidth(right) - 1))}${color('dim', right)} `;
275
274
  }
276
275
  render(width: number): string[] {
package/src/agents.ts CHANGED
@@ -11,7 +11,7 @@ import { memoryTools, searchTool } from './tools.ts';
11
11
  import { VIEW, type Memory } from './memory.ts';
12
12
  import type { ModelChoice } from './compactor.ts';
13
13
  import { cachePayload } from './cache.ts';
14
- import { CUT_OFF, RunHistory, isRunning, transition, sessionMessages, savedSession, undelivered, sentText, type Guidance, type RunInfo, type RunState, type FinishReason } from './runs.ts';
14
+ import { CUT_OFF, reportHead, RunHistory, isRunning, transition, sessionMessages, savedSession, undelivered, sentText, type Guidance, type RunInfo, type RunState, type FinishReason } from './runs.ts';
15
15
  import { UsageLedger } from './usage.ts';
16
16
  import { fullChat, refreshFraction, textContent } from './transcript.ts';
17
17
  import { Type, type Static } from 'typebox';
@@ -19,6 +19,7 @@ import { result } from './tools.ts';
19
19
  import { handoffText, withUndelivered, type HandoffEvidence } from './handoff.ts';
20
20
  import { DEFAULT_SETTINGS, type Settings } from './settings.ts';
21
21
  import { THINKING } from './profiles.ts';
22
+ import { oneLine } from './inspector.ts';
22
23
 
23
24
  export interface LiveRun {
24
25
  session: AgentSession; info: RunInfo; updated: number; streaming?: AgentMessage;
@@ -72,9 +73,10 @@ const packageName = (path: string): string | undefined => {
72
73
  /** A task's cwd may start with `~` and may be relative to the spawning agent's directory. */
73
74
  export const taskDirectory = (cwd: string, path = '.', windows = process.platform === 'win32') =>
74
75
  resolve(cwd, path.replace(windows ? /^~(?=$|[\\/])/ : /^~(?=$|\/)/, homedir()));
76
+ const TITLE_DOC = 'Short label shown to the user, e.g. "Fix steering bug"';
75
77
  const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
76
78
  /** The spawn tools' tasks. What a task leaves out of model and thinking is the profile's subagent choice. */
77
- export const TASKS = Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })),
79
+ export const TASKS = Type.Array(Type.Object({ task: Type.String(), title: Type.Optional(Type.String({ description: TITLE_DOC })), cwd: Type.Optional(Type.String({ description: CWD_DOC })),
78
80
  model: Type.Optional(Type.String({ description: 'Leave unset for the profile\'s subagent model (named in your instructions). Set only when the user asks for a model, as provider/id from models().' })),
79
81
  thinking: Type.Optional(StringEnum(THINKING, { description: 'Leave unset for the profile\'s subagent level. Set only when the user asks for one.' })) }), { minItems: 1 });
80
82
  export type Task = Static<typeof TASKS>[number];
@@ -279,7 +281,7 @@ export class Children {
279
281
  launch.then(done, done);
280
282
  return launch;
281
283
  }
282
- private async launchBatch({ tasks, cwd, depth, parentId, connected, signal, cancelled }: { tasks: { task: string; cwd?: string; model: Model<Api>; thinking: ModelChoice['thinking'] }[]; cwd: string; depth: number;
284
+ private async launchBatch({ tasks, cwd, depth, parentId, connected, signal, cancelled }: { tasks: (Omit<Task, 'model' | 'thinking'> & { model: Model<Api>; thinking: ModelChoice['thinking'] })[]; cwd: string; depth: number;
283
285
  parentId?: string; connected: boolean; signal?: AbortSignal; cancelled: () => boolean }) {
284
286
  const launched: LiveRun[] = [];
285
287
  let reserved = tasks.length;
@@ -291,7 +293,9 @@ export class Children {
291
293
  const id = randomUUID().slice(0, 8), directory = taskDirectory(cwd, task.cwd);
292
294
  const session = await this.open({ id, directory, depth, parentId, connected, model: task.model, thinking: task.thinking,
293
295
  sessionManager: SessionManager.create(directory, join(this.profileDirectory, 'runs')) });
294
- const info: RunInfo = { id, task: task.task, cwd: directory, model: `${task.model.provider}/${task.model.id}`, thinking: session.thinkingLevel,
296
+ // One short line without brackets, so the `[id · title]` head of its reports stays parseable and small.
297
+ const title = oneLine(task.title ?? '').replace(/[[\]]/g, '').trim().slice(0, 128).trimEnd();
298
+ const info: RunInfo = { id, task: task.task, ...(title ? { title } : {}), cwd: directory, model: `${task.model.provider}/${task.model.id}`, thinking: session.thinkingLevel,
295
299
  parentSession: this.options.parentSession ?? '', parentId, depth, sessionFile: session.sessionFile, started: Date.now(), state: 'running', guidance: [], ...(connected ? { connected: true } : {}) };
296
300
  const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [], steering: Promise.resolve() };
297
301
  launched.push(live); this.save(info); this.running.set(id, live);
@@ -372,7 +376,7 @@ export class Children {
372
376
  parameters: Type.Object({ message: Type.String() }), execute: async (_id: string, args: { message: string }) => {
373
377
  const message = args.message.trim();
374
378
  if (!message) throw new Error('Message is empty.');
375
- const text = `[${id}] ${connected ? 'Connected agent message' : 'Message from subagent (still running)'}: ${message}`;
379
+ const text = `${reportHead(this.history.records.get(id) ?? { id })} ${connected ? 'Connected agent message' : 'Message from subagent (still running)'}: ${message}`;
376
380
  if (!await this.toParent(parentId, text)) throw new Error('Your parent is no longer running.');
377
381
  return result(parentId ? `Message sent to parent ${parentId}.` : 'Message sent to the main agent.');
378
382
  },
@@ -420,7 +424,7 @@ export class Children {
420
424
  if (transition(info, 'paused')) {
421
425
  this.save(info);
422
426
  // In a grouped spawn the note takes the run's place, so its siblings' reports are not held until the user comes back; its later report arrives on its own.
423
- const note = `[${info.id}] Interrupted by the user; it waits for their next message, so no report until then.`, batch = live.batch;
427
+ const note = `${reportHead(info)} Interrupted by the user; it waits for their next message, so no report until then.`, batch = live.batch;
424
428
  live.batch = undefined;
425
429
  if (!info.connected) await (batch ? this.deliver(info, batch, note) : this.toParent(info.parentId, note))
426
430
  .catch(error => this.warn(`Could not tell the parent about the interrupt: ${String(error)}`));
@@ -507,7 +511,7 @@ export class Children {
507
511
  // A metadata failure must not suppress delivery of the actual result.
508
512
  try { this.save(info); } catch (error) { this.warn(`Could not save run metadata: ${String(error)}`); }
509
513
  // A deleted run's transcript is gone.
510
- await this.deliver(info, live.batch, `[${info.id}] ${info.report}${this.deleting.has(info.id) ? '' : `\n\n${fullChat(info.id)}`}`);
514
+ await this.deliver(info, live.batch, `${reportHead(info)} ${info.report}${this.deleting.has(info.id) ? '' : `\n\n${fullChat(info.id)}`}`);
511
515
  }
512
516
  /** Sends a run's report to its parent; in a grouped spawn, only once every child of the spawn has one. */
513
517
  private async deliver(info: RunInfo, batch: LiveRun['batch'], text: string) {
package/src/browser.ts CHANGED
@@ -88,7 +88,7 @@ const body = e => { const j = e.origin && e.text.startsWith('[Historical ') ? e.
88
88
  const short = (s, n) => { s = s.replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
89
89
  function who(e) {
90
90
  // Logs from before the work kind held reports as user messages starting "[id] "; another extension's shown custom message is tagged the same way.
91
- if (e.kind === 'work' || e.kind === 'user' && /^\[[\w.:-]+\] /.test(e.text)) return ['Subagent', 'work', 'subagent report'];
91
+ if (e.kind === 'work' || e.kind === 'user' && /^\[[\w.:-]+(?: · [^\]\n]*)?\] /.test(e.text)) return ['Subagent', 'work', 'subagent report'];
92
92
  if (e.kind === 'user') return ['You', 'you', 'from you'];
93
93
  if (e.kind === 'talk') return ['OptChat', 'talk', 'reply'];
94
94
  if (e.kind === 'tool') return ['Tool call', 'tool', 'tool step'];
@@ -87,8 +87,8 @@ class LiveTurn implements Component {
87
87
  export async function openConnectedWindow(pi: ExtensionAPI, ctx: ExtensionContext, profile: string, setTitle: (title: string) => void = title => ctx.ui.setTitle(title), agentId?: string, activeOnly = false) {
88
88
  // Each window draws with its own transcript, so nothing from an earlier window carries over.
89
89
  transcript = undefined;
90
- let started = false, ended = false, liveTurn: LiveTurn | undefined, live: LiveState | undefined, turn: AgentMessage[] = [];
91
- const title = (state: WindowState) => setTitle(windowTitle(profile, state));
90
+ let label: string | undefined, started = false, ended = false, liveTurn: LiveTurn | undefined, live: LiveState | undefined, turn: AgentMessage[] = [];
91
+ const title = (state: WindowState) => setTitle(windowTitle(profile, state, label));
92
92
  const display = (text: string, details: Details = {}) => pi.sendMessage<Details>({ customType: 'optchat-connected', content: text, display: true, details }, { triggerTurn: false });
93
93
  const refresh = () => { if (liveTurn) { liveTurn.turn = turn; liveTurn.live = live; liveTurn.update(); } };
94
94
  /** A reply moves into the chat once all its tool calls have results, as Pi's own chat does when a tool finishes. */
@@ -103,8 +103,8 @@ export async function openConnectedWindow(pi: ExtensionAPI, ctx: ExtensionContex
103
103
  const hideLive = () => { live = undefined; liveTurn = undefined; ctx.ui.setWidget('optchat-connected', undefined); };
104
104
  const connection = await connectWindow(profilePath(profile), event => {
105
105
  if (event.name === 'started') {
106
- started = true; showLive(); title('working');
107
- ctx.ui.setStatus('optchat', `OptChat: ${profile} · ${agentId ? 'attached' : 'connected'} agent ${event.text} · /complete`);
106
+ label = event.title; started = true; showLive(); title('working');
107
+ ctx.ui.setStatus('optchat', `OptChat: ${profile} · ${agentId ? 'attached' : 'connected'} agent ${event.text}${label ? ` · ${label}` : ''} · /complete`);
108
108
  } else if (event.name === 'status') {
109
109
  live = event.live; refresh();
110
110
  // The tab says working exactly when the spinner spins.
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@ import { checkpoint } from './checkpoint.ts';
24
24
  import { memoryDirectory, pendingImport, prepareImport, runImport, discardImport } from './import/job.ts';
25
25
  import { chooseImport, showProgress } from './import/ui.ts';
26
26
  import { UsageLedger } from './usage.ts';
27
- import { oneLine, showInspector, type InspectorPage } from './inspector.ts';
27
+ import { agentLabel, oneLine, showInspector, type InspectorPage } from './inspector.ts';
28
28
  import { showAgentView } from './agent-view.ts';
29
29
  import { inspectorShortcut, mountNavigation } from './navigation.ts';
30
30
  import { joinHeadless, listAttachableAgents, serveWindows } from './window-bridge.ts';
@@ -321,7 +321,7 @@ export default function optchat(pi: ExtensionAPI) {
321
321
  const listed = settled ? undefined : await listAttachableAgents(profilePath(name)).catch(() => undefined);
322
322
  const agents = listed ?? [];
323
323
  const hint = listed?.length === 0 ? '\nNo background agents are running. Ask the main agent to start one.' : '';
324
- const labels = new Map(agents.map(agent => [`Open subagent ${agent.id} · ${agent.state} · ${oneLine(agent.task).slice(0, 80)}`, agent.id]));
324
+ const labels = new Map(agents.map(agent => [`Open subagent ${agent.id} · ${agent.state} · ${agentLabel(agent).slice(0, 80)}`, agent.id]));
325
325
  const choice = await ctx.ui.select(`${name} is open in another window\n${error.owner}${hint}`,
326
326
  [CONNECT, ...labels.keys(), ...(settled ? [] : [BACK])]);
327
327
  const agentId = choice && labels.get(choice);
package/src/inspector.ts CHANGED
@@ -8,6 +8,8 @@ import { ranges, summarizeUsage, type UsageLedger, type UsageRange, type UsageRo
8
8
 
9
9
  export const clean = (text: string) => text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '').replace(/\t/g, ' ');
10
10
  export const oneLine = (text: string) => clean(text).replace(/\n/g, ' ');
11
+ /** How an agent is named to the user: its spawn title, else its task on one line. */
12
+ export const agentLabel = (run: { task: string; title?: string }) => run.title ?? oneLine(run.task);
11
13
  export const count = (n: number) => n.toLocaleString('en-US');
12
14
  const units = ['', 'k', 'M', 'B'];
13
15
  /** 950, 12.3k, 164k, 2.4M: enough precision to compare rows at a glance. A value that rounds to 1000 moves up a unit. */
@@ -191,7 +193,7 @@ export class Inspector implements Component, Focusable {
191
193
  const rows = list.slice(this.top, this.top + this.height).map(run => {
192
194
  const live = children.live(run.id), tools = live ? [...live.tools.values()].map(t => t.name).join(', ') : '';
193
195
  const status = live ? `${run.state === 'stopping' ? 'stopping' : run.state === 'waiting' ? 'waiting for children' : run.state === 'paused' ? 'interrupted · waiting for you' : tools || (live.streaming ? 'responding' : 'working')} · ${elapsed(Date.now() - live.updated)} ago` : isHandingOff(run) ? 'handing off' : run.state;
194
- return { run, task: `${' '.repeat(run.depth - 1)}${run.parentId ? '↳ ' : ''}${oneLine(run.task)}`, status, time: elapsed((run.ended ?? Date.now()) - run.started) };
196
+ return { run, task: `${' '.repeat(run.depth - 1)}${run.parentId ? '↳ ' : ''}${agentLabel(run)}`, status, time: elapsed((run.ended ?? Date.now()) - run.started) };
195
197
  });
196
198
  // Columns: task (flexible) · status · duration (right-aligned); status yields first on narrow screens.
197
199
  const timeWidth = Math.max(0, ...rows.map(r => r.time.length));
@@ -2,7 +2,8 @@ import { getMarkdownTheme, type ExtensionAPI, type Theme } from '@earendil-works
2
2
  import { Box, Markdown, Text } from '@earendil-works/pi-tui';
3
3
  import { REPORT_TYPE, textContent, withoutFullChat } from './transcript.ts';
4
4
 
5
- const HEAD = /^\[([0-9a-f]{8})\] (?:(Message from subagent \(still running\)|Connected agent message): )?/;
5
+ /** `[id]` or `[id · title]` (runs.ts `reportHead`); titles hold no brackets. */
6
+ const HEAD = /^\[([0-9a-f]{8})(?: · ([^\]\n]*))?\] (?:(Message from subagent \(still running\)|Connected agent message): )?/;
6
7
 
7
8
  /** Sent with a report: `count` subagent reports joined in one message, when one spawn's reports arrive together. */
8
9
  export interface ReportDetails { count?: number }
@@ -13,8 +14,9 @@ export function reportParts(text: string, count = 1) {
13
14
  if (count > 1) return { label: `${count} subagent reports`, body: text };
14
15
  const match = HEAD.exec(text);
15
16
  if (!match) return { label: 'subagent', body: text };
16
- const kind = match[2] === undefined ? 'report' : match[2].startsWith('Message') ? 'still running' : 'connected window';
17
- return { label: `subagent ${match[1]} · ${kind}`, body: text.slice(match[0].length) };
17
+ const [, id, title, message] = match;
18
+ const kind = message === undefined ? 'report' : message.startsWith('Message') ? 'still running' : 'connected window';
19
+ return { label: `subagent ${id}${title ? ` · ${title}` : ''} · ${kind}`, body: text.slice(match[0].length) };
18
20
  }
19
21
 
20
22
  /** The theme's neutral grey, darkened on dark themes so the box recedes behind the conversation. */
package/src/runs.ts CHANGED
@@ -12,6 +12,8 @@ export type RunState = typeof runStates[number];
12
12
  export type FinishReason = 'complete' | 'disconnected' | 'owner-stopped' | 'failed';
13
13
  export interface RunInfo {
14
14
  id: string; task: string; cwd: string; model: string; thinking: string;
15
+ /** The spawning agent's short label for the task, shown instead of it where there is room for one line. */
16
+ title?: string;
15
17
  parentSession: string; sessionFile?: string; started: number; ended?: number;
16
18
  parentId?: string; depth: number;
17
19
  state: RunState; report?: string;
@@ -24,6 +26,8 @@ export interface RunInfo {
24
26
  guidance: Guidance[];
25
27
  }
26
28
  export type Guidance = { text: string; date: number; state: 'queued' | 'delivered' | 'undelivered'; from?: 'user' | 'manager'; sent?: string };
29
+ /** How a run's reports and messages start: `[id]`, or `[id · title]` (see `HEAD` in report-message.ts). */
30
+ export const reportHead = (run: { id: string; title?: string }) => `[${run.id}${run.title ? ` · ${run.title}` : ''}]`;
27
31
  /** The text the agent receives, which is how Pi's queue and the session know the message. */
28
32
  export const sentText = (g: Guidance) => g.sent ?? g.text;
29
33
  const moves: Record<RunState, readonly RunState[]> = {
@@ -56,6 +60,7 @@ function isRun(value: unknown): value is RunInfo {
56
60
  && typeof value.id === 'string' && /^[a-zA-Z0-9-]+$/.test(value.id)
57
61
  && runStates.includes(value.state as RunState) && typeof value.started === 'number' && Number.isFinite(value.started)
58
62
  && (value.ended === undefined || typeof value.ended === 'number')
63
+ && (value.title === undefined || typeof value.title === 'string')
59
64
  && (value.sessionFile === undefined || typeof value.sessionFile === 'string')
60
65
  && (value.report === undefined || typeof value.report === 'string')
61
66
  && (value.connected === undefined || typeof value.connected === 'boolean')
package/src/title.ts CHANGED
@@ -4,8 +4,9 @@ export type WindowState = StatusState | 'done' | 'disconnected';
4
4
 
5
5
  const agents = (count: number) => count > 0 ? ` · ${count} ${count === 1 ? 'agent' : 'agents'}` : '';
6
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}` : ''}`;
7
+ /** `label`: the spawn title of the subagent an attached window shows. */
8
+ export const windowTitle = (profile: string, state: WindowState, label?: string) =>
9
+ `${state === 'working' ? '● ' : ''}↳ ${profile}${label ? ` · ${label}` : ''}${state === 'done' || state === 'disconnected' ? ` · ${state}` : ''}`;
9
10
  /** 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
11
  export const statusState = (state: string, agents = 0): StatusState =>
11
12
  state === 'running' || state === 'stopping' || state === 'waiting' && agents > 0 ? 'working' : 'waiting';
package/src/transcript.ts CHANGED
@@ -148,11 +148,11 @@ export function runTranscript(messages: readonly AgentMessage[]) {
148
148
  }).join('\n');
149
149
  }
150
150
 
151
- /** The status line naming running agents by id and the first words of their task; none while nothing runs. */
152
- export function atWork(runs: readonly { id: string; task: string }[]) {
151
+ /** The status line naming running agents by id and the first words of their title or task; none while nothing runs. */
152
+ export function atWork(runs: readonly { id: string; task: string; title?: string }[]) {
153
153
  if (!runs.length) return undefined;
154
- return AT_WORK + runs.map(({ id, task }) => {
155
- const words = flat(task).replaceAll('"', "'").split(/\s+/).filter(Boolean), start = words.slice(0, 6).join(' ');
154
+ return AT_WORK + runs.map(({ id, task, title }) => {
155
+ const words = flat(title ?? task).replaceAll('"', "'").split(/\s+/).filter(Boolean), start = words.slice(0, 6).join(' ');
156
156
  // Six words, but never more than 60 characters: a task can be one long word.
157
157
  const preview = [...start].slice(0, 60).join('');
158
158
  return `${id} "${preview}${words.length > 6 || preview.length < start.length ? '…' : ''}"`;
@@ -24,6 +24,8 @@ export interface LiveState { state: string; model: string; streaming?: AgentMess
24
24
  export interface WindowEvent {
25
25
  kind: 'event'; name: 'started' | 'message' | 'status' | 'finished'; text: string; from?: 'user' | 'agent';
26
26
  message?: AgentMessage; live?: LiveState;
27
+ /** On `started` in an attached window: the subagent's spawn title, if it has one. */
28
+ title?: string;
27
29
  }
28
30
  type Frame = Request | Reply | WindowEvent;
29
31
  const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
@@ -38,7 +40,8 @@ function parse(value: unknown): Frame {
38
40
  const name = value.name, action = value.action, from = value.from;
39
41
  if (value.kind === 'event' && (name === 'started' || name === 'message' || name === 'status' || name === 'finished') && typeof value.text === 'string')
40
42
  return { kind: 'event', name, text: value.text, from: from === 'user' || from === 'agent' ? from : undefined,
41
- message: isMessage(value.message) ? value.message : undefined, live: isLive(value.live) ? value.live : undefined };
43
+ message: isMessage(value.message) ? value.message : undefined, live: isLive(value.live) ? value.live : undefined,
44
+ title: typeof value.title === 'string' ? value.title : undefined };
42
45
  if (typeof value.id !== 'number' || !Number.isSafeInteger(value.id)) throw new Error('Invalid request ID');
43
46
  if (value.kind === 'reply' && (value.error === undefined || typeof value.error === 'string') && (value.text === undefined || typeof value.text === 'string'))
44
47
  return { kind: 'reply', id: value.id, error: value.error, text: value.text };
@@ -113,7 +116,7 @@ export async function serveWindows(directory: string, children: Children, availa
113
116
  const agents: AttachableAgent[] = available() ? children.history.list()
114
117
  .filter(info => !info.connected && !!children.live(info.id)
115
118
  && (info.state === 'running' || info.state === 'waiting' || info.state === 'paused'))
116
- .map(info => ({ id: info.id, task: info.task.slice(0, 200), state: info.state })) : [];
119
+ .map(info => ({ id: info.id, task: info.task.slice(0, 200), title: info.title, state: info.state })) : [];
117
120
  send({ kind: 'reply', id: frame.id, text: JSON.stringify(agents) }); return;
118
121
  } else if (frame.action === 'attach') {
119
122
  if (child) throw new Error('This window already has a conversation');
@@ -130,7 +133,7 @@ export async function serveWindows(directory: string, children: Children, availa
130
133
  // This socket borrows the existing session, never its lifetime, slot or report destination.
131
134
  // Other views (the main window's Agents page, other terminals) stay open alongside it.
132
135
  attached = true; child = id; finalMessages = live ? undefined : messages;
133
- send({ kind: 'event', name: 'started', text: child });
136
+ send({ kind: 'event', name: 'started', text: child, title: info.title });
134
137
  drainMessages(messages, info);
135
138
  } else if (frame.action === 'start') {
136
139
  if (child) throw new Error('This window already has a conversation');
@@ -295,7 +298,7 @@ export async function connectWindow(directory: string, event: (event: WindowEven
295
298
  };
296
299
  }
297
300
 
298
- export interface AttachableAgent { id: string; task: string; state: string }
301
+ export interface AttachableAgent { id: string; task: string; title?: string; state: string }
299
302
  /** Query the owner, not run metadata on disk: it knows which main/terminal views are actually open. */
300
303
  export async function listAttachableAgents(directory: string, timeout = 2000): Promise<AttachableAgent[]> {
301
304
  const deadline = Date.now() + timeout;
@@ -303,7 +306,7 @@ export async function listAttachableAgents(directory: string, timeout = 2000): P
303
306
  try {
304
307
  const agents: unknown = JSON.parse(await connection.request('list-agents', undefined, undefined, { timeout: Math.max(1, deadline - Date.now()) }) ?? '[]');
305
308
  if (!Array.isArray(agents) || !agents.every(agent => record(agent) && typeof agent.id === 'string'
306
- && typeof agent.task === 'string' && (agent.state === 'running' || agent.state === 'waiting' || agent.state === 'paused')))
309
+ && typeof agent.task === 'string' && (agent.title === undefined || typeof agent.title === 'string') && (agent.state === 'running' || agent.state === 'waiting' || agent.state === 'paused')))
307
310
  throw new Error('Invalid background agent list from the profile owner');
308
311
  return agents;
309
312
  } finally { connection.close(); }