pi-optchat 0.10.0 → 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.0",
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, 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,13 +19,18 @@ 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;
25
26
  tools: Map<string, { name: string; args: unknown; output?: unknown; started: number }>;
26
- pendingReports: string[]; pendingGuidance: string[]; wake?: () => void; completion?: Promise<void>; primed?: Promise<unknown>;
27
+ pendingReports: string[]; pendingGuidance: Guidance[]; wake?: () => void; completion?: Promise<void>; primed?: Promise<unknown>;
27
28
  /** Set by the user's interrupt: the queued guidance the run continues with once its current step is aborted; with none, it pauses. */
28
- interruption?: RunInfo['guidance'];
29
+ interruption?: Guidance[];
30
+ /** Guidance just handed to session.prompt(): the next user message is it, as Pi may rewrite the text (a /skill: line becomes the skill). */
31
+ prompting?: Guidance;
32
+ /** Every steer into Pi's queue, one at a time, so each knows which queued text is its own (see `steer`). */
33
+ steering: Promise<unknown>;
29
34
  /** Messages being steered back into Pi's queue after it was cleared; other queue changes wait for it. */
30
35
  requeueing?: Promise<void>;
31
36
  /** Shared by the children of one spawn whose reports are delivered together, in spawn order. */
@@ -68,9 +73,10 @@ const packageName = (path: string): string | undefined => {
68
73
  /** A task's cwd may start with `~` and may be relative to the spawning agent's directory. */
69
74
  export const taskDirectory = (cwd: string, path = '.', windows = process.platform === 'win32') =>
70
75
  resolve(cwd, path.replace(windows ? /^~(?=$|[\\/])/ : /^~(?=$|\/)/, homedir()));
76
+ const TITLE_DOC = 'Short label shown to the user, e.g. "Fix steering bug"';
71
77
  const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
72
78
  /** The spawn tools' tasks. What a task leaves out of model and thinking is the profile's subagent choice. */
73
- 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 })),
74
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().' })),
75
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 });
76
82
  export type Task = Static<typeof TASKS>[number];
@@ -210,8 +216,9 @@ export class Children {
210
216
  if (event.type === 'turn_end' || event.type === 'agent_settled') this.options.usage?.backfill(live.session.sessionManager.getEntries(), live.info.parentSession, 'subagent', live.info.id);
211
217
  if (event.type === 'message_start' && event.message.role === 'user') {
212
218
  const text = textContent(event.message.content);
213
- const guidance = live.info.guidance.find(g => g.state === 'queued' && g.text === text);
214
- if (guidance) { guidance.state = 'delivered'; this.save(live.info); }
219
+ const guidance = live.prompting ?? live.info.guidance.find(g => g.state === 'queued' && sentText(g) === text);
220
+ live.prompting = undefined;
221
+ if (guidance) { if (text !== guidance.text) guidance.sent = text; guidance.state = 'delivered'; this.save(live.info); }
215
222
  }
216
223
  if (event.type === 'tool_execution_start') live.tools.set(event.toolCallId, { name: event.toolName, args: event.args, started: Date.now() });
217
224
  if (event.type === 'tool_execution_update') {
@@ -274,7 +281,7 @@ export class Children {
274
281
  launch.then(done, done);
275
282
  return launch;
276
283
  }
277
- 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;
278
285
  parentId?: string; connected: boolean; signal?: AbortSignal; cancelled: () => boolean }) {
279
286
  const launched: LiveRun[] = [];
280
287
  let reserved = tasks.length;
@@ -286,9 +293,11 @@ export class Children {
286
293
  const id = randomUUID().slice(0, 8), directory = taskDirectory(cwd, task.cwd);
287
294
  const session = await this.open({ id, directory, depth, parentId, connected, model: task.model, thinking: task.thinking,
288
295
  sessionManager: SessionManager.create(directory, join(this.profileDirectory, 'runs')) });
289
- 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,
290
299
  parentSession: this.options.parentSession ?? '', parentId, depth, sessionFile: session.sessionFile, started: Date.now(), state: 'running', guidance: [], ...(connected ? { connected: true } : {}) };
291
- const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [] };
300
+ const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [], steering: Promise.resolve() };
292
301
  launched.push(live); this.save(info); this.running.set(id, live);
293
302
  this.launching--; reserved--;
294
303
  session.subscribe(event => this.observe(live, event));
