@north-light/crouter 0.3.302 → 0.3.304

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.
Files changed (85) hide show
  1. package/dist/api/__tests__/integration/client.test.js +37 -36
  2. package/dist/api/client.d.ts +32 -55
  3. package/dist/api/client.js +102 -98
  4. package/dist/api/dto/messages.d.ts +6 -5
  5. package/dist/api/errors.d.ts +3 -0
  6. package/dist/api/errors.js +10 -0
  7. package/dist/api/index.d.ts +1 -1
  8. package/dist/api/index.js +1 -1
  9. package/dist/builtin-memory/internal/plugins.md +1 -1
  10. package/dist/clients/attach/viewer.js +532 -532
  11. package/dist/commands/__tests__/seam/daemon-status.test.d.ts +1 -0
  12. package/dist/commands/__tests__/seam/daemon-status.test.js +29 -0
  13. package/dist/commands/api-client.d.ts +3 -3
  14. package/dist/commands/api-client.js +3 -3
  15. package/dist/commands/memory/read.js +2 -1
  16. package/dist/commands/node/bash.js +6 -6
  17. package/dist/commands/node/message.js +3 -3
  18. package/dist/commands/sys/daemon.js +11 -13
  19. package/dist/core/__tests__/bash-guard.test.d.ts +1 -0
  20. package/dist/core/__tests__/bash-guard.test.js +190 -0
  21. package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +0 -2
  22. package/dist/core/__tests__/daemon-boot.test.js +1 -1
  23. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  24. package/dist/core/__tests__/fixtures/fake-engine.js +51 -11
  25. package/dist/core/__tests__/helpers/harness.d.ts +2 -0
  26. package/dist/core/__tests__/helpers/harness.js +9 -0
  27. package/dist/core/__tests__/integration/worktree-land.test.js +50 -0
  28. package/dist/core/__tests__/integration/worktree-reap.test.js +184 -5
  29. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +14 -0
  30. package/dist/core/__tests__/seam/broker-attach-stream.test.js +13 -0
  31. package/dist/core/__tests__/seam/broker-provider-retry.test.js +57 -0
  32. package/dist/core/__tests__/seam/broker-startup-diagnostics.test.d.ts +1 -0
  33. package/dist/core/__tests__/seam/broker-startup-diagnostics.test.js +82 -0
  34. package/dist/core/bash-guard.d.ts +6 -0
  35. package/dist/core/bash-guard.js +393 -0
  36. package/dist/core/bash-jobs.d.ts +20 -8
  37. package/dist/core/bash-jobs.js +40 -21
  38. package/dist/core/canvas/types.d.ts +4 -2
  39. package/dist/core/command.js +1 -1
  40. package/dist/core/fault-classifier.d.ts +1 -1
  41. package/dist/core/runtime/broker/event-projection.d.ts +0 -3
  42. package/dist/core/runtime/broker/event-projection.js +2 -15
  43. package/dist/core/runtime/broker/fault-retry.d.ts +4 -0
  44. package/dist/core/runtime/broker/fault-retry.js +74 -6
  45. package/dist/core/runtime/broker-persona-guidance.js +12 -0
  46. package/dist/core/runtime/broker.js +0 -5
  47. package/dist/core/runtime/fault.js +1 -1
  48. package/dist/core/runtime/host.js +10 -1
  49. package/dist/core/runtime/spawn.js +5 -6
  50. package/dist/core/shell-segments.d.ts +42 -0
  51. package/dist/core/shell-segments.js +169 -0
  52. package/dist/core/substrate/surface-match.d.ts +0 -9
  53. package/dist/core/substrate/surface-match.js +4 -160
  54. package/dist/core/worktree-close.d.ts +4 -0
  55. package/dist/core/worktree-close.js +225 -0
  56. package/dist/core/worktree-containment.d.ts +14 -0
  57. package/dist/core/worktree-containment.js +48 -0
  58. package/dist/core/worktree-mutation-async.d.ts +13 -0
  59. package/dist/core/worktree-mutation-async.js +281 -0
  60. package/dist/core/worktree-sweep.js +17 -79
  61. package/dist/core/worktree.d.ts +1 -0
  62. package/dist/core/worktree.js +1 -1
  63. package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +10 -8
  64. package/dist/daemon/api/__tests__/seam/api-server.test.js +70 -1
  65. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  66. package/dist/daemon/api/handlers/messages.js +7 -5
  67. package/dist/daemon/api/handlers/reports.js +3 -2
  68. package/dist/daemon/api/handlers/worktree.js +7 -6
  69. package/dist/daemon/fleet.d.ts +1 -1
  70. package/dist/daemon/fleet.js +30 -12
  71. package/dist/daemon/manage.d.ts +8 -5
  72. package/dist/daemon/manage.js +63 -40
  73. package/dist/daemon/reconcilers/managed-worktree-sweep.js +12 -0
  74. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +0 -6
  75. package/dist/daemon/reconcilers/node-lifecycle/tick.js +1 -13
  76. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.d.ts +5 -0
  77. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.js +11 -0
  78. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +83 -0
  79. package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.d.ts +1 -0
  80. package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.js +133 -0
  81. package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -3
  82. package/dist/pi-extensions/canvas-bash-valve.js +75 -77
  83. package/dist/pi-extensions/canvas-inbox-watcher.js +0 -2
  84. package/package.json +1 -1
  85. package/runtime.lock.json +5 -5
