@bermudi/pi-delegate 0.1.13 → 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.
@@ -0,0 +1,266 @@
1
+ import { existsSync } from "node:fs";
2
+ import type { ResolvedTask } from "./types.ts";
3
+ import { canonicalPath } from "./trusted-paths.ts";
4
+ import { resolveCwd } from "./utils.ts";
5
+
6
+ /**
7
+ * Internal, non-serialized hand-off for a runner that returned before its
8
+ * AgentSession was proven quiescent. Symbol properties survive object spreads
9
+ * inside delegate, but JSON/tool-result serialization ignores them.
10
+ */
11
+ const SESSION_QUARANTINE = Symbol("delegate.sessionQuarantine");
12
+
13
+ export interface SessionQuarantine {
14
+ /** Resolves only after background termination and quiescence checks confirm
15
+ * that the abandoned AgentSession can no longer mutate its workspace. */
16
+ safe: Promise<void>;
17
+ }
18
+
19
+ export type WithSessionQuarantine = {
20
+ [SESSION_QUARANTINE]?: SessionQuarantine;
21
+ };
22
+
23
+ interface QuarantineReservation {
24
+ task: ResolvedTask;
25
+ quarantine: SessionQuarantine;
26
+ /** Physical transcript identity used to acquire the abandoned session.
27
+ * This is passed through from acquisition and must never be re-resolved at
28
+ * publication time: a symlink may have changed while the task was running. */
29
+ resumeFromIdentity?: string;
30
+ }
31
+
32
+ /** Identity snapshot supplied while a resume transcript lock is held. */
33
+ export interface LockedResumeTranscript {
34
+ /** Stable absolute spelling used even when the transcript does not exist. */
35
+ lexicalPath: string;
36
+ /** Physical target captured once for this acquisition. */
37
+ canonicalPath?: string;
38
+ /** The captured physical target still existed after this call acquired all
39
+ * applicable lexical/physical locks. */
40
+ exists: boolean;
41
+ }
42
+
43
+ /** Process-local admission reservations for abandoned tasks. A reservation is
44
+ * removed only when its safety proof fulfills; rejection is not evidence that
45
+ * the session stopped mutating. */
46
+ const quarantineReservations = new Map<symbol, QuarantineReservation>();
47
+
48
+ /** Per-transcript locks close the validation-to-dispatch race for resume-only
49
+ * tasks (which have no sessionId and therefore do not use the pool lock). */
50
+ const resumeFromLocks = new Map<string, Promise<void>>();
51
+
52
+ /** Resolve an existing resume transcript to its current physical identity.
53
+ * Admission-time callers use this as a best-effort early rejection only; the
54
+ * lifecycle lock captures and carries the authoritative acquisition identity. */
55
+ export function canonicalResumeFromIdentity(
56
+ resumeFrom: string | undefined,
57
+ ): string | undefined {
58
+ return resumeFrom ? canonicalPath(resolveCwd(resumeFrom)) : undefined;
59
+ }
60
+
61
+ function logObserverFailure(description: string, error: unknown): void {
62
+ try {
63
+ console.error(`[delegate] ${description}`, error);
64
+ } catch {
65
+ // A cleanup observer must never turn a handled background failure into an
66
+ // unhandled rejection, even when a test or host replaces console.error.
67
+ }
68
+ }
69
+
70
+ /** Observe a safety proof without creating a dangling rejecting promise.
71
+ * Callback failures are surfaced and swallowed because cleanup must not become
72
+ * a process-level unhandled rejection. */
73
+ export function observeQuarantineSafety(
74
+ quarantine: SessionQuarantine,
75
+ description: string,
76
+ onSafe: () => void | Promise<void>,
77
+ onFailure?: (error: unknown) => void | Promise<void>,
78
+ ): void {
79
+ const invoke = async (
80
+ callback: (() => void | Promise<void>) | undefined,
81
+ ): Promise<void> => {
82
+ if (!callback) return;
83
+ try {
84
+ await callback();
85
+ } catch (error) {
86
+ logObserverFailure(`${description} callback failed`, error);
87
+ }
88
+ };
89
+
90
+ // Both branches fulfill. The final catch is defensive against an unusual
91
+ // Promise implementation or callback plumbing regression.
92
+ void quarantine.safe
93
+ .then(
94
+ () => invoke(onSafe),
95
+ (error) =>
96
+ invoke(
97
+ onFailure
98
+ ? () => onFailure(error)
99
+ : () =>
100
+ logObserverFailure(`${description} safety proof failed`, error),
101
+ ),
102
+ )
103
+ .catch((error) =>
104
+ logObserverFailure(`${description} observer failed`, error),
105
+ );
106
+ }
107
+
108
+ /** Reserve the task's shared-write capability and logical session identities
109
+ * until safe. `resumeFromIdentity` must be the exact canonical path passed to
110
+ * acquisition; publication deliberately performs no mutable-path lookup. */
111
+ export function reserveSessionQuarantine(
112
+ task: ResolvedTask,
113
+ quarantine: SessionQuarantine,
114
+ resumeFromIdentity: string | undefined,
115
+ ): void {
116
+ const key = Symbol("quarantined-task");
117
+ quarantineReservations.set(key, {
118
+ task,
119
+ quarantine,
120
+ resumeFromIdentity,
121
+ });
122
+ observeQuarantineSafety(
123
+ quarantine,
124
+ "quarantine admission reservation release",
125
+ () => {
126
+ quarantineReservations.delete(key);
127
+ },
128
+ // Fail closed: a rejected proof retains the reservation indefinitely.
129
+ () => {},
130
+ );
131
+ }
132
+
133
+ /** Snapshot abandoned tasks that admission must treat as still active. */
134
+ export function quarantinedTasks(): ResolvedTask[] {
135
+ return [...quarantineReservations.values()].map(({ task }) => task);
136
+ }
137
+
138
+ export function isSessionIdQuarantined(sessionId: string): boolean {
139
+ for (const { task } of quarantineReservations.values()) {
140
+ if (task.sessionId === sessionId) return true;
141
+ }
142
+ return false;
143
+ }
144
+
145
+ export function isResumeFromIdentityQuarantined(identity: string): boolean {
146
+ for (const reservation of quarantineReservations.values()) {
147
+ if (reservation.resumeFromIdentity === identity) return true;
148
+ }
149
+ return false;
150
+ }
151
+
152
+ export function isResumeFromQuarantined(resumeFrom: string): boolean {
153
+ const identity = canonicalResumeFromIdentity(resumeFrom);
154
+ return identity ? isResumeFromIdentityQuarantined(identity) : false;
155
+ }
156
+
157
+ async function withResumeLockKeys<T>(
158
+ identities: string[],
159
+ fn: () => Promise<T>,
160
+ ): Promise<T> {
161
+ const keys = [...new Set(identities)].sort();
162
+ const previous = keys
163
+ .map((identity) => resumeFromLocks.get(identity))
164
+ .filter((pending): pending is Promise<void> => pending !== undefined);
165
+ let release!: () => void;
166
+ const current = new Promise<void>((resolve) => {
167
+ release = resolve;
168
+ });
169
+ // Publish every claim before waiting. A later caller sharing any lexical or
170
+ // physical identity therefore queues behind this owner rather than racing a
171
+ // different key in the set.
172
+ for (const identity of keys) resumeFromLocks.set(identity, current);
173
+ try {
174
+ await Promise.all(previous);
175
+ return await fn();
176
+ } finally {
177
+ release();
178
+ for (const identity of keys) {
179
+ if (resumeFromLocks.get(identity) === current) {
180
+ resumeFromLocks.delete(identity);
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ /** Serialize transcript acquisition without trusting a mutable path twice.
187
+ *
188
+ * Every call claims its stable absolute lexical spelling, so missing and
189
+ * unresolvable paths never fail open. Existing targets additionally claim the
190
+ * pre-wait canonical snapshot, which serializes symlink aliases and pins a
191
+ * queued call to the target it named when it entered. If a formerly missing
192
+ * path becomes resolvable while waiting, its new physical key is claimed
193
+ * before the callback runs. Existence is then checked under all applicable
194
+ * locks and the same canonical path is passed through to acquisition. */
195
+ export async function withResumeTranscriptLock<T>(
196
+ resumeFrom: string | undefined,
197
+ fn: (transcript: LockedResumeTranscript | undefined) => Promise<T>,
198
+ ): Promise<T> {
199
+ if (!resumeFrom) return fn(undefined);
200
+
201
+ const lexicalPath = resolveCwd(resumeFrom);
202
+ const preWaitCanonicalPath = canonicalPath(lexicalPath);
203
+ const initialKeys = preWaitCanonicalPath
204
+ ? [lexicalPath, preWaitCanonicalPath]
205
+ : [lexicalPath];
206
+
207
+ return withResumeLockKeys(initialKeys, async () => {
208
+ // Preserve an existing target captured before waiting. Only retry
209
+ // canonicalization when there was no target to capture at call entry.
210
+ const canonicalPathOnce =
211
+ preWaitCanonicalPath ?? canonicalPath(lexicalPath);
212
+ const invoke = async (): Promise<T> =>
213
+ fn({
214
+ lexicalPath,
215
+ canonicalPath: canonicalPathOnce,
216
+ exists:
217
+ canonicalPathOnce !== undefined && existsSync(canonicalPathOnce),
218
+ });
219
+
220
+ // A missing/broken symlink can become a physical alias while queued. Claim
221
+ // that identity before validation/acquisition so it cannot race an owner
222
+ // that entered through the physical path or another alias.
223
+ if (canonicalPathOnce && !initialKeys.includes(canonicalPathOnce)) {
224
+ return withResumeLockKeys([canonicalPathOnce], invoke);
225
+ }
226
+ return invoke();
227
+ });
228
+ }
229
+
230
+ /** @internal Test-only reset. Existing safety observers are token-scoped, so a
231
+ * late fulfillment cannot delete a reservation created after the reset. */
232
+ export function _resetQuarantineRegistryForTesting(): void {
233
+ quarantineReservations.clear();
234
+ resumeFromLocks.clear();
235
+ }
236
+
237
+ export function markSessionQuarantined<T extends object>(
238
+ value: T,
239
+ quarantine: SessionQuarantine,
240
+ ): T & WithSessionQuarantine {
241
+ Object.defineProperty(value, SESSION_QUARANTINE, {
242
+ value: quarantine,
243
+ enumerable: true,
244
+ configurable: false,
245
+ writable: false,
246
+ });
247
+ return value as T & WithSessionQuarantine;
248
+ }
249
+
250
+ export function sessionQuarantineOf(
251
+ value: object | undefined,
252
+ ): SessionQuarantine | undefined {
253
+ return value
254
+ ? (value as WithSessionQuarantine)[SESSION_QUARANTINE]
255
+ : undefined;
256
+ }
257
+
258
+ /** Preserve quarantine across explicit result projections that do not use an
259
+ * object spread. */
260
+ export function propagateSessionQuarantine<T extends object>(
261
+ source: object,
262
+ target: T,
263
+ ): T {
264
+ const quarantine = sessionQuarantineOf(source);
265
+ return quarantine ? markSessionQuarantined(target, quarantine) : target;
266
+ }
package/spill.ts CHANGED
@@ -4,6 +4,34 @@ import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { getOutputSpillTail, getOutputSpillThreshold } from "./config.ts";
6
6
 
7
+ const SPILL_PREFIX = "delegate-output-";
8
+ const RANDOM_SUFFIX_BYTES = 16;
9
+ const CREATE_ATTEMPTS = 5;
10
+
11
+ type SpillFileOperations = {
12
+ open: (filePath: string, flags: string, mode: number) => number;
13
+ write: (fd: number, output: string) => void;
14
+ close: (fd: number) => void;
15
+ remove: (filePath: string) => void;
16
+ };
17
+
18
+ const defaultSpillFileOperations: SpillFileOperations = {
19
+ open: (filePath, flags, mode) => fs.openSync(filePath, flags, mode),
20
+ write: (fd, output) => fs.writeFileSync(fd, output),
21
+ close: (fd) => fs.closeSync(fd),
22
+ remove: (filePath) => fs.rmSync(filePath, { force: true }),
23
+ };
24
+ let spillFileOperations = defaultSpillFileOperations;
25
+
26
+ /** @internal Test-only I/O seam for deterministic write/cleanup failures. */
27
+ export function _setSpillFileOperationsForTesting(
28
+ overrides: Partial<SpillFileOperations> | undefined,
29
+ ): void {
30
+ spillFileOperations = overrides
31
+ ? { ...defaultSpillFileOperations, ...overrides }
32
+ : defaultSpillFileOperations;
33
+ }
34
+
7
35
  // ── Spill: keep subagent final-output bloat out of the LLM context ───────
8
36
  //
9
37
  // Two audiences share one source of truth (`result.output`):
@@ -13,8 +41,10 @@ import { getOutputSpillTail, getOutputSpillThreshold } from "./config.ts";
13
41
  //
14
42
  // The spill is a greppable plain-text `.md` projection of the *final output*
15
43
  // only. The full transcript already lives in the session `.jsonl`; this does
16
- // not duplicate it. Design: lossless always if the spill write fails, we
17
- // degrade to today's behavior (full output in context) rather than hard-truncate.
44
+ // not duplicate it. Delegate never deletes spills because their pointers can
45
+ // persist in transcripts; lifecycle is left to the OS temp policy. Design:
46
+ // lossless always — if the spill write fails, we degrade to today's behavior
47
+ // (full output in context) rather than hard-truncate.
18
48
 
19
49
  /** Decision over an output string: spill or not, and what stays in-context. */
20
50
  export interface SpillDecision {
@@ -50,33 +80,68 @@ export function decideSpill(
50
80
  return { spill: true, inContext: tailOf(output, opts.tailChars), fullChars };
51
81
  }
52
82
 
83
+ function errorCode(error: unknown): unknown {
84
+ return typeof error === "object" && error !== null && "code" in error
85
+ ? (error as { code?: unknown }).code
86
+ : undefined;
87
+ }
88
+
53
89
  /**
54
90
  * Write the full output to a temp `.md` file and return its path, or `null`
55
91
  * on failure. Never throws — callers rely on the lossless-degrade guarantee.
56
92
  *
57
- * Path shape: `os.tmpdir()/delegate-output-<sanitized-label>-<6hex>.md`,
58
- * mode 0o600. The label is sanitized (subagent precedent) so agent names
59
- * can't escape the filename. `suffix` (and `dir`) are injectable so tests get
60
- * deterministic, collision-free names and can point at an unwritable dir to
61
- * exercise the failure path without mocking.
93
+ * Files are mode 0o600 and opened with `wx`: even an improbable collision can
94
+ * never overwrite another spill. Production names use 128 bits of randomness
95
+ * and retry collisions. `suffix` and `dir` remain injectable for focused I/O
96
+ * tests; a supplied suffix is attempted exactly once.
62
97
  */
63
98
  export function spillToTempFile(
64
99
  output: string,
65
100
  label: string,
66
- suffix: string = randomBytes(3).toString("hex"),
101
+ suffix?: string,
67
102
  dir: string = os.tmpdir(),
68
103
  ): string | null {
69
- const safeLabel = label.replace(/[^\w.-]+/g, "_");
70
- const filePath = path.join(dir, `delegate-output-${safeLabel}-${suffix}.md`);
71
- try {
72
- fs.writeFileSync(filePath, output, { mode: 0o600 });
73
- return filePath;
74
- } catch (e) {
75
- console.warn(
76
- `[delegate] spill write failed (${filePath}): ${e instanceof Error ? e.message : String(e)}`,
77
- );
78
- return null;
104
+ const safeLabel = label.replace(/[^\w.-]+/g, "_").slice(0, 64) || "agent";
105
+ const attempts = suffix === undefined ? CREATE_ATTEMPTS : 1;
106
+ let lastPath = path.join(dir, `${SPILL_PREFIX}${safeLabel}-unknown.md`);
107
+ let lastError: unknown;
108
+
109
+ for (let attempt = 0; attempt < attempts; attempt++) {
110
+ let fd: number | undefined;
111
+ try {
112
+ const candidate =
113
+ suffix ?? randomBytes(RANDOM_SUFFIX_BYTES).toString("hex");
114
+ lastPath = path.join(dir, `${SPILL_PREFIX}${safeLabel}-${candidate}.md`);
115
+ fd = spillFileOperations.open(lastPath, "wx", 0o600);
116
+ spillFileOperations.write(fd, output);
117
+ spillFileOperations.close(fd);
118
+ return lastPath;
119
+ } catch (error) {
120
+ lastError = error;
121
+ if (fd !== undefined) {
122
+ try {
123
+ spillFileOperations.close(fd);
124
+ } catch {
125
+ // Continue to remove the incomplete file below.
126
+ }
127
+ try {
128
+ spillFileOperations.remove(lastPath);
129
+ } catch (cleanupError) {
130
+ console.warn(
131
+ `[delegate] spill partial-file cleanup failed (${lastPath}): ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
132
+ );
133
+ }
134
+ }
135
+ const code = errorCode(error);
136
+ if (suffix === undefined && code === "EEXIST") continue;
137
+ break;
138
+ }
79
139
  }
140
+
141
+ console.warn(
142
+ `[delegate] spill write failed (${lastPath}): ${lastError instanceof Error ? lastError.message : String(lastError)}`,
143
+ );
144
+ return null;
80
145
  }
81
146
 
82
147
  /**
@@ -119,17 +184,23 @@ export function renderOutputForLLM(
119
184
  * The output is a moving target mid-flight (a done task's full spill lands at
120
185
  * ticket completion via `formatCompletedTask`); writing a file per poll would
121
186
  * churn paths and confuse the LLM. So the poll stays bounded with a tail and
122
- * a note pointing to the eventual spill. Under the tail budget → unchanged.
187
+ * accurately says whether completion will spill or include the full output.
188
+ * Under the tail budget → unchanged.
123
189
  */
124
190
  export function renderOutputForPoll(
125
191
  output: string,
126
- opts?: { tailChars?: number },
192
+ opts?: { tailChars?: number; thresholdChars?: number },
127
193
  ): string {
128
194
  if (!output || !output.trim() || output === "(no output)") return output;
129
195
  const tailChars = opts?.tailChars ?? getOutputSpillTail();
130
196
  if (output.length <= tailChars) return output;
197
+ const thresholdChars = opts?.thresholdChars ?? getOutputSpillThreshold();
131
198
  const tail = tailOf(output, tailChars);
132
- return `…${tail}\n[truncated — full output is spilled to a file when the ticket completes]`;
199
+ const completionNote =
200
+ output.length > thresholdChars
201
+ ? "full output will spill to a file if possible when the ticket completes, with full in-result inclusion as the fallback"
202
+ : "full output will be included when the ticket completes";
203
+ return `…${tail}\n[truncated in this poll — ${completionNote}]`;
133
204
  }
134
205
 
135
206
  /** Assemble the tail + pointer block emitted on a successful spill. */
@@ -138,7 +209,7 @@ function spillPointer(
138
209
  filePath: string,
139
210
  fullChars: number,
140
211
  ): string {
141
- return `…${tail}\n\n[full output (${humanSize(fullChars)}) spilled to ${filePath} —\n \`read\`/\`grep\` it if completeness matters here; above is the tail]`;
212
+ return `…${tail}\n\n[full output (${humanSize(fullChars)}) spilled to ${filePath} —\n retention follows OS temp policy; \`read\`/\`grep\` it if completeness matters here; above is the tail]`;
142
213
  }
143
214
 
144
215
  /**
@@ -13,6 +13,10 @@ import {
13
13
  } from "./tools.ts";
14
14
  import { configFor } from "./pool.ts";
15
15
  import { isSessionBusy } from "./tickets.ts";
16
+ import {
17
+ isResumeFromQuarantined,
18
+ isSessionIdQuarantined,
19
+ } from "./session-quarantine.ts";
16
20
  import { BUILTIN_AGENT_CONFIGS, buildSubagentSystemPrompt } from "./agents.ts";
17
21
  import { buildParentTranscript } from "./parent-context.ts";
18
22
  import { findAvailableAlternative, resolveModelRequest } from "./model.ts";
@@ -25,10 +29,6 @@ import {
25
29
  } from "./config.ts";
26
30
  import type { DelegateConfig } from "./config.ts";
27
31
  import { resolveCwd } from "./utils.ts";
28
- import {
29
- loadDelegateSettings,
30
- warnLegacyDelegateSettingsMoved,
31
- } from "./settings.ts";
32
32
  import type {
33
33
  AgentConfig,
34
34
  DelegateToolCtx,
@@ -171,6 +171,32 @@ export function validateTasks(
171
171
  );
172
172
  }
173
173
 
174
+ // An abandoned session is detached from the pool, but reusing its caller key
175
+ // before background safety confirmation would create a second live owner for
176
+ // the same logical session in both sync and async dispatches.
177
+ const quarantinedSessions = sessionIds.filter(isSessionIdQuarantined);
178
+ if (quarantinedSessions.length) {
179
+ return noticeResult(
180
+ `SessionId(s) quarantined after abandonment: ${quarantinedSessions.join(", ")}. Wait for background safety confirmation before reusing each sessionId.`,
181
+ tasks,
182
+ parentModelId,
183
+ );
184
+ }
185
+
186
+ const quarantinedResumes = tasks
187
+ .map((task) => task.resumeFrom)
188
+ .filter(
189
+ (resumeFrom): resumeFrom is string =>
190
+ resumeFrom !== undefined && isResumeFromQuarantined(resumeFrom),
191
+ );
192
+ if (quarantinedResumes.length) {
193
+ return noticeResult(
194
+ `resumeFrom transcript(s) quarantined after abandonment: ${[...new Set(quarantinedResumes)].join(", ")}. Wait for background safety confirmation before resuming each transcript.`,
195
+ tasks,
196
+ parentModelId,
197
+ );
198
+ }
199
+
174
200
  // Disallow sessionIds already claimed by a running async ticket.
175
201
  const busyConflicts: string[] = [];
176
202
  for (const sid of sessionIds) {
@@ -238,9 +264,7 @@ export function resolveTasks(
238
264
  ctx.getSystemPrompt?.(),
239
265
  );
240
266
 
241
- // Modern overrides come from delegate.json. A one-release bridge below also
242
- // reads legacy settings.json model/thinking values; modern values win
243
- // field-by-field and legacy tools are never honored.
267
+ // Agent overrides come exclusively from the user-scoped delegate.json.
244
268
  const agentOverrides = getAgentOverrides(dispatchConfig);
245
269
  const overridesByParentModel = getAgentOverridesByParentModel(dispatchConfig);
246
270
 
@@ -252,13 +276,6 @@ export function resolveTasks(
252
276
  const isBuiltinAgent = agent?.builtin === true;
253
277
  const cwd = resolveCwd(t.cwd ?? ctx.cwd, ctx.cwd);
254
278
 
255
- // A task can resolve to a different cwd than the parent; legacy
256
- // `delegate` blocks in those project settings must be surfaced too.
257
- warnLegacyDelegateSettingsMoved(cwd, (message) =>
258
- ctx.ui?.notify(message, "warning"),
259
- );
260
- const legacySettings = loadDelegateSettings(cwd);
261
-
262
279
  // delegate.json agent overrides for this agent. `default` bypasses them
263
280
  // entirely (it mirrors the live parent by contract).
264
281
  const parentModelKey = ctx.model
@@ -275,20 +292,6 @@ export function resolveTasks(
275
292
  t.agent && !isDefaultAgent
276
293
  ? getOwnMapValue(agentOverrides, t.agent)
277
294
  : undefined;
278
- const legacyParentModelOverride =
279
- t.agent && !isDefaultAgent && parentModelKey
280
- ? getOwnMapValue(
281
- getOwnMapValue(
282
- legacySettings?.agentOverridesByParentModel,
283
- parentModelKey,
284
- ),
285
- t.agent,
286
- )
287
- : undefined;
288
- const legacyAgentOverride =
289
- t.agent && !isDefaultAgent
290
- ? getOwnMapValue(legacySettings?.agentOverrides, t.agent)
291
- : undefined;
292
295
 
293
296
  // Build system prompt. Explicit task prompts and named agent prompts
294
297
  // win; ad-hoc subagents inherit the parent's base prompt when Pi exposes
@@ -426,11 +429,10 @@ export function resolveTasks(
426
429
 
427
430
  if (t.sessionAction !== "close" && t.sessionAction !== "list") {
428
431
  const agentType = t.agent ?? "inline";
429
- // The built-in `default` profile bypasses delegate/settings model
430
- // overrides for backwards compatibility. The other built-ins accept
431
- // task and settings.json model overrides, but deliberately ignore the
432
- // legacy delegate.json agent model map so they inherit the parent unless
433
- // an explicit modern override wins.
432
+ // The built-in `default` profile bypasses delegate.json model overrides.
433
+ // The other built-ins accept task and modern agent overrides, but
434
+ // deliberately ignore the legacy delegate.json agent model map so they
435
+ // inherit the parent unless an explicit modern override wins.
434
436
  // Overridden built-ins can still provide an explicit `model` in their
435
437
  // Markdown frontmatter – when `explicitModel` is set, honor it instead
436
438
  // of silently ignoring it (which would contradict the Markdown contract).
@@ -440,16 +442,10 @@ export function resolveTasks(
440
442
  ? (t.model ??
441
443
  parentModelOverride?.model ??
442
444
  agentOverride?.model ??
443
- legacyParentModelOverride?.model ??
444
- legacyAgentOverride?.model ??
445
445
  (agent?.explicitModel ? agent.model : undefined))
446
446
  : resolveModelSpec({
447
447
  taskModel:
448
- t.model ??
449
- parentModelOverride?.model ??
450
- agentOverride?.model ??
451
- legacyParentModelOverride?.model ??
452
- legacyAgentOverride?.model,
448
+ t.model ?? parentModelOverride?.model ?? agentOverride?.model,
453
449
  agentType,
454
450
  frontmatterModel: agent?.model,
455
451
  config: dispatchConfig,
@@ -477,7 +473,16 @@ export function resolveTasks(
477
473
  } else if (isDefaultAgent) {
478
474
  requestedModel = ctx.model;
479
475
  }
480
- model = pooledConfig.model;
476
+ const frozenModel = pooledConfig.model;
477
+ // configFor() returns a defensive clone. Recover the registry's
478
+ // canonical instance for downstream identity compatibility while the
479
+ // pool keeps validating against its immutable value snapshot.
480
+ model =
481
+ ctx.model?.provider === frozenModel.provider &&
482
+ ctx.model.id === frozenModel.id
483
+ ? ctx.model
484
+ : (ctx.modelRegistry.find(frozenModel.provider, frozenModel.id) ??
485
+ frozenModel);
481
486
  } else {
482
487
  const resolvedRequest = modelSpec
483
488
  ? resolveModelRequest(modelSpec, ctx.modelRegistry, ctx.model)
@@ -488,7 +493,7 @@ export function resolveTasks(
488
493
  modelSuffix = resolvedRequest?.strippedSuffix;
489
494
 
490
495
  // The selected model spec is explicit regardless of whether it came
491
- // from the task, settings, or named-agent frontmatter. If it cannot
496
+ // from the task, delegate config, or named-agent frontmatter. If it cannot
492
497
  // resolve, fail loudly instead of silently falling back to the parent.
493
498
  const explicitRequest = modelSpec;
494
499
  if (explicitRequest && !resolvedModel) {
@@ -524,8 +529,6 @@ export function resolveTasks(
524
529
  ? (t.thinking ??
525
530
  parentModelOverride?.thinking ??
526
531
  agentOverride?.thinking ??
527
- legacyParentModelOverride?.thinking ??
528
- legacyAgentOverride?.thinking ??
529
532
  (agent?.explicitThinking ? agent.thinking : undefined) ??
530
533
  modelSuffix ??
531
534
  parentDefaults.thinking ??
@@ -534,8 +537,6 @@ export function resolveTasks(
534
537
  : (t.thinking ??
535
538
  parentModelOverride?.thinking ??
536
539
  agentOverride?.thinking ??
537
- legacyParentModelOverride?.thinking ??
538
- legacyAgentOverride?.thinking ??
539
540
  (agent?.explicitThinking ? agent.thinking : undefined) ??
540
541
  (isPoolHit ? pooledConfig?.thinking : undefined) ??
541
542
  modelSuffix ??
@@ -544,8 +545,6 @@ export function resolveTasks(
544
545
  : (t.thinking ??
545
546
  parentModelOverride?.thinking ??
546
547
  agentOverride?.thinking ??
547
- legacyParentModelOverride?.thinking ??
548
- legacyAgentOverride?.thinking ??
549
548
  agent?.thinking ??
550
549
  (isPoolHit ? pooledConfig?.thinking : undefined) ??
551
550
  modelSuffix ??
package/telemetry.ts CHANGED
@@ -695,7 +695,7 @@ export interface TaskSpanInput {
695
695
 
696
696
  function outcomeFromResult(result: TaskResult): string {
697
697
  if (result.error) {
698
- return result.error === "Aborted" ? "cancelled" : "failed";
698
+ return result.failureKind === "cancelled" ? "cancelled" : "failed";
699
699
  }
700
700
  return "success";
701
701
  }
package/ticket-format.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  formatTouchedOverlapWarning,
13
13
  } from "./format.ts";
14
14
  import { renderOutputForPoll } from "./spill.ts";
15
- import { getOutputSpillTail } from "./config.ts";
15
+ import { getOutputSpillTail, getOutputSpillThreshold } from "./config.ts";
16
16
  import type {
17
17
  AsyncTicket,
18
18
  DelegateDetails,
@@ -120,12 +120,15 @@ function formatSettledPollLines(
120
120
  const meta = taskMetaBase(result);
121
121
  appendTouchedMeta(meta, result, task);
122
122
  const tailChars = getOutputSpillTail(ticket.config);
123
+ const thresholdChars = getOutputSpillThreshold(ticket.config);
123
124
  if (!failed) {
124
125
  const lines = [
125
126
  `✓ ${result.agent}${formatTaskId(result.id)} · ${meta.join(" · ")}`,
126
127
  ];
127
128
  if (result.output && result.output !== "(no output)") {
128
- lines.push(renderOutputForPoll(result.output, { tailChars }));
129
+ lines.push(
130
+ renderOutputForPoll(result.output, { tailChars, thresholdChars }),
131
+ );
129
132
  }
130
133
  return lines;
131
134
  }
@@ -136,7 +139,9 @@ function formatSettledPollLines(
136
139
  if (result.sessionFile)
137
140
  lines.push(` session: ${shortenPath(result.sessionFile)}`);
138
141
  if (result.output && result.output !== "(no output)") {
139
- lines.push(renderOutputForPoll(result.output, { tailChars }));
142
+ lines.push(
143
+ renderOutputForPoll(result.output, { tailChars, thresholdChars }),
144
+ );
140
145
  }
141
146
  return lines;
142
147
  }