@north-light/crouter 0.3.306 → 0.3.308

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.
@@ -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,
@@ -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
@@ -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 },
@@ -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;