@bermudi/pi-delegate 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -151,7 +151,7 @@ Async tickets keep running after the parent's turn settles, and pi renders an
151
151
  idle session — so delegate adds three signals:
152
152
 
153
153
  - **Footer status** — while any ticket is active, the footer shows
154
- `⏳ 2 subagents · t5042v19`, updated live as subagents start and finish.
154
+ `⏳ 2 subagents · t5042v19 · /subagents`, updated live as subagents start and finish.
155
155
  - **Settle warning** — the first time a turn settles with a ticket still
156
156
  active, a warning notification names the ticket and reminds you that
157
157
  quitting aborts it. Once per ticket; the footer carries it from there.
@@ -164,6 +164,54 @@ the mitigation there; on quit, delegate also prints a trace line to the
164
164
  terminal naming the aborted tickets and agents, and on `/reload` it shows a
165
165
  warning notification.
166
166
 
167
+ ### Live subagent browser
168
+
169
+ Press **Ctrl+Shift+B** from the editor, or run **`/subagents`**, to open a live
170
+ browser without sending a model request. It shows both sync tasks and async
171
+ tickets, including retained completed work. Your draft is left untouched.
172
+
173
+ | Key | Action |
174
+ | --- | --- |
175
+ | ↑ / ↓ | Select an agent |
176
+ | Tab / ← / → | Switch between tool activity and assistant responses |
177
+ | PgUp / PgDn | Scroll the selected view |
178
+ | Home / End | Jump to oldest retained text / follow live output |
179
+ | p | Pause or resume the selected agent's **whole async ticket** |
180
+ | Esc | Close and return to your draft |
181
+
182
+ The view shows running commands, tool results, edited file paths, queue
183
+ dependencies, errors, token counts and activity age. Pause controls use the
184
+ same cooperative boundary described below; they do not freeze subprocesses.
185
+ Down in the editor keeps its normal behavior rather than intercepting menus
186
+ or replacing a custom editor.
187
+
188
+ This is a bounded live preview, not a transcript archive: assistant text keeps
189
+ the current attempt's last 32K characters (no thinking blocks); tool activity
190
+ shows up to 100 recent calls with output tails. The browser retains the last
191
+ 20 completed sync calls. Async results remain available until the existing
192
+ ticket cleanup removes them (eligible after 30 minutes). Nothing is recovered
193
+ from transcripts after a restart. A refresh timer runs only while the browser
194
+ is open and is cleared on close or shutdown. This feature requires Pi's TUI,
195
+ not print/JSON/RPC mode.
196
+
197
+ ### Pause and resume
198
+
199
+ Pause an async ticket with `delegate({ ticketAction: "pause", ticket: "<id>" })`;
200
+ continue it with `ticketAction: "resume"`. Both return immediately.
201
+
202
+ Each current model response and its tool calls finish, then the agent waits
203
+ before its next model request. Queued tasks do not start. **Pausing** means
204
+ work is reaching that boundary; **paused** means it has reached it. A task
205
+ that finishes naturally may complete instead. Resume keeps the same live
206
+ conversations, including scratch workspaces.
207
+
208
+ Pause is not cancellation, rollback, or a process freeze. Background commands
209
+ can keep running, and files may be unfinished. In-progress isolated preparation
210
+ or application finishes before pausing. Sessions, concurrency slots and
211
+ workspace reservations remain held. Inactivity checks stop while parked, but
212
+ explicit wall-clock deadlines keep counting. Wait does not resume work; cancel
213
+ still works. Pauses do not survive Pi exit or reload.
214
+
167
215
  ### Stall detection and cancellation
168
216
 
169
217
  `stallTimeoutMs` is an inactivity watchdog, not a hard execution deadline. When
@@ -231,7 +279,7 @@ over an installed extension.
231
279
  - **Resumed subagent** — A subagent rehydrated from a previous session `.jsonl`
232
280
  via `resumeFrom`. It can also be pooled by providing a `sessionId`.
233
281
  - **Async ticket** — A background execution handle returned when top-level
234
- `async: true` is used. Poll, wait, or cancel tickets with top-level
282
+ `async: true` is used. Poll, wait, pause, resume, or cancel tickets with top-level
235
283
  `ticketAction: "poll"`, `ticketAction: "wait"` (blocks until the ticket settles;
236
284
  optional `timeoutMs`), or `ticketAction: "cancel"`.
