@bermudi/pi-delegate 0.1.12 → 0.1.14

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 CHANGED
@@ -67,21 +67,17 @@ across `.pi/agents/`, `~/.pi/agent/agents/`, `~/.agents/`, `.claude/agents/`,
67
67
  workspace — `scout` stays read-only and `reviewer` stays scratch unless the
68
68
  file explicitly sets `tools` or `workspace`. Fresh built-ins inherit the
69
69
  parent's exact model object and thinking level; an explicit `model`/`thinking`
70
- in the Markdown file replaces that inheritance. Task fields always win, and for
71
- `scout`/`coder`/`reviewer` overrides in `~/.pi/agent/delegate.json`
72
- (`agentOverrides` / `agentOverridesByParentModel`) win over the
73
- Markdown file, while `default` ignores overrides and uses only an explicit
74
- Markdown `model`/`thinking` when present. `delegate.json` is the permanent
75
- config file (user scope, global), and edits apply from the next delegate call.
76
-
77
- For the v0.1.12 migration release only, legacy user and nearest-project
78
- `settings.json` `delegate.agentOverrides` /
79
- `delegate.agentOverridesByParentModel` still supply `model` and `thinking`
80
- when a modern value is absent. Modern `delegate.json` wins field-by-field.
81
- Legacy `tools` is never honored because a project file must not restore shell
82
- capability. This bridge is removed in v0.1.13. Project-local replacements are
83
- `.pi/agents/*.md` profiles or explicit task `model`/`thinking` fields; there
84
- will be no new project-level delegate config file.
70
+ in the Markdown file replaces that inheritance. Task fields always win. For
71
+ each unset `model`, `thinking`, or `tools` field on
72
+ `scout`/`coder`/`reviewer`, an exact-parent-model override in
73
+ `agentOverridesByParentModel` wins over the unconditional `agentOverrides`,
74
+ which wins over explicit Markdown frontmatter. `default` is the exception: it
75
+ ignores both `delegate.json` override maps, but task fields still win and
76
+ explicit `default.md` fields still override inherited parent values.
77
+ `~/.pi/agent/delegate.json` is the only delegate config file (user scope,
78
+ global), and edits apply from the next delegate call. Project-local
79
+ configuration belongs in `.pi/agents/*.md` profiles or explicit task fields;
80
+ Delegate ignores legacy `delegate` fields in project `.pi/settings.json`.
85
81
 
86
82
  ### Disposable scratch workspace
87
83
 
@@ -217,8 +213,9 @@ over an installed extension.
217
213
  model, tools, and thinking level; the Markdown body is its system prompt. A
218
214
  same-named file for a built-in (`default`/`scout`/`coder`/`reviewer`)
219
215
  overrides that built-in; a prompt-only override keeps the built-in's tools
220
- and workspace, and an explicit `model`/`thinking` replaces parent inheritance
221
- (for `default` settings are ignored, for others settings win over the file).
216
+ and workspace, and an explicit `model`/`thinking` replaces parent inheritance.
217
+ For `default`, the `delegate.json` override maps are ignored (task fields still
218
+ win); for the other built-ins, those maps win field-by-field over frontmatter.
222
219
  - **Ad-hoc subagent** — A subagent created from inline task fields instead of a
223
220
  named Markdown agent profile. In current output this is labeled `ad-hoc`.
224
221
  - **Inline task** — The task object itself when its configuration is supplied
package/delegate.ts CHANGED
@@ -145,14 +145,6 @@ export {
145
145
  resolveModelRequest,
146
146
  findAvailableAlternative,
147
147
  } from "./model.ts";
148
- export {
149
- readDelegateSettingsFile,
150
- findLegacyDelegateSettings,
151
- warnLegacyDelegateSettingsMoved,
152
- loadDelegateSettings,
153
- clearDelegateSettingsCache,
154
- } from "./settings.ts";
155
- export type { DelegateSettings } from "./settings.ts";
156
148
  export { resolveCwd, extractOutput, extractUsage } from "./utils.ts";
