@north-light/crouter 0.3.305 → 0.3.307

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 (31) hide show
  1. package/dist/api/plugin-manifest-schema.d.ts +2 -1
  2. package/dist/builtin-memory/internal/plugins.md +1 -1
  3. package/dist/commands/cron.js +9 -7
  4. package/dist/core/__tests__/integration/command-plugins.test.js +44 -4
  5. package/dist/core/__tests__/integration/git-optional-locks.test.d.ts +1 -0
  6. package/dist/core/__tests__/integration/git-optional-locks.test.js +81 -0
  7. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +5 -0
  8. package/dist/core/__tests__/relaunch-root.test.js +2 -2
  9. package/dist/core/command-hooks/lifecycle-catalog.d.ts +1 -1
  10. package/dist/core/command-hooks/lifecycle-catalog.js +1 -1
  11. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +1 -1
  12. package/dist/core/command-hooks/transport/exec-lifecycle.js +1 -1
  13. package/dist/core/command-manifests/schema.js +2 -1
  14. package/dist/core/command-plugins/discovery.js +36 -1
  15. package/dist/core/command.d.ts +2 -1
  16. package/dist/core/git.js +16 -2
  17. package/dist/core/human/component-docs.js +3 -1
  18. package/dist/core/runtime/bearings-render.js +13 -4
  19. package/dist/core/runtime/close.d.ts +2 -0
  20. package/dist/core/runtime/close.js +51 -9
  21. package/dist/core/runtime/host.d.ts +13 -5
  22. package/dist/core/runtime/host.js +144 -177
  23. package/dist/core/runtime/placement.d.ts +1 -1
  24. package/dist/core/runtime/placement.js +3 -2
  25. package/dist/daemon/api/handlers/nodes.js +3 -0
  26. package/dist/daemon/control.d.ts +2 -0
  27. package/dist/daemon/crtrd.js +1 -1
  28. package/dist/hook-authoring.d.ts +2 -2
  29. package/dist/hook-authoring.js +2 -2
  30. package/package.json +1 -1
  31. package/runtime.lock.json +5 -5
@@ -103,7 +103,8 @@ export interface ManifestTimeouts {
103
103
  streamIdleMs?: number;
104
104
  }
105
105
  /** Exec transport only: forward every argv token after this branch to an
106
- * external binary instead of parsing children. A passthrough branch is
106
+ * external binary instead of parsing children. `bin` is either a bare PATH
107
+ * command or a plugin-root-relative executable path. A passthrough branch is
107
108
  * childless by construction; an HTTP manifest rejects it, because an HTTP
108
109
  * transport must not name a local binary to execute. */
109
110
  export interface ManifestPassthrough {
@@ -266,7 +266,7 @@ An HTTP-transport plugin replaces that declaration with:
266
266
  "transport": { "kind": "http", "endpoint": "https://example.com", "authEnv": "DEPLOY_TOKEN" }
267
267
  ```
268
268
 
269
- For `exec`, `executable` is required when the manifest has any executable leaf; a passthrough-only manifest omits it. When declared, it is plugin-root-relative, resolves inside the plugin root to a regular file, and carries the POSIX exec bit. For `http`, `transport.endpoint` is the backend origin and `authEnv` is optional. crouter stores only the environment variable **name**, never its credential; it reads the credential when invoking a leaf. A crouter-materialized archive plugin additionally records its archive URL separately in `bundle` for installation and update.
269
+ For `exec`, `executable` is required when the manifest has any executable leaf; a passthrough-only manifest omits it. When declared, it is plugin-root-relative, resolves inside the plugin root to a regular file, and carries the POSIX exec bit. A passthrough `bin` is either a bare command resolved through `PATH` or a plugin-root-relative executable path subject to the same containment and permission checks. For `http`, `transport.endpoint` is the backend origin and `authEnv` is optional. crouter stores only the environment variable **name**, never its credential; it reads the credential when invoking a leaf. A crouter-materialized archive plugin additionally records its archive URL separately in `bundle` for installation and update.
270
270
 
271
271
  Only an installed, **enabled** plugin's command manifest contributes. Discovery is per-invocation and reads the installed `commands.json` only: enable, disable, update, and remove take effect on the next `crtr` call with no daemon restart, cache clearing, or network fetch.
272
272
 
@@ -132,7 +132,7 @@ const addLeaf = defineLeaf({
132
132
  { kind: 'stdin', name: 'command', required: true, constraint: "Arbitrary bash the daemon runs at each fire. Pipe it from a single-quoted heredoc (`<<'EOF'`) to preserve literal bytes. Runs via `bash -c` in this cwd with $CRTR_CRON_ID, $CRTR_CRON_NAME, and $CRTR_RUN_ID in the env ($CRTR_ANCHOR too when anchored). The scheduler does not interpret it as a node action; a bash predicate can decide whether this fire acts, not arm a native predicate trigger. A run that must branch on canvas state should call `crtr --json <cmd>` and read the leaf's declared outputs, rather than parsing the prose output." },
133
133
  { kind: 'flag', name: 'name', type: 'string', required: true, constraint: 'Agent-legible label — how the cron shows in `cron list`.' },
134
134
  { kind: 'flag', name: 'at', type: 'string', required: false, constraint: 'One-shot: run the stored bash once at <when> — a duration ("90s","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", interpreted in --tz else host zone). Exactly one of --at / --every is required. The row is deleted after its run unless the run\'s disposition pauses it.' },
135
- { kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'Recurring: run the stored bash at each cadence — a fixed interval ("6h" — first fire one interval from now), a 5-field cron ("0 9 * * *"), or an @alias ("@daily"). Each fire is another bash run. Minimum cadence 60s. Exactly one of --at / --every is required. For a gated cron (a command that exits 75 while ineligible), pick a LONG cadence: each natural slot is only the backstop re-check, and the daemon poke supplies the latency — "6h" plus poke beats "15m" polling on both latency and cost.' },
135
+ { kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'Recurring: run the stored bash at each cadence — a fixed interval ("6h" — first fire one interval from now), a 5-field cron ("0 9 * * *"), or an @alias ("@daily"). Each fire is another bash run. Minimum cadence 60s. Exactly one of --at / --every is required. Use this for a bash condition that only the local filesystem knows: if it exits 75, the next cadence re-checks it. A host platform can also re-check it early by calling the daemon\'s `POST /v1/crons/poke` endpoint.' },
136
136
  { kind: 'flag', name: 'tz', type: 'string', required: false, constraint: 'IANA zone (e.g. "America/New_York") for a bare-ISO --at or a calendar --every. Defaults to the host zone.' },
137
137
  { kind: 'flag', name: 'on-output', type: 'enum', choices: ['silent', 'on-failure', 'always', 'on-change'], required: false, constraint: 'What each run does with its output. Defaults to on-change for a node:<id> sink; otherwise on-failure. silent: record in the run log, tell nobody — never escalate. on-failure: no success delivery; a failing run pauses the cron and spawns a node to deal with it. always: deliver stdout every run to --sink at --tier. on-change: deliver stdout only when it differs from the previous run\'s — the shape polling wants.' },
138
138
  { kind: 'flag', name: 'sink', type: 'string', required: false, constraint: 'Where always/on-change stdout deliveries go: "node:<id>" (an existing node\'s inbox — must exist now; gone/finalized at fire time is a failure), "spawn:<kind>" (each delivered output creates a self-finishing node with stdout as kickoff — a managed child while its creator exists, otherwise a terminal parentless root), or "human" (the humanloop inbox). Required by always/on-change, rejected otherwise.' },
@@ -282,7 +282,7 @@ const listLeaf = defineLeaf({
282
282
  summary: 'every cron in scope, next-fire order',
283
283
  params: [],
284
284
  output: [
285
- { name: 'crons', type: 'object[]', required: true, constraint: 'One row per cron: cron_id, name, fire_at (next fire, UTC), recur (cadence display), state (active|paused), held (true while parked by an exit-75 gate — fires on daemon poke, else at fire_at), on_output, sink, expires_at, last_run (the most recent settled run: finished, exit_code, delivered — null if it never ran).' },
285
+ { name: 'crons', type: 'object[]', required: true, constraint: 'One row per cron: cron_id, name, fire_at (next fire, UTC), recur (cadence display), state (active|paused), held (true after an exit-75 gate; a recurring row re-checks at fire_at or when a host platform calls `POST /v1/crons/poke`, while a one-shot needs that call or `crtr cron run <cron-id>`), on_output, sink, expires_at, last_run (the most recent settled run: finished, exit_code, delivered — null if it never ran).' },
286
286
  ],
287
287
  outputKind: 'object',
288
288
  effects: ['None. Read-only.'],
@@ -325,7 +325,9 @@ const listLeaf = defineLeaf({
325
325
  ? ' [PAUSED (held) — resume or cancel]'
326
326
  : ' [PAUSED — resume or cancel]'
327
327
  : c.held
328
- ? ' [HELD — last run exited 75; fires on daemon poke]'
328
+ ? c.recur === 'none'
329
+ ? ` [HELD — last run exited 75; run manually with \`crtr cron run ${c.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`]`
330
+ : ' [HELD — last run exited 75; re-checks at the next cadence or when a host platform calls `POST /v1/crons/poke`]'
329
331
  : '';
330
332
  return `- ${c.name} (${c.cron_id}) — ${bits.join(', ')}${marker}`;
331
333
  });
@@ -366,10 +368,10 @@ const showLeaf = defineLeaf({
366
368
  const scheduleLine = !c.held
367
369
  ? `- schedule: ${schedule}${tzPart} — next fire ${c.fire_at}`
368
370
  : c.recur !== null
369
- ? `- schedule: ${schedule}${tzPart} — backstop ${c.fire_at} (held — fires early on daemon poke; last run exited 75)`
371
+ ? `- schedule: ${schedule}${tzPart} — next re-check ${c.fire_at} (held — also re-checks when a host platform calls \`POST /v1/crons/poke\`; last run exited 75)`
370
372
  : c.expires_at !== null
371
- ? `- schedule: ${schedule}${tzPart} — backstop none — expires ${c.expires_at} (held — fires on daemon poke; deleted unfired at expiry)`
372
- : `- schedule: ${schedule}${tzPart} — backstop none (held — fires only on daemon poke)`;
373
+ ? `- schedule: ${schedule}${tzPart} — no scheduled re-check — expires ${c.expires_at} (held — run manually with \`crtr cron run ${c.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`; deleted unfired at expiry)`
374
+ : `- schedule: ${schedule}${tzPart} — no scheduled re-check (held — run manually with \`crtr cron run ${c.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`)`;
373
375
  const stateTag = c.state === 'paused' ? (c.held ? ' — PAUSED (held)' : ' — PAUSED') : c.held ? ' — HELD' : '';
374
376
  const lines = [
375
377
  `# ${c.name} (${c.cron_id})${stateTag}`,
@@ -525,7 +527,7 @@ export function registerCron() {
525
527
  help: {
526
528
  name: 'cron',
527
529
  summary: 'scheduled bash commands, daemon-run',
528
- model: 'A cron stores and daemon-runs arbitrary bash at its clock. Use ordinary bash for ordinary work; choose node:<id> for stdout as inbox information to an existing node (its disposition defaults to on-change), an existing node\'s lifecycle fresh-revive action in the bash for a clean re-check without inbox output, or spawn:<kind> to route each output-producing fire to a fresh self-finishing node (a managed child while its creator exists, otherwise a terminal parentless root). Per-fire fresh agent work belongs on the existing spawn:<kind> sink, not an independent resident-root birth. A gate is bash, at the top of the command. Exit 0 after deciding not to act and the occurrence is simply spent — right when the recurrence is your polling cadence. Exit 75 and the occurrence is OWED: the row is held, fires again the moment the daemon receives an eligibility poke from the host platform, and otherwise re-checks at its next natural slot (a held one-shot instead waits for a poke until --expires deletes it unfired). Any other nonzero exit is a real failure and escalates per --on-output. Use 75 for "not yet — retry when conditions change" (a device coming online); keep gate ERRORS nonzero so a broken probe is loud instead of silently parked. Beyond the exit-code contract, cron has no native predicate-trigger semantics. --at runs once and deletes the row unless the run\'s disposition pauses it; --every runs each cadence. Output disposition (--on-output) records silently, escalates failures, or routes stdout to its --sink. Anchor a cron to delete it with a node; --cancel-on-wake instead deletes it when the anchor wakes. An unanchored recurring cron ends through --expires, explicit cancellation, or self-cancellation from its bash. Every run lands in a bounded per-cron run log (`cron show`); cwd, env, and profile are snapshotted at arm time, and --scope controls who lists and cancels it.',
530
+ model: 'A cron stores and daemon-runs arbitrary bash at its clock. Use ordinary bash for ordinary work; choose node:<id> for stdout as inbox information to an existing node (its disposition defaults to on-change), an existing node\'s lifecycle fresh-revive action in the bash for a clean re-check without inbox output, or spawn:<kind> to route each output-producing fire to a fresh self-finishing node (a managed child while its creator exists, otherwise a terminal parentless root). Per-fire fresh agent work belongs on the existing spawn:<kind> sink, not an independent resident-root birth. A gate is bash, at the top of the command. Exit 0 after deciding not to act and the occurrence is simply spent — right when the recurrence is your polling cadence. Exit 75 holds the row. A held recurring row re-checks at its next natural slot, or when a host platform calls the daemon\'s `POST /v1/crons/poke` endpoint. A held one-shot has no scheduled re-check: it stays held until that endpoint is called, or you execute its bash explicitly with `crtr cron run <cron-id>` (which does not unhold or reschedule the row). A local filesystem condition does not call the daemon, so use --every for that condition or run the cron explicitly after it changes. Any other nonzero exit is a real failure and escalates per --on-output. Keep gate errors nonzero so a broken probe is loud instead of silently held. Beyond the exit-code contract, cron has no native predicate-trigger semantics. --at runs once and deletes the row unless the run\'s disposition pauses it; --every runs each cadence. Output disposition (--on-output) records silently, escalates failures, or routes stdout to its --sink. Anchor a cron to delete it with a node; --cancel-on-wake instead deletes it when the anchor wakes. An unanchored recurring cron ends through --expires, explicit cancellation, or self-cancellation from its bash. Every run lands in a bounded per-cron run log (`cron show`); cwd, env, and profile are snapshotted at arm time, and --scope controls who lists and cancels it.',
529
531
  },
530
532
  children: [addLeaf, listLeaf, showLeaf, runLeaf, pauseLeaf, resumeLeaf, cancelLeaf],
531
533
  });
