@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/README.md +64 -15
- package/agents.ts +1 -1
- package/assistant-preview.ts +31 -0
- package/browser-state.ts +250 -0
- package/browser.ts +334 -0
- package/concurrency.ts +7 -0
- package/delegate.ts +8 -0
- package/dispatch.ts +512 -163
- package/extension.ts +65 -28
- package/format.ts +27 -9
- package/host.ts +1 -1
- package/isolated-workspace.ts +154 -8
- package/lifecycle.ts +34 -20
- package/manual.ts +21 -8
- package/package.json +2 -1
- package/parent-context.ts +1 -1
- package/pause.ts +81 -0
- package/pool.ts +492 -428
- package/render-branches.ts +19 -5
- package/render-result.ts +7 -0
- package/runner.ts +55 -1
- package/runtime.ts +36 -0
- package/schema.ts +29 -18
- package/status.ts +38 -11
- package/task-resolution.ts +12 -9
- package/test-harness.ts +81 -0
- package/ticket-format.ts +29 -7
- package/tickets.ts +724 -576
- package/types.ts +33 -0
- package/workspace.ts +58 -27
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,
|