157
149
  export {
158
150
  decideSpill,
package/dispatch.ts CHANGED
@@ -37,6 +37,8 @@ import {
37
37
  } from "./shared-write-safety.ts";
38
38
  import type { CallSpan } from "./telemetry.ts";
39
39
  import { prepareIsolatedBatch } from "./isolated-workspace.ts";
40
+ import { sanitizeTerminalLine } from "./utils.ts";
41
+ import { quarantinedTasks } from "./session-quarantine.ts";
40
42
  import type {
41
43
  AgentConfig,
42
44
  AsyncTicket,
@@ -110,7 +112,7 @@ export function initProgress(resolved: ResolvedTask[]): TaskProgress[] {
110
112
  id: t.id,
111
113
  index: i,
112
114
  agent: t.agentName,
113
- task: trunc(t.prompt || t.sessionAction || "", 50),
115
+ task: trunc(sanitizeTerminalLine(t.prompt || t.sessionAction || ""), 50),
114
116
  status: "pending" as const,
115
117
  durationMs: 0,
116
118
  tokens: 0,
@@ -366,6 +368,12 @@ export async function dispatchDelegate(
366
368
  );
367
369
  }
368
370
  }
371
+ for (const quarantined of quarantinedTasks()) {
372
+ activeResolved.push(asAdmissionWriter(quarantined));
373
+ references.push(
374
+ `quarantined ${quarantined.agentName}${quarantined.id ? ` task #${quarantined.id}` : " task"}`,
375
+ );
376
+ }
369
377
 
370
378
  const incomingCount = resolved.length;
371
379
  const conflicts = (
@@ -646,6 +654,11 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
646
654
  ticketSignal,
647
655
  )
648
656
  .then(() => {
657
+ // mapConcurrentByModel is the worker-settled barrier. Publish that before
658
+ // terminal formatting/delivery so the final spill projection is safely
659
+ // memoized; shutdown may already have formatted an intentionally uncached
660
+ // partial snapshot while this flag was false.
661
+ ticket.workersSettled = true;
649
662
  // Shutdown marks the ticket terminal before cooperative worker aborts
650
663
  // have finished. Still write one final aggregate after every result has
651
664
  // landed; the immediate shutdown snapshot may have missed late usage.
@@ -673,6 +686,8 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
673
686
  finishLiveSettlement(ticket);
674
687
  })
675
688
  .catch((err) => {
689
+ // mapConcurrentByModel waits for all sibling workers before rejecting.
690
+ ticket.workersSettled = true;
676
691
  // Defense-in-depth — should not happen if individual tasks catch properly.
677
692
  // Even an unexpected worker rejection must leave the shutdown aggregate
678
693
  // with every result that did settle, without touching the stale UI.
@@ -713,6 +728,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
713
728
  parentModel: parentModelId,
714
729
  ticketId,
715
730
  status: ticket.status,
731
+ elapsedMs: Date.now() - ticket.created,
716
732
  dispatchWarning,
717
733
  },
718
734
  };
@@ -859,12 +875,18 @@ export async function dispatchSync(
859
875
  results: finalResults,
860
876
  progress,
861
877
  parentModel: parentModelId,
878
+ elapsedMs: elapsedTotal,
862
879
  overlapWarning: overlapWarning || undefined,
863
880
  dispatchWarning,
864
881
  },
865
882
  // Aggregate subagent spend so Pi folds it into the parent's
866
883
  // session/footer totals. Sync dispatch only — async results arrive via a
867
884
  // follow-up message that has no usage slot (see DelegateToolResult).
868
- usage: sumUsage(finalResults.map((r) => r.usage)),
885
+ // A quarantined task can continue spending after this terminal snapshot.
886
+ // Omitting top-level usage avoids presenting a lower bound as final nested
887
+ // accounting to Pi; per-task results retain the observed lower bound.
888
+ ...(finalResults.some((r) => r.incomplete)
889
+ ? {}
890
+ : { usage: sumUsage(finalResults.map((r) => r.usage)) }),
869
891
  };
870
892
  }