@@ -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
+ }
@@ -12,6 +12,9 @@ export interface BashJobPaths {
12
12
  * Inspector's cancel) — without it on disk, only the agent's own handoff
13
13
  * notice knows the group to signal. */
14
14
  jobPgid: string;
15
+ /** Launch-time identity of the detached supervisor, used to refuse a PID that
16
+ * has since been reused by an unrelated process. */
17
+ jobPgidIdentity: string;
15
18
  /** Human-readable label supplied with the bash call, when any. */
16
19
  jobPurpose: string;
17
20
  /** Epoch-ms cancel deadline for an AUTO-backgrounded job. Present only when
@@ -33,6 +36,9 @@ export declare function bashJobPaths(contextDir: string, jobId: string): BashJob
33
36
  /** The job's supervisor process group, or undefined for a job started before
34
37
  * pgid was persisted (or a partially-written dir). */
35
38
  export declare function readJobPgid(contextDir: string, jobId: string): number | undefined;
39
+ /** Launch-time identity of the supervisor. Missing for jobs created before
40
+ * process-identity persistence, which are deliberately not safe to signal. */
41
+ export declare function readJobPgidIdentity(contextDir: string, jobId: string): string | undefined;
36
42
  /** The optional human-readable job label. Jobs created before labels existed
37
43
  * have no file and deliberately project null. */
38
44
  export declare function readJobPurpose(contextDir: string, jobId: string): string | null;
@@ -73,6 +79,12 @@ export declare function clearJobDeadline(paths: BashJobPaths): void;
73
79
  * means the group exists but cannot be signaled by this process; it is still
74
80
  * live for the status surface. */
75
81
  export declare function isBashJobProcessGroupAlive(pgid: number): boolean;
82
+ /** Best-effort cancellation for a detached bash supervisor. It snapshots the
83
+ * observable descendant tree while the supervisor is still alive, signals its
84
+ * leaves before their parents, then uses the shared identity-guarded KILL
85
+ * escalation. A descendant that detached and reparented before this snapshot
86
+ * is outside this guarantee. */
87
+ export declare function cancelBashJobProcessTree(pgid: number, expectedIdentity: string): Promise<boolean>;
76
88
  export type StopBackgroundBashJobResult = {
77
89
  kind: 'stopped';
78
90
  signaled: boolean;
@@ -87,15 +99,15 @@ export type StopBackgroundBashJobResult = {
87
99
  kind: 'not-stoppable';
88
100
  logPath: string;
89
101
  };
90
- /** Stop one live background job using the same process-group and sentinel
91
- * semantics as `crtr node bash kill`. The caller owns the user-facing
92
- * notification; this primitive owns only the job control-plane mutation.
102
+ /** Stop one live background job using the same best-effort tree cancellation
103
+ * semantics as inline abort and timeout. The caller owns the user-facing
104
+ * notification; this primitive owns only the job control-plane mutation.
93
105
  *
94
- * Only a job in the live-background state the roster projects (job.bg
95
- * present, job.exit absent) may be signaled. Job directories persist after
96
- * the supervisor exits, so a retired, still-foreground, or already-stopped
97
- * job would otherwise have its recorded pgid signaled long after the OS
98
- * could have reused that id for an unrelated process group. */
106
+ * Only a job in the live-background state the roster projects (job.bg present,
107
+ * job.exit absent) may be signaled. Job directories persist after the
108
+ * supervisor exits, so a retired, still-foreground, or already-stopped job
109
+ * would otherwise have its recorded pgid signaled long after the OS could have
110
+ * reused that id for an unrelated process group. */
99
111
  export declare function stopBackgroundBashJob(contextDir: string, jobId: string): Promise<StopBackgroundBashJobResult>;
100
112
  /** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
101
113
  * rather than the whole file, which for a long-running job can be enormous. */
@@ -5,6 +5,7 @@
5
5
  import { randomBytes } from 'node:crypto';
6
6
  import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
+ import { captureTeardownSnapshot, isAnyPidAlive, killProcessTreePids } from './canvas/pid.js';
8
9
  const MAX_BASH_JOB_PURPOSE_BYTES = 280;
9
10
  /** A purpose is display text, never shell input. Keep the file-backed control
10
11
  * plane to one bounded, visible line so old or hand-written job directories
@@ -41,6 +42,7 @@ export function bashJobPaths(contextDir, jobId) {
41
42
  jobBg: join(dir, 'job.bg'),
42
43
  jobDone: join(dir, 'job.done'),
43
44
  jobPgid: join(dir, 'job.pgid'),
45
+ jobPgidIdentity: join(dir, 'job.pgid-identity'),
44
46
  jobPurpose: join(dir, 'purpose'),
45
47
  jobDeadline: join(dir, 'job.deadline'),
46
48
  };
@@ -56,6 +58,17 @@ export function readJobPgid(contextDir, jobId) {
56
58
  return undefined;
57
59
  }
58
60
  }
61
+ /** Launch-time identity of the supervisor. Missing for jobs created before
62
+ * process-identity persistence, which are deliberately not safe to signal. */
63
+ export function readJobPgidIdentity(contextDir, jobId) {
64
+ try {
65
+ const identity = readFileSync(bashJobPaths(contextDir, jobId).jobPgidIdentity, 'utf8').trim();
66
+ return identity === '' ? undefined : identity;
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
59
72
  /** The optional human-readable job label. Jobs created before labels existed
60
73
  * have no file and deliberately project null. */
61
74
  export function readJobPurpose(contextDir, jobId) {
@@ -143,15 +156,31 @@ export function isBashJobProcessGroupAlive(pgid) {
143
156
  return err.code === 'EPERM';
144
157
  }
145
158
  }
146
- /** Stop one live background job using the same process-group and sentinel
147
- * semantics as `crtr node bash kill`. The caller owns the user-facing
148
- * notification; this primitive owns only the job control-plane mutation.
159
+ /** Best-effort cancellation for a detached bash supervisor. It snapshots the
160
+ * observable descendant tree while the supervisor is still alive, signals its
161
+ * leaves before their parents, then uses the shared identity-guarded KILL
162
+ * escalation. A descendant that detached and reparented before this snapshot
163
+ * is outside this guarantee. */
164
+ export async function cancelBashJobProcessTree(pgid, expectedIdentity) {
165
+ const snapshot = captureTeardownSnapshot(pgid, expectedIdentity);
166
+ if (snapshot.reused || snapshot.tree.length === 0)
167
+ return false;
168
+ const tree = [...snapshot.tree].reverse();
169
+ const signaled = isAnyPidAlive(tree);
170
+ killProcessTreePids(tree, 'SIGTERM', snapshot.identities);
171
+ await new Promise((resolve) => setTimeout(resolve, 400));
172
+ killProcessTreePids(tree, 'SIGKILL', snapshot.identities);
173
+ return signaled;
174
+ }
175
+ /** Stop one live background job using the same best-effort tree cancellation
176
+ * semantics as inline abort and timeout. The caller owns the user-facing
177
+ * notification; this primitive owns only the job control-plane mutation.
149
178
  *
150
- * Only a job in the live-background state the roster projects (job.bg
151
- * present, job.exit absent) may be signaled. Job directories persist after
152
- * the supervisor exits, so a retired, still-foreground, or already-stopped
153
- * job would otherwise have its recorded pgid signaled long after the OS
154
- * could have reused that id for an unrelated process group. */
179
+ * Only a job in the live-background state the roster projects (job.bg present,
180
+ * job.exit absent) may be signaled. Job directories persist after the
181
+ * supervisor exits, so a retired, still-foreground, or already-stopped job
182
+ * would otherwise have its recorded pgid signaled long after the OS could have
183
+ * reused that id for an unrelated process group. */
155
184
  export async function stopBackgroundBashJob(contextDir, jobId) {
156
185
  const paths = bashJobPaths(contextDir, jobId);
157
186
  if (!existsSync(paths.dir))
@@ -159,7 +188,8 @@ export async function stopBackgroundBashJob(contextDir, jobId) {
159
188
  if (!existsSync(paths.jobBg) || existsSync(paths.jobExit))
160
189
  return { kind: 'retired', logPath: paths.jobLog };
161
190
  const pgid = readJobPgid(contextDir, jobId);
162
- if (pgid === undefined)
191
+ const pgidIdentity = readJobPgidIdentity(contextDir, jobId);
192
+ if (pgid === undefined || pgidIdentity === undefined)
163
193
  return { kind: 'not-stoppable', logPath: paths.jobLog };
164
194
  // Claim the stop by creating job.exit exclusively, before signaling. The
165
195
  // loser of two concurrent stops — and a stop that races the supervisor's own
@@ -179,18 +209,7 @@ export async function stopBackgroundBashJob(contextDir, jobId) {
179
209
  return { kind: 'retired', logPath: paths.jobLog };
180
210
  throw err;
181
211
  }
182
- let signaled = true;
183
- try {
184
- process.kill(-pgid, 'SIGTERM');
185
- }
186
- catch {
187
- signaled = false;
188
- }
189
- await new Promise((resolve) => setTimeout(resolve, 400));
190
- try {
191
- process.kill(-pgid, 'SIGKILL');
192
- }
193
- catch { /* already gone */ }
212
+ const signaled = await cancelBashJobProcessTree(pgid, pgidIdentity);
194
213
  return { kind: 'stopped', signaled, pgid, logPath: paths.jobLog };
195
214
  }
196
215
  /** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
@@ -76,11 +76,13 @@ export interface LaunchSpec {
76
76
  env: Record<string, string>;
77
77
  }
78
78
  /** The repository fingerprint a refusal was decided against. An examination
79
- * that reads the same three values can skip the whole proof cascade: nothing
80
- * about the refusal can have changed. */
79
+ * that reads the same values can skip the whole proof cascade: nothing about
80
+ * the refusal can have changed. */
81
81
  export interface ManagedWorktreeObservation {
82
82
  /** Tip of `refs/heads/<base_ref>`, or null when it no longer resolves. */
83
83
  base: string | null;
84
+ /** Tip of the base branch's upstream (or `origin/<base_ref>` fallback). */
85
+ upstream: string | null;
84
86
  /** Tip of `refs/heads/<branch>`, or null when the branch is gone. */
85
87
  branch: string | null;
86
88
  /** `absent`, or a hash of the checkout's HEAD plus `git status --porcelain`. */
@@ -403,7 +403,7 @@ export async function parseArgv(params, tokens, options) {
403
403
  continue;
404
404
  }
405
405
  const rawVal = inlineValue !== undefined ? inlineValue : tokens[++i];
406
- if (rawVal === undefined || rawVal.startsWith('--')) {
406
+ if (rawVal === undefined || (inlineValue === undefined && rawVal.startsWith('--'))) {
407
407
  const focusedNext = flagDef.focusedHelp !== undefined && (options?.leafPath?.length ?? 0) > 0
408
408
  ? `Run \`crtr ${options.leafPath.join(' ')} --${flagName} -h\` and read the focused value contract before retrying.`
409
409
  : undefined;
@@ -1,5 +1,5 @@
1
1
  import type { ErrorClass, OperationId } from './events/types.js';
2
- export type FaultLink = 'pi→provider' | 'viewer↔broker' | 'relay↔broker' | 'viewer↔crtrd' | 'daemon→node' | 'crtr→pi';
2
+ export type FaultLink = 'pi→provider' | 'viewer↔broker' | 'relay↔broker' | 'viewer↔crtrd' | 'broker↔crtrd' | 'daemon→node' | 'crtr→pi';
3
3
  export type FaultKind = 'rate-limit' | 'overloaded' | 'connection' | 'auth' | 'protocol' | 'context-overflow' | 'other' | 'wedged' | 'model-not-found';
4
4
  export type FaultRetryDisposition = 'auto' | 'manual' | 'fatal';
5
5
  export type FaultRetryOwner = 'sdk' | 'daemon' | 'client';
@@ -11,8 +11,6 @@ type EventProjectionDeps = {
11
11
  notifyTurnAccepted: () => void;
12
12
  emitStartupMilestone: (event: string, model: string | null) => void;
13
13
  formatModelSpec: (model: BrokerSession['model'], thinkingLevel?: string) => string | null;
14
- refreshIntent: () => Promise<boolean>;
15
- onRefreshIntentError: (error: unknown) => void;
16
14
  };
17
15
  /** Projects one live engine event stream to broker viewers and owns its relay timers. */
18
16
  export declare class EventProjection {
@@ -40,7 +38,6 @@ export declare class EventProjection {
40
38
  private flushPendingUpdate;
41
39
  private invalidatePendingBroadcasts;
42
40
  private broadcast;
43
- private broadcastMessageEnd;
44
41
  private setPendingBroadcast;
45
42
  }
46
43
  export {};
@@ -128,8 +128,8 @@ export class EventProjection {
128
128
  return;
129
129
  }
130
130
  this.flushPendingUpdate();
131
- if (type === 'message_end') {
132
- this.broadcastMessageEnd(event);
131
+ if (type === 'message_end' && generation.stagedRefreshAbort) {
132
+ this.broadcast({ ...event, message: withoutYieldAbort(event.message) });
133
133
  return;
134
134
  }
135
135
  this.broadcast(event);
@@ -183,19 +183,6 @@ export class EventProjection {
183
183
  this.deps.registry.broadcast(frame);
184
184
  }));
185
185
  }
186
- broadcastMessageEnd(event) {
187
- const preceding = this.pendingBroadcast ?? Promise.resolve();
188
- const generation = this.broadcastGeneration;
189
- this.setPendingBroadcast(preceding.then(async () => {
190
- const refresh = await this.deps.refreshIntent();
191
- if (this.broadcastGeneration !== generation)
192
- return;
193
- this.deps.registry.broadcast(refresh ? { ...event, message: withoutYieldAbort(event.message) } : event);
194
- }).catch((error) => {
195
- if (this.broadcastGeneration === generation)
196
- this.deps.onRefreshIntentError(error);
197
- }));
198
- }
199
186
  setPendingBroadcast(work) {
200
187
  this.pendingBroadcast = work;
201
188
  void work.then(() => {
@@ -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;