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 +5 -1
- package/package.json +1 -1
- package/src/agent-view.ts +4 -5
- package/src/agents.ts +46 -26
- package/src/browser.ts +1 -1
- package/src/connected-window.ts +4 -4
- package/src/index.ts +2 -2
- package/src/inspector.ts +3 -1
- package/src/report-message.ts +5 -3
- package/src/runs.ts +11 -3
- package/src/settings-page.ts +2 -2
- package/src/settings.ts +13 -13
- package/src/title.ts +3 -2
- package/src/transcript.ts +4 -4
- package/src/window-bridge.ts +12 -5
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]
|
|
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
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 (
|
|
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(
|
|
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:
|
|
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?:
|
|
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
|
|
214
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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:
|
|
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)
|
|
547
|
-
|
|
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
|
-
|
|
566
|
-
|
|
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.
|
|
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
|
|
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
|
|
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.:-]
|
|
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'];
|
package/src/connected-window.ts
CHANGED
|
@@ -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} · ${
|
|
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 ? '↳ ' : ''}${
|
|
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));
|
package/src/report-message.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
17
|
-
|
|
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:
|
|
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[] {
|
package/src/settings-page.ts
CHANGED
|
@@ -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
|
|
11
|
-
subagent: { label: 'Subagent model', description: 'Runs every subagent. Shortcut: /optchat agents model.', applies: 'Applies to subagents
|
|
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
|
|
7
|
-
applies: 'Applies to
|
|
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
|
|
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: '
|
|
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
|
|
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
|
|
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: '
|
|
22
|
-
applies: 'Applies from the next turn
|
|
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: `
|
|
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
|
|
28
|
-
applies: 'Applies to the next import, and to chat
|
|
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: '
|
|
31
|
-
applies: 'Applies from the next call
|
|
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
|
-
|
|
8
|
-
|
|
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 ? '…' : ''}"`;
|
package/src/window-bridge.ts
CHANGED
|
@@ -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(); }
|