pi-optchat 0.10.1 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/subagents.md +5 -1
- package/package.json +1 -1
- package/src/agent-view.ts +4 -5
- package/src/agents.ts +11 -7
- 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 +5 -0
- package/src/title.ts +3 -2
- package/src/transcript.ts +4 -4
- package/src/window-bridge.ts +8 -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, sentText, type Guidance, type RunInfo, type RunState, type FinishReason } from './runs.ts';
|
|
14
|
+
import { CUT_OFF, reportHead, RunHistory, isRunning, transition, sessionMessages, savedSession, undelivered, sentText, type Guidance, type RunInfo, type RunState, type FinishReason } from './runs.ts';
|
|
15
15
|
import { UsageLedger } from './usage.ts';
|
|
16
16
|
import { fullChat, refreshFraction, textContent } from './transcript.ts';
|
|
17
17
|
import { Type, type Static } from 'typebox';
|
|
@@ -19,6 +19,7 @@ import { result } from './tools.ts';
|
|
|
19
19
|
import { handoffText, withUndelivered, type HandoffEvidence } from './handoff.ts';
|
|
20
20
|
import { DEFAULT_SETTINGS, type Settings } from './settings.ts';
|
|
21
21
|
import { THINKING } from './profiles.ts';
|
|
22
|
+
import { oneLine } from './inspector.ts';
|
|
22
23
|
|
|
23
24
|
export interface LiveRun {
|
|
24
25
|
session: AgentSession; info: RunInfo; updated: number; streaming?: AgentMessage;
|
|
@@ -72,9 +73,10 @@ const packageName = (path: string): string | undefined => {
|
|
|
72
73
|
/** A task's cwd may start with `~` and may be relative to the spawning agent's directory. */
|
|
73
74
|
export const taskDirectory = (cwd: string, path = '.', windows = process.platform === 'win32') =>
|
|
74
75
|
resolve(cwd, path.replace(windows ? /^~(?=$|[\\/])/ : /^~(?=$|\/)/, homedir()));
|
|
76
|
+
const TITLE_DOC = 'Short label shown to the user, e.g. "Fix steering bug"';
|
|
75
77
|
const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
|
|
76
78
|
/** The spawn tools' tasks. What a task leaves out of model and thinking is the profile's subagent choice. */
|
|
77
|
-
export const TASKS = Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })),
|
|
79
|
+
export const TASKS = Type.Array(Type.Object({ task: Type.String(), title: Type.Optional(Type.String({ description: TITLE_DOC })), cwd: Type.Optional(Type.String({ description: CWD_DOC })),
|
|
78
80
|
model: Type.Optional(Type.String({ description: 'Leave unset for the profile\'s subagent model (named in your instructions). Set only when the user asks for a model, as provider/id from models().' })),
|
|
79
81
|
thinking: Type.Optional(StringEnum(THINKING, { description: 'Leave unset for the profile\'s subagent level. Set only when the user asks for one.' })) }), { minItems: 1 });
|
|
80
82
|
export type Task = Static<typeof TASKS>[number];
|
|
@@ -279,7 +281,7 @@ export class Children {
|
|
|
279
281
|
launch.then(done, done);
|
|
280
282
|
return launch;
|
|
281
283
|
}
|
|
282
|
-
private async launchBatch({ tasks, cwd, depth, parentId, connected, signal, cancelled }: { tasks:
|
|
284
|
+
private async launchBatch({ tasks, cwd, depth, parentId, connected, signal, cancelled }: { tasks: (Omit<Task, 'model' | 'thinking'> & { model: Model<Api>; thinking: ModelChoice['thinking'] })[]; cwd: string; depth: number;
|
|
283
285
|
parentId?: string; connected: boolean; signal?: AbortSignal; cancelled: () => boolean }) {
|
|
284
286
|
const launched: LiveRun[] = [];
|
|
285
287
|
let reserved = tasks.length;
|
|
@@ -291,7 +293,9 @@ export class Children {
|
|
|
291
293
|
const id = randomUUID().slice(0, 8), directory = taskDirectory(cwd, task.cwd);
|
|
292
294
|
const session = await this.open({ id, directory, depth, parentId, connected, model: task.model, thinking: task.thinking,
|
|
293
295
|
sessionManager: SessionManager.create(directory, join(this.profileDirectory, 'runs')) });
|
|
294
|
-
|
|
296
|
+
// One short line without brackets, so the `[id · title]` head of its reports stays parseable and small.
|
|
297
|
+
const title = oneLine(task.title ?? '').replace(/[[\]]/g, '').trim().slice(0, 128).trimEnd();
|
|
298
|
+
const info: RunInfo = { id, task: task.task, ...(title ? { title } : {}), cwd: directory, model: `${task.model.provider}/${task.model.id}`, thinking: session.thinkingLevel,
|
|
295
299
|
parentSession: this.options.parentSession ?? '', parentId, depth, sessionFile: session.sessionFile, started: Date.now(), state: 'running', guidance: [], ...(connected ? { connected: true } : {}) };
|
|
296
300
|
const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [], steering: Promise.resolve() };
|
|
297
301
|
launched.push(live); this.save(info); this.running.set(id, live);
|
|
@@ -372,7 +376,7 @@ export class Children {
|
|
|
372
376
|
parameters: Type.Object({ message: Type.String() }), execute: async (_id: string, args: { message: string }) => {
|
|
373
377
|
const message = args.message.trim();
|
|
374
378
|
if (!message) throw new Error('Message is empty.');
|
|
375
|
-
const text =
|
|
379
|
+
const text = `${reportHead(this.history.records.get(id) ?? { id })} ${connected ? 'Connected agent message' : 'Message from subagent (still running)'}: ${message}`;
|
|
376
380
|
if (!await this.toParent(parentId, text)) throw new Error('Your parent is no longer running.');
|
|
377
381
|
return result(parentId ? `Message sent to parent ${parentId}.` : 'Message sent to the main agent.');
|
|
378
382
|
},
|
|
@@ -420,7 +424,7 @@ export class Children {
|
|
|
420
424
|
if (transition(info, 'paused')) {
|
|
421
425
|
this.save(info);
|
|
422
426
|
// In a grouped spawn the note takes the run's place, so its siblings' reports are not held until the user comes back; its later report arrives on its own.
|
|
423
|
-
const note =
|
|
427
|
+
const note = `${reportHead(info)} Interrupted by the user; it waits for their next message, so no report until then.`, batch = live.batch;
|
|
424
428
|
live.batch = undefined;
|
|
425
429
|
if (!info.connected) await (batch ? this.deliver(info, batch, note) : this.toParent(info.parentId, note))
|
|
426
430
|
.catch(error => this.warn(`Could not tell the parent about the interrupt: ${String(error)}`));
|
|
@@ -507,7 +511,7 @@ export class Children {
|
|
|
507
511
|
// A metadata failure must not suppress delivery of the actual result.
|
|
508
512
|
try { this.save(info); } catch (error) { this.warn(`Could not save run metadata: ${String(error)}`); }
|
|
509
513
|
// A deleted run's transcript is gone.
|
|
510
|
-
await this.deliver(info, live.batch,
|
|
514
|
+
await this.deliver(info, live.batch, `${reportHead(info)} ${info.report}${this.deleting.has(info.id) ? '' : `\n\n${fullChat(info.id)}`}`);
|
|
511
515
|
}
|
|
512
516
|
/** Sends a run's report to its parent; in a grouped spawn, only once every child of the spawn has one. */
|
|
513
517
|
private async deliver(info: RunInfo, batch: LiveRun['batch'], text: string) {
|
package/src/browser.ts
CHANGED
|
@@ -88,7 +88,7 @@ const body = e => { const j = e.origin && e.text.startsWith('[Historical ') ? e.
|
|
|
88
88
|
const short = (s, n) => { s = s.replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
|
|
89
89
|
function who(e) {
|
|
90
90
|
// Logs from before the work kind held reports as user messages starting "[id] "; another extension's shown custom message is tagged the same way.
|
|
91
|
-
if (e.kind === 'work' || e.kind === 'user' && /^\[[\w.:-]
|
|
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;
|
|
@@ -24,6 +26,8 @@ export interface RunInfo {
|
|
|
24
26
|
guidance: Guidance[];
|
|
25
27
|
}
|
|
26
28
|
export type Guidance = { text: string; date: number; state: 'queued' | 'delivered' | 'undelivered'; from?: 'user' | 'manager'; sent?: string };
|
|
29
|
+
/** How a run's reports and messages start: `[id]`, or `[id · title]` (see `HEAD` in report-message.ts). */
|
|
30
|
+
export const reportHead = (run: { id: string; title?: string }) => `[${run.id}${run.title ? ` · ${run.title}` : ''}]`;
|
|
27
31
|
/** The text the agent receives, which is how Pi's queue and the session know the message. */
|
|
28
32
|
export const sentText = (g: Guidance) => g.sent ?? g.text;
|
|
29
33
|
const moves: Record<RunState, readonly RunState[]> = {
|
|
@@ -56,6 +60,7 @@ function isRun(value: unknown): value is RunInfo {
|
|
|
56
60
|
&& typeof value.id === 'string' && /^[a-zA-Z0-9-]+$/.test(value.id)
|
|
57
61
|
&& runStates.includes(value.state as RunState) && typeof value.started === 'number' && Number.isFinite(value.started)
|
|
58
62
|
&& (value.ended === undefined || typeof value.ended === 'number')
|
|
63
|
+
&& (value.title === undefined || typeof value.title === 'string')
|
|
59
64
|
&& (value.sessionFile === undefined || typeof value.sessionFile === 'string')
|
|
60
65
|
&& (value.report === undefined || typeof value.report === 'string')
|
|
61
66
|
&& (value.connected === undefined || typeof value.connected === 'boolean')
|
package/src/title.ts
CHANGED
|
@@ -4,8 +4,9 @@ export type WindowState = StatusState | 'done' | 'disconnected';
|
|
|
4
4
|
|
|
5
5
|
const agents = (count: number) => count > 0 ? ` · ${count} ${count === 1 ? 'agent' : 'agents'}` : '';
|
|
6
6
|
export const mainTitle = (profile: string, working: boolean, running: number) => `${working ? '● ' : ''}π ${profile}${agents(running)}`;
|
|
7
|
-
|
|
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');
|
|
@@ -295,7 +298,7 @@ export async function connectWindow(directory: string, event: (event: WindowEven
|
|
|
295
298
|
};
|
|
296
299
|
}
|
|
297
300
|
|
|
298
|
-
export interface AttachableAgent { id: string; task: string; state: string }
|
|
301
|
+
export interface AttachableAgent { id: string; task: string; title?: string; state: string }
|
|
299
302
|
/** Query the owner, not run metadata on disk: it knows which main/terminal views are actually open. */
|
|
300
303
|
export async function listAttachableAgents(directory: string, timeout = 2000): Promise<AttachableAgent[]> {
|
|
301
304
|
const deadline = Date.now() + timeout;
|
|
@@ -303,7 +306,7 @@ export async function listAttachableAgents(directory: string, timeout = 2000): P
|
|
|
303
306
|
try {
|
|
304
307
|
const agents: unknown = JSON.parse(await connection.request('list-agents', undefined, undefined, { timeout: Math.max(1, deadline - Date.now()) }) ?? '[]');
|
|
305
308
|
if (!Array.isArray(agents) || !agents.every(agent => record(agent) && typeof agent.id === 'string'
|
|
306
|
-
&& typeof agent.task === 'string' && (agent.state === 'running' || agent.state === 'waiting' || agent.state === 'paused')))
|
|
309
|
+
&& typeof agent.task === 'string' && (agent.title === undefined || typeof agent.title === 'string') && (agent.state === 'running' || agent.state === 'waiting' || agent.state === 'paused')))
|
|
307
310
|
throw new Error('Invalid background agent list from the profile owner');
|
|
308
311
|
return agents;
|
|
309
312
|
} finally { connection.close(); }
|