@bermudi/pi-delegate 0.1.0 → 0.1.2
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 +55 -10
- package/agents.ts +176 -19
- package/concurrency.ts +70 -7
- package/constants.ts +3 -0
- package/delegate.ts +26 -1
- package/dispatch.ts +65 -9
- package/extension.ts +86 -6
- package/file-tracking.ts +27 -5
- package/format.ts +46 -7
- package/host-compat.ts +47 -12
- package/host.ts +93 -16
- package/leaf.ts +48 -0
- package/lifecycle.ts +292 -95
- package/manual.ts +45 -11
- package/model.ts +3 -4
- package/package.json +25 -20
- package/patches/@marcfargas%2Fpi-test-harness@0.6.1.patch +13 -0
- package/pool.ts +169 -51
- package/render-branches.ts +52 -16
- package/render-result.ts +12 -0
- package/runner.ts +255 -51
- package/schema.ts +252 -63
- package/status.ts +269 -0
- package/task-resolution.ts +196 -77
- package/tickets.ts +173 -62
- package/tools.ts +16 -15
- package/types.ts +62 -13
package/dispatch.ts
CHANGED
|
@@ -10,11 +10,19 @@ import {
|
|
|
10
10
|
notifyWaiters,
|
|
11
11
|
} from "./tickets.ts";
|
|
12
12
|
import { getConcurrencyLimit, getMaxAsyncTickets } from "./config.ts";
|
|
13
|
+
import { getCurrentLeafId } from "./leaf.ts";
|
|
13
14
|
import { getModelKey, mapConcurrentByModel } from "./concurrency.ts";
|
|
14
15
|
import { sumUsage } from "./usage.ts";
|
|
15
16
|
import { runResolvedTask, updateProgressFromRun } from "./lifecycle.ts";
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
fmtDuration,
|
|
19
|
+
formatCompletedTask,
|
|
20
|
+
trunc,
|
|
21
|
+
findTouchedOverlaps,
|
|
22
|
+
formatTouchedOverlapWarning,
|
|
23
|
+
} from "./format.ts";
|
|
17
24
|
import { validateDelegateOperation } from "./schema.ts";
|
|
25
|
+
import { notifyCrossLeafDelivery, syncDelegateStatus } from "./status.ts";
|
|
18
26
|
import { validateTasks, resolveTasks } from "./task-resolution.ts";
|
|
19
27
|
import type {
|
|
20
28
|
AgentConfig,
|
|
@@ -23,6 +31,7 @@ import type {
|
|
|
23
31
|
DelegateDetails,
|
|
24
32
|
DelegateToolCtx,
|
|
25
33
|
DelegateToolResult,
|
|
34
|
+
ParentAgentDefaults,
|
|
26
35
|
ResolvedTask,
|
|
27
36
|
TaskDef,
|
|
28
37
|
TaskProgress,
|
|
@@ -55,9 +64,10 @@ export function validateDelegateOperationResult(
|
|
|
55
64
|
/** Build the initial per-task progress rows from resolved tasks. */
|
|
56
65
|
export function initProgress(resolved: ResolvedTask[]): TaskProgress[] {
|
|
57
66
|
return resolved.map((t, i) => ({
|
|
67
|
+
id: t.id,
|
|
58
68
|
index: i,
|
|
59
69
|
agent: t.agentName,
|
|
60
|
-
task: trunc(t.prompt || t.
|
|
70
|
+
task: trunc(t.prompt || t.sessionAction || "", 50),
|
|
61
71
|
status: "pending" as const,
|
|
62
72
|
durationMs: 0,
|
|
63
73
|
tokens: 0,
|
|
@@ -123,6 +133,7 @@ export interface DelegateDispatchInput {
|
|
|
123
133
|
ctx: DelegateToolCtx;
|
|
124
134
|
agents: Map<string, AgentConfig>;
|
|
125
135
|
parentModelId: string | undefined;
|
|
136
|
+
parentDefaults: ParentAgentDefaults;
|
|
126
137
|
signal: AbortSignal | undefined;
|
|
127
138
|
onUpdate: AgentToolUpdateCallback<DelegateDetails> | undefined;
|
|
128
139
|
}
|
|
@@ -131,13 +142,22 @@ export interface DelegateDispatchInput {
|
|
|
131
142
|
export async function dispatchDelegate(
|
|
132
143
|
input: DelegateDispatchInput,
|
|
133
144
|
): Promise<DelegateToolResult> {
|
|
134
|
-
const {
|
|
145
|
+
const {
|
|
146
|
+
pi,
|
|
147
|
+
params,
|
|
148
|
+
ctx,
|
|
149
|
+
agents,
|
|
150
|
+
parentModelId,
|
|
151
|
+
parentDefaults,
|
|
152
|
+
signal,
|
|
153
|
+
onUpdate,
|
|
154
|
+
} = input;
|
|
135
155
|
const tasks = params.tasks ?? [];
|
|
136
156
|
|
|
137
157
|
const validationError = validateTasks(tasks, agents, parentModelId);
|
|
138
158
|
if (validationError) return validationError;
|
|
139
159
|
|
|
140
|
-
const resolved = resolveTasks(tasks, ctx, agents);
|
|
160
|
+
const resolved = resolveTasks(tasks, ctx, agents, parentDefaults);
|
|
141
161
|
const progress = initProgress(resolved);
|
|
142
162
|
const fire = makeFireUpdater(
|
|
143
163
|
onUpdate,
|
|
@@ -170,6 +190,15 @@ export async function dispatchDelegate(
|
|
|
170
190
|
});
|
|
171
191
|
}
|
|
172
192
|
|
|
193
|
+
/** Deliver a settled ticket and, when leaf affinity downgraded delivery to a
|
|
194
|
+
* non-waking `nextTurn` message, tell the human — otherwise the completion is
|
|
195
|
+
* silent apart from the footer clearing. */
|
|
196
|
+
function finishTicketDelivery(pi: ExtensionAPI, ticket: AsyncTicket): void {
|
|
197
|
+
if (deliverTicketResults(pi, ticket) === "deferred") {
|
|
198
|
+
notifyCrossLeafDelivery(ticket);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
173
202
|
/** Fire-and-forget background execution. Registers an `AsyncTicket`, kicks off
|
|
174
203
|
* the concurrent run, and returns the ticket acknowledgment immediately.
|
|
175
204
|
* Results are delivered via `deliverTicketResults` when all tasks settle. */
|
|
@@ -204,8 +233,15 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
204
233
|
progress: [...progress],
|
|
205
234
|
controller,
|
|
206
235
|
parentModelId,
|
|
236
|
+
// Leaf affinity for delivery: a ticket that outlives a /tree navigation
|
|
237
|
+
// must not wake the agent on the branch the user moved to (issue #30).
|
|
238
|
+
spawnLeafId: getCurrentLeafId(),
|
|
207
239
|
};
|
|
208
240
|
ticketRegistry.set(ticketId, ticket);
|
|
241
|
+
// Footer visibility for the new background work (see status.ts). Uses the
|
|
242
|
+
// ctx cached from the dispatch path in extension.ts — DelegateToolCtx is
|
|
243
|
+
// the intentionally narrowed surface and does not carry `ui`.
|
|
244
|
+
syncDelegateStatus();
|
|
209
245
|
|
|
210
246
|
// Capture values for the closure — do NOT use `signal` from execute()
|
|
211
247
|
// The parent turn's signal dies when execute() returns.
|
|
@@ -221,9 +257,13 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
221
257
|
onProgress: (p, u) => {
|
|
222
258
|
updateProgressFromRun(p, u);
|
|
223
259
|
notifyWaiters(ticket);
|
|
260
|
+
// Live subagent counts in the footer. Deduped by text, so only
|
|
261
|
+
// running/pending count transitions trigger a render.
|
|
262
|
+
syncDelegateStatus();
|
|
224
263
|
},
|
|
225
264
|
onStatusChange: () => {
|
|
226
265
|
notifyWaiters(ticket);
|
|
266
|
+
syncDelegateStatus();
|
|
227
267
|
},
|
|
228
268
|
};
|
|
229
269
|
|
|
@@ -243,6 +283,12 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
243
283
|
ticketSignal,
|
|
244
284
|
)
|
|
245
285
|
.then(() => {
|
|
286
|
+
// A ticket that is already terminally "cancelled" at this point was
|
|
287
|
+
// finalized by cancelTicketForShutdown (user cancels pass through
|
|
288
|
+
// "cancelling" first): the extension runtime is being torn down, the
|
|
289
|
+
// captured `pi` is stale or about to be, and a follow-up message has
|
|
290
|
+
// no live session to land in. Skip delivery entirely.
|
|
291
|
+
if (ticket.status === "cancelled") return;
|
|
246
292
|
// All tasks settled — determine final ticket status.
|
|
247
293
|
// Use progress (set by runResolvedTask) for settled-ness so the
|
|
248
294
|
// status reflects work completion, not just result-array density.
|
|
@@ -262,10 +308,13 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
262
308
|
ticket.completedAt = Date.now();
|
|
263
309
|
syncTicketBusyIndex(ticket);
|
|
264
310
|
}
|
|
265
|
-
|
|
311
|
+
syncDelegateStatus();
|
|
312
|
+
finishTicketDelivery(pi, ticket);
|
|
266
313
|
})
|
|
267
314
|
.catch((err) => {
|
|
268
|
-
// Defense-in-depth — should not happen if individual tasks catch properly
|
|
315
|
+
// Defense-in-depth — should not happen if individual tasks catch properly.
|
|
316
|
+
// Same shutdown guard as the .then path above.
|
|
317
|
+
if (ticket.status === "cancelled") return;
|
|
269
318
|
if (ticket.status === "cancelling") {
|
|
270
319
|
ticket.status = "cancelled";
|
|
271
320
|
} else if (ticket.status === "running") {
|
|
@@ -274,7 +323,8 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
274
323
|
ticket.error = err instanceof Error ? err.message : String(err);
|
|
275
324
|
ticket.completedAt = Date.now();
|
|
276
325
|
syncTicketBusyIndex(ticket);
|
|
277
|
-
|
|
326
|
+
syncDelegateStatus();
|
|
327
|
+
finishTicketDelivery(pi, ticket);
|
|
278
328
|
});
|
|
279
329
|
|
|
280
330
|
return {
|
|
@@ -286,8 +336,8 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
286
336
|
`${resolved.length} task(s) dispatched · ${runningCount + 1}/${getMaxAsyncTickets()} async slots in use`,
|
|
287
337
|
"",
|
|
288
338
|
"Completed task results are available via poll. Final results delivered automatically when all tasks complete.",
|
|
289
|
-
`Check progress: delegate({
|
|
290
|
-
`Cancel if needed: delegate({
|
|
339
|
+
`Check progress: delegate({ ticketAction: "poll", ticket: "${ticketId}" }) — avoid polling in a tight loop`,
|
|
340
|
+
`Cancel if needed: delegate({ ticketAction: "cancel", ticket: "${ticketId}", force: true }) — first call without force is a preview`,
|
|
291
341
|
].join("\n"),
|
|
292
342
|
},
|
|
293
343
|
],
|
|
@@ -346,6 +396,11 @@ export async function dispatchSync(
|
|
|
346
396
|
parts.push(...formatCompletedTask(t, r));
|
|
347
397
|
}
|
|
348
398
|
|
|
399
|
+
const overlapWarning = formatTouchedOverlapWarning(
|
|
400
|
+
findTouchedOverlaps(finalResults),
|
|
401
|
+
);
|
|
402
|
+
if (overlapWarning) parts.push("", overlapWarning);
|
|
403
|
+
|
|
349
404
|
return {
|
|
350
405
|
content: [{ type: "text", text: parts.join("\n\n") }],
|
|
351
406
|
details: {
|
|
@@ -353,6 +408,7 @@ export async function dispatchSync(
|
|
|
353
408
|
results: finalResults,
|
|
354
409
|
progress,
|
|
355
410
|
parentModel: parentModelId,
|
|
411
|
+
overlapWarning: overlapWarning || undefined,
|
|
356
412
|
},
|
|
357
413
|
// Aggregate subagent spend so Pi folds it into the parent's
|
|
358
414
|
// session/footer totals. Sync dispatch only — async results arrive via a
|
package/extension.ts
CHANGED
|
@@ -18,7 +18,18 @@ import {
|
|
|
18
18
|
} from "./dispatch.ts";
|
|
19
19
|
import { renderDelegateCall, renderDelegateResult } from "./render-result.ts";
|
|
20
20
|
import { hostCompatError } from "./host-compat.ts";
|
|
21
|
+
import { invalidateHostDepsCache } from "./host.ts";
|
|
22
|
+
import { recordTreeNavigation, resetLeafTracking } from "./leaf.ts";
|
|
21
23
|
import { closeAllPooledAgents } from "./pool.ts";
|
|
24
|
+
import {
|
|
25
|
+
activeTicketSummary,
|
|
26
|
+
clearDelegateStatusContext,
|
|
27
|
+
describeActiveTickets,
|
|
28
|
+
guardSessionReplacement,
|
|
29
|
+
guardTreeNavigation,
|
|
30
|
+
notifyActiveTicketsOnSettled,
|
|
31
|
+
syncDelegateStatus,
|
|
32
|
+
} from "./status.ts";
|
|
22
33
|
import type { DelegateArguments } from "./types.ts";
|
|
23
34
|
|
|
24
35
|
/** Register the delegate tool and clean up its parent-session resources. */
|
|
@@ -27,7 +38,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
27
38
|
name: "delegate",
|
|
28
39
|
label: "Delegate to Subagents",
|
|
29
40
|
description:
|
|
30
|
-
"Run parallel subagents via tasks:[{prompt}]. Sync returns results; async
|
|
41
|
+
"Run parallel subagents via tasks:[{prompt}]. Sync returns results; async=ticket. tasks:[]=full manual.",
|
|
31
42
|
parameters: delegateArgumentsSchema,
|
|
32
43
|
// Runs before schema validation — recovers stringified `tasks` arrays
|
|
33
44
|
// (a common model mistake that would otherwise be rejected upstream).
|
|
@@ -49,17 +60,21 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
49
60
|
if (operationResult) return operationResult;
|
|
50
61
|
|
|
51
62
|
// ── Poll action ───────────────────────────────────────────────────
|
|
52
|
-
if (params.
|
|
63
|
+
if (params.ticketAction === "poll") {
|
|
53
64
|
return handlePoll(params, ctx);
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
// ── Cancel action ─────────────────────────────────────────────────
|
|
57
|
-
if (params.
|
|
58
|
-
|
|
68
|
+
if (params.ticketAction === "cancel") {
|
|
69
|
+
const result = handleCancel(params);
|
|
70
|
+
// A forced cancel flips the ticket to "cancelling" — keep the
|
|
71
|
+
// footer status in step (deduped; the preview path is a no-op).
|
|
72
|
+
syncDelegateStatus(ctx);
|
|
73
|
+
return result;
|
|
59
74
|
}
|
|
60
75
|
|
|
61
76
|
// ── Wait action ────────────────────────────────────────────────────
|
|
62
|
-
if (params.
|
|
77
|
+
if (params.ticketAction === "wait") {
|
|
63
78
|
return handleWait(params, signal, onUpdate, ctx);
|
|
64
79
|
}
|
|
65
80
|
|
|
@@ -83,12 +98,26 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
83
98
|
};
|
|
84
99
|
}
|
|
85
100
|
|
|
101
|
+
// Cache the full ExtensionContext for the footer-status module before
|
|
102
|
+
// dispatch narrows it to DelegateToolCtx (which has no `ui`). The
|
|
103
|
+
// status push itself is a deduped no-op here; dispatchAsync re-syncs
|
|
104
|
+
// after registering its ticket.
|
|
105
|
+
syncDelegateStatus(ctx);
|
|
106
|
+
|
|
107
|
+
// Keep expensive host deps shared within this dispatch, not indefinitely
|
|
108
|
+
// across dispatches: edits to auth/models/settings/context files must be
|
|
109
|
+
// visible without restarting Pi.
|
|
110
|
+
invalidateHostDepsCache();
|
|
86
111
|
return dispatchDelegate({
|
|
87
112
|
pi,
|
|
88
113
|
params,
|
|
89
114
|
ctx,
|
|
90
115
|
agents,
|
|
91
116
|
parentModelId,
|
|
117
|
+
parentDefaults: {
|
|
118
|
+
thinking: pi.getThinkingLevel(),
|
|
119
|
+
tools: pi.getActiveTools(),
|
|
120
|
+
},
|
|
92
121
|
signal,
|
|
93
122
|
onUpdate,
|
|
94
123
|
});
|
|
@@ -98,11 +127,62 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
98
127
|
renderResult: renderDelegateResult,
|
|
99
128
|
});
|
|
100
129
|
|
|
130
|
+
// ── Background-work visibility (see status.ts) ──────────────────────────
|
|
131
|
+
// The turn settling with live tickets is the "looks idle but isn't" moment:
|
|
132
|
+
// warn once per ticket. The footer status carries it from there.
|
|
133
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
134
|
+
notifyActiveTicketsOnSettled(ctx);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Session replacements are cancellable — confirm before killing live work.
|
|
138
|
+
pi.on("session_before_switch", (_event, ctx) =>
|
|
139
|
+
guardSessionReplacement(ctx, "switch"),
|
|
140
|
+
);
|
|
141
|
+
pi.on("session_before_fork", (_event, ctx) =>
|
|
142
|
+
guardSessionReplacement(ctx, "fork"),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// /tree navigation stays inside the same session: nothing is torn down and
|
|
146
|
+
// live tickets keep running, but their results would land on the branch the
|
|
147
|
+
// user moves to. Ask first, and record the new leaf either way so delivery
|
|
148
|
+
// can detect the mismatch (issue #30). `session_tree` also fires for
|
|
149
|
+
// extension-driven ctx.navigateTree, which never reaches the guard.
|
|
150
|
+
pi.on("session_before_tree", (_event, ctx) => guardTreeNavigation(ctx));
|
|
151
|
+
pi.on("session_tree", (event, ctx) => {
|
|
152
|
+
recordTreeNavigation(event.newLeafId);
|
|
153
|
+
syncDelegateStatus(ctx);
|
|
154
|
+
});
|
|
155
|
+
|
|
101
156
|
// ── Session shutdown: abort tickets and dispose live pooled sessions ──
|
|
102
|
-
pi.on("session_shutdown", async () => {
|
|
157
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
158
|
+
// Quit and /reload kill background work with no cancellable hook, so
|
|
159
|
+
// leave a trace. For quit the TUI is already stopped — stderr lands in
|
|
160
|
+
// the scrollback. For reload the TUI survives — warn in place. Switch
|
|
161
|
+
// and fork already passed the confirm guard above.
|
|
162
|
+
const active = activeTicketSummary();
|
|
163
|
+
if (active.tickets.length) {
|
|
164
|
+
if (event.reason === "quit") {
|
|
165
|
+
console.error(
|
|
166
|
+
`[delegate] pi exited with ${describeActiveTickets(active)} — aborted.`,
|
|
167
|
+
);
|
|
168
|
+
} else if (event.reason === "reload") {
|
|
169
|
+
ctx.ui.notify(
|
|
170
|
+
`[delegate] reload aborted ${describeActiveTickets(active)}`,
|
|
171
|
+
"warning",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
103
175
|
for (const ticket of ticketRegistry.values()) {
|
|
104
176
|
cancelTicketForShutdown(ticket);
|
|
105
177
|
}
|
|
178
|
+
syncDelegateStatus(ctx);
|
|
179
|
+
// The runtime is invalidated right after this handler returns; aborted
|
|
180
|
+
// tickets keep unwinding asynchronously and must find no cached ctx (or
|
|
181
|
+
// captured pi) to touch. See the "cancelled"-at-entry guard in dispatch.
|
|
182
|
+
clearDelegateStatusContext();
|
|
183
|
+
// A replacement session starts on its own leaf; stale tracking would make
|
|
184
|
+
// every ticket look cross-leaf (or, worse, look same-leaf by accident).
|
|
185
|
+
resetLeafTracking();
|
|
106
186
|
// Do NOT clear the ticket registry here — completed tickets are retained
|
|
107
187
|
// until their TTL cleanup. Pooled AgentSessions, however, own listeners
|
|
108
188
|
// and must be disposed before the parent session exits.
|
package/file-tracking.ts
CHANGED
|
@@ -2,9 +2,19 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { ToolActivity } from "./types.ts";
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
*
|
|
7
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Return absolute paths reported as changed by Git in the task cwd.
|
|
7
|
+
*
|
|
8
|
+
* Touched-file tracking is best-effort, not authoritative. On success this
|
|
9
|
+
* returns the set of changed paths (possibly empty for a clean repo). On
|
|
10
|
+
* failure (non-git directory, git unavailable, timeout) it returns `undefined`
|
|
11
|
+
* so callers can tell "git failed" from "clean repo". A failed baseline
|
|
12
|
+
* suppresses git-based attribution in the runner; only explicit edit/write tool
|
|
13
|
+
* activity is captured by {@link extractTouchedFromActivities}.
|
|
14
|
+
*/
|
|
15
|
+
export async function getGitChangedFiles(
|
|
16
|
+
cwd: string,
|
|
17
|
+
): Promise<Set<string> | undefined> {
|
|
8
18
|
try {
|
|
9
19
|
const runGit = (args: string[]) =>
|
|
10
20
|
new Promise<string>((resolve, reject) => {
|
|
@@ -37,11 +47,22 @@ export async function getGitChangedFiles(cwd: string): Promise<Set<string>> {
|
|
|
37
47
|
}
|
|
38
48
|
return files;
|
|
39
49
|
} catch {
|
|
40
|
-
return
|
|
50
|
+
return undefined;
|
|
41
51
|
}
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Extract file paths from explicit edit/write tool calls in the activity log.
|
|
56
|
+
*
|
|
57
|
+
* This is the reliable, activity-based contribution to touched-file tracking.
|
|
58
|
+
* Only completed, successful tool calls are counted: an activity must have a
|
|
59
|
+
* terminal `result` and `result.isError` must be false. Interrupted or in-flight
|
|
60
|
+
* calls (no `result`) and failed calls (`result.isError` true) are skipped,
|
|
61
|
+
* because they did not actually mutate the file. bash mutations are NOT captured
|
|
62
|
+
* here; they are only captured by git diff when the task cwd is inside a git
|
|
63
|
+
* repo with git available. The combined touchedFiles list is therefore a lower
|
|
64
|
+
* bound: absence does not mean a file was unchanged.
|
|
65
|
+
*/
|
|
45
66
|
export function extractTouchedFromActivities(
|
|
46
67
|
activities: ToolActivity[],
|
|
47
68
|
cwd: string,
|
|
@@ -49,6 +70,7 @@ export function extractTouchedFromActivities(
|
|
|
49
70
|
const files = new Set<string>();
|
|
50
71
|
for (const a of activities) {
|
|
51
72
|
if (a.name !== "edit" && a.name !== "write") continue;
|
|
73
|
+
if (!a.result || a.result.isError) continue;
|
|
52
74
|
const raw = a.args?.path ?? a.args?.file_path ?? a.args?.filePath;
|
|
53
75
|
if (typeof raw !== "string" || !raw) continue;
|
|
54
76
|
files.add(path.resolve(cwd, raw));
|
package/format.ts
CHANGED
|
@@ -185,6 +185,11 @@ export function trunc(s: string, n: number): string {
|
|
|
185
185
|
return s.length <= n ? s : s.slice(0, n - 1) + "…";
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/** Render an optional task `id` in a compact, visually distinct form. */
|
|
189
|
+
export function formatTaskId(id: string | undefined): string {
|
|
190
|
+
return id ? ` #${id}` : "";
|
|
191
|
+
}
|
|
192
|
+
|
|
188
193
|
/**
|
|
189
194
|
* Extract a single-line preview of agent output for collapsed final display.
|
|
190
195
|
*
|
|
@@ -340,7 +345,7 @@ function isResumableSessionFile(sessionFile: string): boolean {
|
|
|
340
345
|
* path that didn't exist on disk.
|
|
341
346
|
*
|
|
342
347
|
* Emits:
|
|
343
|
-
* [FAILED|ABORTED: <error> · session: <shortpath> · touched: <files>]
|
|
348
|
+
* [FAILED|ABORTED: <error> · session: <shortpath> · touched (best-effort): <files>]
|
|
344
349
|
* <partial output, when available>
|
|
345
350
|
* → To retry: delegate({ tasks: [{ resumeFrom: "<path>", prompt: "continue" }] })
|
|
346
351
|
*
|
|
@@ -351,12 +356,12 @@ function isResumableSessionFile(sessionFile: string): boolean {
|
|
|
351
356
|
*/
|
|
352
357
|
export function formatFailedTask(r: TaskResult, cwd?: string): string[] {
|
|
353
358
|
const parts: string[] = [];
|
|
354
|
-
const isAbort =
|
|
359
|
+
const isAbort = r.error === "Aborted";
|
|
355
360
|
// Empty string is falsy but not nullish — `||` covers both undefined and "".
|
|
356
361
|
const failParts = [r.error || "unknown error"];
|
|
357
362
|
if (r.sessionFile) failParts.push(`session: ${shortenPath(r.sessionFile)}`);
|
|
358
363
|
const touched = cwd ? relativeTouchedSummary(r.touchedFiles, cwd) : null;
|
|
359
|
-
if (touched) failParts.push(`touched: ${touched}`);
|
|
364
|
+
if (touched) failParts.push(`touched (best-effort): ${touched}`);
|
|
360
365
|
parts.push(`[${isAbort ? "ABORTED" : "FAILED"}: ${failParts.join(" · ")}]`);
|
|
361
366
|
|
|
362
367
|
// Surface partial assistant output even when the task did not complete.
|
|
@@ -398,7 +403,7 @@ export function formatFailedTask(r: TaskResult, cwd?: string): string[] {
|
|
|
398
403
|
* Emits:
|
|
399
404
|
* === <agent>: <truncated prompt> ===
|
|
400
405
|
* [WARNING: <w>] (per warning, if any)
|
|
401
|
-
* [FAILED: ...] / [OK | <duration> | <tokens> tokens · <sessionFile> · touched: <files>]
|
|
406
|
+
* [FAILED: ...] / [OK | <duration> | <tokens> tokens · <sessionFile> · touched (best-effort): <files>]
|
|
402
407
|
*
|
|
403
408
|
* <output> (success body only)
|
|
404
409
|
*
|
|
@@ -410,10 +415,10 @@ export function formatCompletedTask(
|
|
|
410
415
|
result: TaskResult,
|
|
411
416
|
): string[] {
|
|
412
417
|
const parts: string[] = [];
|
|
413
|
-
// `|| task.
|
|
418
|
+
// `|| task.sessionAction` covers action-only tasks (close/list/...) where prompt is
|
|
414
419
|
// empty. Async prompt tasks always set prompt, so this is a no-op there.
|
|
415
420
|
parts.push(
|
|
416
|
-
`=== ${result.agent}: ${trunc(task.prompt || task.
|
|
421
|
+
`=== ${result.agent}${formatTaskId(result.id ?? task.id)}: ${trunc(task.prompt || task.sessionAction || "", 80)} ===`,
|
|
417
422
|
);
|
|
418
423
|
if (task.warnings?.length) {
|
|
419
424
|
for (const w of task.warnings) parts.push(`[WARNING: ${w}]`);
|
|
@@ -426,7 +431,7 @@ export function formatCompletedTask(
|
|
|
426
431
|
];
|
|
427
432
|
if (result.sessionFile) meta.push(shortenPath(result.sessionFile));
|
|
428
433
|
const touched = relativeTouchedSummary(result.touchedFiles, task.cwd);
|
|
429
|
-
if (touched) meta.push(`touched: ${touched}`);
|
|
434
|
+
if (touched) meta.push(`touched (best-effort): ${touched}`);
|
|
430
435
|
parts.push(
|
|
431
436
|
`[${meta.join(" · ")}]\n\n${renderOutputForLLM(result.output, result.agent)}`,
|
|
432
437
|
);
|
|
@@ -504,3 +509,37 @@ export function relativeTouchedSummary(
|
|
|
504
509
|
.filter((f) => f && !f.startsWith(".."));
|
|
505
510
|
return rel.length ? rel.join(", ") : null;
|
|
506
511
|
}
|
|
512
|
+
|
|
513
|
+
/** Find absolute paths directly attributed to more than one task result.
|
|
514
|
+
*
|
|
515
|
+
* Overlap is computed from {@link TaskResult.attributedFiles} (edit/write
|
|
516
|
+
* tool calls), not from {@link TaskResult.touchedFiles}, so concurrent tasks
|
|
517
|
+
* in the same repository do not fabricate false conflicts from shared
|
|
518
|
+
* repository-wide git snapshots. */
|
|
519
|
+
export function findTouchedOverlaps(
|
|
520
|
+
results: readonly { attributedFiles?: string[] }[],
|
|
521
|
+
): string[] {
|
|
522
|
+
const counts = new Map<string, number>();
|
|
523
|
+
for (const r of results) {
|
|
524
|
+
for (const f of r.attributedFiles ?? []) {
|
|
525
|
+
counts.set(f, (counts.get(f) ?? 0) + 1);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return [...counts.entries()]
|
|
529
|
+
.filter(([, count]) => count > 1)
|
|
530
|
+
.map(([file]) => file)
|
|
531
|
+
.sort();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Format a post-dispatch overlap warning, or null when there is no overlap.
|
|
536
|
+
*
|
|
537
|
+
* The warning is deliberately conservative: it only reports paths that two or
|
|
538
|
+
* more tasks claimed to touch. It does NOT claim that disjoint touchedFiles
|
|
539
|
+
* mean there was no conflict, and it does NOT claim filesystem isolation or
|
|
540
|
+
* rollback.
|
|
541
|
+
*/
|
|
542
|
+
export function formatTouchedOverlapWarning(overlaps: string[]): string | null {
|
|
543
|
+
if (!overlaps.length) return null;
|
|
544
|
+
return `WARNING: These tasks reported touching the same file(s): ${overlaps.join(", ")}. Delegate does not isolate or serialize file access and does not roll back completed writes.`;
|
|
545
|
+
}
|
package/host-compat.ts
CHANGED
|
@@ -8,16 +8,27 @@ import type { DelegateToolResult } from "./types.ts";
|
|
|
8
8
|
* Keep this list in sync with the actual import sites (host.ts, sessions.ts,
|
|
9
9
|
* lifecycle.ts, agents.ts).
|
|
10
10
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
type ExportCheck = {
|
|
12
|
+
name: string;
|
|
13
|
+
requiredMember?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Host symbols actually dereferenced by delegate. Static members are listed by
|
|
18
|
+
* `<symbol>.<member>` so we fail fast when a constructor is present but no longer
|
|
19
|
+
* exposes the required factory methods.
|
|
20
|
+
*/
|
|
21
|
+
const REQUIRED_EXPORTS: ExportCheck[] = [
|
|
22
|
+
{ name: "ModelRuntime", requiredMember: "create" },
|
|
23
|
+
{ name: "SettingsManager", requiredMember: "create" },
|
|
24
|
+
{ name: "SessionManager", requiredMember: "create" },
|
|
25
|
+
{ name: "SessionManager", requiredMember: "open" },
|
|
26
|
+
{ name: "DefaultResourceLoader" },
|
|
27
|
+
{ name: "DefaultPackageManager" },
|
|
28
|
+
{ name: "createAgentSession" },
|
|
29
|
+
{ name: "getAgentDir" },
|
|
30
|
+
{ name: "parseFrontmatter" },
|
|
31
|
+
];
|
|
21
32
|
|
|
22
33
|
/**
|
|
23
34
|
* Build a tool result describing any required symbols missing from a pi
|
|
@@ -27,9 +38,33 @@ const REQUIRED_SYMBOLS = [
|
|
|
27
38
|
export function hostCompatResult(
|
|
28
39
|
ns: Record<string, unknown>,
|
|
29
40
|
): DelegateToolResult | null {
|
|
30
|
-
const missing =
|
|
41
|
+
const missing: string[] = [];
|
|
42
|
+
for (const entry of REQUIRED_EXPORTS) {
|
|
43
|
+
const value = ns[entry.name];
|
|
44
|
+
if (value === undefined) {
|
|
45
|
+
missing.push(`'${entry.name}'`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typeof value !== "function") {
|
|
50
|
+
missing.push(`'${entry.name}'`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (entry.requiredMember) {
|
|
55
|
+
const symbolValue = value as unknown as { [key: string]: unknown };
|
|
56
|
+
if (!(entry.requiredMember in symbolValue)) {
|
|
57
|
+
missing.push(`'${entry.name}.${entry.requiredMember}'`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (typeof symbolValue[entry.requiredMember] !== "function") {
|
|
61
|
+
missing.push(`'${entry.name}.${entry.requiredMember}'`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
31
66
|
if (missing.length === 0) return null;
|
|
32
|
-
const listed = missing.
|
|
67
|
+
const listed = missing.join(", ");
|
|
33
68
|
return {
|
|
34
69
|
content: [
|
|
35
70
|
{
|