@bermudi/pi-delegate 0.1.0
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 +92 -0
- package/agents.ts +347 -0
- package/concurrency.ts +126 -0
- package/config.ts +358 -0
- package/constants.ts +41 -0
- package/delegate.ts +115 -0
- package/dispatch.ts +362 -0
- package/extension.ts +126 -0
- package/file-tracking.ts +57 -0
- package/format.ts +506 -0
- package/host-compat.ts +73 -0
- package/host.ts +814 -0
- package/lifecycle.ts +704 -0
- package/manual.ts +184 -0
- package/model.ts +81 -0
- package/package.json +43 -0
- package/parent-context.ts +42 -0
- package/pool.ts +420 -0
- package/render-branches.ts +380 -0
- package/render-result.ts +182 -0
- package/runner.ts +686 -0
- package/schema.ts +289 -0
- package/sessions.ts +102 -0
- package/settings.ts +78 -0
- package/spill.ts +161 -0
- package/task-resolution.ts +321 -0
- package/tickets.ts +795 -0
- package/timer.ts +46 -0
- package/tools.ts +41 -0
- package/types.ts +243 -0
- package/usage.ts +121 -0
- package/utils.ts +131 -0
package/dispatch.ts
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import {
|
|
4
|
+
ticketRegistry,
|
|
5
|
+
generateTicketId,
|
|
6
|
+
deliverTicketResults,
|
|
7
|
+
sweepTickets,
|
|
8
|
+
resolveFinalTicketStatus,
|
|
9
|
+
syncTicketBusyIndex,
|
|
10
|
+
notifyWaiters,
|
|
11
|
+
} from "./tickets.ts";
|
|
12
|
+
import { getConcurrencyLimit, getMaxAsyncTickets } from "./config.ts";
|
|
13
|
+
import { getModelKey, mapConcurrentByModel } from "./concurrency.ts";
|
|
14
|
+
import { sumUsage } from "./usage.ts";
|
|
15
|
+
import { runResolvedTask, updateProgressFromRun } from "./lifecycle.ts";
|
|
16
|
+
import { fmtDuration, formatCompletedTask, trunc } from "./format.ts";
|
|
17
|
+
import { validateDelegateOperation } from "./schema.ts";
|
|
18
|
+
import { validateTasks, resolveTasks } from "./task-resolution.ts";
|
|
19
|
+
import type {
|
|
20
|
+
AgentConfig,
|
|
21
|
+
AsyncTicket,
|
|
22
|
+
DelegateArguments,
|
|
23
|
+
DelegateDetails,
|
|
24
|
+
DelegateToolCtx,
|
|
25
|
+
DelegateToolResult,
|
|
26
|
+
ResolvedTask,
|
|
27
|
+
TaskDef,
|
|
28
|
+
TaskProgress,
|
|
29
|
+
TaskResult,
|
|
30
|
+
TaskRunEnv,
|
|
31
|
+
} from "./types.ts";
|
|
32
|
+
|
|
33
|
+
/** Return the structured result for an invalid top-level operation, or null when
|
|
34
|
+
* the call may proceed to a ticket control/help/dispatch path. */
|
|
35
|
+
export function validateDelegateOperationResult(
|
|
36
|
+
params: DelegateArguments,
|
|
37
|
+
parentModelId: string | undefined,
|
|
38
|
+
): DelegateToolResult | null {
|
|
39
|
+
const operationError = validateDelegateOperation(params);
|
|
40
|
+
if (!operationError) return null;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
content: [
|
|
44
|
+
{ type: "text", text: `Invalid delegate call: ${operationError}` },
|
|
45
|
+
],
|
|
46
|
+
details: {
|
|
47
|
+
tasks: params.tasks ?? [],
|
|
48
|
+
results: [],
|
|
49
|
+
progress: [],
|
|
50
|
+
parentModel: parentModelId,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Build the initial per-task progress rows from resolved tasks. */
|
|
56
|
+
export function initProgress(resolved: ResolvedTask[]): TaskProgress[] {
|
|
57
|
+
return resolved.map((t, i) => ({
|
|
58
|
+
index: i,
|
|
59
|
+
agent: t.agentName,
|
|
60
|
+
task: trunc(t.prompt || t.action || "", 50),
|
|
61
|
+
status: "pending" as const,
|
|
62
|
+
durationMs: 0,
|
|
63
|
+
tokens: 0,
|
|
64
|
+
toolUses: 0,
|
|
65
|
+
activities: [],
|
|
66
|
+
model: t.model?.id,
|
|
67
|
+
warnings: t.warnings.length ? [...t.warnings] : undefined,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Construct the `fire()` updater that pushes a "Running N subagents" progress
|
|
72
|
+
* frame to the parent TUI. Used for the initial dispatch frame and (in sync
|
|
73
|
+
* mode) after every progress/status mutation. */
|
|
74
|
+
export function makeFireUpdater(
|
|
75
|
+
onUpdate: AgentToolUpdateCallback<DelegateDetails> | undefined,
|
|
76
|
+
tasks: TaskDef[],
|
|
77
|
+
progress: TaskProgress[],
|
|
78
|
+
resolved: ResolvedTask[],
|
|
79
|
+
parentModelId: string | undefined,
|
|
80
|
+
): () => void {
|
|
81
|
+
return () =>
|
|
82
|
+
onUpdate?.({
|
|
83
|
+
content: [
|
|
84
|
+
{
|
|
85
|
+
type: "text",
|
|
86
|
+
text: `Running ${resolved.length} subagent${resolved.length > 1 ? "s" : ""}…`,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
details: {
|
|
90
|
+
tasks,
|
|
91
|
+
results: [],
|
|
92
|
+
progress: [...progress],
|
|
93
|
+
parentModel: parentModelId,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Inputs needed by the async (fire-and-forget) dispatch path. */
|
|
99
|
+
export interface AsyncDispatchInput {
|
|
100
|
+
pi: ExtensionAPI;
|
|
101
|
+
ctx: DelegateToolCtx;
|
|
102
|
+
tasks: TaskDef[];
|
|
103
|
+
resolved: ResolvedTask[];
|
|
104
|
+
progress: TaskProgress[];
|
|
105
|
+
parentModelId: string | undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Inputs needed by the sync (blocking) dispatch path. */
|
|
109
|
+
export interface SyncDispatchInput {
|
|
110
|
+
ctx: DelegateToolCtx;
|
|
111
|
+
tasks: TaskDef[];
|
|
112
|
+
resolved: ResolvedTask[];
|
|
113
|
+
progress: TaskProgress[];
|
|
114
|
+
parentModelId: string | undefined;
|
|
115
|
+
signal: AbortSignal | undefined;
|
|
116
|
+
fire: () => void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Inputs for the normal task-validation, resolution, and dispatch path. */
|
|
120
|
+
export interface DelegateDispatchInput {
|
|
121
|
+
pi: ExtensionAPI;
|
|
122
|
+
params: DelegateArguments;
|
|
123
|
+
ctx: DelegateToolCtx;
|
|
124
|
+
agents: Map<string, AgentConfig>;
|
|
125
|
+
parentModelId: string | undefined;
|
|
126
|
+
signal: AbortSignal | undefined;
|
|
127
|
+
onUpdate: AgentToolUpdateCallback<DelegateDetails> | undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Validate, resolve, and dispatch a non-short-circuit delegate operation. */
|
|
131
|
+
export async function dispatchDelegate(
|
|
132
|
+
input: DelegateDispatchInput,
|
|
133
|
+
): Promise<DelegateToolResult> {
|
|
134
|
+
const { pi, params, ctx, agents, parentModelId, signal, onUpdate } = input;
|
|
135
|
+
const tasks = params.tasks ?? [];
|
|
136
|
+
|
|
137
|
+
const validationError = validateTasks(tasks, agents, parentModelId);
|
|
138
|
+
if (validationError) return validationError;
|
|
139
|
+
|
|
140
|
+
const resolved = resolveTasks(tasks, ctx, agents);
|
|
141
|
+
const progress = initProgress(resolved);
|
|
142
|
+
const fire = makeFireUpdater(
|
|
143
|
+
onUpdate,
|
|
144
|
+
tasks,
|
|
145
|
+
progress,
|
|
146
|
+
resolved,
|
|
147
|
+
parentModelId,
|
|
148
|
+
);
|
|
149
|
+
fire();
|
|
150
|
+
|
|
151
|
+
if (params.async) {
|
|
152
|
+
return dispatchAsync({
|
|
153
|
+
pi,
|
|
154
|
+
ctx,
|
|
155
|
+
tasks,
|
|
156
|
+
resolved,
|
|
157
|
+
progress,
|
|
158
|
+
parentModelId,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return dispatchSync({
|
|
163
|
+
ctx,
|
|
164
|
+
tasks,
|
|
165
|
+
resolved,
|
|
166
|
+
progress,
|
|
167
|
+
parentModelId,
|
|
168
|
+
signal,
|
|
169
|
+
fire,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Fire-and-forget background execution. Registers an `AsyncTicket`, kicks off
|
|
174
|
+
* the concurrent run, and returns the ticket acknowledgment immediately.
|
|
175
|
+
* Results are delivered via `deliverTicketResults` when all tasks settle. */
|
|
176
|
+
export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
177
|
+
const { pi, ctx, tasks, resolved, progress, parentModelId } = input;
|
|
178
|
+
|
|
179
|
+
sweepTickets();
|
|
180
|
+
const runningCount = [...ticketRegistry.values()].filter(
|
|
181
|
+
(t) => t.status === "running" || t.status === "cancelling",
|
|
182
|
+
).length;
|
|
183
|
+
if (runningCount >= getMaxAsyncTickets()) {
|
|
184
|
+
return {
|
|
185
|
+
content: [
|
|
186
|
+
{
|
|
187
|
+
type: "text",
|
|
188
|
+
text: `Too many async tickets running or cancelling (${runningCount}/${getMaxAsyncTickets()}). Poll existing tickets or cancel one first.`,
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
details: { tasks, results: [], progress: [], parentModel: parentModelId },
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const ticketId = generateTicketId();
|
|
196
|
+
const controller = new AbortController();
|
|
197
|
+
const ticket: AsyncTicket = {
|
|
198
|
+
id: ticketId,
|
|
199
|
+
created: Date.now(),
|
|
200
|
+
tasks,
|
|
201
|
+
resolved,
|
|
202
|
+
status: "running",
|
|
203
|
+
results: new Array(resolved.length),
|
|
204
|
+
progress: [...progress],
|
|
205
|
+
controller,
|
|
206
|
+
parentModelId,
|
|
207
|
+
};
|
|
208
|
+
ticketRegistry.set(ticketId, ticket);
|
|
209
|
+
|
|
210
|
+
// Capture values for the closure — do NOT use `signal` from execute()
|
|
211
|
+
// The parent turn's signal dies when execute() returns.
|
|
212
|
+
const ticketSignal = controller.signal;
|
|
213
|
+
const modelRegistry = ctx.modelRegistry;
|
|
214
|
+
|
|
215
|
+
const asyncEnv: TaskRunEnv = {
|
|
216
|
+
signal: ticketSignal,
|
|
217
|
+
modelRegistry,
|
|
218
|
+
parentSessionManager: ctx.sessionManager,
|
|
219
|
+
ticketId,
|
|
220
|
+
delegateStartedAt: ticket.created,
|
|
221
|
+
onProgress: (p, u) => {
|
|
222
|
+
updateProgressFromRun(p, u);
|
|
223
|
+
notifyWaiters(ticket);
|
|
224
|
+
},
|
|
225
|
+
onStatusChange: () => {
|
|
226
|
+
notifyWaiters(ticket);
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Fire and forget — runs on the event loop.
|
|
231
|
+
// Worker must store the TaskResult back into ticket.results, since
|
|
232
|
+
// formatCompletedTicket/handlePoll read from there. Without the write,
|
|
233
|
+
// completed async tasks would be reported as PENDING.
|
|
234
|
+
mapConcurrentByModel(
|
|
235
|
+
resolved,
|
|
236
|
+
(t) => getModelKey(t.model),
|
|
237
|
+
getConcurrencyLimit,
|
|
238
|
+
async (t, i) => {
|
|
239
|
+
const result = await runResolvedTask(asyncEnv, t, ticket.progress[i]!, i);
|
|
240
|
+
ticket.results[i] = result;
|
|
241
|
+
return result;
|
|
242
|
+
},
|
|
243
|
+
ticketSignal,
|
|
244
|
+
)
|
|
245
|
+
.then(() => {
|
|
246
|
+
// All tasks settled — determine final ticket status.
|
|
247
|
+
// Use progress (set by runResolvedTask) for settled-ness so the
|
|
248
|
+
// status reflects work completion, not just result-array density.
|
|
249
|
+
// A partial ticket (not all settled, e.g. aborted mid-flight) must
|
|
250
|
+
// NOT be marked "done" — that would mask incomplete work as
|
|
251
|
+
// complete. resolveFinalTicketStatus returns "failed" for that
|
|
252
|
+
// case and for any case with a failed task.
|
|
253
|
+
if (ticket.status === "running") {
|
|
254
|
+
ticket.status = resolveFinalTicketStatus(ticket);
|
|
255
|
+
ticket.completedAt = Date.now();
|
|
256
|
+
syncTicketBusyIndex(ticket);
|
|
257
|
+
} else if (ticket.status === "cancelling") {
|
|
258
|
+
// Cancellation was requested while tasks were still settling. The
|
|
259
|
+
// per-task results record what actually happened; the ticket state
|
|
260
|
+
// reports that the batch was aborted by the caller.
|
|
261
|
+
ticket.status = "cancelled";
|
|
262
|
+
ticket.completedAt = Date.now();
|
|
263
|
+
syncTicketBusyIndex(ticket);
|
|
264
|
+
}
|
|
265
|
+
deliverTicketResults(pi, ticket);
|
|
266
|
+
})
|
|
267
|
+
.catch((err) => {
|
|
268
|
+
// Defense-in-depth — should not happen if individual tasks catch properly
|
|
269
|
+
if (ticket.status === "cancelling") {
|
|
270
|
+
ticket.status = "cancelled";
|
|
271
|
+
} else if (ticket.status === "running") {
|
|
272
|
+
ticket.status = "failed";
|
|
273
|
+
}
|
|
274
|
+
ticket.error = err instanceof Error ? err.message : String(err);
|
|
275
|
+
ticket.completedAt = Date.now();
|
|
276
|
+
syncTicketBusyIndex(ticket);
|
|
277
|
+
deliverTicketResults(pi, ticket);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
content: [
|
|
282
|
+
{
|
|
283
|
+
type: "text",
|
|
284
|
+
text: [
|
|
285
|
+
`Async ticket: ${ticketId}`,
|
|
286
|
+
`${resolved.length} task(s) dispatched · ${runningCount + 1}/${getMaxAsyncTickets()} async slots in use`,
|
|
287
|
+
"",
|
|
288
|
+
"Completed task results are available via poll. Final results delivered automatically when all tasks complete.",
|
|
289
|
+
`Check progress: delegate({ action: "poll", ticket: "${ticketId}" }) — avoid polling in a tight loop`,
|
|
290
|
+
`Cancel if needed: delegate({ action: "cancel", ticket: "${ticketId}", force: true }) — first call without force is a preview`,
|
|
291
|
+
].join("\n"),
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
details: {
|
|
295
|
+
tasks,
|
|
296
|
+
results: [],
|
|
297
|
+
progress: [...progress],
|
|
298
|
+
parentModel: parentModelId,
|
|
299
|
+
ticketId,
|
|
300
|
+
status: ticket.status,
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Synchronous concurrent execution — awaits all tasks and formats the
|
|
306
|
+
* combined LLM-facing result. Pooled sessions remain live until closed. */
|
|
307
|
+
export async function dispatchSync(
|
|
308
|
+
input: SyncDispatchInput,
|
|
309
|
+
): Promise<DelegateToolResult> {
|
|
310
|
+
const { ctx, tasks, resolved, progress, parentModelId, signal, fire } = input;
|
|
311
|
+
|
|
312
|
+
const startedAt = Date.now();
|
|
313
|
+
const syncEnv: TaskRunEnv = {
|
|
314
|
+
signal,
|
|
315
|
+
modelRegistry: ctx.modelRegistry,
|
|
316
|
+
parentSessionManager: ctx.sessionManager,
|
|
317
|
+
ticketId: undefined,
|
|
318
|
+
delegateStartedAt: startedAt,
|
|
319
|
+
onProgress: (p, u) => {
|
|
320
|
+
updateProgressFromRun(p, u);
|
|
321
|
+
fire();
|
|
322
|
+
},
|
|
323
|
+
onStatusChange: () => fire(),
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const results = await mapConcurrentByModel(
|
|
327
|
+
resolved,
|
|
328
|
+
(t) => getModelKey(t.model),
|
|
329
|
+
getConcurrencyLimit,
|
|
330
|
+
async (t, i) => runResolvedTask(syncEnv, t, progress[i]!, i),
|
|
331
|
+
signal,
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
// ── Format for LLM ────────────────────────────────────────────
|
|
335
|
+
const finalResults: TaskResult[] = results;
|
|
336
|
+
const elapsedTotal = Date.now() - startedAt;
|
|
337
|
+
|
|
338
|
+
const parts: string[] = [];
|
|
339
|
+
const succeeded = finalResults.filter((r) => !r.error).length;
|
|
340
|
+
parts.push(
|
|
341
|
+
`${succeeded}/${finalResults.length} tasks completed successfully · ${fmtDuration(elapsedTotal)} wall time\n`,
|
|
342
|
+
);
|
|
343
|
+
for (let i = 0; i < finalResults.length; i++) {
|
|
344
|
+
const r = finalResults[i]!;
|
|
345
|
+
const t = resolved[i]!;
|
|
346
|
+
parts.push(...formatCompletedTask(t, r));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
content: [{ type: "text", text: parts.join("\n\n") }],
|
|
351
|
+
details: {
|
|
352
|
+
tasks,
|
|
353
|
+
results: finalResults,
|
|
354
|
+
progress,
|
|
355
|
+
parentModel: parentModelId,
|
|
356
|
+
},
|
|
357
|
+
// Aggregate subagent spend so Pi folds it into the parent's
|
|
358
|
+
// session/footer totals. Sync dispatch only — async results arrive via a
|
|
359
|
+
// follow-up message that has no usage slot (see DelegateToolResult).
|
|
360
|
+
usage: sumUsage(finalResults.map((r) => r.usage)),
|
|
361
|
+
};
|
|
362
|
+
}
|
package/extension.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
handleCancel,
|
|
4
|
+
handlePoll,
|
|
5
|
+
handleWait,
|
|
6
|
+
cancelTicketForShutdown,
|
|
7
|
+
ticketRegistry,
|
|
8
|
+
} from "./tickets.ts";
|
|
9
|
+
import { discoverAgents } from "./agents.ts";
|
|
10
|
+
import { getSubagentManualMarkdown } from "./manual.ts";
|
|
11
|
+
import {
|
|
12
|
+
delegateArgumentsSchema,
|
|
13
|
+
normalizeDelegateArguments,
|
|
14
|
+
} from "./schema.ts";
|
|
15
|
+
import {
|
|
16
|
+
dispatchDelegate,
|
|
17
|
+
validateDelegateOperationResult,
|
|
18
|
+
} from "./dispatch.ts";
|
|
19
|
+
import { renderDelegateCall, renderDelegateResult } from "./render-result.ts";
|
|
20
|
+
import { hostCompatError } from "./host-compat.ts";
|
|
21
|
+
import { closeAllPooledAgents } from "./pool.ts";
|
|
22
|
+
import type { DelegateArguments } from "./types.ts";
|
|
23
|
+
|
|
24
|
+
/** Register the delegate tool and clean up its parent-session resources. */
|
|
25
|
+
export default function delegateExtension(pi: ExtensionAPI): void {
|
|
26
|
+
pi.registerTool({
|
|
27
|
+
name: "delegate",
|
|
28
|
+
label: "Delegate to Subagents",
|
|
29
|
+
description:
|
|
30
|
+
"Run parallel subagents via tasks:[{prompt}]. Sync returns results; async returns a ticket.",
|
|
31
|
+
parameters: delegateArgumentsSchema,
|
|
32
|
+
// Runs before schema validation — recovers stringified `tasks` arrays
|
|
33
|
+
// (a common model mistake that would otherwise be rejected upstream).
|
|
34
|
+
prepareArguments: normalizeDelegateArguments,
|
|
35
|
+
|
|
36
|
+
async execute(_id, params: DelegateArguments, signal, onUpdate, ctx) {
|
|
37
|
+
// Guard against pi dropping/renaming a symbol this extension imports
|
|
38
|
+
// before any operation-specific validation or early return.
|
|
39
|
+
const compatError = hostCompatError();
|
|
40
|
+
if (compatError) return compatError;
|
|
41
|
+
|
|
42
|
+
const parentModelId = ctx.model?.id;
|
|
43
|
+
const tasks = params.tasks ?? [];
|
|
44
|
+
|
|
45
|
+
const operationResult = validateDelegateOperationResult(
|
|
46
|
+
params,
|
|
47
|
+
parentModelId,
|
|
48
|
+
);
|
|
49
|
+
if (operationResult) return operationResult;
|
|
50
|
+
|
|
51
|
+
// ── Poll action ───────────────────────────────────────────────────
|
|
52
|
+
if (params.action === "poll") {
|
|
53
|
+
return handlePoll(params, ctx);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Cancel action ─────────────────────────────────────────────────
|
|
57
|
+
if (params.action === "cancel") {
|
|
58
|
+
return handleCancel(params);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Wait action ────────────────────────────────────────────────────
|
|
62
|
+
if (params.action === "wait") {
|
|
63
|
+
return handleWait(params, signal, onUpdate, ctx);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Agent discovery is intentionally parent-cwd-scoped: agent profiles are a
|
|
67
|
+
// session-level resource, not per-task. Per-task cwd governs settings,
|
|
68
|
+
// and AGENTS.md resolution (see resolveCwd below), but not which
|
|
69
|
+
// named agents exist. Changing this would let a task's throwaway cwd
|
|
70
|
+
// silently swap the agent roster.
|
|
71
|
+
const agents = discoverAgents(ctx.cwd);
|
|
72
|
+
|
|
73
|
+
// ── Help mode ─────────────────────────────────────────────────
|
|
74
|
+
if (!tasks.length) {
|
|
75
|
+
return {
|
|
76
|
+
content: [{ type: "text", text: getSubagentManualMarkdown(agents) }],
|
|
77
|
+
details: {
|
|
78
|
+
tasks: [],
|
|
79
|
+
results: [],
|
|
80
|
+
progress: [],
|
|
81
|
+
parentModel: parentModelId,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return dispatchDelegate({
|
|
87
|
+
pi,
|
|
88
|
+
params,
|
|
89
|
+
ctx,
|
|
90
|
+
agents,
|
|
91
|
+
parentModelId,
|
|
92
|
+
signal,
|
|
93
|
+
onUpdate,
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
renderCall: renderDelegateCall,
|
|
98
|
+
renderResult: renderDelegateResult,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ── Session shutdown: abort tickets and dispose live pooled sessions ──
|
|
102
|
+
pi.on("session_shutdown", async () => {
|
|
103
|
+
for (const ticket of ticketRegistry.values()) {
|
|
104
|
+
cancelTicketForShutdown(ticket);
|
|
105
|
+
}
|
|
106
|
+
// Do NOT clear the ticket registry here — completed tickets are retained
|
|
107
|
+
// until their TTL cleanup. Pooled AgentSessions, however, own listeners
|
|
108
|
+
// and must be disposed before the parent session exits.
|
|
109
|
+
//
|
|
110
|
+
// closeAllPooledAgents attempts every session (Promise.allSettled) before
|
|
111
|
+
// aggregating failures into an AggregateError, so swallowing here does not
|
|
112
|
+
// abandon remaining cleanup. Catch and log so a wedged session's failure
|
|
113
|
+
// stays observable instead of becoming an unhandled rejection — pi.on is
|
|
114
|
+
// EventEmitter-style and does not surface handler rejections — and so
|
|
115
|
+
// shutdown completes even when one pooled session failed to abort/dispose.
|
|
116
|
+
try {
|
|
117
|
+
await closeAllPooledAgents();
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const failures = error instanceof AggregateError ? error.errors : [error];
|
|
120
|
+
console.error(
|
|
121
|
+
"[delegate] pooled-session cleanup failed during shutdown:",
|
|
122
|
+
failures,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
package/file-tracking.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ToolActivity } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/** Return absolute paths reported as changed by Git in the task cwd.
|
|
6
|
+
* Git failures degrade to an empty set because file tracking is observational. */
|
|
7
|
+
export async function getGitChangedFiles(cwd: string): Promise<Set<string>> {
|
|
8
|
+
try {
|
|
9
|
+
const runGit = (args: string[]) =>
|
|
10
|
+
new Promise<string>((resolve, reject) => {
|
|
11
|
+
execFile("git", args, { cwd, timeout: 5000 }, (err, stdout) => {
|
|
12
|
+
if (err) reject(err);
|
|
13
|
+
else resolve(stdout);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
const repoRoot = (await runGit(["rev-parse", "--show-toplevel"])).trim();
|
|
17
|
+
const result = await runGit([
|
|
18
|
+
"status",
|
|
19
|
+
"--porcelain=v1",
|
|
20
|
+
"--untracked-files=all",
|
|
21
|
+
"-z",
|
|
22
|
+
]);
|
|
23
|
+
const files = new Set<string>();
|
|
24
|
+
const entries = result.split("\0");
|
|
25
|
+
for (let i = 0; i < entries.length; i++) {
|
|
26
|
+
const entry = entries[i]!;
|
|
27
|
+
if (entry.length < 4) continue;
|
|
28
|
+
|
|
29
|
+
const status = entry.slice(0, 2);
|
|
30
|
+
const rawPath = entry.slice(3);
|
|
31
|
+
const isRenameOrCopy = status.includes("R") || status.includes("C");
|
|
32
|
+
if (rawPath) files.add(path.resolve(repoRoot, rawPath));
|
|
33
|
+
|
|
34
|
+
// With -z, Git emits the destination first and the source second for
|
|
35
|
+
// rename/copy entries. Consume the source so it is not reported too.
|
|
36
|
+
if (isRenameOrCopy) i++;
|
|
37
|
+
}
|
|
38
|
+
return files;
|
|
39
|
+
} catch {
|
|
40
|
+
return new Set();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Extract file paths mutated by edit/write from the activity log. */
|
|
45
|
+
export function extractTouchedFromActivities(
|
|
46
|
+
activities: ToolActivity[],
|
|
47
|
+
cwd: string,
|
|
48
|
+
): string[] {
|
|
49
|
+
const files = new Set<string>();
|
|
50
|
+
for (const a of activities) {
|
|
51
|
+
if (a.name !== "edit" && a.name !== "write") continue;
|
|
52
|
+
const raw = a.args?.path ?? a.args?.file_path ?? a.args?.filePath;
|
|
53
|
+
if (typeof raw !== "string" || !raw) continue;
|
|
54
|
+
files.add(path.resolve(cwd, raw));
|
|
55
|
+
}
|
|
56
|
+
return [...files];
|
|
57
|
+
}
|