237
285
  - **Skill** — A `SKILL.md` instruction bundle injected into the subagent system
@@ -0,0 +1,31 @@
1
+ /** Human-facing, bounded text preview. Never includes thinking blocks or reads
2
+ * transcripts from disk. Updates replace the current response, not append
3
+ * streaming snapshots; completed responses remain available for scrollback. */
4
+ export class AssistantPreview {
5
+ private completed = "";
6
+ private current = "";
7
+ static readonly limit = 32_768;
8
+
9
+ update(text: string): void {
10
+ // Empty synthetic provider failures must not erase a streamed response.
11
+ if (text) this.current = text.slice(-AssistantPreview.limit);
12
+ }
13
+
14
+ finish(text: string): void {
15
+ this.update(text);
16
+ if (this.current) {
17
+ this.completed = [this.completed, this.current]
18
+ .filter(Boolean)
19
+ .join("\n\n")
20
+ .slice(-AssistantPreview.limit);
21
+ this.current = "";
22
+ }
23
+ }
24
+
25
+ get text(): string {
26
+ return [this.completed, this.current]
27
+ .filter(Boolean)
28
+ .join("\n\n")
29
+ .slice(-AssistantPreview.limit);
30
+ }
31
+ }
@@ -0,0 +1,250 @@
1
+ import type { DelegateRuntime } from "./runtime.ts";
2
+ import type {
3
+ AsyncTicket,
4
+ DelegateDetails,
5
+ TaskProgress,
6
+ ToolActivity,
7
+ } from "./types.ts";
8
+
9
+ interface SyncDetails {
10
+ tasks: { prompt?: string }[];
11
+ progress: TaskProgress[];
12
+ results: { output?: string; error?: string }[];
13
+ serializedNotice?: string;
14
+ dispatchWarning?: string;
15
+ }
16
+
17
+ /** Finished sync calls must not pin entire tool arguments/results after the
18
+ * parent compacts. Keep only primitive display arguments and bounded text. */
19
+ function compactProgress(p: TaskProgress): TaskProgress {
20
+ let budget = 65_536;
21
+ const activities: ToolActivity[] = [];
22
+ const take = (text: string): string => {
23
+ const limit = Math.min(4096, budget);
24
+ if (limit <= 0) return "";
25
+ const value = text.length > limit ? `…${text.slice(-limit)}` : text;
26
+ budget -= value.length;
27
+ return value;
28
+ };
29
+ for (const tool of p.activities.slice(-100).reverse()) {
30
+ if (budget <= 0) break;
31
+ const args: Record<string, unknown> = {};
32
+ for (const key of [
33
+ "path",
34
+ "file_path",
35
+ "command",
36
+ "pattern",
37
+ "query",
38
+ "url",
39
+ "task",
40
+ "prompt",
41
+ "offset",
42
+ "limit",
43
+ ]) {
44
+ const value = tool.args[key];
45
+ if (typeof value === "string" && budget > 0) args[key] = take(value);
46
+ else if (typeof value === "number") args[key] = value;
47
+ }
48
+ const output = tool.result
49
+ ? tool.result.content
50
+ .filter((part) => part.type === "text")
51
+ .slice(-20)
52
+ .map((part) => part.text?.slice(-4096) ?? "")
53
+ .join("\n")
54
+ : (tool.liveOutput ?? "");
55
+ const text = budget > 0 ? take(output) : "";
56
+ activities.push({
57
+ id: tool.id,
58
+ name: tool.name,
59
+ args,
60
+ startTime: tool.startTime,
61
+ endTime: tool.endTime,
62
+ result: tool.result
63
+ ? { isError: tool.result.isError, content: [{ type: "text", text }] }
64
+ : undefined,
65
+ liveOutput: tool.result ? undefined : text,
66
+ });
67
+ }
68
+ return {
69
+ ...p,
70
+ assistantPreview: p.assistantPreview?.slice(-32_768),
71
+ error: p.error?.slice(0, 4096),
72
+ activities: activities.reverse(),
73
+ warnings: [
74
+ ...(p.warnings ?? []).slice(0, 10).map((s) => s.slice(0, 4096)),
75
+ ...(activities.length < p.activities.length
76
+ ? ["Earlier tool activity omitted from retained sync preview."]
77
+ : []),
78
+ ],
79
+ };
80
+ }
81
+
82
+ function compactDetails(details: SyncDetails): SyncDetails {
83
+ return {
84
+ tasks: details.tasks.map((task) => ({
85
+ prompt: task.prompt?.slice(0, 4096),
86
+ })),
87
+ progress: details.progress.map(compactProgress),
88
+ results: details.results.map((result) => ({
89
+ output: result.output?.slice(-32_768),
90
+ error: result.error?.slice(0, 4096),
91
+ })),
92
+ serializedNotice: details.serializedNotice?.slice(0, 4096),
93
+ dispatchWarning: details.dispatchWarning?.slice(0, 4096),
94
+ };
95
+ }
96
+
97
+ interface SyncRun {
98
+ details: SyncDetails;
99
+ finished: boolean;
100
+ created: number;
101
+ error?: string;
102
+ }
103
+
104
+ /** UI-only history. Active sync runs are never evicted; retain the last twenty
105
+ * finished calls. Async history stays owned by the existing ticket registry. */
106
+ export class BrowserHistory {
107
+ readonly runs = new Map<string, SyncRun>();
108
+ generation = 0;
109
+
110
+ reset(): void {
111
+ this.generation++;
112
+ this.runs.clear();
113
+ }
114
+
115
+ update(
116
+ id: string,
117
+ details: DelegateDetails | undefined,
118
+ finished: boolean,
119
+ generation = this.generation,
120
+ ): void {
121
+ if (generation !== this.generation) return;
122
+ if (!details?.progress.length) {
123
+ if (finished)
124
+ this.fail(
125
+ id,
126
+ "Dispatch ended before tasks could start; see the delegate result.",
127
+ );
128
+ return;
129
+ }
130
+ const created = this.runs.get(id)?.created ?? Date.now();
131
+ this.runs.delete(id);
132
+ this.runs.set(id, {
133
+ details: finished ? compactDetails(details) : details,
134
+ finished,
135
+ created,
136
+ });
137
+ this.prune();
138
+ }
139
+
140
+ fail(id: string, error: unknown, generation = this.generation): void {
141
+ if (generation !== this.generation) return;
142
+ const run = this.runs.get(id);
143
+ if (!run) return;
144
+ run.finished = true;
145
+ run.details = compactDetails(run.details);
146
+ run.error = (error instanceof Error ? error.message : String(error)).slice(
147
+ 0,
148
+ 4096,
149
+ );
150
+ this.runs.delete(id);
151
+ this.runs.set(id, run);
152
+ this.prune();
153
+ }
154
+
155
+ private prune(): void {
156
+ const finished = [...this.runs].filter(([, run]) => run.finished);
157
+ for (const [id] of finished.slice(0, -20)) this.runs.delete(id);
158
+ }
159
+ }
160
+
161
+ export interface BrowserRow {
162
+ key: string;
163
+ batch: string;
164
+ created: number;
165
+ progress: TaskProgress;
166
+ siblings: TaskProgress[];
167
+ prompt: string;
168
+ output?: string;
169
+ error?: string;
170
+ ticket?: AsyncTicket;
171
+ finished: boolean;
172
+ notice?: string;
173
+ }
174
+
175
+ export function browserRows(
176
+ runtime: DelegateRuntime,
177
+ history: BrowserHistory,
178
+ ): BrowserRow[] {
179
+ const rows: BrowserRow[] = [];
180
+ for (const ticket of runtime.tickets.values()) {
181
+ for (const progress of ticket.progress) {
182
+ const result = ticket.results[progress.index];
183
+ rows.push({
184
+ key: `${ticket.id}:${progress.index}`,
185
+ batch: ticket.id,
186
+ created: ticket.created,
187
+ progress,
188
+ siblings: ticket.progress,
189
+ prompt: ticket.tasks[progress.index]?.prompt ?? progress.task,
190
+ output: result?.output,
191
+ error: result?.error ?? ticket.error,
192
+ ticket,
193
+ finished: ticket.status !== "running" && ticket.status !== "cancelling",
194
+ notice: [ticket.serializedNotice, ticket.dispatchWarning]
195
+ .filter(Boolean)
196
+ .join("\n"),
197
+ });
198
+ }
199
+ }
200
+ for (const [id, run] of history.runs) {
201
+ for (const progress of run.details.progress) {
202
+ const result = run.details.results[progress.index];
203
+ rows.push({
204
+ key: `sync:${id}:${progress.index}`,
205
+ batch: `sync ${id.slice(-8)}`,
206
+ created: run.created,
207
+ progress,
208
+ siblings: run.details.progress,
209
+ prompt: run.details.tasks[progress.index]?.prompt ?? progress.task,
210
+ output: result?.output,
211
+ error: result?.error ?? run.error,
212
+ finished: run.finished,
213
+ notice: [run.details.serializedNotice, run.details.dispatchWarning]
214
+ .filter(Boolean)
215
+ .join("\n"),
216
+ });
217
+ }
218
+ }
219
+ return rows.sort(
220
+ (a, b) =>
221
+ Number(a.finished) - Number(b.finished) ||
222
+ b.created - a.created ||
223
+ a.batch.localeCompare(b.batch) ||
224
+ a.progress.index - b.progress.index,
225
+ );
226
+ }
227
+
228
+ export function browserRowStatus(row: BrowserRow): string {
229
+ const p = row.progress;
230
+ if (p.incomplete) return "incomplete — worker may still be active";
231
+ if (p.status === "done") return "done";
232
+ if (p.status === "failed") return "failed";
233
+ if (row.ticket?.status === "cancelling") return "cancelling";
234
+ if (row.finished)
235
+ return row.error ? "failed" : (row.ticket?.status ?? "finished");
236
+ if (p.paused) return "paused";
237
+ if (p.status === "pending") {
238
+ if (row.ticket?.pause && row.ticket.pause.state !== "running")
239
+ return "queued — ticket paused";
240
+ const before =
241
+ p.waitingFor === undefined ? undefined : row.siblings[p.waitingFor];
242
+ if (before && before.status !== "done" && before.status !== "failed")
243
+ return `queued — waiting for ${before.id ?? `task ${before.index + 1}`}`;
244
+ return "queued — waiting for preparation or capacity";
245
+ }
246
+ const phase = p.activity ?? "starting";
247
+ return row.ticket?.pause?.state === "pausing"
248
+ ? `${phase} — pausing after turn`
249
+ : phase;
250
+ }
package/browser.ts ADDED
@@ -0,0 +1,334 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ Theme,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ matchesKey,
8
+ SelectList,
9
+ truncateToWidth,
10
+ wrapTextWithAnsi,
11
+ type Component,
12
+ } from "@earendil-works/pi-tui";
13
+ import {
14
+ BrowserHistory,
15
+ browserRows,
16
+ browserRowStatus,
17
+ type BrowserRow,
18
+ } from "./browser-state.ts";
19
+ import type { DelegateRuntime } from "./runtime.ts";
20
+ import { fmtDuration, fmtTokens, formatToolCallShort } from "./format.ts";
21
+ import { sanitizeTerminalLine, sanitizeTerminalText } from "./utils.ts";
22
+
23
+ function displayTail(text: string, limit: number): string {
24
+ return text.length > limit
25
+ ? `[Earlier text omitted]\n${text.slice(-limit)}`
26
+ : text;
27
+ }
28
+
29
+ /** Bounded display, not a second transcript store. Full final output and tool
30
+ * evidence continue to belong to the task/ticket. Never render terminal escapes
31
+ * from model output, command output, prompts, IDs or errors. */
32
+ export function browserDetailText(row: BrowserRow, responses: boolean): string {
33
+ if (responses) {
34
+ const text = row.output || row.progress.assistantPreview;
35
+ return sanitizeTerminalText(
36
+ displayTail(
37
+ text || "No assistant text yet. Tool-only turns may have no text.",
38
+ 32_768,
39
+ ),
40
+ );
41
+ }
42
+ const context = [
43
+ row.error || row.progress.error
44
+ ? `ERROR: ${row.error ?? row.progress.error}`
45
+ : "",
46
+ row.notice ?? "",
47
+ ...(row.progress.warnings ?? []),
48
+ row.ticket?.pause?.state !== undefined &&
49
+ row.ticket.pause.state !== "running"
50
+ ? "Pause is cooperative: current turns finish; subprocesses are not frozen. Deadlines still count."
51
+ : "",
52
+ ]
53
+ .filter(Boolean)
54
+ .map((line) => line.slice(0, 4096))
55
+ .join("\n")
56
+ .slice(0, 8192);
57
+ const lines: string[] = [];
58
+ const activities = row.progress.activities.slice(-100);
59
+ if (row.progress.activities.length > activities.length)
60
+ lines.push("[Earlier tool calls omitted]");
61
+ for (const tool of activities) {
62
+ const call =
63
+ tool.name === "bash" && typeof tool.args.command === "string"
64
+ ? `$ ${tool.args.command.slice(0, 4096)}`
65
+ : formatToolCallShort(tool.name, tool.args);
66
+ lines.push(
67
+ `\n${tool.endTime !== undefined ? (tool.result?.isError ? "FAILED" : "DONE") : "RUNNING"} ${call.slice(0, 4096)}`,
68
+ );
69
+ const output = tool.result
70
+ ? tool.result.content
71
+ .filter((part) => part.type === "text")
72
+ .slice(-20)
73
+ .map((part) => displayTail(part.text ?? "", 4096))
74
+ .join("\n")
75
+ : tool.liveOutput;
76
+ if (output) lines.push(displayTail(output, 4096));
77
+ }
78
+ if (!activities.length) lines.push("No tool calls yet.");
79
+ return sanitizeTerminalText(
80
+ [context, displayTail(lines.join("\n"), 57_344)].filter(Boolean).join("\n"),
81
+ );
82
+ }
83
+
84
+ export class SubagentBrowser implements Component {
85
+ private selectedKey?: string;
86
+ private rows: BrowserRow[] = [];
87
+ private list?: SelectList;
88
+ private responses = false;
89
+ private scroll: number | undefined;
90
+ private pageSize = 8;
91
+ private maxScroll = 0;
92
+ private message = "";
93
+ private controlsVisible = false;
94
+
95
+ constructor(
96
+ private readonly getRows: () => BrowserRow[],
97
+ private readonly theme: Pick<Theme, "fg">,
98
+ private readonly height: () => number,
99
+ private readonly requestRender: () => void,
100
+ private readonly close: () => void,
101
+ private readonly pause: (row: BrowserRow) => string,
102
+ ) {}
103
+
104
+ invalidate(): void {}
105
+
106
+ handleInput(data: string): void {
107
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
108
+ this.close();
109
+ return;
110
+ }
111
+ if (
112
+ matchesKey(data, "tab") ||
113
+ matchesKey(data, "left") ||
114
+ matchesKey(data, "right")
115
+ ) {
116
+ this.responses = !this.responses;
117
+ this.scroll = undefined;
118
+ } else if (matchesKey(data, "pageUp")) {
119
+ this.scroll = Math.max(
120
+ 0,
121
+ (this.scroll ?? this.maxScroll) - this.pageSize,
122
+ );
123
+ } else if (matchesKey(data, "pageDown")) {
124
+ const next = (this.scroll ?? this.maxScroll) + this.pageSize;
125
+ this.scroll = next >= this.maxScroll ? undefined : next;
126
+ } else if (matchesKey(data, "home")) {
127
+ this.scroll = 0;
128
+ } else if (matchesKey(data, "end")) {
129
+ this.scroll = undefined;
130
+ } else if (data === "p") {
131
+ if (!this.controlsVisible) return;
132
+ const row = this.getRows().find((r) => r.key === this.selectedKey);
133
+ if (row) this.message = this.pause(row);
134
+ } else {
135
+ this.list?.handleInput(data);
136
+ }
137
+ this.requestRender();
138
+ }
139
+
140
+ render(width: number): string[] {
141
+ const height = Math.max(1, this.height());
142
+ this.controlsVisible = height >= 14 && width >= 25;
143
+ if (width <= 0) return [];
144
+ const w = Math.max(1, width);
145
+ if (!this.controlsVisible)
146
+ return [truncateToWidth("Subagents · enlarge terminal · Esc closes", w)];
147
+ this.rows = this.getRows();
148
+ if (!this.rows.some((row) => row.key === this.selectedKey)) {
149
+ this.selectedKey = this.rows[0]?.key;
150
+ this.scroll = undefined;
151
+ }
152
+ const rosterHeight = Math.min(
153
+ this.rows.length,
154
+ 5,
155
+ Math.max(1, Math.floor(height / 4)),
156
+ );
157
+ this.list = new SelectList(
158
+ this.rows.map((row) => ({
159
+ value: row.key,
160
+ label: sanitizeTerminalLine(
161
+ `${row.progress.id ?? `task ${row.progress.index + 1}`} · ${row.progress.agent} · ${row.batch}`,
162
+ ),
163
+ description: sanitizeTerminalLine(browserRowStatus(row)),
164
+ })),
165
+ rosterHeight,
166
+ {
167
+ selectedPrefix: (s) => this.theme.fg("accent", s),
168
+ selectedText: (s) => this.theme.fg("accent", s),
169
+ description: (s) => this.theme.fg("muted", s),
170
+ scrollInfo: (s) => this.theme.fg("dim", s),
171
+ noMatch: (s) => this.theme.fg("muted", s),
172
+ },
173
+ );
174
+ this.list.setSelectedIndex(
175
+ Math.max(
176
+ 0,
177
+ this.rows.findIndex((r) => r.key === this.selectedKey),
178
+ ),
179
+ );
180
+ this.list.onSelectionChange = (item) => {
181
+ this.selectedKey = item.value;
182
+ this.scroll = undefined;
183
+ this.message = "";
184
+ };
185
+ const row = this.rows.find((r) => r.key === this.selectedKey);
186
+ const lines = [
187
+ this.theme.fg("accent", "Subagents · live browser"),
188
+ ...this.list.render(w),
189
+ ];
190
+ if (row) {
191
+ const p = row.progress;
192
+ lines.push(
193
+ sanitizeTerminalLine(
194
+ `${browserRowStatus(row)} · ${fmtDuration(p.durationMs)} · ${fmtTokens(p.tokens)} tokens · ${p.toolUses} tools · ${p.model ?? ""}`,
195
+ ),
196
+ ...(!row.finished && p.lastActivityAt
197
+ ? [
198
+ sanitizeTerminalLine(
199
+ `Last event ${fmtDuration(Math.max(0, Date.now() - p.lastActivityAt))} ago`,
200
+ ),
201
+ ]
202
+ : []),
203
+ sanitizeTerminalLine(`Task: ${row.prompt.slice(0, 4096)}`),
204
+ this.theme.fg(
205
+ "accent",
206
+ `${this.responses ? "Responses (32K character tail)" : "Tool activity"} · ${this.scroll === undefined ? "following live" : "scrollback"} · Tab switches view`,
207
+ ),
208
+ );
209
+ this.pageSize = Math.max(1, height - lines.length - 3);
210
+ const detail = browserDetailText(row, this.responses)
211
+ .split("\n")
212
+ .flatMap((line) => wrapTextWithAnsi(line, w));
213
+ this.maxScroll = Math.max(0, detail.length - this.pageSize);
214
+ const start =
215
+ this.scroll === undefined
216
+ ? this.maxScroll
217
+ : Math.min(this.scroll, this.maxScroll);
218
+ lines.push(...detail.slice(start, start + this.pageSize));
219
+ while (lines.length < height - 3) lines.push("");
220
+ const pauseLabel =
221
+ row.ticket?.status === "running" && row.ticket.pause
222
+ ? `p ${row.ticket.pause.state === "running" ? "pause" : "resume"} WHOLE ticket ${row.ticket.id} (${row.siblings.length} tasks)`
223
+ : "Pause/resume available only for live async tickets";
224
+ lines.push(
225
+ this.theme.fg(
226
+ "muted",
227
+ sanitizeTerminalLine(this.message || pauseLabel),
228
+ ),
229
+ );
230
+ } else {
231
+ lines.push(
232
+ "No subagents yet. This view includes live and retained completed tasks.",
233
+ );
234
+ }
235
+ lines.push(
236
+ this.theme.fg(
237
+ "dim",
238
+ "↑↓ agent · PgUp/PgDn scroll · Home oldest · End live",
239
+ ),
240
+ this.theme.fg("dim", "Tab activity/responses · Esc back to draft"),
241
+ );
242
+ return lines.slice(0, height).map((line) => truncateToWidth(line, w));
243
+ }
244
+ }
245
+
246
+ /** One UI per extension lifetime. Refresh only while visible: closed browsers
247
+ * own no timer, no terminal listener, and no extra ticket waiter. */
248
+ export function registerSubagentBrowser(
249
+ pi: ExtensionAPI,
250
+ runtime: DelegateRuntime,
251
+ ): BrowserHistory {
252
+ const history = new BrowserHistory();
253
+ let closeCurrent: (() => void) | undefined;
254
+ let opening = false;
255
+ const open = async (ctx: ExtensionContext): Promise<void> => {
256
+ if (ctx.mode !== "tui") {
257
+ ctx.ui.notify(
258
+ "The subagent browser requires Pi's terminal UI.",
259
+ "warning",
260
+ );
261
+ return;
262
+ }
263
+ if (opening) return;
264
+ opening = true;
265
+ let timer: ReturnType<typeof setInterval> | undefined;
266
+ try {
267
+ await ctx.ui.custom<void>(
268
+ (tui, theme, _keys, done) => {
269
+ closeCurrent = () => done();
270
+ timer = setInterval(() => tui.requestRender(), 200);
271
+ timer.unref?.();
272
+ return new SubagentBrowser(
273
+ () => browserRows(runtime, history),
274
+ theme,
275
+ () => Math.max(1, Math.floor(tui.terminal.rows * 0.85)),
276
+ () => tui.requestRender(),
277
+ () => done(),
278
+ (row) => {
279
+ try {
280
+ const ticket = row.ticket;
281
+ if (!ticket?.pause || ticket.status !== "running")
282
+ return "This task has no live async ticket to pause.";
283
+ const action =
284
+ ticket.pause.state === "running" ? "pause" : "resume";
285
+ const result = runtime.tickets.handlePause({
286
+ ticket: ticket.id,
287
+ ticketAction: action,
288
+ });
289
+ return result.details.ticketId !== ticket.id
290
+ ? result.content
291
+ .filter((c) => c.type === "text")
292
+ .map((c) => c.text)
293
+ .join(" ")
294
+ : "";
295
+ } catch (error) {
296
+ console.error("[delegate] browser pause/resume failed", error);
297
+ return `Pause/resume failed: ${error instanceof Error ? error.message : String(error)}`;
298
+ }
299
+ },
300
+ );
301
+ },
302
+ {
303
+ overlay: true,
304
+ overlayOptions: { width: "95%", maxHeight: "85%", anchor: "center" },
305
+ },
306
+ );
307
+ } catch (error) {
308
+ console.error("[delegate] subagent browser failed", error);
309
+ ctx.ui.notify(
310
+ sanitizeTerminalLine(
311
+ `Subagent browser failed: ${error instanceof Error ? error.message : String(error)}`,
312
+ ),
313
+ "error",
314
+ );
315
+ } finally {
316
+ if (timer !== undefined) clearInterval(timer);
317
+ closeCurrent = undefined;
318
+ opening = false;
319
+ }
320
+ };
321
+ pi.registerCommand("subagents", {
322
+ description: "Browse live subagents and retained results",
323
+ handler: async (_args, ctx) => open(ctx),
324
+ });
325
+ pi.registerShortcut("ctrl+shift+b", {
326
+ description: "Open live subagent browser",
327
+ handler: open,
328
+ });
329
+ pi.on("session_shutdown", () => {
330
+ closeCurrent?.();
331
+ history.reset();
332
+ });
333
+ return history;
334
+ }
package/delegate.ts CHANGED
@@ -21,6 +21,7 @@ export type {
21
21
  TaskRunEnv,
22
22
  } from "./types.ts";
23
23
  export type { DelegateConfig } from "./config.ts";
24
+ export type { PauseState } from "./pause.ts";
24
25
 
25
26
  export {
26
27
  DEFAULT_AGENT_NAME,
@@ -76,6 +77,7 @@ export {
76
77
  isSessionBusy,
77
78
  handlePoll,
78
79
  handleCancel,
80
+ handlePause,
79
81
  handleWait,
80
82
  notifyWaiters,
81
83
  deliverTicketResults,