@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/render-branches.ts
CHANGED
|
@@ -54,6 +54,7 @@ export interface RenderState {
|
|
|
54
54
|
|
|
55
55
|
/** Shared inputs for the partial and final render branches. */
|
|
56
56
|
export interface BranchCtx {
|
|
57
|
+
pauseState?: import("./pause.ts").PauseState;
|
|
57
58
|
progress: TaskProgress[];
|
|
58
59
|
taskResults: (TaskResult | { error: string })[];
|
|
59
60
|
total: number;
|
|
@@ -98,7 +99,11 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
98
99
|
// Keep the live and final summaries in the same order so the header remains
|
|
99
100
|
// easy to scan as a partial result resolves into its final form.
|
|
100
101
|
const headerParts: string[] = [];
|
|
101
|
-
|
|
102
|
+
const paused = progress.filter(
|
|
103
|
+
(p) => p.status === "running" && p.paused,
|
|
104
|
+
).length;
|
|
105
|
+
if (running > paused) headerParts.push(`${running - paused} running`);
|
|
106
|
+
if (paused > 0) headerParts.push(`${paused} paused`);
|
|
102
107
|
headerParts.push(`${finished}/${total} finished`);
|
|
103
108
|
if (failed > 0) headerParts.push(`${failed} failed`);
|
|
104
109
|
headerParts.push(
|
|
@@ -111,7 +116,9 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
111
116
|
const stateLabel =
|
|
112
117
|
ctx.ticketStatus === "cancelling"
|
|
113
118
|
? `${theme.fg("error", "■ cancelling")} · `
|
|
114
|
-
: ""
|
|
119
|
+
: ctx.pauseState && ctx.pauseState !== "running"
|
|
120
|
+
? `${theme.fg("warning", `Ⅱ ${ctx.pauseState}`)} · `
|
|
121
|
+
: "";
|
|
115
122
|
const expandHint = toolExpandHint();
|
|
116
123
|
const detailHint =
|
|
117
124
|
!expanded && running > 0 && expandHint
|
|
@@ -362,7 +369,11 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
362
369
|
if (ticketId && ticketIsLive) {
|
|
363
370
|
// Background ticket — frame it as in-progress, not a finished result.
|
|
364
371
|
const ticketParts = [`${finalized}/${total} finished`];
|
|
365
|
-
|
|
372
|
+
const paused = progress.filter(
|
|
373
|
+
(p) => p.status === "running" && p.paused,
|
|
374
|
+
).length;
|
|
375
|
+
if (running > paused) ticketParts.push(`${running - paused} active`);
|
|
376
|
+
if (paused > 0) ticketParts.push(`${paused} paused`);
|
|
366
377
|
if (pending > 0) ticketParts.push(`${pending} queued`);
|
|
367
378
|
if (failed > 0) ticketParts.push(`${failed} failed`);
|
|
368
379
|
if (cancelled > 0) ticketParts.push(`${cancelled} cancelled`);
|
|
@@ -373,7 +384,9 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
373
384
|
const stateLabel =
|
|
374
385
|
ticketStatus === "cancelling"
|
|
375
386
|
? ` ${theme.fg("error", "cancelling")}`
|
|
376
|
-
: ""
|
|
387
|
+
: ctx.pauseState && ctx.pauseState !== "running"
|
|
388
|
+
? ` ${theme.fg("warning", ctx.pauseState)}`
|
|
389
|
+
: "";
|
|
377
390
|
lines.push(
|
|
378
391
|
`${glyph}${stateLabel} ${theme.fg("muted", `${ticketLabel}${ticketParts.join(" · ")}`)}${detailHint}`,
|
|
379
392
|
"",
|
|
@@ -523,7 +536,8 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
523
536
|
const integration = r.integration;
|
|
524
537
|
const tone =
|
|
525
538
|
integration.status === "applied_unverified" ||
|
|
526
|
-
integration.status === "no_changes"
|
|
539
|
+
integration.status === "no_changes" ||
|
|
540
|
+
integration.status === "retained"
|
|
527
541
|
? "warning"
|
|
528
542
|
: "error";
|
|
529
543
|
lines.push(
|
package/render-result.ts
CHANGED
|
@@ -202,6 +202,7 @@ export function renderDelegateResult(
|
|
|
202
202
|
lines,
|
|
203
203
|
ticketId,
|
|
204
204
|
ticketStatus,
|
|
205
|
+
pauseState: details.pauseState,
|
|
205
206
|
elapsedMs: details.elapsedMs,
|
|
206
207
|
};
|
|
207
208
|
|
|
@@ -213,6 +214,12 @@ export function renderDelegateResult(
|
|
|
213
214
|
lines.push(truncLine(theme.fg("warning", `⚠ ${warning}`), w), "");
|
|
214
215
|
}
|
|
215
216
|
}
|
|
217
|
+
if (details?.serializedNotice) {
|
|
218
|
+
const notice = sanitizeTerminalLine(details.serializedNotice);
|
|
219
|
+
if (notice) {
|
|
220
|
+
lines.push(truncLine(theme.fg("dim", `⏳ ${notice}`), w), "");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
216
223
|
if (details?.overlapWarning) {
|
|
217
224
|
const warning = sanitizeTerminalLine(details.overlapWarning);
|
|
218
225
|
if (warning) {
|
package/runner.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
AgentSession,
|
|
4
4
|
AgentSessionEvent,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { AssistantPreview } from "./assistant-preview.ts";
|
|
6
7
|
import {
|
|
7
8
|
getGitChangedFiles,
|
|
8
9
|
extractAttributedFromActivities,
|
|
@@ -127,6 +128,10 @@ export async function runAgentSession(
|
|
|
127
128
|
deadlineAt?: number,
|
|
128
129
|
/** Dispatch-scoped delegate.json snapshot for the stall timeout. */
|
|
129
130
|
delegateConfig?: import("./config.ts").DelegateConfig,
|
|
131
|
+
pause?: {
|
|
132
|
+
controller: import("./pause.ts").PauseController;
|
|
133
|
+
index: number;
|
|
134
|
+
},
|
|
130
135
|
): Promise<{
|
|
131
136
|
output: string;
|
|
132
137
|
error?: string;
|
|
@@ -176,6 +181,8 @@ export async function runAgentSession(
|
|
|
176
181
|
let recoveryBarrier: QuiescenceBarrier | undefined;
|
|
177
182
|
let abandonmentSafety: Promise<void> | undefined;
|
|
178
183
|
let unsubscribeFull: (() => void) | undefined;
|
|
184
|
+
let unsubscribePause: (() => void) | undefined;
|
|
185
|
+
let pausedAtTurnBoundary = false;
|
|
179
186
|
let unsubscribeRecovery: (() => void) | undefined;
|
|
180
187
|
const safeLog = (message: string, error?: unknown): void => {
|
|
181
188
|
try {
|
|
@@ -187,11 +194,18 @@ export async function runAgentSession(
|
|
|
187
194
|
const removeFullListener = (): void => {
|
|
188
195
|
const remove = unsubscribeFull;
|
|
189
196
|
unsubscribeFull = undefined;
|
|
197
|
+
const removePause = unsubscribePause;
|
|
198
|
+
unsubscribePause = undefined;
|
|
190
199
|
try {
|
|
191
200
|
remove?.();
|
|
192
201
|
} catch (error) {
|
|
193
202
|
safeLog("[delegate] full AgentSession listener cleanup failed", error);
|
|
194
203
|
}
|
|
204
|
+
try {
|
|
205
|
+
removePause?.();
|
|
206
|
+
} catch (error) {
|
|
207
|
+
safeLog("[delegate] turn-pause listener cleanup failed", error);
|
|
208
|
+
}
|
|
195
209
|
};
|
|
196
210
|
const removeRecoveryListener = (): void => {
|
|
197
211
|
const remove = unsubscribeRecovery;
|
|
@@ -206,6 +220,7 @@ export async function runAgentSession(
|
|
|
206
220
|
}
|
|
207
221
|
};
|
|
208
222
|
const activities: ToolActivity[] = [];
|
|
223
|
+
const assistantPreview = new AssistantPreview();
|
|
209
224
|
const pendingById = new Map<string, ToolActivity>();
|
|
210
225
|
let notifyCancellationRequested!: () => void;
|
|
211
226
|
const cancellationRequested = new Promise<void>((resolve) => {
|
|
@@ -377,6 +392,8 @@ export async function runAgentSession(
|
|
|
377
392
|
try {
|
|
378
393
|
const cancellationSource = currentCancellationSource();
|
|
379
394
|
onProgress({
|
|
395
|
+
assistantPreview: assistantPreview.text,
|
|
396
|
+
activity: phase,
|
|
380
397
|
tokens: delta,
|
|
381
398
|
toolUses,
|
|
382
399
|
durationMs: Date.now() - startTime,
|
|
@@ -415,7 +432,13 @@ export async function runAgentSession(
|
|
|
415
432
|
};
|
|
416
433
|
const armStallWatchdog = (graceMs = 0) => {
|
|
417
434
|
clearStallWatchdog();
|
|
418
|
-
if (
|
|
435
|
+
if (
|
|
436
|
+
!stallTimeoutMs ||
|
|
437
|
+
stalled ||
|
|
438
|
+
signal?.aborted ||
|
|
439
|
+
deadlineExceeded ||
|
|
440
|
+
pausedAtTurnBoundary
|
|
441
|
+
)
|
|
419
442
|
return;
|
|
420
443
|
|
|
421
444
|
const grace = Number.isFinite(graceMs) && graceMs > 0 ? graceMs : 0;
|
|
@@ -625,6 +648,35 @@ export async function runAgentSession(
|
|
|
625
648
|
// result, isError) — AgentSession forwards the underlying agent events
|
|
626
649
|
// verbatim. Retry and compaction events are handled below; queue/bookkeeping
|
|
627
650
|
// events and thinking changes are intentionally ignored.
|
|
651
|
+
// AgentSession subscribers are notifications, not an awaited barrier.
|
|
652
|
+
// Pi core explicitly awaits Agent subscribers before proceeding from
|
|
653
|
+
// turn_start to the next model request, including retries/continuations.
|
|
654
|
+
// Never gate turn_end: a final turn should be allowed to finish the task.
|
|
655
|
+
if (pause) {
|
|
656
|
+
unsubscribePause = session.agent.subscribe(async (event, turnSignal) => {
|
|
657
|
+
if (event.type !== "turn_start" || pause.controller.state === "running")
|
|
658
|
+
return;
|
|
659
|
+
pausedAtTurnBoundary = true;
|
|
660
|
+
clearStallWatchdog();
|
|
661
|
+
try {
|
|
662
|
+
await pause.controller.checkpoint(
|
|
663
|
+
pause.index,
|
|
664
|
+
turnSignal && signal
|
|
665
|
+
? AbortSignal.any([turnSignal, signal])
|
|
666
|
+
: (turnSignal ?? signal),
|
|
667
|
+
);
|
|
668
|
+
if (turnSignal?.aborted || signal?.aborted) {
|
|
669
|
+
throw new Error(
|
|
670
|
+
"Paused turn cancelled before the next model request",
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
} finally {
|
|
674
|
+
pausedAtTurnBoundary = false;
|
|
675
|
+
noteActivity("resuming at turn boundary");
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
628
680
|
unsubscribeFull = session.subscribe((event: AgentSessionEvent) => {
|
|
629
681
|
barrier.noteEvent();
|
|
630
682
|
recoveryBarrier?.noteEvent();
|
|
@@ -678,12 +730,14 @@ export async function runAgentSession(
|
|
|
678
730
|
case "message_start":
|
|
679
731
|
case "message_update":
|
|
680
732
|
if (event.message?.role === "assistant") {
|
|
733
|
+
assistantPreview.update(extractOutput([event.message]));
|
|
681
734
|
rememberPartialAssistant(event.message);
|
|
682
735
|
}
|
|
683
736
|
noteActivity("streaming model output");
|
|
684
737
|
break;
|
|
685
738
|
case "message_end":
|
|
686
739
|
if (event.message.role === "assistant") {
|
|
740
|
+
assistantPreview.finish(extractOutput([event.message]));
|
|
687
741
|
assistantMessagesForAttempt.push(event.message);
|
|
688
742
|
// Keep a text-bearing partial if the host follows a provider
|
|
689
743
|
// exception with an empty synthetic failure message. A real
|
package/runtime.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { SessionPool, defaultSessionPool } from "./pool.ts";
|
|
2
|
+
import { TicketRegistry, ticketRegistry } from "./tickets.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Injectable runtime context for one delegate extension lifetime.
|
|
6
|
+
*
|
|
7
|
+
* A runtime bundles a {@link SessionPool} and a {@link TicketRegistry} so
|
|
8
|
+
* tests and nested callers can create fully isolated dispatch/lifecycle
|
|
9
|
+
* environments without touching the module-level default pool or ticket
|
|
10
|
+
* registry. Production Pi uses the single default runtime returned by
|
|
11
|
+
* {@link getDefaultDelegateRuntime}; the public barrel still exposes the
|
|
12
|
+
* familiar checkout/commit/ticket wrapper functions for compatibility.
|
|
13
|
+
*/
|
|
14
|
+
export interface DelegateRuntime {
|
|
15
|
+
pool: SessionPool;
|
|
16
|
+
tickets: TicketRegistry;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Create a fresh, isolated runtime with its own pool and ticket registry. */
|
|
20
|
+
export function createDelegateRuntime(): DelegateRuntime {
|
|
21
|
+
return {
|
|
22
|
+
pool: new SessionPool(),
|
|
23
|
+
tickets: new TicketRegistry(),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const defaultRuntime: DelegateRuntime = {
|
|
28
|
+
pool: defaultSessionPool,
|
|
29
|
+
tickets: ticketRegistry,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The runtime used by the one-argument Pi extension entry point and the
|
|
33
|
+
* default-runtime compatibility wrappers exported from the barrel. */
|
|
34
|
+
export function getDefaultDelegateRuntime(): DelegateRuntime {
|
|
35
|
+
return defaultRuntime;
|
|
36
|
+
}
|
package/schema.ts
CHANGED
|
@@ -42,7 +42,7 @@ export const delegateTaskSchema = Type.Object({
|
|
|
42
42
|
agent: Type.Optional(
|
|
43
43
|
Type.String({
|
|
44
44
|
description:
|
|
45
|
-
"
|
|
45
|
+
"Use built-ins first: default=general; scout/coder/reviewer specialize. Omit only for inline; it gets * even if parent is narrower.",
|
|
46
46
|
}),
|
|
47
47
|
),
|
|
48
48
|
cwd: Type.Optional(
|
|
@@ -65,19 +65,20 @@ export const delegateTaskSchema = Type.Object({
|
|
|
65
65
|
),
|
|
66
66
|
model: Type.Optional(
|
|
67
67
|
Type.String({
|
|
68
|
-
description:
|
|
68
|
+
description:
|
|
69
|
+
"Override only if requested or required; else keep its default.",
|
|
69
70
|
}),
|
|
70
71
|
),
|
|
71
72
|
tools: Type.Optional(
|
|
72
73
|
Type.Array(Type.String(), {
|
|
73
74
|
description:
|
|
74
|
-
"
|
|
75
|
+
"Override only if requested or required. *=read/write/edit/bash; ro=read/grep/find/ls (read-only).",
|
|
75
76
|
}),
|
|
76
77
|
),
|
|
77
78
|
thinking: Type.Optional(
|
|
78
79
|
StringEnum(VALID_THINKING_LEVELS, {
|
|
79
80
|
description:
|
|
80
|
-
"off/minimal/low/medium/high/xhigh/max. Omit
|
|
81
|
+
"off/minimal/low/medium/high/xhigh/max. Omit by default: overrides the agent's configured thinking budget.",
|
|
81
82
|
}),
|
|
82
83
|
),
|
|
83
84
|
sessionId: Type.Optional(
|
|
@@ -101,7 +102,7 @@ export const delegateTaskSchema = Type.Object({
|
|
|
101
102
|
workspace: Type.Optional(
|
|
102
103
|
StringEnum(["shared", "scratch", "isolated"], {
|
|
103
104
|
description:
|
|
104
|
-
"shared
|
|
105
|
+
"shared/scratch/isolated. Override if requested or required. scratch discards; isolated reconciles Git; not a security boundary.",
|
|
105
106
|
}),
|
|
106
107
|
),
|
|
107
108
|
});
|
|
@@ -112,9 +113,9 @@ export const delegateTaskSchema = Type.Object({
|
|
|
112
113
|
export const delegateArgumentsSchema = Type.Object(
|
|
113
114
|
{
|
|
114
115
|
ticketAction: Type.Optional(
|
|
115
|
-
StringEnum(["poll", "cancel", "wait"], {
|
|
116
|
+
StringEnum(["poll", "cancel", "wait", "pause", "resume"], {
|
|
116
117
|
description:
|
|
117
|
-
"
|
|
118
|
+
"poll=snapshot; wait=await results; pause=stop between turns; resume=continue; cancel=abort. Prefer wait over polling.",
|
|
118
119
|
}),
|
|
119
120
|
),
|
|
120
121
|
sessionAction: Type.Optional(
|
|
@@ -159,7 +160,7 @@ export const delegateArgumentsSchema = Type.Object(
|
|
|
159
160
|
Type.Array(delegateTaskSchema, {
|
|
160
161
|
minItems: 0,
|
|
161
162
|
description:
|
|
162
|
-
"Tasks run concurrently; shared
|
|
163
|
+
"Use built-in defaults first. Tasks run concurrently; overlapping shared writers run in task order. []=manual.",
|
|
163
164
|
}),
|
|
164
165
|
),
|
|
165
166
|
},
|
|
@@ -212,7 +213,7 @@ export function validateDelegateOperation(
|
|
|
212
213
|
const rawParams = params as Record<string, unknown>;
|
|
213
214
|
if ("action" in rawParams) {
|
|
214
215
|
return (
|
|
215
|
-
"unsupported field 'action'; use 'ticketAction' for poll/cancel/wait " +
|
|
216
|
+
"unsupported field 'action'; use 'ticketAction' for poll/cancel/wait/pause/resume " +
|
|
216
217
|
"or 'sessionAction' for close/list."
|
|
217
218
|
);
|
|
218
219
|
}
|
|
@@ -286,9 +287,21 @@ function validateTicketMode(params: DelegateArguments): string | undefined {
|
|
|
286
287
|
[...TASK_FIELD_NAMES, "tasks", "sessionAction"] as const
|
|
287
288
|
).filter((field) => rawParams[field] !== undefined);
|
|
288
289
|
if (incompatibleFields.length) {
|
|
289
|
-
|
|
290
|
+
const base = `ticket control cannot be combined with field(s) ${incompatibleFields
|
|
290
291
|
.map((field) => `'${field}'`)
|
|
291
292
|
.join(", ")}; call it separately.`;
|
|
293
|
+
// Kitchen-sink callers attach default-shaped ticket control to a real
|
|
294
|
+
// dispatch and then repeat the identical rejected call (observed in the
|
|
295
|
+
// wild: glm-5.3 sent `ticketAction:"wait"` + tasks + empty ticket six
|
|
296
|
+
// times in a row). With tasks present and no ticket id, address the
|
|
297
|
+
// likely intent — dispatch already blocks; async creates the tickets
|
|
298
|
+
// that ticketAction manages — instead of restating the field rule.
|
|
299
|
+
const wantsDispatch =
|
|
300
|
+
Array.isArray(params.tasks) && params.tasks.length > 0 && !params.ticket;
|
|
301
|
+
if (wantsDispatch) {
|
|
302
|
+
return `${base} Dispatched tasks run to completion before returning — omit ticketAction entirely; only async:true produces a ticket to wait on.`;
|
|
303
|
+
}
|
|
304
|
+
return base;
|
|
292
305
|
}
|
|
293
306
|
if (params.async === true) {
|
|
294
307
|
return "ticket control cannot include async; call it separately.";
|
|
@@ -314,7 +327,7 @@ function validateDispatchOrHelpMode(
|
|
|
314
327
|
const tasks = params.tasks ?? [];
|
|
315
328
|
|
|
316
329
|
if (params.ticket !== undefined) {
|
|
317
|
-
return "ticket requires ticketAction 'poll', 'cancel', or '
|
|
330
|
+
return "ticket requires ticketAction 'poll', 'cancel', 'wait', 'pause', or 'resume'.";
|
|
318
331
|
}
|
|
319
332
|
if (params.force === true)
|
|
320
333
|
return "force is valid only with ticketAction 'cancel'.";
|
|
@@ -327,10 +340,6 @@ function validateDispatchOrHelpMode(
|
|
|
327
340
|
: undefined; // Intentional help request.
|
|
328
341
|
}
|
|
329
342
|
|
|
330
|
-
if (params.async && tasks.some((task) => task.workspace === "isolated")) {
|
|
331
|
-
return 'workspace "isolated" is synchronous; remove async.';
|
|
332
|
-
}
|
|
333
|
-
|
|
334
343
|
// Reject mixed shapes: flat task fields at the top level alongside a
|
|
335
344
|
// nonempty tasks array. The normalize shim only wraps flat fields when
|
|
336
345
|
// there is no tasks array, so a mixed call silently lets tasks win —
|
|
@@ -406,12 +415,14 @@ function normalizeToolsField(value: string): unknown {
|
|
|
406
415
|
|
|
407
416
|
/** True when `record` carries a top-level ticket-control intent that makes a
|
|
408
417
|
* flat task-field wrap illegitimate: an explicit `ticketAction`, or a bare
|
|
409
|
-
* `ticket` id (which only makes sense with
|
|
418
|
+
* `ticket` id (which only makes sense with ticket controls). */
|
|
410
419
|
function hasTicketControlIntent(record: Record<string, unknown>): boolean {
|
|
411
420
|
return (
|
|
412
421
|
record.ticketAction === "poll" ||
|
|
413
422
|
record.ticketAction === "cancel" ||
|
|
414
423
|
record.ticketAction === "wait" ||
|
|
424
|
+
record.ticketAction === "pause" ||
|
|
425
|
+
record.ticketAction === "resume" ||
|
|
415
426
|
record.ticket !== undefined
|
|
416
427
|
);
|
|
417
428
|
}
|
|
@@ -450,7 +461,7 @@ function wrapFlatTaskFields(record: Record<string, unknown>): void {
|
|
|
450
461
|
}
|
|
451
462
|
|
|
452
463
|
/** Per-entry recovery for one task: stringified (or bare-token) `tools` → a
|
|
453
|
-
* real array, and `agent: ""` → omitted (
|
|
464
|
+
* real array, and `agent: ""` → omitted (inline). Other malformed input is
|
|
454
465
|
* left for schema validation to reject loudly. */
|
|
455
466
|
function normalizeTaskEntry(entry: unknown): unknown {
|
|
456
467
|
if (!entry || typeof entry !== "object") return entry;
|
|
@@ -476,7 +487,7 @@ function normalizeTaskEntry(entry: unknown): unknown {
|
|
|
476
487
|
* unless ticket- or session-control intent makes the call legitimately
|
|
477
488
|
* taskless (see `hasTicketControlIntent` / `hasSessionControlIntent`);
|
|
478
489
|
* - `tools` as a JSON string (or bare token) inside a task entry;
|
|
479
|
-
* - `agent: ""` inside a task entry — treated as omitted (
|
|
490
|
+
* - `agent: ""` inside a task entry — treated as omitted (inline);
|
|
480
491
|
* All other invalid input is left for normal schema validation to reject
|
|
481
492
|
* loudly.
|
|
482
493
|
*
|
package/status.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* there.
|
|
27
27
|
*/
|
|
28
28
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
29
|
-
import {
|
|
29
|
+
import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
|
|
30
30
|
import type { AsyncTicket } from "./types.ts";
|
|
31
31
|
|
|
32
32
|
const STATUS_KEY = "delegate";
|
|
@@ -39,10 +39,12 @@ export interface ActiveTicketSummary {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** Snapshot the live background work from the ticket registry. */
|
|
42
|
-
export function activeTicketSummary(
|
|
42
|
+
export function activeTicketSummary(
|
|
43
|
+
runtime: DelegateRuntime = getDefaultDelegateRuntime(),
|
|
44
|
+
): ActiveTicketSummary {
|
|
43
45
|
const tickets: AsyncTicket[] = [];
|
|
44
46
|
let activeSubagents = 0;
|
|
45
|
-
for (const ticket of
|
|
47
|
+
for (const ticket of runtime.tickets.values()) {
|
|
46
48
|
if (ticket.status !== "running" && ticket.status !== "cancelling") continue;
|
|
47
49
|
tickets.push(ticket);
|
|
48
50
|
activeSubagents += ticket.progress.filter(
|
|
@@ -60,8 +62,22 @@ function plural(n: number, noun: string): string {
|
|
|
60
62
|
export function buildStatusText(
|
|
61
63
|
summary: ActiveTicketSummary,
|
|
62
64
|
): string | undefined {
|
|
65
|
+
const text = buildStatusSummary(summary);
|
|
66
|
+
return text === undefined ? undefined : `${text} · /subagents`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildStatusSummary(summary: ActiveTicketSummary): string | undefined {
|
|
63
70
|
const { tickets, activeSubagents } = summary;
|
|
64
71
|
if (tickets.length === 0) return undefined;
|
|
72
|
+
const held = tickets.filter(
|
|
73
|
+
(ticket) =>
|
|
74
|
+
ticket.status === "running" &&
|
|
75
|
+
ticket.pause &&
|
|
76
|
+
ticket.pause.state !== "running",
|
|
77
|
+
);
|
|
78
|
+
if (held.length) {
|
|
79
|
+
return `Ⅱ ${held.map((ticket) => `${ticket.id} ${ticket.pause!.state}`).join(" · ")}${tickets.length > held.length ? ` · ${tickets.length - held.length} other ticket(s)` : ""}`;
|
|
80
|
+
}
|
|
65
81
|
// Wind-down window: tasks have settled but the ticket has not flipped to a
|
|
66
82
|
// terminal status yet. "settling" is more honest than "0 subagents".
|
|
67
83
|
if (activeSubagents === 0) {
|
|
@@ -88,10 +104,13 @@ const settledWarnedTicketIds = new Set<string>();
|
|
|
88
104
|
* Called on every ticket lifecycle mutation (create, progress, complete,
|
|
89
105
|
* cancel); event-driven only — no timers, so the text never goes stale
|
|
90
106
|
* (counts are the only content). */
|
|
91
|
-
export function syncDelegateStatus(
|
|
107
|
+
export function syncDelegateStatus(
|
|
108
|
+
ctx?: ExtensionContext,
|
|
109
|
+
runtime?: DelegateRuntime,
|
|
110
|
+
): void {
|
|
92
111
|
if (ctx) lastCtx = ctx;
|
|
93
112
|
|
|
94
|
-
const summary = activeTicketSummary();
|
|
113
|
+
const summary = activeTicketSummary(runtime);
|
|
95
114
|
const text = buildStatusText(summary);
|
|
96
115
|
|
|
97
116
|
if (settledWarnedTicketIds.size) {
|
|
@@ -127,9 +146,12 @@ export function clearDelegateStatusContext(): void {
|
|
|
127
146
|
/** Warn once per ticket at the first agent_settled with that ticket active —
|
|
128
147
|
* the "looks idle but isn't" moment. The persistent footer status carries
|
|
129
148
|
* the information from then on, so later settles stay quiet. */
|
|
130
|
-
export function notifyActiveTicketsOnSettled(
|
|
149
|
+
export function notifyActiveTicketsOnSettled(
|
|
150
|
+
ctx: ExtensionContext,
|
|
151
|
+
runtime?: DelegateRuntime,
|
|
152
|
+
): void {
|
|
131
153
|
lastCtx = ctx;
|
|
132
|
-
const summary = activeTicketSummary();
|
|
154
|
+
const summary = activeTicketSummary(runtime);
|
|
133
155
|
const fresh = summary.tickets.filter(
|
|
134
156
|
(t) => !settledWarnedTicketIds.has(t.id),
|
|
135
157
|
);
|
|
@@ -167,9 +189,10 @@ export function notifyActiveTicketsOnSettled(ctx: ExtensionContext): void {
|
|
|
167
189
|
export async function guardSessionReplacement(
|
|
168
190
|
ctx: ExtensionContext,
|
|
169
191
|
action: "switch" | "fork",
|
|
192
|
+
runtime?: DelegateRuntime,
|
|
170
193
|
): Promise<{ cancel: true } | undefined> {
|
|
171
194
|
lastCtx = ctx;
|
|
172
|
-
const summary = activeTicketSummary();
|
|
195
|
+
const summary = activeTicketSummary(runtime);
|
|
173
196
|
if (!summary.tickets.length || !ctx.hasUI) return undefined;
|
|
174
197
|
|
|
175
198
|
const ids = summary.tickets.map((t) => t.id).join(", ");
|
|
@@ -194,9 +217,11 @@ export async function guardSessionReplacement(
|
|
|
194
217
|
* extension) is still handled at delivery time via leaf affinity. */
|
|
195
218
|
export async function guardTreeNavigation(
|
|
196
219
|
ctx: ExtensionContext,
|
|
220
|
+
runtime?: DelegateRuntime,
|
|
197
221
|
): Promise<{ cancel: true } | undefined> {
|
|
198
222
|
lastCtx = ctx;
|
|
199
|
-
const
|
|
223
|
+
const rt = runtime ?? getDefaultDelegateRuntime();
|
|
224
|
+
const summary = activeTicketSummary(rt);
|
|
200
225
|
if (!summary.tickets.length || !ctx.hasUI) return undefined;
|
|
201
226
|
|
|
202
227
|
const ids = summary.tickets.map((t) => t.id).join(", ");
|
|
@@ -220,8 +245,10 @@ export async function guardTreeNavigation(
|
|
|
220
245
|
|
|
221
246
|
if (choice === hold) return undefined;
|
|
222
247
|
if (choice === cancel) {
|
|
223
|
-
for (const ticket of summary.tickets)
|
|
224
|
-
|
|
248
|
+
for (const ticket of summary.tickets) {
|
|
249
|
+
rt.tickets.requestTicketCancel(ticket);
|
|
250
|
+
}
|
|
251
|
+
syncDelegateStatus(ctx, rt);
|
|
225
252
|
return undefined;
|
|
226
253
|
}
|
|
227
254
|
return { cancel: true };
|
package/task-resolution.ts
CHANGED
|
@@ -12,8 +12,7 @@ import {
|
|
|
12
12
|
availableToolNames,
|
|
13
13
|
resolveToolGroups,
|
|
14
14
|
} from "./tools.ts";
|
|
15
|
-
import {
|
|
16
|
-
import { isSessionBusy } from "./tickets.ts";
|
|
15
|
+
import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
|
|
17
16
|
import {
|
|
18
17
|
isResumeFromQuarantined,
|
|
19
18
|
isSessionIdQuarantined,
|
|
@@ -115,6 +114,7 @@ export function validateTasks(
|
|
|
115
114
|
tasks: DispatchableTask[],
|
|
116
115
|
agents: Map<string, AgentConfig>,
|
|
117
116
|
parentModelId: string | undefined,
|
|
117
|
+
runtime: DelegateRuntime = getDefaultDelegateRuntime(),
|
|
118
118
|
): DelegateToolResult | null {
|
|
119
119
|
const unknown: string[] = [];
|
|
120
120
|
for (const task of tasks) {
|
|
@@ -203,7 +203,7 @@ export function validateTasks(
|
|
|
203
203
|
// Disallow sessionIds already claimed by a running async ticket.
|
|
204
204
|
const busyConflicts: string[] = [];
|
|
205
205
|
for (const sid of sessionIds) {
|
|
206
|
-
const owner = isSessionBusy(sid);
|
|
206
|
+
const owner = runtime.tickets.isSessionBusy(sid);
|
|
207
207
|
if (owner) busyConflicts.push(`${sid} (ticket ${owner})`);
|
|
208
208
|
}
|
|
209
209
|
if (busyConflicts.length) {
|
|
@@ -247,6 +247,7 @@ export function resolveTasks(
|
|
|
247
247
|
agents: Map<string, AgentConfig>,
|
|
248
248
|
parentDefaults: ParentAgentDefaults,
|
|
249
249
|
dispatchConfig: DelegateConfig = getDelegateConfigSnapshot(),
|
|
250
|
+
runtime: DelegateRuntime = getDefaultDelegateRuntime(),
|
|
250
251
|
): ResolveTasksResult {
|
|
251
252
|
// Build parent transcript lazily — only computed once if any task uses with-parent-transcript
|
|
252
253
|
let parentTranscript: string | null = null;
|
|
@@ -302,10 +303,12 @@ export function resolveTasks(
|
|
|
302
303
|
: undefined;
|
|
303
304
|
|
|
304
305
|
// Build system prompt. Explicit task prompts and named agent prompts
|
|
305
|
-
// win;
|
|
306
|
+
// win; inline subagents inherit the parent's base prompt when Pi exposes
|
|
306
307
|
// it. The assembled parent project-context section was stripped above;
|
|
307
308
|
// the child ResourceLoader supplies context for this task's cwd.
|
|
308
|
-
const pooledConfig = t.sessionId
|
|
309
|
+
const pooledConfig = t.sessionId
|
|
310
|
+
? runtime.pool.configFor(t.sessionId)
|
|
311
|
+
: undefined;
|
|
309
312
|
const isPoolHit = pooledConfig !== undefined;
|
|
310
313
|
const parentNativeTools = parentDefaults.tools.filter((name) =>
|
|
311
314
|
Object.hasOwn(TOOL_FACTORIES, name),
|
|
@@ -625,12 +628,12 @@ export function resolveTasks(
|
|
|
625
628
|
// display code treats "" and absent alike (`t.prompt || …`).
|
|
626
629
|
prompt: prompt ?? "",
|
|
627
630
|
// Keep the built-in selector visible in progress/results. Omitted-agent
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
+
// tasks use the `inline` label and config namespace — except resumes: a
|
|
632
|
+
// continued transcript is not a fresh inline spawn, so it carries the
|
|
633
|
+
// resumed-transcript identity instead.
|
|
631
634
|
agentName:
|
|
632
635
|
agent?.name ??
|
|
633
|
-
(resumeFromDisplay ? `resume:${resumeFromDisplay}` : "
|
|
636
|
+
(resumeFromDisplay ? `resume:${resumeFromDisplay}` : "inline"),
|
|
634
637
|
resumeFromDisplay,
|
|
635
638
|
warnings,
|
|
636
639
|
reuseIntent: {
|
package/test-harness.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createTestSession,
|
|
3
|
+
type TestSession,
|
|
4
|
+
type TestSessionOptions,
|
|
5
|
+
} from "@marcfargas/pi-test-harness";
|
|
6
|
+
import type {
|
|
7
|
+
AgentSession,
|
|
8
|
+
AgentToolResult,
|
|
9
|
+
ExtensionContext,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
12
|
+
import type { DelegateDetails, TaskResult } from "./types.ts";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
|
|
15
|
+
export const DELEGATE_EXTENSION = resolve(import.meta.dirname, "./delegate.ts");
|
|
16
|
+
|
|
17
|
+
export { type TestSession };
|
|
18
|
+
|
|
19
|
+
export function createDelegateTestSession(
|
|
20
|
+
options: TestSessionOptions = {},
|
|
21
|
+
): Promise<TestSession> {
|
|
22
|
+
return createTestSession({
|
|
23
|
+
...options,
|
|
24
|
+
extensions: [...(options.extensions ?? []), DELEGATE_EXTENSION],
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TestToolDefinition {
|
|
29
|
+
name: string;
|
|
30
|
+
label: string;
|
|
31
|
+
description: string;
|
|
32
|
+
promptSnippet?: string;
|
|
33
|
+
promptGuidelines?: string[];
|
|
34
|
+
parameters: unknown;
|
|
35
|
+
prepareArguments?: (args: unknown) => unknown;
|
|
36
|
+
execute(...args: unknown[]): Promise<AgentToolResult<DelegateDetails>>;
|
|
37
|
+
renderCall: (...args: unknown[]) => Component;
|
|
38
|
+
renderResult: (...args: unknown[]) => Component;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getToolDef(ts: TestSession, name: string): TestToolDefinition {
|
|
42
|
+
const tool = (ts.session as AgentSession).extensionRunner.getToolDefinition(
|
|
43
|
+
name,
|
|
44
|
+
);
|
|
45
|
+
if (!tool) throw new Error(`${name} tool not found`);
|
|
46
|
+
return tool as unknown as TestToolDefinition;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getDelegateTool(ts: TestSession): TestToolDefinition {
|
|
50
|
+
return getToolDef(ts, "delegate");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getExecContext(ts: TestSession): ExtensionContext {
|
|
54
|
+
return (ts.session as AgentSession).extensionRunner.createContext();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function firstText(result: { content: readonly unknown[] }): string {
|
|
58
|
+
const content = result.content[0];
|
|
59
|
+
if (
|
|
60
|
+
!content ||
|
|
61
|
+
typeof content !== "object" ||
|
|
62
|
+
!("type" in content) ||
|
|
63
|
+
content.type !== "text" ||
|
|
64
|
+
!("text" in content) ||
|
|
65
|
+
typeof content.text !== "string"
|
|
66
|
+
) {
|
|
67
|
+
throw new Error("Expected first tool result content item to be text");
|
|
68
|
+
}
|
|
69
|
+
return content.text;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function taskResultAt(
|
|
73
|
+
results: readonly (TaskResult | { error: string })[],
|
|
74
|
+
index: number,
|
|
75
|
+
): TaskResult {
|
|
76
|
+
const result = results[index];
|
|
77
|
+
if (!result || !("agent" in result)) {
|
|
78
|
+
throw new Error(`Expected task result at index ${index}`);
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|