@@ -367,7 +376,7 @@ export class Children {
367
376
  parameters: Type.Object({ message: Type.String() }), execute: async (_id: string, args: { message: string }) => {
368
377
  const message = args.message.trim();
369
378
  if (!message) throw new Error('Message is empty.');
370
- 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}`;
371
380
  if (!await this.toParent(parentId, text)) throw new Error('Your parent is no longer running.');
372
381
  return result(parentId ? `Message sent to parent ${parentId}.` : 'Message sent to the main agent.');
373
382
  },
@@ -378,7 +387,7 @@ export class Children {
378
387
  if (!parentId) { await this.report(text); return true; }
379
388
  const parent = this.running.get(parentId);
380
389
  if (!parent || !['running', 'waiting', 'paused'].includes(parent.info.state)) return false;
381
- if (parent.info.state === 'running') await parent.session.steer(text);
390
+ if (parent.info.state === 'running') await this.steer(parent, text);
382
391
  else { parent.pendingReports.push(text); parent.wake?.(); } // A paused parent keeps it until the user resumes it.
383
392
  this.changed();
384
393
  return true;
@@ -407,7 +416,7 @@ export class Children {
407
416
  // Read Pi's queue only once a rebuild has landed, and take the interrupt's messages off it so they arrive once.
408
417
  // Until this is done, tell() holds new messages in pendingGuidance, where the turn below or a pause picks them up.
409
418
  await live.requeueing;
410
- const texts = new Set(guidance.map(g => g.text));
419
+ const texts = new Set(guidance.map(sentText));
411
420
  await this.requeue(live, session.clearQueue().steering.filter(text => !texts.has(text)));
412
421
  live.interruption = undefined;
413
422
  if (!guidance.length) {
@@ -415,7 +424,7 @@ export class Children {
415
424
  if (transition(info, 'paused')) {
416
425
  this.save(info);
417
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.
418
- 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;
419
428
  live.batch = undefined;
420
429
  if (!info.connected) await (batch ? this.deliver(info, batch, note) : this.toParent(info.parentId, note))
421
430
  .catch(error => this.warn(`Could not tell the parent about the interrupt: ${String(error)}`));
@@ -425,7 +434,7 @@ export class Children {
425
434
  if (!transition(info, 'running')) continue; // A stop during the cleanup wins.
426
435
  for (const g of guidance) g.state = 'delivered';
427
436
  this.save(info);
428
- await session.prompt(interruptedWith(guidance.map(g => g.text)));
437
+ await session.prompt(interruptedWith(guidance.map(sentText)));
429
438
  continue;
430
439
  }
431
440
  const paused = info.state === 'paused';
@@ -438,7 +447,9 @@ export class Children {
438
447
  if (!paused && last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted') && !live.pendingReports.length && !this.directChildren(info.id).length) break;
439
448
  if (live.pendingGuidance.length) {
440
449
  transition(info, 'running'); this.save(info);
441
- await session.prompt(live.pendingGuidance.shift()!);
450
+ live.prompting = live.pendingGuidance.shift()!;
451
+ // An extension command sends no message at all, so this can't outlive the prompt and claim a later one.
452
+ try { await session.prompt(live.prompting.text); } finally { live.prompting = undefined; }
442
453
  continue;
443
454
  }
444
455
  if (live.pendingReports.length) {
@@ -474,7 +485,7 @@ export class Children {
474
485
  // Pi's queue must be empty before the handoff turn, or it would take a cancelled message as new input. Guidance in it is
475
486
  // already marked undelivered; the rest (a child's message) joins the reports the window never read.
476
487
  await live.requeueing;
477
- const guidance = new Set(info.guidance.map(g => g.text));
488
+ const guidance = new Set(info.guidance.map(sentText));
478
489
  const unread = [...live.pendingReports.splice(0), ...session.clearQueue().steering.filter(text => !guidance.has(text))];
479
490
  if (unread.length) info.handoff.unread = unread;
480
491
  transition(info, info.handoff.reason === 'complete' ? 'completed' : 'interrupted');
@@ -500,7 +511,7 @@ export class Children {
500
511
  // A metadata failure must not suppress delivery of the actual result.
501
512
  try { this.save(info); } catch (error) { this.warn(`Could not save run metadata: ${String(error)}`); }
502
513
  // A deleted run's transcript is gone.
503
- 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)}`}`);
504
515
  }
505
516
  /** Sends a run's report to its parent; in a grouped spawn, only once every child of the spawn has one. */
506
517
  private async deliver(info: RunInfo, batch: LiveRun['batch'], text: string) {
@@ -539,12 +550,14 @@ export class Children {
539
550
  if (!live) throw new Error(`No running subagent ${id}.`);
540
551
  const text = live.info.connected && source === 'manager' ? `[Main agent guidance]\n${message.trim()}` : message.trim(); if (!message.trim()) throw new Error('Message is empty.');
541
552
  if (source === 'user') this.memory.append('user', `Direct guidance to subagent [${id}]: ${text}`);
542
- const guidance: RunInfo['guidance'][number] = { text, date: Date.now(), state: 'queued', from: source };
553
+ const guidance: Guidance = { text, date: Date.now(), state: 'queued', from: source };
543
554
  live.info.guidance.push(guidance); this.save(live.info);
544
555
  try {
545
556
  await live.requeueing; // Keeps the queue in the order messages were sent.
546
- if (live.info.state === 'running' && !live.interruption) await live.session.steer(text);
547
- else { live.pendingGuidance.push(text); live.wake?.(); } // Waiting, paused or being interrupted: this message starts its next turn.
557
+ if (live.info.state === 'running' && !live.interruption) {
558
+ const sent = await this.steer(live, text);
559
+ if (sent !== undefined && sent !== text) guidance.sent = sent;
560
+ } else { live.pendingGuidance.push(guidance); live.wake?.(); } // Waiting, paused or being interrupted: this message starts its next turn.
548
561
  }
549
562
  catch (error) { guidance.state = 'undelivered'; this.save(live.info); throw error; }
550
563
  if (guidance.state === 'undelivered') throw new Error(`${id} finished before it read the message. Tell it again to resume it.`);
@@ -562,8 +575,8 @@ export class Children {
562
575
  // Nothing yields before the abort starts, so the run cannot finish in between and drop the messages.
563
576
  // They leave pendingGuidance and Pi's steering queue here, and execute() sends them in one prompt. Pi's queue must be empty
564
577
  // before the abort: after an aborted tool call Pi hands it to a request the abort cancels.
565
- const texts = new Set(guidance.map(g => g.text));
566
- live.pendingGuidance = live.pendingGuidance.filter(text => !texts.has(text));
578
+ live.pendingGuidance = live.pendingGuidance.filter(g => !guidance.includes(g));
579
+ const texts = new Set(guidance.map(sentText));
567
580
  // The rest of the queue (a child's tell_parent message) follows the interrupt's prompt, as reports to a paused run do.
568
581
  live.pendingReports.push(...live.session.clearQueue().steering.filter(text => !texts.has(text)));
569
582
  live.interruption = guidance;
@@ -578,11 +591,11 @@ export class Children {
578
591
  if (!live || text === undefined || live.requeueing || live.interruption) return undefined;
579
592
  // The queue holds only texts, so of equal ones the last is taken back, record and queue entry alike.
580
593
  const guidance = live.info.guidance.findLast(g => g.state === 'queued' && g.text === text)!;
581
- const pending = live.pendingGuidance.lastIndexOf(guidance.text);
594
+ const pending = live.pendingGuidance.indexOf(guidance);
582
595
  if (pending >= 0) live.pendingGuidance.splice(pending, 1);
583
596
  else {
584
597
  const steering = live.session.getSteeringMessages();
585
- const at = steering.lastIndexOf(guidance.text);
598
+ const at = steering.lastIndexOf(sentText(guidance));
586
599
  if (at < 0) return undefined; // Already on its way to the agent.
587
600
  // Pi cannot drop a single queued message: clear the queue and steer the rest back in order.
588
601
  void this.requeue(live, live.session.clearQueue().steering.filter((_, i) => i !== at));
@@ -590,9 +603,16 @@ export class Children {
590
603
  live.info.guidance.splice(live.info.guidance.indexOf(guidance), 1); this.save(live.info);
591
604
  return guidance.text;
592
605
  }
606
+ /** Pi's steer(), returning what Pi queued, which may differ from `text` (a /skill: line becomes the skill): that is what the agent receives. */
607
+ private steer(live: LiveRun, text: string) {
608
+ // One at a time, so the newest entry in Pi's queue is this one's.
609
+ const sent = live.steering.then(async () => await live.session.steer(text) === 'queued' ? live.session.getSteeringMessages().at(-1) : undefined);
610
+ live.steering = sent.catch(() => undefined);
611
+ return sent;
612
+ }
593
613
  private requeue(live: LiveRun, texts: string[]) {
594
614
  const work: Promise<void> = (async () => {
595
- try { for (const text of texts) await live.session.steer(text); }
615
+ try { for (const text of texts) await this.steer(live, text); }
596
616
  catch (error) { this.warn(`Could not requeue a message for ${live.info.id}: ${String(error)}`); }
597
617
  })().finally(() => { if (live.requeueing === work) live.requeueing = undefined; });
598
618
  live.requeueing = work;
@@ -634,7 +654,7 @@ export class Children {
634
654
  const info: RunInfo = { ...rest, state: 'running', started: Date.now(), parentSession: this.options.parentSession ?? run.parentSession,
635
655
  guidance: [...run.guidance, { text: prompt, date: Date.now(), state: 'queued', from: 'manager' }] };
636
656
  try { this.save(info); } catch (error) { this.history.records.set(id, run); await this.shutdown(session); this.dispose(session); throw error; }
637
- const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [] };
657
+ const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [], steering: Promise.resolve() };
638
658
  this.running.set(id, live);
639
659
  this.launching--; reserved = false;
640
660
  session.subscribe(event => this.observe(live, event));
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;
@@ -20,9 +22,14 @@ export interface RunInfo {
20
22
  handoff?: { reason: FinishReason; text?: string; delivered?: boolean; unread?: string[] };
21
23
  /** Working when Pi closed or crashed: the next start resumes it (see `Children.resumeCutOff`). */
22
24
  cutOff?: boolean;
23
- /** `from` is missing on runs saved before senders were recorded. */
24
- guidance: { text: string; date: number; state: 'queued' | 'delivered' | 'undelivered'; from?: 'user' | 'manager' }[];
25
+ /** `from` is missing on runs saved before senders were recorded. `sent`: what Pi queued, when it rewrote `text` (a `/skill:` line becomes the skill itself). */
26
+ guidance: Guidance[];
25
27
  }
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}` : ''}]`;
31
+ /** The text the agent receives, which is how Pi's queue and the session know the message. */
32
+ export const sentText = (g: Guidance) => g.sent ?? g.text;
26
33
  const moves: Record<RunState, readonly RunState[]> = {
27
34
  running: ['waiting', 'paused', 'stopping', 'completed', 'failed', 'stopped', 'interrupted'],
28
35
  waiting: ['running', 'paused', 'stopping', 'completed', 'failed', 'stopped', 'interrupted'],
@@ -53,6 +60,7 @@ function isRun(value: unknown): value is RunInfo {
53
60
  && typeof value.id === 'string' && /^[a-zA-Z0-9-]+$/.test(value.id)
54
61
  && runStates.includes(value.state as RunState) && typeof value.started === 'number' && Number.isFinite(value.started)
55
62
  && (value.ended === undefined || typeof value.ended === 'number')
63
+ && (value.title === undefined || typeof value.title === 'string')
56
64
  && (value.sessionFile === undefined || typeof value.sessionFile === 'string')
57
65
  && (value.report === undefined || typeof value.report === 'string')
58
66
  && (value.connected === undefined || typeof value.connected === 'boolean')
@@ -63,7 +71,7 @@ function isRun(value: unknown): value is RunInfo {
63
71
  && (value.handoff.unread === undefined || Array.isArray(value.handoff.unread) && value.handoff.unread.every(text => typeof text === 'string')))
64
72
  && typeof value.depth === 'number' && Number.isInteger(value.depth) && value.depth >= 1
65
73
  && (value.parentId === undefined || typeof value.parentId === 'string')
66
- && Array.isArray(value.guidance) && value.guidance.every(g => record(g) && typeof g.text === 'string' && typeof g.date === 'number' && ['queued', 'delivered', 'undelivered'].includes(String(g.state)) && (g.from === undefined || g.from === 'user' || g.from === 'manager'));
74
+ && Array.isArray(value.guidance) && value.guidance.every(g => record(g) && typeof g.text === 'string' && typeof g.date === 'number' && ['queued', 'delivered', 'undelivered'].includes(String(g.state)) && (g.from === undefined || g.from === 'user' || g.from === 'manager') && (g.sent === undefined || typeof g.sent === 'string'));
67
75
  }
68
76
  export const CUT_OFF = 'Pi closed before this agent finished. Its partial transcript is retained.';
69
77
  export function sessionMessages(file: string): AgentMessage[] {
@@ -7,8 +7,8 @@ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
7
7
 
8
8
  type Role = 'compactor' | 'subagent';
9
9
  const ROLES: Record<Role, { label: string; description: string; applies: string }> = {
10
- compactor: { label: 'Compactor model', description: 'Writes memory summaries, imports and connected-window handoffs. Shortcut: /optchat model.', applies: 'Applies to the next summary.' },
11
- subagent: { label: 'Subagent model', description: 'Runs every subagent. Shortcut: /optchat agents model.', applies: 'Applies to subagents started after this.' },
10
+ compactor: { label: 'Compactor model', description: 'Writes memory summaries, imports and fallback handoffs. Shortcut: /optchat model.', applies: 'Applies to the next summary.' },
11
+ subagent: { label: 'Subagent model', description: 'Runs every subagent. Shortcut: /optchat agents model.', applies: 'Applies to new subagents.' },
12
12
  };
13
13
  const modelName = (choice: ModelChoice) => `${choice.provider}/${choice.model}`;
14
14
  export const showModel = (choice: ModelChoice) => `${modelName(choice)} · ${choice.thinking}`;
package/src/settings.ts CHANGED
@@ -3,32 +3,32 @@ import { NODE } from './memory.ts';
3
3
  /** Per-profile choices where OptChat departs from Victor's recipe. Each default is the recipe's behaviour unless noted. */
4
4
  export const SETTINGS = {
5
5
  subagentLevels: { kind: 'number', label: 'Subagent levels', default: 1, min: 1, unit: '',
6
- description: 'How many levels of subagents. 1: only the main agent starts subagents. 2: those subagents may start their own, and so on.',
7
- applies: 'Applies to subagents started or resumed after this.' },
6
+ description: 'How deep subagents can nest. 1: only the main agent starts them.',
7
+ applies: 'Applies to new or resumed subagents.' },
8
8
  maxAgents: { kind: 'number', label: 'Max active agents', default: 8, min: 1, unit: '',
9
- description: 'Subagents running at once in this profile, all levels together. It also caps how deep a chain can go.',
9
+ description: 'Subagents running at once, all levels together. Also caps chain depth.',
10
10
  applies: 'Applies to the next spawn.' },
11
11
  groupReports: { kind: 'toggle', label: 'Group subagent reports', default: false,
12
- description: 'Off: each subagent reports as soon as it finishes, as in Victor\'s recipe. On: the subagents started by one spawn report together, in one message once the last of them finishes.',
12
+ description: 'On: subagents from one spawn report together, once the last finishes. Off: each reports when done (Victor\'s recipe).',
13
13
  applies: 'Applies to the next spawn.' },
14
14
  previousExchange: { kind: 'toggle', label: 'Previous exchange', default: true,
15
- description: 'Send your last request and its answer in full with the next turn, so a follow-up reads the exact wording. Off: memory view only, as in Victor\'s recipe. On by default: a study found it costs little and saves zooms.',
15
+ description: 'Send your last request and its answer in full with the next turn, so follow-ups see the exact wording. Off: memory view only (Victor\'s recipe).',
16
16
  applies: 'Applies from the next turn.' },
17
17
  previousExchangeKB: { kind: 'number', label: 'Previous exchange limit', default: 16, min: 1, unit: 'KB',
18
- description: 'A larger last exchange, usually a big paste, is left out and the model zooms into memory instead.',
18
+ description: 'A larger exchange, usually a big paste, is left out; the model zooms instead.',
19
19
  applies: 'Applies from the next turn.' },
20
20
  memorySearch: { kind: 'toggle', label: 'Memory search', default: false,
21
- description: 'Give agents a search tool that finds original messages by plain text, newest first, for exact names, numbers, paths or errors the view doesn\'t show. Off: zoom and date only, as in Victor\'s recipe. Turning it on or off makes the next turn re-cache its prompt once.',
22
- applies: 'Applies from the next turn, and to subagents started or resumed after this.' },
21
+ description: 'Lets agents search original messages by plain text. Off: zoom and date only (Victor\'s recipe). Toggling re-caches the prompt once.',
22
+ applies: 'Applies from the next turn and to new or resumed subagents.' },
23
23
  summaryAcceptBytes: { kind: 'number', label: 'Summary size tolerance', default: NODE * 1.25, min: NODE, unit: 'bytes',
24
- description: `The compactor is always asked for ${NODE}-byte lines; a longer line up to this size is kept instead of retried. ${NODE}: strict, as in Victor's recipe. The default saves retries for a little more view space.`,
24
+ description: `Summary lines up to this size are kept instead of retried (the compactor aims for ${NODE}). ${NODE}: strict (Victor's recipe).`,
25
25
  applies: 'Applies to the next summary.' },