@@ -153,7 +153,7 @@ process.stdin.on('end', () => {
153
153
  process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true, result: { value: request.input.thing } }));
154
154
  });
155
155
  `;
156
- function passthroughCommandsJson() {
156
+ function passthroughCommandsJson(bin = 'capture') {
157
157
  return {
158
158
  schemaVersion: 1,
159
159
  mounts: [{
@@ -169,7 +169,7 @@ function passthroughCommandsJson() {
169
169
  whenToUse: 'driving a browser through the Capture CLI',
170
170
  },
171
171
  summary: 'browser automation through Capture',
172
- passthrough: { bin: 'capture', installHint: 'Install @crouton-kit/capture globally.' },
172
+ passthrough: { bin, installHint: 'Install @crouton-kit/capture globally.' },
173
173
  children: [],
174
174
  },
175
175
  }],
@@ -417,17 +417,17 @@ describe('protocol integration', () => {
417
417
  });
418
418
  }
419
419
  });
420
+ const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', 'dist', 'cli.js');
420
421
  // 4b. True end-to-end CLI invocation (argv → parseArgv → adapter → renderer)
421
422
  describe('end-to-end CLI invocation', () => {
422
423
  // dist/cli.js is up 4 from src/core/__tests__/integration/. Requires a prior
423
424
  // `npm run build` (the harness/CI builds before tests). Drives the WHOLE seam: the
424
425
  // declared positional `app-id` must arrive as `appId` in the request, and
425
426
  // the rendered + --json outputs come through the real dispatcher.
426
- const cli = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', 'dist', 'cli.js');
427
427
  const runCrtr = (args) => {
428
428
  installPlugin(userRoot, 'deploy-tools');
429
429
  resetScopeCache();
430
- return execFileSync(process.execPath, [cli, ...args], {
430
+ return execFileSync(process.execPath, [CLI, ...args], {
431
431
  cwd: emptyStart,
432
432
  env: { ...process.env, HOME: home, CRTR_FRONT_DOOR: '' },
433
433
  encoding: 'utf8',
@@ -445,6 +445,38 @@ describe('end-to-end CLI invocation', () => {
445
445
  assert.equal(obj.status, 'running');
446
446
  });
447
447
  });
448
+ // Passthrough paths resolve inside their plugin during discovery. Bare names are
449
+ // deliberately left for the invoking process's PATH.
450
+ describe('passthrough binary resolution', () => {
451
+ function runPassthrough(args, env = process.env) {
452
+ return execFileSync(process.execPath, [CLI, ...args], {
453
+ cwd: emptyStart,
454
+ env: { ...env, HOME: home, CRTR_FRONT_DOOR: '' },
455
+ encoding: 'utf8',
456
+ });
457
+ }
458
+ test('a plugin-relative bin runs the plugin program', () => {
459
+ const root = installPlugin(userRoot, 'passthrough-fixture', { manifest: passthroughCommandsJson('bin/passthrough.js'), executable: null });
460
+ const program = join(root, 'bin', 'passthrough.js');
461
+ writeFileSync(program, '#!/bin/sh\nprintf "plugin program: %s\\n" "$*"\n');
462
+ chmodSync(program, 0o755);
463
+ const validation = validatePluginCommands(listInstalledPluginsInRoot('user', userRoot).find((plugin) => plugin.name === 'passthrough-fixture'), RESERVED);
464
+ assert.equal(validation.issues.length, 0, JSON.stringify(validation.issues));
465
+ assert.equal(validation.contributions[0].node.passthrough?.bin, realpathSync(program));
466
+ assert.equal(runPassthrough(['capture', 'one', '--two']).trim(), 'plugin program: one --two');
467
+ });
468
+ test('a bare bin runs the program from PATH', () => {
469
+ installPlugin(userRoot, 'passthrough-fixture', { manifest: passthroughCommandsJson('capture-fixture'), executable: null });
470
+ const pathDir = mintDir('crtr-cmdplugin-path-');
471
+ const program = join(pathDir, 'capture-fixture');
472
+ writeFileSync(program, '#!/bin/sh\nprintf "PATH program: %s\\n" "$*"\n');
473
+ chmodSync(program, 0o755);
474
+ const validation = validatePluginCommands(listInstalledPluginsInRoot('user', userRoot).find((plugin) => plugin.name === 'passthrough-fixture'), RESERVED);
475
+ assert.equal(validation.issues.length, 0, JSON.stringify(validation.issues));
476
+ assert.equal(validation.contributions[0].node.passthrough?.bin, 'capture-fixture');
477
+ assert.equal(runPassthrough(['capture', 'three'], { ...process.env, PATH: `${pathDir}:${process.env['PATH'] ?? ''}` }).trim(), 'PATH program: three');
478
+ });
479
+ });
448
480
  // Extensible plugin command branches — repository fragments attach to a marked
449
481
  // top-level branch as one native forest and stay entirely inside the existing
450
482
  // parser/help/exec contract.
@@ -794,6 +826,14 @@ describe('typed validation issues', () => {
794
826
  const v = validate(commandsJson(), { executable: '../../../../bin/sh' });
795
827
  assert.ok(v.issues.some((i) => i.code === 'command_path_unsafe'));
796
828
  });
829
+ test('passthrough path escaping the plugin root → command_path_unsafe', () => {
830
+ const v = validate(passthroughCommandsJson('../../../../bin/sh'), { executable: null });
831
+ assert.ok(v.issues.some((i) => i.code === 'command_path_unsafe'));
832
+ });
833
+ test('non-executable passthrough path → command_not_executable', () => {
834
+ const v = validate(passthroughCommandsJson('bin/cmd.js'), { executable: null, execBit: false });
835
+ assert.ok(v.issues.some((i) => i.code === 'command_not_executable'));
836
+ });
797
837
  test('a top-level leaf (no rootEntry) → command_node_invalid', () => {
798
838
  const m = {
799
839
  schemaVersion: 1,
@@ -0,0 +1,81 @@
1
+ // Run with: npm run test:integration
2
+ //
3
+ // Regression: crouter's read-only git calls must not take `.git/index.lock`.
4
+ //
5
+ // `git status` refreshes the index stat cache and writes the index back, which
6
+ // means creating `.git/index.lock` and renaming it over `.git/index`. A process
7
+ // killed between those two steps orphans the lock, and it then blocks every
8
+ // later writer in that checkout with no owning process left to name. Agent
9
+ // processes are killed mid-command routinely — a closed node, a cancelled daemon
10
+ // tick, a timed-out shell — so the residue accumulates in a shared checkout
11
+ // until someone removes it by hand.
12
+ //
13
+ // `GIT_OPTIONAL_LOCKS=0` suppresses exactly that optional lock. These tests pin
14
+ // the two paths that reach git: the shared spawner in `core/git.ts`, and the
15
+ // bearings project-context block, which renders on every node boot and revive
16
+ // and is therefore the most frequent git caller in the runtime.
17
+ import { test } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import { mkdtempSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
20
+ import { spawnSync } from 'node:child_process';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { gitSync } from '../../git.js';
24
+ import { buildProjectContextBlock } from '../../runtime/bearings-render.js';
25
+ /** A repo with one committed file whose stat cache is deliberately stale, so a
26
+ * status that refreshes the index has a reason to write it back. Without the
27
+ * stale stat there is nothing to refresh and the assertion passes vacuously. */
28
+ function repoWithStaleStatCache() {
29
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-git-locks-'));
30
+ const git = (args) => {
31
+ const result = spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
32
+ assert.equal(result.status, 0, `git ${args.join(' ')}: ${result.stderr}`);
33
+ };
34
+ writeFileSync(join(dir, 'f.txt'), 'a\n', 'utf8');
35
+ git(['init', '-q', '.']);
36
+ git(['-c', 'user.email=t@t', '-c', 'user.name=t', 'add', 'f.txt']);
37
+ git(['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init']);
38
+ // Move the file's mtime forward so git's cached stat no longer matches.
39
+ const future = new Date(Date.now() + 10_000);
40
+ utimesSync(join(dir, 'f.txt'), future, future);
41
+ return dir;
42
+ }
43
+ function indexMtime(dir) {
44
+ return statSync(join(dir, '.git', 'index')).mtimeMs;
45
+ }
46
+ test('gitSync status does not rewrite the index', (t) => {
47
+ if (spawnSync('git', ['--version']).status !== 0) {
48
+ t.skip('git is unavailable');
49
+ return;
50
+ }
51
+ const dir = repoWithStaleStatCache();
52
+ try {
53
+ const before = indexMtime(dir);
54
+ const result = gitSync(['status', '--porcelain'], dir);
55
+ assert.equal(result.status, 0, result.stderr);
56
+ // Suppressing the lock must not change what the command reports.
57
+ assert.equal(result.stdout, '');
58
+ assert.equal(indexMtime(dir), before, 'status took .git/index.lock and rewrote the index');
59
+ }
60
+ finally {
61
+ rmSync(dir, { recursive: true, force: true });
62
+ }
63
+ });
64
+ test('the bearings project-context block does not rewrite the index', (t) => {
65
+ if (spawnSync('git', ['--version']).status !== 0) {
66
+ t.skip('git is unavailable');
67
+ return;
68
+ }
69
+ const dir = repoWithStaleStatCache();
70
+ try {
71
+ const before = indexMtime(dir);
72
+ const block = buildProjectContextBlock(dir);
73
+ // The snapshot still reports real git state; it just does not lock to do it.
74
+ assert.match(block, /Git: /);
75
+ assert.match(block, /clean/);
76
+ assert.equal(indexMtime(dir), before, 'the bearings snapshot took .git/index.lock');
77
+ }
78
+ finally {
79
+ rmSync(dir, { recursive: true, force: true });
80
+ }
81
+ });
@@ -171,6 +171,11 @@ test('hooks.json v1 remains command-only while v2 accepts a lifecycle-only decla
171
171
  hooks: [],
172
172
  lifecycle: [{ event: 'node:start', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }],
173
173
  });
174
+ const close = validateHookManifest({
175
+ schemaVersion: 2,
176
+ lifecycle: [{ event: 'node:close', phase: 'on', op: 'cleanup', description: 'cleanup', effects: ['removes a directory'] }],
177
+ });
178
+ assert.deepEqual(close.issues, []);
174
179
  for (const manifest of [
175
180
  { schemaVersion: 2, hooks: [], lifecycle: [] },
176
181
  { schemaVersion: 2, lifecycle: [{ event: 'node:stop', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }] },
@@ -61,7 +61,7 @@ function makeDeps(pid) {
61
61
  },
62
62
  waitForViewSocket: () => true,
63
63
  respawnViewer: () => true,
64
- teardownBroker: () => { },
64
+ teardownBroker: async () => ({ confirmed: true }),
65
65
  };
66
66
  return { deps, launched };
67
67
  }
@@ -151,7 +151,7 @@ test('relaunchRoot at capacity refuses visibly and settles the half-born row bef
151
151
  },
152
152
  waitForViewSocket: () => true,
153
153
  respawnViewer: () => true,
154
- teardownBroker: () => { },
154
+ teardownBroker: async () => ({ confirmed: true }),
155
155
  };
156
156
  await assert.rejects(() => relaunchRoot('root', deps), (error) => {
157
157
  const details = error.details ?? {};
@@ -1,3 +1,3 @@
1
- export declare const LIFECYCLE_EVENTS: readonly ["node:start"];
1
+ export declare const LIFECYCLE_EVENTS: readonly ["node:start", "node:close"];
2
2
  export type LifecycleEvent = typeof LIFECYCLE_EVENTS[number];
3
3
  export declare function isLifecycleEvent(value: string): value is LifecycleEvent;
@@ -1,4 +1,4 @@
1
- export const LIFECYCLE_EVENTS = ['node:start'];
1
+ export const LIFECYCLE_EVENTS = ['node:start', 'node:close'];
2
2
  export function isLifecycleEvent(value) {
3
3
  return LIFECYCLE_EVENTS.includes(value);
4
4
  }
@@ -8,5 +8,5 @@ export interface LifecycleHookInvocation {
8
8
  }
9
9
  /** Runs one lifecycle hook. The node launch remains authoritative on every failure. */
10
10
  export declare function invokeLifecycleHook(hook: EffectiveLifecycleHook, invocation: LifecycleHookInvocation, timeoutMs: number): Promise<void>;
11
- /** Executes lifecycle hooks in discovery order, within one bounded start-event budget. */
11
+ /** Executes lifecycle hooks in discovery order, within one bounded event budget. */
12
12
  export declare function invokeLifecycleHooks(hooks: readonly EffectiveLifecycleHook[], invocation: LifecycleHookInvocation): Promise<void>;
@@ -34,7 +34,7 @@ export async function invokeLifecycleHook(hook, invocation, timeoutMs) {
34
34
  diag(`crtr: ${label(hook)} failed: ${describeError(error)}`);
35
35
  }
36
36
  }
37
- /** Executes lifecycle hooks in discovery order, within one bounded start-event budget. */
37
+ /** Executes lifecycle hooks in discovery order, within one bounded event budget. */
38
38
  export async function invokeLifecycleHooks(hooks, invocation) {
39
39
  const deadline = Date.now() + LIFECYCLE_EVENT_BUDGET_MS;
40
40
  for (const hook of hooks) {
@@ -436,7 +436,8 @@ function validateBranch(raw, path, topLevel, transport, issue, options) {
436
436
  ...(passthrough !== undefined ? { passthrough } : {}), children: validated,
437
437
  };
438
438
  }
439
- /** `passthrough: { bin, installHint }` — both required non-empty strings. */
439
+ /** `passthrough: { bin, installHint }` — `bin` is a bare PATH command or a
440
+ * plugin-root-relative executable path; both fields are required non-empty strings. */
440
441
  function validatePassthrough(raw, path, issue) {
441
442
  if (!isRecord(raw)) {
442
443
  issue('command_node_invalid', 'passthrough must be an object', typeName(raw), 'an object { bin, installHint }', 'Fix or remove passthrough.', path);
@@ -1,5 +1,5 @@
1
1
  import { realpathSync, readFileSync, statSync } from 'node:fs';
2
- import { resolve, sep } from 'node:path';
2
+ import { isAbsolute, resolve, sep } from 'node:path';
3
3
  import { listInstalledPlugins, listInstalledPluginsInRoot } from '../resolver.js';
4
4
  import { projectScopeRoots } from '../scope.js';
5
5
  import { validateCommandManifest } from '../command-manifests/manifest.js';
@@ -82,6 +82,38 @@ function validateExecExecutable(plugin, transport, manifest, issues) {
82
82
  }
83
83
  return { kind: 'exec', executable };
84
84
  }
85
+ function namesPath(bin) {
86
+ return isAbsolute(bin) || bin.startsWith('.') || bin.includes('/') || bin.includes('\\');
87
+ }
88
+ /** Resolve plugin-owned passthrough programs during discovery. Bare program
89
+ * names intentionally remain untouched so spawn resolves them through PATH. */
90
+ function resolvePassthroughBinaries(plugin, manifest, issues) {
91
+ let valid = true;
92
+ const visit = (node) => {
93
+ if (node.kind !== 'branch')
94
+ return;
95
+ const passthrough = node.passthrough;
96
+ if (passthrough !== undefined && namesPath(passthrough.bin)) {
97
+ const executable = safePath(plugin.root, passthrough.bin);
98
+ if (executable === null) {
99
+ issues.push({ code: 'command_path_unsafe', plugin: plugin.name, message: 'passthrough binary escapes the plugin root or is not a regular file', received: passthrough.bin, expected: 'a relative path to a regular file inside the plugin root', next: 'Fix passthrough.bin in commands.json.', path: 'passthrough.bin' });
100
+ valid = false;
101
+ }
102
+ else if ((statSync(executable).mode & 0o111) === 0) {
103
+ issues.push({ code: 'command_not_executable', plugin: plugin.name, message: 'passthrough binary lacks the POSIX exec bit', received: passthrough.bin, expected: 'a file with an executable permission bit', next: 'chmod +x the plugin executable, then update commands.json.', path: 'passthrough.bin' });
104
+ valid = false;
105
+ }
106
+ else {
107
+ node.passthrough = { ...passthrough, bin: executable };
108
+ }
109
+ }
110
+ for (const child of node.children)
111
+ visit(child);
112
+ };
113
+ for (const root of manifest.roots)
114
+ visit(root);
115
+ return valid;
116
+ }
85
117
  export function validatePluginCommands(plugin, reservedNames = new Set(), coreCommandPaths) {
86
118
  const issues = [];
87
119
  const commands = plugin.manifest.commands;
@@ -131,6 +163,9 @@ export function validatePluginCommands(plugin, reservedNames = new Set(), coreCo
131
163
  : transport;
132
164
  if (resolvedTransport === undefined)
133
165
  return { plugin, manifestPath, transport, contributions: [], issues };
166
+ if (resolvedTransport.kind === 'exec' && !resolvePassthroughBinaries(plugin, validation.manifest, issues)) {
167
+ return { plugin, manifestPath, transport: resolvedTransport, contributions: [], issues };
168
+ }
134
169
  const roots = validation.manifest.roots;
135
170
  if (!roots.every((node) => node.kind === 'branch')) {
136
171
  throw new Error('plugin manifest validation returned a top-level leaf');
@@ -82,7 +82,8 @@ export interface BranchDef {
82
82
  /** Opt this branch out of the tree model entirely: every token after this
83
83
  * branch's name is forwarded VERBATIM (raw argv, not the `--json`-filtered
84
84
  * tokens) to an external binary via spawn, with stdio inherited and the
85
- * child's exit code propagated. A deliberate, documented exception for
85
+ * child's exit code propagated. Plugin-owned paths have already resolved to
86
+ * absolute executable paths; bare program names resolve through PATH. A deliberate, documented exception for
86
87
  * wrapping an external CLI whose own schema crtr cannot and must not
87
88
  * duplicate (the official marketplace's `capture` plugin mounts `crtr
