@bermudi/pi-delegate 0.1.18 → 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/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/concurrency.ts CHANGED
@@ -181,6 +181,11 @@ export function getModelKey(model: Model<Api> | undefined): string {
181
181
  *
182
182
  * The total number of concurrently running tasks is also capped by the
183
183
  * configured `maxConcurrent` value, shared across all `delegate` invocations.
184
+ *
185
+ * `beforeAcquire` runs per item before its global slot is acquired — a
186
+ * serialized successor awaits its predecessor there while holding only its
187
+ * per-model worker slot, never a global slot, so a serialized batch cannot
188
+ * pin the shared semaphore while running one task at a time.
184
189
  */
185
190
  export async function mapConcurrentByModel<T, R>(
186
191
  items: T[],
@@ -188,6 +193,7 @@ export async function mapConcurrentByModel<T, R>(
188
193
  getConcurrency: (modelKey: string) => number,
189
194
  fn: (item: T, index: number) => Promise<R>,
190
195
  signal?: AbortSignal,
196
+ beforeAcquire?: (index: number) => Promise<void>,
191
197
  ): Promise<R[]> {
192
198
  if (items.length === 0) return [];
193
199
  const results: R[] = new Array(items.length);
@@ -215,6 +221,7 @@ export async function mapConcurrentByModel<T, R>(
215
221
  group.limit,
216
222
  async (_item, localIdx) => {
217
223
  const globalIdx = group.indices[localIdx]!;
224
+ if (beforeAcquire) await beforeAcquire(globalIdx);
218
225
  const acquired = await acquireGlobal(signal);
219
226
  if (!acquired) {
220
227
  // Aborted while queued for a global slot: we hold no slot, so we
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,
@@ -52,11 +53,14 @@ export type { AgentOverride } from "./config.ts";
52
53
  export {
53
54
  checkout,
54
55
  commit,
56
+ recordUse,
55
57
  configFor,
56
58
  closePooledAgent,
57
59
  closeAllPooledAgents,
58
60
  listPooledAgents,
59
61
  withSessionLock,
62
+ SessionPool,
63
+ defaultSessionPool,
60
64
  } from "./pool.ts";
61
65
  export type {
62
66
  FrozenConfig,
@@ -73,14 +77,18 @@ export {
73
77
  isSessionBusy,
74
78
  handlePoll,
75
79
  handleCancel,
80
+ handlePause,
76
81
  handleWait,
77
82
  notifyWaiters,
78
83
  deliverTicketResults,
79
84
  resolveFinalTicketStatus,
80
85
  settleTicket,
81
86
  formatCompletedTicket,
87
+ TicketRegistry,
82
88
  } from "./tickets.ts";
83
89
  export type { TicketDelivery, SettleTicketOptions } from "./tickets.ts";
90
+ export { createDelegateRuntime, getDefaultDelegateRuntime } from "./runtime.ts";
91
+ export type { DelegateRuntime } from "./runtime.ts";
84
92
  export {
85
93
  recordTreeNavigation,
86
94
  getCurrentLeafId,