26
26
  jobs: { kind: 'number', label: 'Summaries at once', default: 8, min: 1, max: 64, unit: '',
27
- description: 'Summaries built at once, by chat compaction and by an import. With N at once, a summary can see up to N−1 lines not summarized yet before it, so higher is faster but a little less accurate (at 8 that costs about 0.8 points per summary, #108). Higher also hits your provider\'s rate limits harder; the compactor retries what is refused. If your provider caps parallel requests, set it below that cap so the main turn has room.',
28
- applies: 'Applies to the next import, and to chat once the profile opens again (e.g. after /reload).' },
27
+ description: 'Summaries built in parallel. Higher is faster, slightly less accurate and harder on rate limits; keep it below your provider\'s parallel cap.',
28
+ applies: 'Applies to the next import, and to chat after /reload.' },
29
29
  refreshAt: { kind: 'percent', label: 'Turn refresh threshold', default: 0.3, min: 0, max: 0.9,
30
- description: 'Once the current turn\'s messages pass this share of the model\'s context window, they move into memory and the turn goes on with a fresh window: the rebuilt view, the task word for word, and its last tool call and result. 0%: off, as in Victor\'s recipe, and a long turn can run out of window. OPTCHAT_REFRESH_AT (a fraction, e.g. 0.3; 0 is off) overrides it. Subagents use Pi\'s own compaction at about the same point.',
31
- applies: 'Applies from the next call, and to subagents started or resumed after this.' },
30
+ description: 'When a turn fills this share of the context window, it moves into memory and continues in a fresh window. 0%: off (Victor\'s recipe). OPTCHAT_REFRESH_AT overrides it.',
31
+ applies: 'Applies from the next call and to new or resumed subagents.' },
32
32
  } as const;