88
89
  * capture` this way) — not a general escape hatch. A
package/dist/core/git.js CHANGED
@@ -1,7 +1,21 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { network } from './errors.js';
3
+ /** Git takes `.git/index.lock` to refresh the index stat cache even during
4
+ * otherwise read-only commands — `status` is the common one. Killed between
5
+ * creating that lock and renaming it over the index, the lock orphans and
6
+ * blocks every later writer in the checkout with no owning process left to
7
+ * name. Agent processes are killed mid-command routinely (a closed node, a
8
+ * cancelled daemon tick, a timed-out shell), so the residue accumulates.
9
+ *
10
+ * `GIT_OPTIONAL_LOCKS=0` suppresses exactly those optional locks and nothing
11
+ * else: a commit, rebase, or `add` still takes the lock it requires, and
12
+ * command output is byte-identical. Built per call rather than snapshotted at
13
+ * module load, so a later change to the ambient environment still reaches git. */
14
+ function gitEnv(base = process.env) {
15
+ return { ...base, GIT_OPTIONAL_LOCKS: '0' };
16
+ }
3
17
  export function gitSync(args, cwd, input, options = {}) {
4
- const res = spawnSync('git', args, { cwd, encoding: 'utf8', input, timeout: options.timeoutMs });
18
+ const res = spawnSync('git', args, { cwd, encoding: 'utf8', input, env: gitEnv(), timeout: options.timeoutMs });
5
19
  const status = typeof res.status === 'number' ? res.status : 1;
6
20
  const completed = typeof res.status === 'number';
7
21
  const stdout = typeof res.stdout === 'string' ? res.stdout : '';
@@ -12,7 +26,7 @@ export function gitSync(args, cwd, input, options = {}) {
12
26
  }
13
27
  export async function gitAsync(args, cwd, input, options = {}) {
14
28
  return new Promise((resolve) => {
15
- const child = spawn('git', args, { cwd, ...(options.env === undefined ? {} : { env: options.env }) });
29
+ const child = spawn('git', args, { cwd, env: gitEnv(options.env) });
16
30
  if (input === undefined)
17
31
  child.stdin.end();
18
32
  else
@@ -9,6 +9,7 @@ const SLOT_RULES = `Component contract
9
9
  - \`id\` is required, must match \`^[A-Za-z0-9_-]{1,64}$\`, and must be unique across the page.
10
10
  - When a page has two or more response-bearing components, every one needs a nonempty \`label\`.
11
11
  - Config objects and every documented nested object reject unknown fields.
12
+ - A quoted JSX attribute does not interpret escape sequences, so \`body="one.\\n\\ntwo."\` reaches the reader as one run-on paragraph with a literal backslash-n in the middle of it. Write any markdown body that has a line or paragraph break as a braced string — \`body={"one.\\n\\ntwo."}\` — where the escape is real.
12
13
  - The page is rendered once at submit to build the ticket manifest, so every prop reaches it whatever its shape — mapped data, a helper component, a value from \`useState\`. Event handlers are dropped, because a manifest is JSON. The user's answer lands in \`response.json\` keyed by this \`id\`.`;
