@north-light/crouter 0.3.302 → 0.3.303

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,393 @@
1
+ // bash-guard.ts — the refusals the bash valve applies to a command's TEXT
2
+ // before any of it runs. Pure: command text and the node's cwd in, a refusal
3
+ // message or null out.
4
+ //
5
+ // Two refusals live here, for the same reason: both are things no correct
6
+ // agent command ever does, and both are cheaper to stop than to recover from.
7
+ //
8
+ // - A long leading `sleep` burns a live window on a clock the runtime already
9
+ // owns a cheaper primitive for.
10
+ // - A destructive command aimed at the home directory, a system root, or a
11
+ // directory above the node's own working directory is unrecoverable.
12
+ //
13
+ // The second exists because of a real incident. A node ran:
14
+ //
15
+ // cd star-theory-2 && HOME="$(mktemp -d …)" godot … ; rm -rf "$HOME" …
16
+ //
17
+ // A `VAR=value cmd` prefix applies only to that ONE command, so the trailing
18
+ // `rm -rf "$HOME"` used the real home directory and deleted for 108 seconds
19
+ // before another node killed it. A memory doc telling agents not to do this
20
+ // already exists; it is advisory and did not survive contact. This is the
21
+ // block.
22
+ //
23
+ // What this is NOT: a sandbox. Command text is read best-effort — expansions,
24
+ // subshells, `eval`, and a script the command invokes are all out of reach. It
25
+ // stops the plausible mistake a competent agent makes, not an agent trying to
26
+ // get around it. Anything it cannot resolve, it allows: a guard that guesses
27
+ // would refuse ordinary work, and an agent that learns the guard is noisy
28
+ // starts routing around it.
29
+ import { homedir, userInfo } from 'node:os';
30
+ import { isAbsolute, resolve, sep } from 'node:path';
31
+ import { commandSegments, stripHeredocs } from './shell-segments.js';
32
+ // Long-sleep guard. A command that opens with `sleep <long>` is a node burning
33
+ // a live window to wait on a clock — the runtime already has waiting primitives
34
+ // that cost nothing (`crtr cron` for a scheduled wake, `crtr node wait deadline`
35
+ // for an inbox-vs-deadline race). Anything over 10s is refused before it runs.
36
+ const MAX_INLINE_SLEEP_SECONDS = 10;
37
+ const SLEEP_UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86400 };
38
+ /** Seconds a leading `sleep ...` will block for, or null when the command does
39
+ * not open with a sleep (or its duration isn't statically knowable). */
40
+ export function leadingSleepSeconds(command) {
41
+ const match = /^\s*sleep(\s+[^;&|\n]+)/.exec(command);
42
+ if (!match)
43
+ return null;
44
+ // `sleep 1 2m 3` sums its operands.
45
+ let total = 0;
46
+ for (const operand of match[1].trim().split(/\s+/)) {
47
+ const parsed = /^([0-9]*\.?[0-9]+)([smhd]?)$/.exec(operand);
48
+ if (!parsed)
49
+ return null; // variable/expansion/flag — not statically knowable
50
+ total += Number(parsed[1]) * SLEEP_UNIT_SECONDS[parsed[2] || 's'];
51
+ }
52
+ return total;
53
+ }
54
+ function longSleepRefusal(command) {
55
+ const seconds = leadingSleepSeconds(command);
56
+ if (seconds === null || seconds <= MAX_INLINE_SLEEP_SECONDS)
57
+ return null;
58
+ return (`Refused: this command starts by sleeping ${seconds}s, over the ${MAX_INLINE_SLEEP_SECONDS}s inline limit.\n` +
59
+ `Holding a live window on a clock is never how you wait here. Instead:\n` +
60
+ ` - schedule the wake and end your turn: crtr cron -h (one-shot at a time, or recurring)\n` +
61
+ ` - race an inbox message against a deadline: crtr node wait deadline -h\n` +
62
+ ` - waiting on a child, a reply from the user, or any spine event: just stop — the runtime wakes you.\n` +
63
+ `If you truly need a short pause inline, sleep ${MAX_INLINE_SLEEP_SECONDS}s or less.`);
64
+ }
65
+ // Protected paths
66
+ /** System directories whose removal, relocation, or recursive permission
67
+ * change breaks the machine rather than the task. Matched EXACTLY after
68
+ * resolution — `/tmp` is protected, `/tmp/build-1234` is ordinary work. */
69
+ const PROTECTED_SYSTEM_PATHS = [
70
+ '/',
71
+ '/Applications',
72
+ '/bin',
73
+ '/dev',
74
+ '/etc',
75
+ '/home',
76
+ '/Library',
77
+ '/opt',
78
+ '/private',
79
+ '/private/etc',
80
+ '/private/tmp',
81
+ '/private/var',
82
+ '/sbin',
83
+ '/System',
84
+ '/tmp',
85
+ '/usr',
86
+ '/Users',
87
+ '/var',
88
+ '/Volumes',
89
+ ];
90
+ /** Why a resolved target is protected, in the words the refusal uses. `null`
91
+ * when the target is ordinary and the command may run. */
92
+ function protectedReason(target, home, cwd) {
93
+ if (PROTECTED_SYSTEM_PATHS.includes(target))
94
+ return 'a system directory';
95
+ if (target === home)
96
+ return 'your home directory';
97
+ if (target === `${home}/.crouter`)
98
+ return 'the canvas home — every node\u2019s conversation and state';
99
+ // Strict ancestors only: a node may delete its OWN working directory (a
100
+ // merged worktree is a real case), just nothing that contains it.
101
+ if (cwd === target || !cwd.startsWith(target.endsWith(sep) ? target : target + sep))
102
+ return null;
103
+ return `a directory containing your working directory (${cwd})`;
104
+ }
105
+ function initialVariables(home, cwd) {
106
+ const user = userInfo().username;
107
+ return new Map([['HOME', home], ['PWD', cwd], ['USER', user], ['LOGNAME', user]]);
108
+ }
109
+ /** The text with `~` and every known `$VAR` substituted, or null when what is
110
+ * left still depends on something the guard cannot see. */
111
+ function expandText(text, vars) {
112
+ let t = text;
113
+ if (t === '~' || t.startsWith('~/')) {
114
+ const home = vars.get('HOME');
115
+ if (home === undefined)
116
+ return null;
117
+ t = home + t.slice(1);
118
+ }
119
+ else if (t.startsWith('~')) {
120
+ return null; // ~otheruser — not ours to resolve
121
+ }
122
+ t = t.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (whole, braced, bare) => vars.get(braced ?? bare) ?? whole);
123
+ // Anything still carrying an expansion or a glob is not statically knowable.
124
+ return /[$`*?[]/.test(t) ? null : t;
125
+ }
126
+ /** The absolute path an operand names, or null when it is not statically
127
+ * knowable. A trailing `/*` or `/.` is stripped first: `rm -rf ~/*` empties
128
+ * the same tree as `rm -rf ~`. A relative operand resolves against `base` —
129
+ * null when an earlier `cd` in the same line went somewhere the guard cannot
130
+ * follow. */
131
+ function resolveTarget(text, vars, base) {
132
+ const expanded = expandText(text.replace(/\/+\*?\.?$/, '') || '/', vars);
133
+ if (expanded === null)
134
+ return null;
135
+ if (isAbsolute(expanded))
136
+ return resolve(expanded);
137
+ return base === null ? null : resolve(base, expanded);
138
+ }
139
+ /** Fold `VAR=value` assignments into a variable map. A value the guard cannot
140
+ * expand deletes the variable rather than leaving a stale one behind: reading
141
+ * a later `$HOME` as the real home when the line reassigned it is exactly the
142
+ * false refusal that would teach agents to route around this guard. */
143
+ function applyAssignments(vars, assignments) {
144
+ for (const assignment of assignments) {
145
+ const eq = assignment.indexOf('=');
146
+ if (eq <= 0)
147
+ continue;
148
+ const name = assignment.slice(0, eq);
149
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
150
+ continue;
151
+ const value = expandText(assignment.slice(eq + 1), vars);
152
+ if (value === null)
153
+ vars.delete(name);
154
+ else
155
+ vars.set(name, value);
156
+ }
157
+ }
158
+ // The destructive operations
159
+ /** A redirection, whole (`2>/dev/null`) or as its bare operator (`>`), which
160
+ * takes the next word as its file. Neither is an operand of the command. */
161
+ const REDIRECTION = /^[0-9]*(&?>>?|<<?|>&|<&)/;
162
+ /** Operands of a segment, minus flags, the `--` end-of-flags marker, and
163
+ * redirections. */
164
+ function operands(tokens) {
165
+ const out = [];
166
+ let flagsEnded = false;
167
+ let skipNext = false;
168
+ for (const token of tokens.slice(1)) {
169
+ if (skipNext) {
170
+ skipNext = false;
171
+ continue;
172
+ }
173
+ const redirection = REDIRECTION.exec(token.text);
174
+ if (redirection !== null && !token.opaque) {
175
+ skipNext = token.text === redirection[0]; // bare operator — its file is the next word
176
+ continue;
177
+ }
178
+ if (!flagsEnded && token.text === '--') {
179
+ flagsEnded = true;
180
+ continue;
181
+ }
182
+ if (!flagsEnded && token.text.startsWith('-') && token.text !== '-')
183
+ continue;
184
+ out.push(token.text);
185
+ }
186
+ return out;
187
+ }
188
+ /** Short and long flags of a segment, short combinations split into letters. */
189
+ function flagLetters(tokens) {
190
+ const letters = new Set();
191
+ const long = new Set();
192
+ for (const token of tokens.slice(1)) {
193
+ if (token.text === '--')
194
+ break;
195
+ if (token.text.startsWith('--'))
196
+ long.add(token.text.slice(2));
197
+ else if (token.text.startsWith('-') && token.text.length > 1)
198
+ for (const ch of token.text.slice(1))
199
+ letters.add(ch);
200
+ }
201
+ return { letters, long };
202
+ }
203
+ function isRecursive(tokens) {
204
+ const { letters, long } = flagLetters(tokens);
205
+ return letters.has('r') || letters.has('R') || long.has('recursive');
206
+ }
207
+ /** A block device holding a filesystem. `/dev/null`, `/dev/stdout`, and
208
+ * `/dev/zero` are ordinary output; a disk node is not. */
209
+ const RAW_DISK_DEVICE = /^\/dev\/r?(disk|sd[a-z]|nvme|hd[a-z]|vd[a-z]|mmcblk|md)/;
210
+ /** `find`'s start paths: the words between its leading global options and the
211
+ * first predicate of the expression. Those options are why the paths cannot
212
+ * simply be "everything before the first `-` word" — `find -H ~ -delete` and
213
+ * `find -- ~ -delete` both walk the home tree. */
214
+ function findPaths(tokens) {
215
+ const paths = [];
216
+ let i = 1;
217
+ while (i < tokens.length) {
218
+ const text = tokens[i].text;
219
+ if (text === '--' || /^-[HLPEdsx]$/.test(text))
220
+ i += 1;
221
+ else if (text === '-D' || text === '-O')
222
+ i += 2; // GNU debug/optimisation, each with a value
223
+ else if (text === '-f') { // BSD's explicit start path
224
+ if (i + 1 < tokens.length)
225
+ paths.push(tokens[i + 1].text);
226
+ i += 2;
227
+ }
228
+ else
229
+ break;
230
+ }
231
+ for (; i < tokens.length; i += 1) {
232
+ if (tokens[i].text.startsWith('-'))
233
+ break;
234
+ paths.push(tokens[i].text);
235
+ }
236
+ return paths;
237
+ }
238
+ /** Command names that stand in front of the real command and must be stepped
239
+ * over before it can be read. Their own flags are skipped with them. */
240
+ const COMMAND_PREFIXES = new Set(['sudo', 'doas', 'command', 'env', 'nice', 'nohup', 'time', 'stdbuf', 'xargs', 'exec', 'builtin']);
241
+ /** Strip every leading prefix command and its flags, leaving the real command
242
+ * as `tokens[0]`. `sudo -E nice -n 10 rm -rf ~` reads as `rm -rf ~`. */
243
+ function stripCommandPrefixes(tokens) {
244
+ let rest = tokens;
245
+ while (rest.length > 0 && !rest[0].opaque && COMMAND_PREFIXES.has(rest[0].text)) {
246
+ let i = 1;
247
+ while (i < rest.length && rest[i].text.startsWith('-')) {
248
+ // `nice -n 10` and `env -u VAR` take a value; a lone flag does not.
249
+ const takesValue = /^-[nu]$/.test(rest[i].text);
250
+ i += takesValue ? 2 : 1;
251
+ }
252
+ // `env FOO=bar cmd` — the assignments belong to env, not to the command.
253
+ while (i < rest.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(rest[i].text))
254
+ i += 1;
255
+ if (i >= rest.length)
256
+ return [];
257
+ rest = rest.slice(i);
258
+ }
259
+ return rest;
260
+ }
261
+ /** Targets this segment would destroy: the operands whose loss the guard
262
+ * cares about, or `[]` when the segment is not a destructive operation. A
263
+ * verb appears here only when ONE invocation of it can take out a whole tree.
264
+ * A device or subcommand named directly (`mkfs.ext4`, `diskutil eraseDisk`)
265
+ * has no path operand to resolve, so it reports itself. */
266
+ function destructiveTargets(tokens) {
267
+ const none = { targets: [], named: null };
268
+ const head = tokens[0];
269
+ if (head === undefined || head.opaque)
270
+ return none;
271
+ const name = head.text.slice(head.text.lastIndexOf('/') + 1);
272
+ const args = operands(tokens);
273
+ switch (name) {
274
+ // `rm` without a recursive flag cannot remove a directory at all.
275
+ case 'rm':
276
+ return isRecursive(tokens) ? { targets: args, named: null } : none;
277
+ // Every operand but the destination is a source that stops existing.
278
+ case 'mv':
279
+ return args.length >= 2 ? { targets: args.slice(0, -1), named: null } : none;
280
+ // A recursive mode or ownership sweep over a system tree is as final as a
281
+ // deletion. The first operand is the mode/owner, not a path.
282
+ case 'chmod':
283
+ case 'chown':
284
+ case 'chgrp':
285
+ return isRecursive(tokens) && args.length >= 2 ? { targets: args.slice(1), named: null } : none;
286
+ // `find <path> … -delete` and `-exec` walk the tree and act on every hit.
287
+ case 'find': {
288
+ const acts = tokens.some((t) => !t.opaque && (t.text === '-delete' || t.text === '-exec' || t.text === '-execdir'));
289
+ return acts ? { targets: findPaths(tokens), named: null } : none;
290
+ }
291
+ // `dd of=…` overwrites its output in place; a raw disk is unrecoverable,
292
+ // while `of=/dev/null` and friends are ordinary output redirection.
293
+ case 'dd': {
294
+ const of = tokens.find((t) => t.text.startsWith('of='));
295
+ if (of === undefined)
296
+ return none;
297
+ const path = of.text.slice(3);
298
+ if (RAW_DISK_DEVICE.test(path))
299
+ return { targets: [], named: path };
300
+ return path.startsWith('/dev/') ? none : { targets: [path], named: null };
301
+ }
302
+ default:
303
+ // Filesystem creation and disk erasure destroy a volume outright; there
304
+ // is no safe target for them inside an agent's task.
305
+ if (/^(mkfs|newfs)([.-].+)?$/.test(name))
306
+ return { targets: [], named: name };
307
+ if (name === 'diskutil') {
308
+ const verb = args[0];
309
+ if (verb !== undefined && /^(erase|reformat|partition|zeroDisk|randomDisk|secureErase)/i.test(verb)) {
310
+ return { targets: [], named: `diskutil ${verb}` };
311
+ }
312
+ }
313
+ return none;
314
+ }
315
+ }
316
+ /** Walk the line segment by segment, carrying the variable values and working
317
+ * directory the shell itself would carry, and refuse the first destructive
318
+ * segment aimed at a protected path.
319
+ *
320
+ * Carrying that state IS the guard's core distinction. `export HOME=/tmp/x`
321
+ * as its own command changes `$HOME` for everything after it, so a later
322
+ * `rm -rf "$HOME"` is a scratch directory and runs. A `HOME=/tmp/x cmd`
323
+ * PREFIX applies to that one command only, so a later `rm -rf "$HOME"` is the
324
+ * real home — which is precisely the mistake that destroyed one. */
325
+ function destructiveCommandRefusal(command, cwd, home) {
326
+ const vars = initialVariables(home, cwd);
327
+ // Every directory a relative operand might be resolved from. A `cd` ADDS its
328
+ // destination rather than replacing the previous one, because the guard
329
+ // cannot know whether the shell's `cd` succeeded: after `cd missing-dir`,
330
+ // bash stays put and `rm -rf ..` really is the ancestor. Refusing when ANY
331
+ // candidate is protected keeps that case caught; the refusal names the
332
+ // resolved path, and an absolute operand is never ambiguous.
333
+ let bases = [cwd];
334
+ for (const segment of commandSegments(stripHeredocs(command))) {
335
+ const tokens = stripCommandPrefixes(segment.tokens);
336
+ const head = tokens[0];
337
+ if (head !== undefined) {
338
+ // This segment's own prefix assignments apply to this command only.
339
+ const local = new Map(vars);
340
+ applyAssignments(local, segment.assignments);
341
+ const innermost = bases[bases.length - 1];
342
+ if (innermost !== undefined)
343
+ local.set('PWD', innermost);
344
+ const { targets, named } = destructiveTargets(tokens);
345
+ if (named !== null)
346
+ return refusalMessage(segment.text, named, 'a whole disk or volume — never a task boundary');
347
+ for (const target of targets) {
348
+ for (const base of bases.length === 0 ? [null] : bases) {
349
+ const resolved = resolveTarget(target, local, base);
350
+ if (resolved === null)
351
+ continue;
352
+ const reason = protectedReason(resolved, home, cwd);
353
+ if (reason !== null)
354
+ return refusalMessage(segment.text, resolved, reason);
355
+ }
356
+ }
357
+ }
358
+ // Then carry this segment's lasting effects into the rest of the line. A
359
+ // backgrounded segment runs in a subshell, so it has none.
360
+ if (segment.background)
361
+ continue;
362
+ if (head === undefined)
363
+ applyAssignments(vars, segment.assignments);
364
+ else if (!head.opaque && SHELL_VARIABLE_BUILTINS.has(head.text)) {
365
+ // A prefix assignment to a special builtin persists in the shell.
366
+ applyAssignments(vars, segment.assignments);
367
+ applyAssignments(vars, operands(tokens));
368
+ }
369
+ else if (!head.opaque && head.text === 'cd') {
370
+ const destination = operands(tokens)[0];
371
+ const next = destination === undefined ? vars.get('HOME') ?? null : resolveTarget(destination, vars, bases[bases.length - 1] ?? null);
372
+ // An unfollowable `cd` leaves no knowable base at all; a relative operand
373
+ // after it is unresolvable, and unresolvable always means allowed.
374
+ bases = next === null ? [] : [...bases, next];
375
+ }
376
+ }
377
+ return null;
378
+ }
379
+ /** Builtins that set shell variables for everything after them. */
380
+ const SHELL_VARIABLE_BUILTINS = new Set(['export', 'declare', 'typeset', 'readonly']);
381
+ function refusalMessage(segment, target, reason) {
382
+ return (`Refused: \`${segment}\` targets ${target}, which is ${reason}.\n` +
383
+ `Nothing an agent is asked to do requires destroying that path, and it cannot be undone.\n` +
384
+ `If you meant somewhere else, name that path explicitly and run the command again. Two traps to check first:\n` +
385
+ ` - a \`VAR=value cmd\` prefix applies ONLY to that one command, so a later \`$VAR\` in the same line is the SHELL's value — this is exactly how a node deleted a home directory.\n` +
386
+ ` - \`~\` and \`$HOME\` are the real home directory, never a scratch dir; build scratch paths with mktemp -d and hold them in a variable of your own.\n` +
387
+ `If you believe this path genuinely must be destroyed, ask the user — do not work around this refusal.`);
388
+ }
389
+ /** The refusal this command earns before it runs, or null when it may run.
390
+ * `cwd` is the directory the command will execute in. */
391
+ export function bashCommandRefusal(command, cwd, home = homedir()) {
392
+ return longSleepRefusal(command) ?? destructiveCommandRefusal(command, cwd, home);
393
+ }
@@ -25,6 +25,7 @@ export declare class FaultRetry {
25
25
  private readonly notFoundModels;
26
26
  private providerFallbackInFlight;
27
27
  private notFoundFallbackInFlight;
28
+ private stagedCapacityFailure;
28
29
  constructor(deps: FaultRetryDeps);
29
30
  clearTimer(): void;
30
31
  reset(): void;
@@ -54,6 +55,9 @@ export declare class FaultRetry {
54
55
  schedule(): void;
55
56
  private maybeSwitchProviderForRetry;
56
57
  private maybeFallbackOnUnusableModel;
58
+ private nextEligibleFallbackRoute;
59
+ private targetCannotFitContext;
60
+ private stageCapacityFailure;
57
61
  private recordPendingProviderFault;
58
62
  private sessionFile;
59
63
  private pendingEpisodeMatches;
@@ -22,6 +22,11 @@ export class FaultRetry {
22
22
  notFoundModels = new Set();
23
23
  providerFallbackInFlight = null;
24
24
  notFoundFallbackInFlight = null;
25
+ // A fallback can learn its target cannot hold this session before the
26
+ // current turn settles. Keep that outcome here rather than on the engine's
27
+ // agent_end staging field: message_end precedes agent_end, which resets that
28
+ // field for the ordinary provider result.
29
+ stagedCapacityFailure = null;
25
30
  constructor(deps) {
26
31
  this.deps = deps;
27
32
  }
@@ -35,6 +40,7 @@ export class FaultRetry {
35
40
  this.clearTimer();
36
41
  this.providerFallbackInFlight = null;
37
42
  this.notFoundFallbackInFlight = null;
43
+ this.stagedCapacityFailure = null;
38
44
  }
39
45
  onEvent(event) {
40
46
  this.maybeSwitchProviderForRetry(event);
@@ -54,6 +60,8 @@ export class FaultRetry {
54
60
  const watchdogAbort = generation.stagedWatchdogAbort;
55
61
  const refreshAbort = generation.stagedRefreshAbort;
56
62
  const overflowFailure = generation.stagedOverflowFailure;
63
+ const capacityFailure = this.stagedCapacityFailure?.generation === generation ? this.stagedCapacityFailure : null;
64
+ this.stagedCapacityFailure = null;
57
65
  generation.stagedProviderAgentEnd = null;
58
66
  generation.stagedWatchdogAbort = false;
59
67
  generation.stagedRefreshAbort = false;
@@ -76,12 +84,12 @@ export class FaultRetry {
76
84
  : { since: episodeFault.since, anchorEntryId: episodeFault.anchorEntryId, attempt: episodeFault.retry.attempt };
77
85
  const messages = Array.isArray(agentEnd?.messages) ? agentEnd.messages : [];
78
86
  const last = [...messages].reverse().find((message) => typeof message === 'object' && message !== null && message.role === 'assistant');
79
- if (overflowFailure !== null) {
87
+ if (capacityFailure !== null || overflowFailure !== null) {
80
88
  clearFault(this.deps.nodeId, { link: 'pi→provider' });
81
89
  clearProviderRetryEpisode(this.deps.nodeId);
82
90
  recordFault(this.deps.nodeId, {
83
91
  link: 'pi→provider', op: 'context overflow recovery', kind: 'context-overflow', retry: { disposition: 'fatal' },
84
- message: overflowFailure.errorMessage, anchorEntryId: settledSession.sessionManager.getLeafId?.() ?? undefined,
92
+ message: capacityFailure?.errorMessage ?? overflowFailure.errorMessage, anchorEntryId: settledSession.sessionManager.getLeafId?.() ?? undefined,
85
93
  });
86
94
  return;
87
95
  }
@@ -250,9 +258,23 @@ export class FaultRetry {
250
258
  const routes = expandModelCandidates(routeRequest, envNodeCwd() ?? this.deps.cfg.cwd, envProfileId());
251
259
  const current = parseModelSpec(currentSpec);
252
260
  const liveRank = routes.find((candidate) => `${candidate.providerId}/${candidate.modelId}` === current.modelSpec && candidate.thinkingLevel === current.thinkingLevel)?.rank;
253
- const nextRoute = liveRank === undefined ? undefined : routes.slice(liveRank + 1).find((candidate) => !this.failedRetryRoutes.has(candidate.routeId) && resolveProviderCandidates([{ ...candidate, ...probeRouteAvailability(candidate, this.deps.registryOf(candidateServices)), automaticFallback: true }]).candidates.length > 0);
254
- if (nextRoute === undefined)
261
+ const fallback = liveRank === undefined
262
+ ? { route: undefined, capacityExhausted: false }
263
+ : this.nextEligibleFallbackRoute(routes.slice(liveRank + 1), this.deps.registryOf(candidateServices), candidateSession.getContextUsage()?.tokens, (candidate) => !this.failedRetryRoutes.has(candidate.routeId));
264
+ if (fallback.route === undefined) {
265
+ if (fallback.capacityExhausted) {
266
+ this.stageCapacityFailure(generation, candidateSession);
267
+ // Pi creates its retry AbortController immediately after it emits this
268
+ // event. Defer the abort so it cancels that controller, then lets the
269
+ // normal agent_settled path publish the staged fatal outcome.
270
+ queueMicrotask(() => {
271
+ if (this.deps.installedGeneration() === generation && this.deps.currentSession() === candidateSession)
272
+ candidateSession.abortRetry();
273
+ });
274
+ }
255
275
  return;
276
+ }
277
+ const nextRoute = fallback.route;
256
278
  const fallbackPromise = (async () => {
257
279
  const target = this.deps.registryOf(candidateServices).find(nextRoute.providerId, nextRoute.modelId);
258
280
  if (!target)
@@ -290,9 +312,15 @@ export class FaultRetry {
290
312
  const routeRequest = modelRequestFromConfig(this.deps.cfg.model, envModelIntent(), false);
291
313
  const routes = routeRequest ? expandModelCandidates(routeRequest, envNodeCwd() ?? this.deps.cfg.cwd, envProfileId()) : [];
292
314
  const liveRank = routes.find((candidate) => `${candidate.providerId}/${candidate.modelId}` === current.modelSpec && candidate.thinkingLevel === current.thinkingLevel)?.rank;
293
- const nextRoute = liveRank === undefined ? undefined : routes.slice(liveRank + 1).find((candidate) => !this.notFoundModels.has(`${candidate.providerId}/${candidate.modelId}`) && resolveProviderCandidates([{ ...candidate, ...probeRouteAvailability(candidate, this.deps.registryOf(candidateServices)), automaticFallback: true }]).candidates.length > 0);
294
- if (!nextRoute)
315
+ const fallback = liveRank === undefined
316
+ ? { route: undefined, capacityExhausted: false }
317
+ : this.nextEligibleFallbackRoute(routes.slice(liveRank + 1), this.deps.registryOf(candidateServices), candidateSession.getContextUsage()?.tokens, (candidate) => !this.notFoundModels.has(`${candidate.providerId}/${candidate.modelId}`));
318
+ if (fallback.route === undefined) {
319
+ if (fallback.capacityExhausted)
320
+ this.stageCapacityFailure(generation, candidateSession);
295
321
  return;
322
+ }
323
+ const nextRoute = fallback.route;
296
324
  const target = `${nextRoute.providerId}/${nextRoute.modelId}${nextRoute.thinkingLevel ? `:${nextRoute.thinkingLevel}` : ''}`;
297
325
  const fallbackPromise = (async () => {
298
326
  const model = this.deps.registryOf(candidateServices).find(nextRoute.providerId, nextRoute.modelId);
@@ -323,6 +351,38 @@ export class FaultRetry {
323
351
  });
324
352
  this.notFoundFallbackInFlight = fallbackPromise;
325
353
  }
354
+ nextEligibleFallbackRoute(routes, registry, contextTokens, include) {
355
+ let available = 0;
356
+ let tooSmall = 0;
357
+ for (const candidate of routes) {
358
+ if (!include(candidate))
359
+ continue;
360
+ if (resolveProviderCandidates([{ ...candidate, ...probeRouteAvailability(candidate, registry), automaticFallback: true }]).candidates.length === 0)
361
+ continue;
362
+ const target = registry.find(candidate.providerId, candidate.modelId);
363
+ if (target === undefined)
364
+ continue;
365
+ available++;
366
+ if (this.targetCannotFitContext(target.contextWindow, contextTokens)) {
367
+ tooSmall++;
368
+ continue;
369
+ }
370
+ return { route: candidate, capacityExhausted: false };
371
+ }
372
+ return { route: undefined, capacityExhausted: available > 0 && available === tooSmall };
373
+ }
374
+ targetCannotFitContext(contextWindow, contextTokens) {
375
+ return Number.isFinite(contextWindow) && Number.isFinite(contextTokens) && contextWindow < contextTokens;
376
+ }
377
+ stageCapacityFailure(generation, session) {
378
+ const tokens = session.getContextUsage()?.tokens;
379
+ this.stagedCapacityFailure = {
380
+ generation,
381
+ errorMessage: Number.isFinite(tokens)
382
+ ? `All available fallback models have context windows smaller than the current conversation (${tokens} tokens).`
383
+ : 'All available fallback models have context windows smaller than the current conversation.',
384
+ };
385
+ }
326
386
  recordPendingProviderFault(session, input) {
327
387
  const sessionFile = this.sessionFile(session);
328
388
  // A pathless session cannot be crash-recovered, but its live broker still
@@ -0,0 +1,42 @@
1
+ /** One shell word. `opaque` marks a token whose text came from inside quotes:
2
+ * it is data, so it must never be treated as an executable or a matched
3
+ * command word. */
4
+ export type CommandToken = {
5
+ text: string;
6
+ opaque: boolean;
7
+ };
8
+ /** One shell segment: the raw text as the author wrote it, its leading
9
+ * `VAR=value` assignments, and the remaining words — so `tokens[0]` is the
10
+ * executable. An assignment-only segment (`export`-less `FOO=bar`) has
11
+ * assignments and no tokens; the distinction matters, because a leading
12
+ * assignment applies ONLY to the command in its own segment.
13
+ *
14
+ * `background` marks a segment ended by a single `&`. It runs in a subshell,
15
+ * so nothing it assigns or `cd`s reaches the commands after it. */
16
+ export type CommandSegment = {
17
+ text: string;
18
+ assignments: string[];
19
+ tokens: CommandToken[];
20
+ background: boolean;
21
+ };
22
+ /** One raw segment and whether a single `&` — not `&&` — ended it. */
23
+ export type RawSegment = {
24
+ text: string;
25
+ background: boolean;
26
+ };
27
+ /** Split a command into shell segments on unquoted `;`, newline, `&&`, `||`,
28
+ * `|`, and a single `&`. Subshell parentheses and backticks are left inside
29
+ * their segment. Heredoc bodies are a separate pre-read concern (see
30
+ * `stripHeredocs`). */
31
+ export declare function splitCommandSegments(command: string): RawSegment[];
32
+ /** Tokenize one segment into shell words, marking quoted spans opaque. */
33
+ export declare function tokenizeCommandSegment(segment: string): CommandToken[];
34
+ /** Every non-empty command segment, paired with its raw text. */
35
+ export declare function commandSegments(command: string): CommandSegment[];
36
+ /** Remove heredoc BODIES (the stdin data between `<<DELIM` and its closing
37
+ * `DELIM` line) from a command before it is read. The opening line is kept —
38
+ * `crtr push final <<'EOF'` is still a real `push final` invocation — but the
39
+ * body lines, which are data fed on stdin and never executed as commands, are
40
+ * dropped. Without this, a doc-writing command whose body merely MENTIONS a
41
+ * matched invocation would have its whole call held or refused. */
42
+ export declare function stripHeredocs(command: string): string;