33
33
 
34
34
  export type SettingKey = keyof typeof SETTINGS;
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');
@@ -191,6 +194,10 @@ export async function serveWindows(directory: string, children: Children, availa
191
194
  if (task) { text = info.task; firstUser = false; }
192
195
  if (message.role === 'user') {
193
196
  // The task, and an interrupt, which continues with your queued messages, are yours too.
197
+ // Your messages show as you typed them, also inside an interrupt's prompt, not as the skill Pi expanded them into.
198
+ // Longest first: a skill sent without arguments is the start of the same skill sent with some.
199
+ const typed = info.guidance.flatMap(g => g.from === 'user' && g.sent ? [{ sent: g.sent, text: g.text }] : []).sort((a, b) => b.sent.length - a.sent.length);
200
+ for (const g of typed) text = text.replaceAll(g.sent, () => g.text);
194
201
  const yours = task || text.startsWith(INTERRUPTED) || info.guidance.some(g => g.from === 'user' && g.text === text.trim());
195
202
  if (text) send({ kind: 'event', name: 'message', from: yours ? 'user' : undefined, text: shorten(text, 200_000) });
196
203
  continue;
@@ -291,7 +298,7 @@ export async function connectWindow(directory: string, event: (event: WindowEven
291
298
  };
292
299
  }
293
300
 
294
- export interface AttachableAgent { id: string; task: string; state: string }
301
+ export interface AttachableAgent { id: string; task: string; title?: string; state: string }
295
302
  /** Query the owner, not run metadata on disk: it knows which main/terminal views are actually open. */
296
303
  export async function listAttachableAgents(directory: string, timeout = 2000): Promise<AttachableAgent[]> {
297
304
  const deadline = Date.now() + timeout;
@@ -299,7 +306,7 @@ export async function listAttachableAgents(directory: string, timeout = 2000): P
299
306
  try {
300
307
  const agents: unknown = JSON.parse(await connection.request('list-agents', undefined, undefined, { timeout: Math.max(1, deadline - Date.now()) }) ?? '[]');
301
308
  if (!Array.isArray(agents) || !agents.every(agent => record(agent) && typeof agent.id === 'string'
302
- && 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')))
303
310
  throw new Error('Invalid background agent list from the profile owner');
304
311
  return agents;
305
312
  } finally { connection.close(); }