13
14
  const COMMENT_SHAPE = `Comments are \`{id:string (nonempty), text:string, anchor:Anchor}\` and qualify the answer itself. \`Anchor\` is \`{kind:"option",optionId:string}\` or \`{kind:"row",rowId:string}\`; each component accepts only the anchors named next. The reader writes one against a specific option or row in whatever host they read in — in the terminal inbox, \`c\` on the selected one — so every option and row needs a label that reads on its own. Feedback on the page's own prose is a separate channel, offered only by hosts that render the page, and never part of a response.`;
14
15
  const DISPLAY_RULES = `Display-only: it is not recorded in the page manifest and contributes nothing to the response. Every display component also takes \`className\` for Tailwind utilities, and standard shadcn props otherwise.`;
@@ -92,7 +93,8 @@ Response rules
92
93
 
93
94
  Example
94
95
  \`\`\`jsx
95
- <UserQuestion id="lane" label="Release lane" body="The audit closes tomorrow." mode="single" allowFreetext
96
+ <UserQuestion id="lane" label="Release lane" mode="single" allowFreetext
97
+ body={"The audit closes tomorrow.\\n\\nBoth release gates are green as of this morning."}
96
98
  freetextLabel="Something else"
97
99
  options={[
98
100
  { id: 'now', label: 'Ship now', description: 'Both gates are green.', recommended: true },
@@ -57,8 +57,8 @@ function directoryListingLines(cwd) {
57
57
  return ['Directory listing: unavailable (cwd missing or unreadable).'];
58
58
  }
59
59
  }
60
- function runCommand(cwd, cmd, args) {
61
- const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
60
+ function runCommand(cwd, cmd, args, env) {
61
+ const result = spawnSync(cmd, args, { cwd, encoding: 'utf8', ...(env === undefined ? {} : { env }) });
62
62
  if (result.error !== undefined) {
63
63
  return { ok: false, stdout: '', stderr: result.error.message };
64
64
  }
@@ -68,8 +68,17 @@ function runCommand(cwd, cmd, args) {
68
68
  stderr: (result.stderr ?? '').trim(),
69
69
  };
70
70
  }
71
+ /** This snapshot renders on every node boot and every revive, which makes it the
72
+ * most frequent git caller in the runtime — and boot is exactly when a process
73
+ * gets killed. `git status` would take `.git/index.lock` to refresh the stat
74
+ * cache; suppressing that optional lock keeps an interrupted render from
75
+ * orphaning a lock that then blocks every writer in the user's checkout.
76
+ * Only optional locks are affected, and the output is unchanged. */
77
+ function gitEnv() {
78
+ return { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
79
+ }
71
80
  function runGit(cwd, args) {
72
- const result = runCommand(cwd, 'git', args);
81
+ const result = runCommand(cwd, 'git', args, gitEnv());
73
82
  return result.ok ? result.stdout : '';
74
83
  }
75
84
  function parseWorktrees(lines) {
@@ -98,7 +107,7 @@ function parseWorktrees(lines) {
98
107
  * benign state. Every other probe here is genuinely optional and already
99
108
  * falls back to 'unknown'/'HEAD'/a zero count on failure, which stays as-is. */
100
109
  function gitSnapshotLines(cwd) {
101
- const probe = runCommand(cwd, 'git', ['rev-parse', '--is-inside-work-tree']);
110
+ const probe = runCommand(cwd, 'git', ['rev-parse', '--is-inside-work-tree'], gitEnv());
102
111
  if (!probe.ok) {
103
112
  if (/not a git repository/i.test(probe.stderr)) {
104
113
  return ['Git: not a git repository.'];
@@ -33,4 +33,6 @@ export interface CloseNodeResult {
33
33
  export declare function closeNode(rootId: string, opts?: {
34
34
  rootEvent?: 'cancel' | 'finish';
35
35
  callerPid?: number;
36
+ /** Daemon-owned close work that must drain before this daemon exits. */
37
+ registerDetached?: (work: Promise<void>) => void;
36
38
  }): CloseNodeResult;
@@ -36,6 +36,10 @@ import { cancelCronsOnWake, getNode, subscriptionsOf, subscribersOf, } from '../
36
36
  import { transition } from './lifecycle.js';
37
37
  import { requestBrokerTeardown } from './host.js';
38
38
  import { tearDownNode, reapIfEmpty } from './placement.js';
39
+ import { crtrHome, contextDir, jobDir, nodeDir, reportsDir } from '../canvas/paths.js';
40
+ import { discoverLifecycleHookRegistry } from '../command-hooks/discovery.js';
41
+ import { invokeLifecycleHooks } from '../command-hooks/transport/exec-lifecycle.js';
42
+ import { emitEvent } from '../events/emit.js';
39
43
  import { appendInbox } from '../feed/inbox.js';
40
44
  import { appendPassive } from '../feed/passive.js';
41
45
  /** Fan a lifecycle or birth notice to every given subscriber. Active subscribers
@@ -173,12 +177,50 @@ export function closeNode(rootId, opts = {}) {
173
177
  // Surviving managers captured BEFORE any teardown — a reap (below) deletes
174
178
  // this node's edges, so the step-4 fan-out must read them up front.
175
179
  const survivors = subscribersOf(id).filter((s) => !closing.has(s.node_id));
180
+ // Capture the target-scoped hook plan and payload before an empty node's
181
+ // reap can remove its row and directories. The executable still runs from
182
+ // its plugin root; the payload paths can therefore name a reaped directory.
183
+ let afterTeardown;
184
+ try {
185
+ const hooks = discoverLifecycleHookRegistry(m.cwd, m.profile_id).plans.get('node:close') ?? [];
186
+ if (hooks.length > 0) {
187
+ const invocation = {
188
+ node: {
189
+ id,
190
+ name: m.name,
191
+ kind: m.kind,
192
+ mode: m.mode,
193
+ lifecycle: m.lifecycle,
194
+ cwd: m.cwd,
195
+ nodeDir: nodeDir(id),
196
+ contextDir: contextDir(id),
197
+ jobDir: jobDir(id),
198
+ reportsDir: reportsDir(id),
199
+ },
200
+ runtime: { isBirth: false, canvasHome: crtrHome(), profile: m.profile_id ?? null },
201
+ };
202
+ afterTeardown = () => invokeLifecycleHooks(hooks, invocation);
203
+ }
204
+ }
205
+ catch (error) {
206
+ emitEvent({ level: 'error', event: 'node.close_hook.discovery_failed', node_id: id, error });
207
+ }
208
+ // Capture the process snapshot while the row exists, then execute after
209
+ // the response settles. A failed teardown skips the lifecycle hook rather
210
+ // than claiming cleanup was safe.
211
+ const teardown = requestBrokerTeardown(id, id === rootId ? { excludePid: opts.callerPid } : {}, afterTeardown);
212
+ try {
213
+ opts.registerDetached?.(teardown.then(() => { }));
214
+ }
215
+ catch (error) {
216
+ emitEvent({ level: 'error', event: 'node.close_hook.detached_registration_failed', node_id: id, error });
217
+ }
176
218
  // 0) A node with no substantive assistant output is a useless shell — don't
177
219
  // park it as a canceled husk; reap it outright (engine + viewer + row +
178
- // dir). reapIfEmpty handles the teardown; when it fires we
179
- // skip the cancel transition + resume notice (the node is gone) but still
180
- // fan the "child gone" wake out to surviving managers below.
181
- if (!reapIfEmpty(id)) {
220
+ // dir). Its teardown was queued above from the preserved snapshot; when
221
+ // reaping fires we skip the cancel transition + resume notice (the node
222
+ // is gone) but still fan the "child gone" wake out to survivors below.
223
+ if (!reapIfEmpty(id, undefined, false)) {
182
224
  // 1) Terminal status set BEFORE the window dies (daemon race). The root may
183
225
  // finish to `done` (a deliberate "mark complete" close-out); every
184
226
  // descendant, and the root by default, `cancel`s. finish is legal only
@@ -199,9 +241,9 @@ export function closeNode(rootId, opts = {}) {
199
241
  else {
200
242
  transition(id, 'cancel', { reason: 'closed' });
201
243
  }
202
- // 2) Ask the daemon to tear the engine down after this close response
203
- // settles. The root caller's shell is excluded from its own broker tree.
204
- requestBrokerTeardown(id, id === rootId ? { excludePid: opts.callerPid } : {});
244
+ // 2) The daemon-owned teardown was queued above after capturing this
245
+ // node's process tree. The root caller's shell was excluded from its
246
+ // own broker tree before the response can settle.
205
247
  tearDownNode(id);
206
248
  // 3) Leave the resume notice AFTER the watcher is gone, so it survives.
207
249
  appendInbox(id, {
@@ -226,8 +268,8 @@ export function closeNode(rootId, opts = {}) {
226
268
  fanDoctrineWake(id, survivors, `Child closed — ${m.name} (${id}) was closed from the canvas and is no longer running.`, { reason: 'child-closed', child: id });
227
269
  closed.push(id);
228
270
  }
229
- catch {
230
- /* one bad node never aborts the cascade */
271
+ catch (error) {
272
+ emitEvent({ level: 'error', event: 'node.close.failed', node_id: id, error });
231
273
  }
232
274
  }
233
275
  return { root: rootId, closed, spared };
@@ -25,6 +25,13 @@ export interface HostHandle {
25
25
  export interface TeardownOptions {
26
26
  excludePid?: number;
27
27
  }
28
+ /** The only completion signal a caller may use for post-teardown work. A false
29
+ * result means the original engine tree could not be confirmed dead, so no
30
+ * cleanup may claim it was safe to remove resources it owned. */
31
+ export interface TeardownResult {
32
+ confirmed: boolean;
33
+ error?: Error;
34
+ }
28
35
  export interface Host {
29
36
  /** Bring a node's ENGINE into existence from its launch recipe; return a
30
37
  * supervisable handle. */
@@ -33,8 +40,9 @@ export interface Host {
33
40
  * both call sites — the revive guard (passes the id) and the daemon (passes a
34
41
  * row) — share one selector. For the broker this IS isPidAlive(pi_pid). */
35
42
  isAlive(node: string | NodeRow): boolean;
36
- /** Tear the engine down (close/cancel teardown). */
37
- teardown(nodeId: string, opts?: TeardownOptions): void;
43
+ /** Tear the engine down (close/cancel teardown). Resolves only after the
44
+ * fixed process snapshot is confirmed dead, or with a diagnostic failure. */
45
+ teardown(nodeId: string, opts?: TeardownOptions): Promise<TeardownResult>;
38
46
  /** Deliver an OS signal to the engine container — present so the daemon never
39
47
  * reaches around the abstraction. */
40
48
  signal(nodeId: string, sig: NodeJS.Signals): void;
@@ -43,7 +51,7 @@ export interface Host {
43
51
  * the fast-fail seam for known module/path errors: a bad engine resolve or a
44
52
  * missing plain session path throws here, before spawn(). */
45
53
  export declare function preflightBrokerLaunch(nodeId: string, inv: PiInvocation, cwd: string): void;
46
- /** Queue broker teardown onto the daemon event loop after the close response can
47
- * settle. Repeated terminal reconciliation coalesces on the same node. */
48
- export declare function requestBrokerTeardown(nodeId: string, opts?: TeardownOptions): void;
54
+ /** Queue broker teardown after the close response settles. Completion includes
55
+ * any post-teardown work, which lets the daemon drain it during shutdown. */
56
+ export declare function requestBrokerTeardown(nodeId: string, opts?: TeardownOptions, after?: () => Promise<void>): Promise<TeardownResult>;
49
57
  export declare const headlessBrokerHost: Host;
@@ -128,84 +128,153 @@ const BROKER_SIGKILL_GRACE_MS = 2_000;
128
128
  * full grace ceilings above are reserved for the genuinely-wedged case,
129
129
  * where the added latency is the whole point. */
130
130
  const TEARDOWN_POLL_MS = 150;
131
- /** Poll `isAlive` every `TEARDOWN_POLL_MS` until it reports dead. If it is
132
- * STILL alive once `timeoutMs` elapses, fire `onTimeout` exactly once (the
133
- * next escalation rung) and keep polling so the caller can confirm that
134
- * rung actually worked — up to `hardCeilingMs` total, after which this gives
135
- * up (an unkillable/D-state descendant is out of scope for this primitive).
136
- *
137
- * Deliberately a recursive, NEVER-`.unref()`'d `setTimeout` rather than a
138
- * fixed unref'd wait: most teardown callers
139
- * are short-lived CLI subprocesses (`node lifecycle close`, `canvas prune`)
140
- * that would otherwise exit before an unref'd SIGKILL timer ever fires,
141
- * silently skipping the last-resort rung. Polling (instead of a blind fixed
142
- * wait) is what keeps the happy path cheap despite being ref'd. */
143
- function pollUntil(isAlive, timeoutMs, onTimeout, hardCeilingMs = timeoutMs + 5_000) {
144
- const start = Date.now();
145
- let escalated = false;
146
- const tick = () => {
147
- if (!isAlive())
148
- return; // confirmed dead — stop polling
149
- const elapsed = Date.now() - start;
150
- if (!escalated && elapsed >= timeoutMs) {
151
- escalated = true;
152
- onTimeout();
153
- }
154
- if (elapsed >= hardCeilingMs)
155
- return; // best-effort exhausted — give up
156
- setTimeout(tick, TEARDOWN_POLL_MS);
157
- };
158
- tick();
131
+ /** Wait against the fixed snapshot. The ref'd timer keeps a short-lived
132
+ * caller alive through the final SIGKILL confirmation. */
133
+ async function waitForTreeExit(tree, timeoutMs) {
134
+ const deadline = Date.now() + timeoutMs;
135
+ while (isAnyPidAlive(tree)) {
136
+ const remaining = deadline - Date.now();
137
+ if (remaining <= 0)
138
+ return false;
139
+ await new Promise((resolve) => setTimeout(resolve, Math.min(TEARDOWN_POLL_MS, remaining)));
140
+ }
141
+ return true;
159
142
  }
160
- /** Escalate teardown of a broker/tree that did not exit cleanly off the
161
- * graceful `shutdown` frame (or was never reachable at all): SIGTERM the
162
- * whole tree, then — once `BROKER_SIGKILL_GRACE_MS` has elapsed with the tree
163
- * still alive — SIGKILL the whole tree as the last resort.
164
- *
165
- * Takes an ALREADY-CAPTURED `tree` (pid list) and `identities` (a
166
- * `captureTeardownSnapshot` identity map over that same list) — both taken
167
- * ONCE by `teardown()` synchronously at its very top, before EITHER
168
- * escalation path (connect-error or post-shutdown-frame) can run, and
169
- * threaded through here unchanged. This
170
- * function must NEVER re-walk the process tree itself: `launch()` spawns the
171
- * broker `detached: true`, so
172
- * signaling the broker's OWN group (the first thing `killProcessTreePids`
173
- * does) typically kills the broker itself almost immediately — and the
174
- * kernel reparents any surviving child away from the broker's pid the
175
- * INSTANT the broker exits, severing the very ppid link a fresh
176
- * `descendantPids` walk depends on. Re-deriving descendants at ANY point
177
- * after the broker may have exited (including a per-rung re-walk here, or a
178
- * poll that re-derives instead of checking this fixed list) would silently
179
- * "lose" a still-alive detached descendant — e.g. the pi bash tool's shell
180
- * child, itself `detached: true` and thus its own group leader, distinct
181
- * from the broker's group, so a plain `kill(-brokerPid)` never reaches it
182
- * either. `identities` guards each signal against
183
- * PID REUSE across the multi-second escalation window —
184
- * see `killProcessTreePids`. `null` means the initial `captureTeardownSnapshot`
185
- * probe itself failed (no baseline to compare against) — `killProcessTreePids`
186
- * falls through to its normal unguarded signal path in that case, never
187
- * reading a probe failure as proof the pid is gone or reused. */
188
- function escalateBrokerTeardown(tree, identities) {
143
+ /** Finish one teardown using the snapshot captured before any signal can sever
144
+ * broker parentage. A failed confirmation is explicit so post-teardown cleanup
145
+ * never runs against an engine tree that may still own its resources. */
146
+ async function terminateBrokerTree(tree, identities, graceful) {
147
+ if (tree.length === 0)
148
+ return { confirmed: true };
149
+ if (graceful && await waitForTreeExit(tree, BROKER_SHUTDOWN_GRACE_MS))
150
+ return { confirmed: true };
189
151
  killProcessTreePids(tree, 'SIGTERM', identities);
190
- pollUntil(() => isAnyPidAlive(tree), BROKER_SIGKILL_GRACE_MS, () => killProcessTreePids(tree, 'SIGKILL', identities));
152
+ if (await waitForTreeExit(tree, BROKER_SIGKILL_GRACE_MS))
153
+ return { confirmed: true };
154
+ killProcessTreePids(tree, 'SIGKILL', identities);
155
+ if (await waitForTreeExit(tree, 5_000))
156
+ return { confirmed: true };
157
+ return { confirmed: false, error: new Error(`broker process tree for ${tree.join(', ')} remained alive after SIGKILL`) };
158
+ }
159
+ /** Capture the one authoritative process snapshot before a caller can reap the
160
+ * node row. No signal or socket operation occurs here; execution remains queued
161
+ * until the close response has settled. */
162
+ function prepareBrokerTeardown(nodeId, opts) {
163
+ const node = getNode(nodeId);
164
+ const pid = node?.pi_pid;
165
+ const snapshot = pid != null
166
+ ? captureTeardownSnapshot(pid, node?.pi_pid_identity ?? null, opts.excludePid)
167
+ : null;
168
+ if (snapshot?.reused === true) {
169
+ emitEvent({
170
+ level: 'warn',
171
+ event: 'broker.teardown.pid_reuse_refused',
172
+ node_id: nodeId,
173
+ fields: { recorded_pid: pid, expected_identity: node?.pi_pid_identity ?? null },
174
+ });
175
+ }
176
+ return {
177
+ tree: snapshot?.tree ?? [],
178
+ identities: snapshot?.identities ?? null,
179
+ sockPath: viewSocketPath(nodeId),
180
+ };
181
+ }
182
+ async function runPreparedBrokerTeardown(prepared) {
183
+ // Even a dead broker pid can still be the process-group leader for an orphan
184
+ // that the initial ppid walk could no longer reach. The connect-failure path
185
+ // must retain that leader id for group signaling rather than treating its dead
186
+ // status as confirmation that no descendant survives.
187
+ return new Promise((resolve) => {
188
+ let connected = false;
189
+ let settled = false;
190
+ const settle = (result) => {
191
+ if (settled)
192
+ return;
193
+ settled = true;
194
+ resolve(result);
195
+ };
196
+ const cleanupSocket = () => {
197
+ try {
198
+ if (existsSync(prepared.sockPath))
199
+ unlinkSync(prepared.sockPath);
200
+ }
201
+ catch {
202
+ /* best-effort cleanup */
203
+ }
204
+ };
205
+ const finish = (graceful) => {
206
+ void terminateBrokerTree(prepared.tree, prepared.identities, graceful).then(settle, (error) => settle({ confirmed: false, error: error instanceof Error ? error : new Error(String(error)) }));
207
+ };
208
+ const sock = connect(prepared.sockPath);
209
+ sock.once('error', () => {
210
+ if (connected)
211
+ return;
212
+ cleanupSocket();
213
+ finish(false);
214
+ });
215
+ sock.once('connect', () => {
216
+ connected = true;
217
+ try {
218
+ sock.write(encodeFrame({ type: 'shutdown' }));
219
+ }
220
+ catch {
221
+ /* the broker may have raced us to exit */
222
+ }
223
+ sock.end();
224
+ cleanupSocket();
225
+ finish(true);
226
+ });
227
+ });
191
228
  }
192
229
  const requestedTeardowns = new Map();
193
- /** Queue broker teardown onto the daemon event loop after the close response can
194
- * settle. Repeated terminal reconciliation coalesces on the same node. */
195
- export function requestBrokerTeardown(nodeId, opts = {}) {
230
+ /** Queue broker teardown after the close response settles. Completion includes
231
+ * any post-teardown work, which lets the daemon drain it during shutdown. */
232
+ export function requestBrokerTeardown(nodeId, opts = {}, after) {
196
233
  const existing = requestedTeardowns.get(nodeId);
197
234
  if (existing !== undefined) {
198
- if (existing.excludePid === undefined && opts.excludePid !== undefined)
199
- existing.excludePid = opts.excludePid;
200
- return;
235
+ if (after !== undefined)
236
+ existing.after.push(after);
237
+ return existing.completion;
238
+ }
239
+ let resolve;
240
+ const completion = new Promise((done) => { resolve = done; });
241
+ const request = { completion, resolve, after: after === undefined ? [] : [after] };
242
+ requestedTeardowns.set(nodeId, request);
243
+ let prepared;
244
+ try {
245
+ prepared = prepareBrokerTeardown(nodeId, opts);
246
+ }
247
+ catch (error) {
248
+ const failure = { confirmed: false, error: error instanceof Error ? error : new Error(String(error)) };
249
+ requestedTeardowns.delete(nodeId);
250
+ emitEvent({ level: 'error', event: 'broker.teardown.unconfirmed', node_id: nodeId, error: failure.error });
251
+ request.resolve(failure);
252
+ return completion;
201
253
  }
202
- requestedTeardowns.set(nodeId, { ...opts });
203
254
  setImmediate(() => {
204
- const request = requestedTeardowns.get(nodeId);
205
255
  requestedTeardowns.delete(nodeId);
206
- if (request !== undefined)
207
- headlessBrokerHost.teardown(nodeId, request);
256
+ void runPreparedBrokerTeardown(prepared).then(async (result) => {
257
+ if (result.confirmed) {
258
+ for (const callback of request.after) {
259
+ try {
260
+ await callback();
261
+ }
262
+ catch (error) {
263
+ emitEvent({ level: 'error', event: 'broker.teardown.after_failed', node_id: nodeId, error });
264
+ }
265
+ }
266
+ }
267
+ else {
268
+ emitEvent({ level: 'error', event: 'broker.teardown.unconfirmed', node_id: nodeId, error: result.error });
269
+ }
270
+ request.resolve(result);
271
+ }, (error) => {
272
+ const failure = { confirmed: false, error: error instanceof Error ? error : new Error(String(error)) };
273
+ emitEvent({ level: 'error', event: 'broker.teardown.unconfirmed', node_id: nodeId, error: failure.error });
274
+ request.resolve(failure);
275
+ });
208
276
  });
277
+ return completion;
209
278
  }
210
279
  export const headlessBrokerHost = {
211
280
  launch(nodeId, inv, opts) {
@@ -364,117 +433,15 @@ export const headlessBrokerHost = {
364
433
  return isPidAlive((typeof node === 'string' ? getNode(node) : node)?.pi_pid);
365
434
  },
366
435
  teardown(nodeId, opts = {}) {
367
- // Graceful: connect to view.sock + send a `shutdown` frame → the broker
368
- // dispose()s the engine, unlinks the socket, exits 0. Status is already
369
- // flipped done/canceled by the caller (crash-safe ordering), so the daemon
370
- // won't revive. On connect failure (broker dead/crashed/wedged) fall back
371
- // to escalateBrokerTeardown — a tree-wide SIGTERM now, a tree-wide SIGKILL
372
- // as the last resort — so no orphaned descendant of a dead/wedged broker
373
- // survives the node — + unlink the stale socket.
374
- //
375
- // Capture the broker pid ONCE, synchronously, right here — never re-query
376
- // `getNode(nodeId)` inside the async callbacks/timers below. Callers such
377
- // as placement.ts's `reapIfEmpty()` call `teardown()` then immediately
378
- // `deleteNode()` in the same tick; by the time an async callback fires the
379
- // row can already be gone, so a re-query would silently resolve to
380
- // `undefined` and skip escalation entirely.
381
- const node = getNode(nodeId);
382
- const pid = node?.pi_pid;
383
- // Snapshot the full descendant TREE — the
384
- // root pid plus every transitive descendant — and a stable identity
385
- // fingerprint for every pid in it, BOTH from ONE `ps`
386
- // table read (`captureTeardownSnapshot`, final review: closes the small
387
- // race between two separate probes), exactly ONCE, right here, while the
388
- // broker (if alive) still holds parentage over any detached descendant.
389
- // EVERY escalation path below — connect-error AND post-shutdown-frame —
390
- // reuses this ONE fixed snapshot; neither may re-walk `ps` by ppid once
391
- // the broker might have exited, because the kernel reparents any
392
- // surviving child away from `pid` the INSTANT the broker exits, severing
393
- // the ppid link a fresh walk depends on — exactly the reparenting hazard
394
- // this fix closes. (If the broker is ALREADY dead when `teardown()`
395
- // starts, its detached descendants were already reparented before we ever
396
- // got here — unrecoverable by ppid, and out of scope: the #98 repro is
397
- // the wedged-but-ALIVE broker, which this covers.)
398
- //
399
- // `node?.pi_pid_identity` is the LAUNCH-time baseline `recordPid` captured
400
- // the instant this broker was spawned/bound (final review, Fix 1): if
401
- // `pid` has ALREADY been recycled for an unrelated process before
402
- // teardown ever ran — the pid-reuse hazard this guard exists for — the
403
- // snapshot's `reused: true` comes back with an EMPTY tree, so nothing
404
- // below ever signals the stranger (or walks its process tree looking for
405
- // "descendants", which would be the stranger's children, not ours).
406
- const snapshot = pid != null ? captureTeardownSnapshot(pid, node?.pi_pid_identity ?? null, opts.excludePid) : null;
407
- if (snapshot?.reused === true) {
408
- emitEvent({
409
- level: 'warn',
410
- event: 'broker.teardown.pid_reuse_refused',
411
- node_id: nodeId,
412
- fields: {
413
- recorded_pid: pid,
414
- expected_identity: node?.pi_pid_identity ?? null,
415
- },
436
+ try {
437
+ return runPreparedBrokerTeardown(prepareBrokerTeardown(nodeId, opts));
438
+ }
439
+ catch (error) {
440
+ return Promise.resolve({
441
+ confirmed: false,
442
+ error: error instanceof Error ? error : new Error(String(error)),
416
443
  });
417
444
  }
418
- const tree = snapshot?.tree ?? [];
419
- const identities = snapshot?.identities ?? null;
420
- const sockPath = viewSocketPath(nodeId);
421
- const cleanupSocket = () => {
422
- try {
423
- if (existsSync(sockPath))
424
- unlinkSync(sockPath);
425
- }
426
- catch {
427
- /* best-effort cleanup */
428
- }
429
- };
430
- const sock = connect(sockPath);
431
- let connected = false;
432
- sock.on('error', () => {
433
- // A post-connect error (broker exiting after we sent shutdown) is benign
434
- // and swallowed; only a connect failure triggers the escalation fallback.
435
- if (connected)
436
- return;
437
- if (tree.length > 0)
438
- escalateBrokerTeardown(tree, identities);
439
- cleanupSocket();
440
- });
441
- sock.once('connect', () => {
442
- connected = true;
443
- try {
444
- sock.write(encodeFrame({ type: 'shutdown' }));
445
- }
446
- catch {
447
- /* the broker may have raced us to exit */
448
- }
449
- sock.end();
450
- cleanupSocket();
451
- // Bounded exit confirmation: a broker that connects fine but then HANGS
452
- // inside session.dispose() (disposeAndExit catches a throw, not a hang)
453
- // would leak the process holding the sole .jsonl writer — and any
454
- // bash→test→app descendants it spawned along the way. Poll
455
- // the whole process TREE (not just the broker's own pid) for up to
456
- // `BROKER_SHUTDOWN_GRACE_MS`, UNCONDITIONAL on the broker's own liveness:
457
- // a broker that exits cleanly can still
458
- // leave a live descendant behind (dispose() partially failed, or never
459
- // reached a detached SDK child), which is exactly the shape this
460
- // closes. `pollUntil` is ref'd — not `.unref()`'d — so a short-lived
461
- // CLI caller stays alive long enough for the SIGKILL rung to actually
462
- // fire, while the happy path (tree already dead) still pays only a
463
- // couple of poll ticks.
464
- //
465
- // Checks `isAnyPidAlive(tree)` against the FIXED pre-signal snapshot,
466
- // NEVER a fresh `isProcessTreeAlive`/`descendantPids` re-walk: the broker
467
- // can exit at any point during this
468
- // grace window while leaving a detached descendant alive, and the
469
- // instant it does, the kernel reparents that descendant away from `pid`
470
- // — a re-walk-by-ppid would then find nothing and silently skip
471
- // escalation, even though the descendant is still very much alive. The
472
- // fixed `tree` snapshot (taken before the shutdown frame was even sent)
473
- // has no such blind spot.
474
- if (tree.length > 0) {
475
- pollUntil(() => isAnyPidAlive(tree), BROKER_SHUTDOWN_GRACE_MS, () => escalateBrokerTeardown(tree, identities));
476
- }
477
- });
478
445
  },
479
446
  signal(nodeId, sig) {
480
447
  const pid = getNode(nodeId)?.pi_pid;
@@ -86,7 +86,7 @@ export declare function detachToBackground(nodeId: string, pane?: string): boole
86
86
  * its node identity so the worktree remains owned and can later be closed.
87
87
  * Returns true when it reaped. No-op (false) on a node that produced output,
88
88
  * is mid-first-turn, owns an open managed worktree, or no longer exists. */
89
- export declare function reapIfEmpty(nodeId: string, keepPane?: string): boolean;
89
+ export declare function reapIfEmpty(nodeId: string, keepPane?: string, teardown?: boolean): boolean;
90
90
  /** Sweep the whole canvas for empty shells and reap them — the bulk cleanup
91
91
  * behind `crtr canvas prune --empty`, for the husks that accumulated before
92
92
  * close/detach learned to reap. Skips: the caller ($CRTR_NODE_ID), dead rows,
@@ -342,11 +342,12 @@ function isEmptyNode(nodeId) {
342
342
  * its node identity so the worktree remains owned and can later be closed.
343
343
  * Returns true when it reaped. No-op (false) on a node that produced output,
344
344
  * is mid-first-turn, owns an open managed worktree, or no longer exists. */
345
- export function reapIfEmpty(nodeId, keepPane) {
345
+ export function reapIfEmpty(nodeId, keepPane, teardown = true) {
346
346
  const meta = getNode(nodeId);
347
347
  if (meta?.managed_worktree?.state === 'open' || !isEmptyNode(nodeId))
348
348
  return false;
349
- headlessBrokerHost.teardown(nodeId);
349
+ if (teardown)
350
+ void headlessBrokerHost.teardown(nodeId);
350
351
  tearDownNode(nodeId, keepPane);
351
352
  return deleteNode(nodeId).deleted;
352
353
  }
@@ -47,6 +47,7 @@ import { fullName } from '../../../core/canvas/labels.js';
47
47
  import { reportsForNode } from './reports.js';
48
48
  import { getCron } from '../../../core/canvas/crons.js';
49
49
  import { cronWakeOrigin } from '../../../core/runtime/bearings.js';
50
+ import { boundDaemonControl } from '../../control.js';
50
51
  import { notFound, usage } from '../../../core/errors.js';
51
52
  import { toCloseResultDTO, toNodeDetailDTO, toNodeSummaryDTO, toReviveResultDTO } from '../map.js';
52
53
  import { ApiError } from '../../../api/index.js';
@@ -604,9 +605,11 @@ function handleClose(ctx) {
604
605
  if (body?.caller_pid !== undefined && (!Number.isInteger(body.caller_pid) || body.caller_pid <= 0)) {
605
606
  throw usage('caller_pid must be a positive integer when provided');
606
607
  }
608
+ const daemon = boundDaemonControl();
607
609
  const result = closeNode(id, {
608
610
  rootEvent: body?.finish === true ? 'finish' : 'cancel',
609
611
  callerPid: body?.caller_pid,
612
+ ...(daemon === null ? {} : { registerDetached: daemon.registerDetached }),
610
613
  });
611
614
  return { status: 200, body: toCloseResultDTO(result) };
612
615
  }
@@ -12,6 +12,8 @@ export interface DaemonControl {
12
12
  requestHandover(): {
13
13
  graceMs: number;
14
14
  };
15
+ /** Register daemon-owned post-response work for shutdown draining. */
16
+ registerDetached(work: Promise<void>): void;
15
17
  }
16
18
  /** Bind this process's control surface. Called once, by `runDaemon`. */
17
19
  export declare function bindDaemonControl(control: DaemonControl): void;
@@ -607,7 +607,7 @@ export async function runDaemon(opts = {}) {
607
607
  // The self-control seam the API's handover handler reaches this closure
608
608
  // through (see control.ts). Bound alongside the fleet so anything that can
609
609
  // launch a broker can also hand the fleet over.
610
- bindDaemonControl({ epoch, requestHandover });
610
+ bindDaemonControl({ epoch, requestHandover, registerDetached });
611
611
  operationIdContext.fresh(() => {
612
612
  emitEvent({
613
613
  level: 'info',
@@ -43,11 +43,11 @@ export interface AfterHookRequest extends HookRequestBase {
43
43
  export interface ReplaceHookRequest extends HookRequestBase {
44
44
  phase: 'replace';
45
45
  }
46
- /** Lifecycle handlers run on every matching event. They must be idempotent because `node:start` fires for birth and every revive. */
46
+ /** Lifecycle handlers run on every matching event. `node:start` handlers must be idempotent because they fire for birth and every revive. */
47
47
  export interface LifecycleHookRequest {
48
48
  protocolVersion: 1;
49
49
  op: string;
50
- event: 'node:start';
50
+ event: 'node:start' | 'node:close';
51
51
  phase: 'on';
52
52
  operationId: string;
53
53
  node: LifecycleHookNode;
@@ -249,7 +249,7 @@ function validateRequest(value) {
249
249
  if (value['phase'] === 'on') {
250
250
  if (!hasOnlyKeys(value, ['protocolVersion', 'op', 'event', 'phase', 'operationId', 'node', 'runtime', 'context']))
251
251
  return invalid('lifecycle request contains unknown fields');
252
- if (value['event'] !== 'node:start')
252
+ if (value['event'] !== 'node:start' && value['event'] !== 'node:close')
253
253
  return invalid('unsupported lifecycle event');
254
254
  if (!isLifecycleNode(value['node']))
255
255
  return invalid('lifecycle request node contains invalid fields');
@@ -260,7 +260,7 @@ function validateRequest(value) {
260
260
  return valid({
261
261
  protocolVersion: 1,
262
262
  op: value['op'],
263
- event: 'node:start',
263
+ event: value['event'],
264
264
  phase: 'on',
265
265
  operationId: value['operationId'],
266
266
  node: value['node'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.305",
3
+ "version": "0.3.307",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.305",
3
+ "version": "0.3.307",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.305",
9
+ "version": "0.3.307",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "workspaces": [
@@ -5206,17 +5206,17 @@
5206
5206
  },
5207
5207
  "packages/crouter-api": {
5208
5208
  "name": "@north-light/crouter-api",
5209
- "version": "0.3.305",
5209
+ "version": "0.3.307",
5210
5210
  "license": "UNLICENSED"
5211
5211
  },
5212
5212
  "packages/crouter-env-docker": {
5213
5213
  "name": "@north-light/crouter-env-docker",
5214
- "version": "0.3.305",
5214
+ "version": "0.3.307",
5215
5215
  "license": "UNLICENSED"
5216
5216
  },
5217
5217
  "packages/crouter-sdk": {
5218
5218
  "name": "@north-light/crouter-sdk",
5219
- "version": "0.3.305",
5219
+ "version": "0.3.307",
5220
5220
  "license": "UNLICENSED",
5221
5221
  "dependencies": {
5222
5222
  "@north-light/crouter-api": "^0.3.295"