package/extension.ts CHANGED
@@ -16,7 +16,11 @@ import {
16
16
  dispatchDelegate,
17
17
  validateDelegateOperationResult,
18
18
  } from "./dispatch.ts";
19
- import { renderDelegateCall, renderDelegateResult } from "./render-result.ts";
19
+ import {
20
+ renderAsyncDelegateMessage,
21
+ renderDelegateCall,
22
+ renderDelegateResult,
23
+ } from "./render-result.ts";
20
24
  import { hostCompatError } from "./host-compat.ts";
21
25
  import { invalidateHostDepsCache } from "./host.ts";
22
26
  import { registerProviderExtensionNotifier } from "./provider-extensions.ts";
@@ -24,7 +28,6 @@ import { recordTreeNavigation, resetLeafTracking } from "./leaf.ts";
24
28
  import { closeAllPooledAgents } from "./pool.ts";
25
29
  import { reconfigureGlobalConcurrency } from "./concurrency.ts";
26
30
  import { reloadDelegateConfig, getMaxConcurrent } from "./config.ts";
27
- import { warnLegacyDelegateSettingsMoved } from "./settings.ts";
28
31
  import {
29
32
  activeTicketSummary,
30
33
  clearDelegateStatusContext,
@@ -41,7 +44,7 @@ import {
41
44
  prepareTelemetryForSession,
42
45
  sealTelemetryWrites,
43
46
  } from "./telemetry.ts";
44
- import type { DelegateArguments } from "./types.ts";
47
+ import type { DelegateArguments, DelegateDetails } from "./types.ts";
45
48
 
46
49
  const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS = 10_000;
47
50
  let shutdownDrainTimeoutMs = DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
@@ -115,6 +118,14 @@ export default function delegateExtension(pi: ExtensionAPI): void {
115
118
  // workers from the old runtime remain blocked from reopening it.
116
119
  prepareTelemetryForSession();
117
120
 
121
+ // Async completion arrives as a custom message after the original tool call
122
+ // has returned. Give it the same compact/expanded UI as sync results while
123
+ // leaving the full message intact for model context.
124
+ pi.registerMessageRenderer<DelegateDetails>(
125
+ "async_delegate_result",
126
+ renderAsyncDelegateMessage,
127
+ );
128
+
118
129
  pi.registerTool({
119
130
  name: "delegate",
120
131
  label: "Delegate to Subagents",
@@ -131,9 +142,6 @@ export default function delegateExtension(pi: ExtensionAPI): void {
131
142
  // the global concurrency cap is reconfigured so hot-reloaded maxConcurrent
132
143
  // takes effect for subsequent acquisitions. A parse/read error keeps the
133
144
  // previous snapshot and warns instead of falling back to defaults.
134
- warnLegacyDelegateSettingsMoved(ctx.cwd, (message) =>
135
- ctx.ui.notify(message, "warning"),
136
- );
137
145
  reloadDelegateConfig();
138
146
  reconfigureGlobalConcurrency(getMaxConcurrent());
139
147
 
package/file-tracking.ts CHANGED
@@ -1,6 +1,253 @@
1
1
  import { execFile } from "node:child_process";
2
+ import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
- import type { ToolActivity } from "./types.ts";
4
+ import type {
5
+ FileAttribution,
6
+ FileAttributionPathSignature,
7
+ ToolActivity,
8
+ } from "./types.ts";
9
+
10
+ function activityPath(
11
+ activity: Pick<ToolActivity, "args">,
12
+ cwd: string,
13
+ ): string | undefined {
14
+ const raw =
15
+ activity.args?.path ?? activity.args?.file_path ?? activity.args?.filePath;
16
+ return typeof raw === "string" && raw ? path.resolve(cwd, raw) : undefined;
17
+ }
18
+
19
+ function errnoOf(error: unknown): string {
20
+ return error instanceof Error && "code" in error
21
+ ? String((error as NodeJS.ErrnoException).code ?? "UNKNOWN")
22
+ : "UNKNOWN";
23
+ }
24
+
25
+ function safePathForLog(candidate: string): string {
26
+ const limit = 1_024;
27
+ const sanitized =
28
+ candidate.length > limit
29
+ ? `${candidate.slice(0, limit)}…[truncated ${candidate.length - limit} chars]`
30
+ : candidate;
31
+ // JSON escaping keeps model-supplied control characters out of the terminal.
32
+ return JSON.stringify(sanitized)
33
+ .replace(/\u2028/g, "\\u2028")
34
+ .replace(/\u2029/g, "\\u2029");
35
+ }
36
+
37
+ function logCanonicalizationFailure(candidate: string, error: unknown): void {
38
+ console.error(
39
+ `[delegate] could not canonicalize edit/write target ${safePathForLog(candidate)} (errno=${errnoOf(error)}); retaining uncertain lexical attribution`,
40
+ );
41
+ }
42
+
43
+ function statKind(stat: fs.Stats): FileAttributionPathSignature["kind"] {
44
+ if (stat.isDirectory()) return "directory";
45
+ if (stat.isSymbolicLink()) return "symlink";
46
+ return "other";
47
+ }
48
+
49
+ function signatureOf(
50
+ candidate: string,
51
+ stat: fs.Stats,
52
+ symlinkTarget?: string,
53
+ ): FileAttributionPathSignature {
54
+ return {
55
+ path: candidate,
56
+ dev: String(stat.dev),
57
+ ino: String(stat.ino),
58
+ birthtimeMs: stat.birthtimeMs,
59
+ kind: statKind(stat),
60
+ ...(symlinkTarget === undefined ? {} : { symlinkTarget }),
61
+ };
62
+ }
63
+
64
+ function sameSignature(
65
+ left: FileAttributionPathSignature,
66
+ right: FileAttributionPathSignature,
67
+ ): boolean {
68
+ return (
69
+ left.path === right.path &&
70
+ left.dev === right.dev &&
71
+ left.ino === right.ino &&
72
+ left.birthtimeMs === right.birthtimeMs &&
73
+ left.kind === right.kind &&
74
+ left.symlinkTarget === right.symlinkTarget
75
+ );
76
+ }
77
+
78
+ interface PhysicalSnapshot {
79
+ path?: string;
80
+ uncertain: boolean;
81
+ signatures: FileAttributionPathSignature[];
82
+ }
83
+
84
+ function pathParts(value: string): { root: string; parts: string[] } {
85
+ const absolute = path.resolve(value);
86
+ const root = path.parse(absolute).root;
87
+ return {
88
+ root,
89
+ parts: absolute.slice(root.length).split(path.sep).filter(Boolean),
90
+ };
91
+ }
92
+
93
+ /** Resolve one component at a time so the physical snapshot carries the exact
94
+ * ancestor/symlink identities that justified it. */
95
+ function resolvePhysicalSnapshot(
96
+ value: string,
97
+ logCandidate: string,
98
+ ): PhysicalSnapshot {
99
+ let { root, parts } = pathParts(value);
100
+ let cursor = root;
101
+ let uncertain = false;
102
+ const signatures: FileAttributionPathSignature[] = [];
103
+ const followed = new Set<string>();
104
+ let symlinkExpansions = 0;
105
+
106
+ while (parts.length) {
107
+ const component = parts.shift()!;
108
+ const candidate = path.join(cursor, component);
109
+ let stat: fs.Stats;
110
+ try {
111
+ stat = fs.lstatSync(candidate);
112
+ } catch (error) {
113
+ const code = errnoOf(error);
114
+ if (code === "ENOENT" || code === "ENOTDIR") {
115
+ // A missing or unresolved component can be created or replaced before
116
+ // the tool opens it. Without an identity to guard that transition, the
117
+ // projected target must remain conservative even on a purely lexical
118
+ // path with no symlink seen yet.
119
+ return {
120
+ path: path.resolve(candidate, ...parts),
121
+ uncertain: true,
122
+ signatures,
123
+ };
124
+ }
125
+ logCanonicalizationFailure(logCandidate, error);
126
+ return { uncertain: true, signatures };
127
+ }
128
+
129
+ if (stat.isSymbolicLink()) {
130
+ symlinkExpansions++;
131
+ if (symlinkExpansions > 256) {
132
+ logCanonicalizationFailure(
133
+ logCandidate,
134
+ Object.assign(new Error("too many symbolic links"), {
135
+ code: "ELOOP",
136
+ }),
137
+ );
138
+ return { uncertain: true, signatures };
139
+ }
140
+ let target: string;
141
+ try {
142
+ target = fs.readlinkSync(candidate);
143
+ const afterRead = fs.lstatSync(candidate);
144
+ const before = signatureOf(candidate, stat, target);
145
+ const after = signatureOf(candidate, afterRead, target);
146
+ signatures.push(before);
147
+ if (!sameSignature(before, after) || !afterRead.isSymbolicLink()) {
148
+ uncertain = true;
149
+ }
150
+ } catch (error) {
151
+ const code = errnoOf(error);
152
+ if (code !== "ENOENT" && code !== "ENOTDIR" && code !== "EINVAL") {
153
+ logCanonicalizationFailure(logCandidate, error);
154
+ }
155
+ return { path: candidate, uncertain: true, signatures };
156
+ }
157
+ const cycleKey = `${candidate}\0${target}`;
158
+ if (followed.has(cycleKey)) {
159
+ logCanonicalizationFailure(
160
+ logCandidate,
161
+ Object.assign(new Error("symbolic link cycle"), { code: "ELOOP" }),
162
+ );
163
+ return { uncertain: true, signatures };
164
+ }
165
+ followed.add(cycleKey);
166
+ const targetPath = path.isAbsolute(target)
167
+ ? target
168
+ : path.resolve(path.dirname(candidate), target);
169
+ const targetParts = pathParts(targetPath);
170
+ root = targetParts.root;
171
+ cursor = root;
172
+ parts = [...targetParts.parts, ...parts];
173
+ continue;
174
+ }
175
+
176
+ // Every existing component is an identity guard, including the leaf. An
177
+ // in-place edit preserves the leaf identity; replacement or creation does
178
+ // not, and may redirect the write through a newly installed symlink.
179
+ signatures.push(signatureOf(candidate, stat));
180
+ cursor = candidate;
181
+ }
182
+
183
+ return { path: cursor, uncertain, signatures };
184
+ }
185
+
186
+ /**
187
+ * Capture edit/write attribution before tool work begins.
188
+ *
189
+ * The target is resolved component by component while recording the identity
190
+ * of every existing component, including the leaf, and the text of every
191
+ * followed symlink. Missing or dangling components retain their projected path
192
+ * but are uncertain because creation can redirect the eventual tool open.
193
+ * Non-ENOENT/ENOTDIR failures are actionable and logged with the lexical
194
+ * candidate and errno; callers retain that uncertain lexical evidence.
195
+ */
196
+ export function snapshotPhysicalToolTarget(
197
+ activity: Pick<ToolActivity, "args" | "name">,
198
+ cwd: string,
199
+ ): FileAttribution | undefined {
200
+ if (activity.name !== "edit" && activity.name !== "write") return undefined;
201
+ const candidate = activityPath(activity, cwd);
202
+ if (!candidate) return undefined;
203
+
204
+ const snapshot = resolvePhysicalSnapshot(candidate, candidate);
205
+ const attribution: FileAttribution = {
206
+ lexicalPath: candidate,
207
+ preExecutionPhysicalPath: snapshot.path,
208
+ preExecutionPathSignatures: snapshot.signatures,
209
+ provenance: activity.name,
210
+ uncertain: snapshot.uncertain,
211
+ };
212
+ // Close lstat/readlink and component-walk races before execution starts. A
213
+ // later terminal revalidation performs the same check after execution.
214
+ return revalidateFileAttribution(attribution);
215
+ }
216
+
217
+ /** Revalidate the identities used by a pre-execution physical snapshot without
218
+ * re-resolving that snapshot through the mutable final tree. */
219
+ export function revalidateFileAttribution(
220
+ attribution: FileAttribution,
221
+ ): FileAttribution {
222
+ if (
223
+ attribution.uncertain ||
224
+ !attribution.preExecutionPathSignatures?.length
225
+ ) {
226
+ return attribution;
227
+ }
228
+ for (const expected of attribution.preExecutionPathSignatures) {
229
+ try {
230
+ const stat = fs.lstatSync(expected.path);
231
+ let target: string | undefined;
232
+ if (stat.isSymbolicLink()) target = fs.readlinkSync(expected.path);
233
+ if (!sameSignature(expected, signatureOf(expected.path, stat, target))) {
234
+ return { ...attribution, uncertain: true };
235
+ }
236
+ } catch (error) {
237
+ const code = errnoOf(error);
238
+ if (code !== "ENOENT" && code !== "ENOTDIR" && code !== "EINVAL") {
239
+ logCanonicalizationFailure(attribution.lexicalPath, error);
240
+ }
241
+ return { ...attribution, uncertain: true };
242
+ }
243
+ }
244
+ return attribution;
245
+ }
246
+
247
+ /** Stable string projection used for overlap reporting. */
248
+ export function projectedAttributionPath(attribution: FileAttribution): string {
249
+ return attribution.preExecutionPhysicalPath ?? attribution.lexicalPath;
250
+ }
4
251
 
5
252
  /**
6
253
  * Return absolute paths reported as changed by Git in the task cwd.
@@ -54,26 +301,50 @@ export async function getGitChangedFiles(
54
301
  /**
55
302
  * Extract file paths from explicit edit/write tool calls in the activity log.
56
303
  *
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.
304
+ * This is the conservative, activity-based contribution to touched-file
305
+ * tracking. Every started edit/write call with a usable path is counted:
306
+ * write/edit tools can mutate before returning an error, and cancellation can
307
+ * interrupt them after mutation but before a terminal result is emitted.
308
+ * bash mutations are NOT captured here; they are only captured by git diff when
309
+ * the task cwd is inside a git repo with git available. The combined
310
+ * touchedFiles list is therefore still a lower bound: absence does not mean a
311
+ * file was unchanged.
65
312
  */
66
313
  export function extractTouchedFromActivities(
67
314
  activities: ToolActivity[],
68
315
  cwd: string,
69
316
  ): string[] {
70
317
  const files = new Set<string>();
71
- for (const a of activities) {
72
- if (a.name !== "edit" && a.name !== "write") continue;
73
- if (!a.result || a.result.isError) continue;
74
- const raw = a.args?.path ?? a.args?.file_path ?? a.args?.filePath;
75
- if (typeof raw !== "string" || !raw) continue;
76
- files.add(path.resolve(cwd, raw));
318
+ for (const activity of activities) {
319
+ if (activity.name !== "edit" && activity.name !== "write") continue;
320
+ const lexicalPath = activityPath(activity, cwd);
321
+ if (lexicalPath) files.add(lexicalPath);
77
322
  }
78
323
  return [...files];
79
324
  }
325
+
326
+ /** Provenance-bearing edit/write evidence attributable to this run. */
327
+ export function extractAttributedFromActivities(
328
+ activities: ToolActivity[],
329
+ cwd: string,
330
+ ): FileAttribution[] {
331
+ const files = new Map<string, FileAttribution>();
332
+ for (const activity of activities) {
333
+ if (activity.name !== "edit" && activity.name !== "write") continue;
334
+ const lexicalPath = activityPath(activity, cwd);
335
+ const attribution =
336
+ activity.fileAttribution ??
337
+ (lexicalPath
338
+ ? {
339
+ lexicalPath,
340
+ provenance: activity.name,
341
+ uncertain: true,
342
+ }
343
+ : undefined);
344
+ if (!attribution) continue;
345
+ const revalidated = revalidateFileAttribution(attribution);
346
+ const key = `${revalidated.provenance}\0${revalidated.lexicalPath}\0${revalidated.preExecutionPhysicalPath ?? ""}\0${revalidated.uncertain}`;
347
+ files.set(key, revalidated);
348
+ }
349
+ return [...files.values()];
350
+ }