@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/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 { fmtDuration, formatCompletedTask, trunc } from "./format.ts";
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.action || "", 50),
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 { pi, params, ctx, agents, parentModelId, signal, onUpdate } = input;
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
- deliverTicketResults(pi, ticket);
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
- deliverTicketResults(pi, ticket);
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({ 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`,
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 returns a ticket.",
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.action === "poll") {
63
+ if (params.ticketAction === "poll") {
53
64
  return handlePoll(params, ctx);
54
65
  }
55
66
 
56
67
  // ── Cancel action ─────────────────────────────────────────────────
57
- if (params.action === "cancel") {
58
- return handleCancel(params);
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.action === "wait") {
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
- /** 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>> {
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 new Set();
50
+ return undefined;
41
51
  }
42
52
  }
43
53
 
44
- /** Extract file paths mutated by edit/write from the activity log. */
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 = /abort/i.test(r.error ?? "");
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.action` covers action-only tasks (close/list/...) where prompt is
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.action || "", 80)} ===`,
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
- const REQUIRED_SYMBOLS = [
12
- "ModelRuntime",
13
- "SettingsManager",
14
- "SessionManager",
15
- "DefaultResourceLoader",
16
- "DefaultPackageManager",
17
- "createAgentSession",
18
- "getAgentDir",
19
- "parseFrontmatter",
20
- ] as const;
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 = REQUIRED_SYMBOLS.filter((name) => ns[name] === undefined);
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.map((m) => `'${m}'`).join(", ");
67
+ const listed = missing.join(", ");
33
68
  return {
34
69
  content: [
35
70
  {