@north-light/crouter 0.3.311 → 0.3.312

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 (51) hide show
  1. package/dist/clients/attach/viewer.js +394 -394
  2. package/dist/core/__tests__/context-intro.test.js +4 -4
  3. package/dist/core/__tests__/human-deliver.test.js +18 -18
  4. package/dist/core/__tests__/integration/human-deliver-e2e.test.js +5 -5
  5. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +299 -1
  6. package/dist/core/__tests__/integration/spawn-root.test.js +11 -11
  7. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +2 -2
  8. package/dist/core/__tests__/on-read-dedup-resume.test.js +6 -6
  9. package/dist/core/__tests__/on-read-nested-store.test.js +6 -6
  10. package/dist/core/__tests__/profile-project-memory-delivery.test.js +31 -31
  11. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -3
  12. package/dist/core/__tests__/warm-claim-preference-snapshot.test.js +1 -1
  13. package/dist/core/command-hooks/artifact.d.ts +6 -0
  14. package/dist/core/command-hooks/artifact.js +22 -6
  15. package/dist/core/command-hooks/discovery.d.ts +2 -0
  16. package/dist/core/command-hooks/discovery.js +2 -0
  17. package/dist/core/command-hooks/lifecycle-catalog.d.ts +20 -2
  18. package/dist/core/command-hooks/lifecycle-catalog.js +7 -2
  19. package/dist/core/command-hooks/lifecycle-create.d.ts +4 -0
  20. package/dist/core/command-hooks/lifecycle-create.js +51 -0
  21. package/dist/core/command-hooks/schema.d.ts +2 -2
  22. package/dist/core/command-hooks/schema.js +5 -4
  23. package/dist/core/command-hooks/transport/exec-invoke.js +3 -1
  24. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +18 -8
  25. package/dist/core/command-hooks/transport/exec-lifecycle.js +89 -30
  26. package/dist/core/errors.d.ts +1 -0
  27. package/dist/core/errors.js +4 -0
  28. package/dist/core/human/feedback-companion.js +1 -1
  29. package/dist/core/review/companion.d.ts +1 -1
  30. package/dist/core/review/companion.js +2 -2
  31. package/dist/core/review/realize.js +6 -6
  32. package/dist/core/runtime/nodes.d.ts +5 -1
  33. package/dist/core/runtime/nodes.js +26 -11
  34. package/dist/core/runtime/recycle.js +16 -12
  35. package/dist/core/runtime/reset.js +4 -1
  36. package/dist/core/runtime/revive.js +13 -12
  37. package/dist/core/runtime/spawn.d.ts +4 -1
  38. package/dist/core/runtime/spawn.js +30 -3
  39. package/dist/core/runtime/warm-pool.d.ts +2 -2
  40. package/dist/core/runtime/warm-pool.js +23 -3
  41. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +2 -2
  42. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
  43. package/dist/daemon/api/__tests__/seam/profile-delete.test.js +2 -2
  44. package/dist/daemon/api/handlers/human.js +5 -2
  45. package/dist/daemon/api/handlers/nodes.d.ts +1 -0
  46. package/dist/daemon/api/handlers/nodes.js +34 -29
  47. package/dist/hook-authoring.d.ts +28 -2
  48. package/dist/hook-authoring.js +53 -1
  49. package/dist/index.d.ts +1 -1
  50. package/package.json +1 -1
  51. package/runtime.lock.json +5 -5
@@ -3,53 +3,109 @@ import { spawn } from 'node:child_process';
3
3
  import { PROCESS_GROUP_KILL_GRACE_MS, posixProcessGroupSpawnOptions, terminateProcessGroup } from '../../../hook-process.js';
4
4
  import { isRecord } from '../../../shared/predicates.js';
5
5
  import { diag } from '../../io.js';
6
+ import { LIFECYCLE_CATALOG } from '../lifecycle-catalog.js';
6
7
  const MAX_STDOUT = 10 * 1024 * 1024;
7
- export const LIFECYCLE_HOOK_TIMEOUT_MS = 5_000;
8
- export const LIFECYCLE_EVENT_BUDGET_MS = 10_000;
9
- /** Runs one lifecycle hook. The node launch remains authoritative on every failure. */
8
+ /** Runs one lifecycle hook. `node:start` and `node:close` remain best-effort. */
10
9
  export async function invokeLifecycleHook(hook, invocation, timeoutMs) {
11
10
  try {
12
- const request = {
13
- protocolVersion: 1,
14
- op: hook.op,
15
- event: hook.event,
16
- phase: hook.phase,
17
- operationId: randomBytes(16).toString('hex'),
18
- node: invocation.node,
19
- runtime: invocation.runtime,
20
- context: { cwd: invocation.node.cwd },
21
- };
22
- const result = await runLifecycleProcess(hook, JSON.stringify(request), timeoutMs);
23
- if (result.signal !== null)
24
- throw new Error(`executable was killed by signal ${result.signal}`);
25
- const envelope = parseEnvelope(result.stdout);
26
- if (envelope.ok) {
27
- if (result.status !== 0)
28
- diag(`crtr: ${label(hook)} returned ok:true but exited ${result.status} (honoring the envelope)`);
29
- return;
30
- }
31
- diag(`crtr: ${label(hook)} failed: ${envelope.error.code}: ${envelope.error.message}`);
11
+ const envelope = await executeLifecycleHook(hook, invocation, timeoutMs);
12
+ if (!envelope.ok)
13
+ diag(`crtr: ${label(hook)} failed: ${envelope.error.code}: ${envelope.error.message}`);
32
14
  }
33
15
  catch (error) {
34
16
  diag(`crtr: ${label(hook)} failed: ${describeError(error)}`);
35
17
  }
36
18
  }
37
- /** Executes lifecycle hooks in discovery order, within one bounded event budget. */
19
+ /** Executes non-blocking lifecycle hooks in discovery order within the event's catalogued budget. */
38
20
  export async function invokeLifecycleHooks(hooks, invocation) {
39
- const deadline = Date.now() + LIFECYCLE_EVENT_BUDGET_MS;
21
+ if (hooks.length === 0)
22
+ return;
23
+ const { hookTimeoutMs, eventBudgetMs } = LIFECYCLE_CATALOG[hooks[0].event];
24
+ const deadline = Date.now() + eventBudgetMs;
40
25
  for (const hook of hooks) {
41
26
  const remaining = deadline - Date.now();
42
- // A timed-out hook must be reaped before launch can proceed. Reserve the
43
- // process-group termination grace inside the event budget rather than
44
- // letting the final cleanup extend the node-start critical path.
45
- const timeoutMs = Math.min(LIFECYCLE_HOOK_TIMEOUT_MS, remaining - PROCESS_GROUP_KILL_GRACE_MS);
27
+ const timeoutMs = Math.min(hookTimeoutMs, remaining - PROCESS_GROUP_KILL_GRACE_MS);
46
28
  if (timeoutMs <= 0) {
47
- diag(`crtr: lifecycle hook event ${hook.event} exceeded its ${LIFECYCLE_EVENT_BUDGET_MS}ms aggregate budget; skipped ${label(hook)}`);
29
+ diag(`crtr: lifecycle hook event ${hook.event} exceeded its ${eventBudgetMs}ms aggregate budget; skipped ${label(hook)}`);
48
30
  return;
49
31
  }
50
32
  await invokeLifecycleHook(hook, invocation, timeoutMs);
51
33
  }
52
34
  }
35
+ /** Runs blocking `node:create` hooks. Every protocol or process failure refuses the create. */
36
+ export async function invokeBlockingLifecycleHooks(hooks, invocation) {
37
+ if (hooks.length === 0)
38
+ return null;
39
+ const { hookTimeoutMs, eventBudgetMs } = LIFECYCLE_CATALOG['node:create'];
40
+ const deadline = Date.now() + eventBudgetMs;
41
+ for (const hook of hooks) {
42
+ const remaining = deadline - Date.now();
43
+ const timeoutMs = Math.min(hookTimeoutMs, remaining - PROCESS_GROUP_KILL_GRACE_MS);
44
+ if (timeoutMs <= 0) {
45
+ return refusal(hook, `lifecycle hook event node:create exceeded its ${eventBudgetMs}ms aggregate budget`);
46
+ }
47
+ try {
48
+ const envelope = await executeLifecycleHook(hook, invocation, timeoutMs);
49
+ if (!envelope.ok)
50
+ return {
51
+ hook,
52
+ message: envelope.error.message,
53
+ next: envelope.error.next ?? attribution(hook),
54
+ };
55
+ }
56
+ catch (error) {
57
+ return refusal(hook, describeError(error));
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+ function refusal(hook, cause) {
63
+ return {
64
+ hook,
65
+ message: `${label(hook)} failed: ${cause}`,
66
+ next: attribution(hook),
67
+ };
68
+ }
69
+ export function lifecycleHookAttribution(hook) {
70
+ return attribution(hook);
71
+ }
72
+ async function executeLifecycleHook(hook, invocation, timeoutMs) {
73
+ const request = hook.event === 'node:create'
74
+ ? createRequest(hook, invocation)
75
+ : nodeRequest(hook, invocation);
76
+ const result = await runLifecycleProcess(hook, JSON.stringify(request), timeoutMs);
77
+ if (result.signal !== null)
78
+ throw new Error(`executable was killed by signal ${result.signal}`);
79
+ const envelope = parseEnvelope(result.stdout);
80
+ if (envelope.ok && result.status !== 0) {
81
+ diag(`crtr: ${label(hook)} returned ok:true but exited ${result.status} (honoring the envelope)`);
82
+ }
83
+ return envelope;
84
+ }
85
+ function nodeRequest(hook, invocation) {
86
+ return {
87
+ protocolVersion: 1,
88
+ op: hook.op,
89
+ event: hook.event,
90
+ phase: hook.phase,
91
+ operationId: randomBytes(16).toString('hex'),
92
+ node: invocation.node,
93
+ runtime: invocation.runtime,
94
+ context: { cwd: invocation.node.cwd },
95
+ };
96
+ }
97
+ function createRequest(hook, invocation) {
98
+ return {
99
+ protocolVersion: 1,
100
+ op: hook.op,
101
+ event: 'node:create',
102
+ phase: 'before',
103
+ operationId: randomBytes(16).toString('hex'),
104
+ create: invocation.create,
105
+ runtime: invocation.runtime,
106
+ context: { cwd: invocation.create.cwd },
107
+ };
108
+ }
53
109
  async function runLifecycleProcess(hook, input, timeoutMs) {
54
110
  let child;
55
111
  try {
@@ -147,6 +203,9 @@ function exactKeys(value, required, optional = []) {
147
203
  function label(hook) {
148
204
  return `plugin "${hook.plugin.name}" lifecycle hook "${hook.op}" for ${hook.event}`;
149
205
  }
206
+ function attribution(hook) {
207
+ return `Refused by plugin ${hook.plugin.name}, op ${hook.op}.`;
208
+ }
150
209
  function describeError(error) {
151
210
  return error instanceof Error ? error.message : String(error);
152
211
  }
@@ -22,6 +22,7 @@ export declare function general(message: string, details?: Record<string, unknow
22
22
  * `apiErrorToCliError` (tier 2) reconstructs the exact code, message, and
23
23
  * actionable `next` instead of degrading to a generic `invalid_request`. */
24
24
  export declare function brokerLaunchFailed(message: string, next: string): CrtrError;
25
+ export declare function nodeCreateRefused(message: string, next: string): CrtrError;
25
26
  /** Thrown by stub handlers for leaves not yet wired in P3+.
26
27
  * code='not_implemented', exitCode=GENERAL, next names the node. */
27
28
  export declare function notImplemented(node: string): CrtrError;
@@ -41,6 +41,10 @@ export function brokerLaunchFailed(message, next) {
41
41
  const code = 'broker_launch_failed';
42
42
  return new CrtrError(code, message, ExitCode.GENERAL, { error: code, message, next });
43
43
  }
44
+ export function nodeCreateRefused(message, next) {
45
+ const code = 'node_create_refused';
46
+ return new CrtrError(code, message, ExitCode.GENERAL, { error: code, message, next });
47
+ }
44
48
  /** Thrown by stub handlers for leaves not yet wired in P3+.
45
49
  * code='not_implemented', exitCode=GENERAL, next names the node. */
46
50
  export function notImplemented(node) {
@@ -65,7 +65,7 @@ export async function realizeFeedbackCompanion(args) {
65
65
  cwd: origin.cwd,
66
66
  profileId: origin.profile_id ?? null,
67
67
  });
68
- const node = spawnNode({
68
+ const node = await spawnNode({
69
69
  kind: origin.kind,
70
70
  mode: origin.mode,
71
71
  lifecycle: 'resident',
@@ -27,7 +27,7 @@ export declare function captureOrigin(originNodeId: string, deps?: {
27
27
  /** Materialize the immutable review fork, reusing only an already-valid branch. */
28
28
  export declare function materializeReviewBranch(reviewId: string, capture: OriginCapture): string;
29
29
  /** Create the ordinary, promptless companion node. Realization owns its launch. */
30
- export declare function spawnReviewCompanion(spec: ReviewCompanionSpec): void;
30
+ export declare function spawnReviewCompanion(spec: ReviewCompanionSpec): Promise<void>;
31
31
  /** A companion is bound only when its session coordinates and visible boundary are durable. */
32
32
  export declare function isReviewCompanionBound(nodeId: string): boolean;
33
33
  /** Wait for the durable companion bind without owning companion liveness. */
@@ -141,7 +141,7 @@ export function materializeReviewBranch(reviewId, capture) {
141
141
  return branchFile;
142
142
  }
143
143
  /** Create the ordinary, promptless companion node. Realization owns its launch. */
144
- export function spawnReviewCompanion(spec) {
144
+ export async function spawnReviewCompanion(spec) {
145
145
  if (!isAbsolute(spec.branchFile) || !isAbsolute(spec.targetFile)) {
146
146
  throw new Error('review companion requires absolute branch and target paths');
147
147
  }
@@ -156,7 +156,7 @@ export function spawnReviewCompanion(spec) {
156
156
  cwd: spec.cwd,
157
157
  profileId: spec.profileId,
158
158
  });
159
- const node = spawnNode({
159
+ const node = await spawnNode({
160
160
  nodeId: spec.nodeId,
161
161
  kind: 'review/companion',
162
162
  mode: 'base',
@@ -57,12 +57,12 @@ function requireOrigin(review) {
57
57
  throw originMissing(review.origin_node_id);
58
58
  return origin;
59
59
  }
60
- function spawnCompanion(review) {
60
+ async function spawnCompanion(review) {
61
61
  if (getNode(review.companion_node_id) !== null)
62
62
  return;
63
63
  const origin = requireOrigin(review);
64
64
  try {
65
- spawnReviewCompanion({
65
+ await spawnReviewCompanion({
66
66
  nodeId: review.companion_node_id,
67
67
  reviewId: review.review_id,
68
68
  originNodeId: review.origin_node_id,
@@ -94,13 +94,13 @@ function ensureBridgeSubscription(review, bridgeNodeId) {
94
94
  throw bridgeUnsubscribed(review, bridgeNodeId);
95
95
  }
96
96
  /** R2: create/bind the ticket bridge and prove its active origin subscription. */
97
- function realizeBridge(review) {
97
+ async function realizeBridge(review) {
98
98
  if (review.origin_kind !== 'ticket')
99
99
  return review;
100
100
  let current = review;
101
101
  if (current.bridge_node_id === null) {
102
102
  const origin = requireOrigin(current);
103
- const bridge = spawnNode({
103
+ const bridge = await spawnNode({
104
104
  kind: 'human',
105
105
  lifecycle: 'terminal',
106
106
  parent: current.origin_node_id,
@@ -160,9 +160,9 @@ export async function realizeReview(reviewId) {
160
160
  throw new ReviewOperationError('branch_lost', 'review branch was lost before realization', { review_id: review.review_id });
161
161
  }
162
162
  // R1: creation consumes only the persisted coordinates and preallocated id.
163
- spawnCompanion(review);
163
+ await spawnCompanion(review);
164
164
  // R2: ticket bridge birth and route assertion precede all public exposure.
165
- review = realizeBridge(requireReview(reviewId));
165
+ review = await realizeBridge(requireReview(reviewId));
166
166
  if (review.state !== 'binding')
167
167
  return review;
168
168
  // R3: only realization owns this initial launch/retry.
@@ -101,6 +101,10 @@ export interface SpawnNodeOpts {
101
101
  deadlineAt?: string;
102
102
  /** Delivery target persisted with the row before any broker can launch. */
103
103
  outcomeDelivery?: OutcomeDeliveryBinding;
104
+ /** Runs after the create hook admitted the birth but before its row is written. */
105
+ beforeCreate?: () => void | Promise<void>;
106
+ /** Root id replaced by this birth (`/new` or recycle); null for all other births. */
107
+ replaces?: string | null;
104
108
  /** Override the generated id — either an internally pre-allocated id
105
109
  * (worktree naming; always valid+fresh) or an external caller's explicit id
106
110
  * (`--node-id`), which is format-validated
@@ -112,4 +116,4 @@ export interface SpawnNodeOpts {
112
116
  * For a child (parent given): the parent auto-subscribes ACTIVE to the child
113
117
  * (so it's woken when the child finishes), and a spawned_by audit edge is
114
118
  * recorded. For a root (no parent): no edges, resident by default. */
115
- export declare function spawnNode(opts: SpawnNodeOpts): NodeMeta;
119
+ export declare function spawnNode(opts: SpawnNodeOpts): Promise<NodeMeta>;
@@ -21,6 +21,7 @@ import { resolveInstallId } from '../canvas/install-id.js';
21
21
  import { canvasDbPath } from '../canvas/paths.js';
22
22
  import { usage } from '../errors.js';
23
23
  import { assertProfileAvailableForBirth } from '../profiles/deletion-reservation.js';
24
+ import { admitNodeCreate } from '../command-hooks/lifecycle-create.js';
24
25
  import { fanDoctrineWake } from './close.js';
25
26
  import { BIRTH_ANNOUNCEMENT_MARKER, birthAnnouncementBody } from '../../shared/birth-announcement.js';
26
27
  // crtrd sets this once at boot (setInstallId) and it wins unconditionally —
@@ -131,14 +132,9 @@ export function preflightNodeId(nodeId) {
131
132
  * satisfy the format unconditionally and are never a duplicate (they embed a
132
133
  * fresh timestamp+random suffix), so this is only ever exercised by an
133
134
  * explicit `opts.nodeId` from a real caller (`--node-id` / API `node_id`).
134
- *
135
- * RACE SAFETY: this function and the `createNode()` call that follows it in
136
- * `spawnNode` run with no `await` between them, and `spawnNode` itself is a
137
- * synchronous function — so on the single-threaded daemon process, the
138
- * existence-check-then-create is atomic relative to any other concurrent
139
- * request. Two concurrent creates at the same id resolve to exactly one
140
- * winner (whichever's synchronous `spawnNode()` call runs first) and one
141
- * `NodeIdConflictError` for the loser. */
135
+ * It runs inside the create critical section immediately before `createNode`,
136
+ * so concurrent explicit-id births resolve to one winner and one
137
+ * `NodeIdConflictError`. */
142
138
  function validatedExplicitNodeId(nodeId) {
143
139
  validateNodeIdFormat(nodeId);
144
140
  if (getNode(nodeId) !== null)
@@ -225,8 +221,8 @@ export function nodeEnv(meta) {
225
221
  * For a child (parent given): the parent auto-subscribes ACTIVE to the child
226
222
  * (so it's woken when the child finishes), and a spawned_by audit edge is
227
223
  * recorded. For a root (no parent): no edges, resident by default. */
228
- export function spawnNode(opts) {
229
- const nodeId = opts.nodeId !== undefined ? validatedExplicitNodeId(opts.nodeId) : newNodeId();
224
+ export async function spawnNode(opts) {
225
+ const nodeId = opts.nodeId !== undefined ? (validateNodeIdFormat(opts.nodeId), opts.nodeId) : newNodeId();
230
226
  const parent = opts.parent ?? null;
231
227
  const isRoot = parent === null;
232
228
  // Provenance is independent of the spine: a root has no parent but still
@@ -279,7 +275,26 @@ export function spawnNode(opts) {
279
275
  throw new Error(`cannot spawn from unknown creator node: ${creator}`);
280
276
  }
281
277
  assertProfileAvailableForBirth(meta.profile_id ?? null);
282
- createNode(meta, opts.outcomeDelivery === undefined ? undefined : { outcomeDelivery: opts.outcomeDelivery });
278
+ const releaseCreateGate = await admitNodeCreate({
279
+ kind: meta.kind,
280
+ mode: meta.mode,
281
+ cwd: meta.cwd,
282
+ profile: meta.profile_id ?? null,
283
+ root: isRoot,
284
+ parentId: parent,
285
+ requestingNodeId: creator,
286
+ lifecycle,
287
+ replaces: opts.replaces ?? null,
288
+ });
289
+ try {
290
+ if (opts.nodeId !== undefined)
291
+ validatedExplicitNodeId(opts.nodeId);
292
+ await opts.beforeCreate?.();
293
+ createNode(meta, opts.outcomeDelivery === undefined ? undefined : { outcomeDelivery: opts.outcomeDelivery });
294
+ }
295
+ finally {
296
+ releaseCreateGate();
297
+ }
283
298
  // Create the node-local memory directory so substrate docs can be written
284
299
  // directly into it without a separate mkdir. Skip for ephemeral human-bridge
285
300
  // rows (kind 'human') — they are not agent nodes.
@@ -27,6 +27,7 @@ import { waitForBrokerViewSocket, viewerSplitEnv } from './placement-tmux.js';
27
27
  import { headlessBrokerHost } from './host.js';
28
28
  import { transition } from './lifecycle.js';
29
29
  import { ensureDaemon } from '../../daemon/manage.js';
30
+ import { invokeBirthLifecycleHooks } from './spawn.js';
30
31
  /** The agent's most recent surfaced message: the newest reports/*.md body with
31
32
  * its YAML frontmatter stripped. Empty string when the node never reported. */
32
33
  function lastReportBody(nodeId) {
@@ -64,17 +65,6 @@ export async function recycleNode(nodeId, callerPane) {
64
65
  if (pane === undefined || pane === '') {
65
66
  return { recycled: false, finalized: false, newRoot: null, delivered: [] };
66
67
  }
67
- // 1. Finalize — fan the agent's last message out as a `final`, mark it done.
68
- const body = lastReportBody(nodeId) ||
69
- `Closed via recycle — no final summary was authored by ${meta.name}.`;
70
- let delivered = [];
71
- let finalized = false;
72
- try {
73
- const res = await pushFinal(nodeId, body);
74
- delivered = res.deliveredTo;
75
- finalized = true;
76
- }
77
- catch { /* recycle the pane even if the report failed */ }
78
68
  // Capture M's focus viewport (if any) before booting its replacement. The
79
69
  // old broker remains live until the new broker proves it can accept a viewer;
80
70
  // a failed replacement must not strand this finished conversation without an
@@ -92,17 +82,31 @@ export async function recycleNode(nodeId, callerPane) {
92
82
  // person's seat, so profile-scope defaults (e.g. a persisted /model default)
93
83
  // must keep applying.
94
84
  const { launch } = await buildLaunchSpecAsync('general', 'base', { lifecycle: 'resident', hasManager: false, cwd: meta.cwd, profileId: meta.profile_id });
95
- const root = spawnNode({
85
+ const body = lastReportBody(nodeId) ||
86
+ `Closed via recycle — no final summary was authored by ${meta.name}.`;
87
+ let delivered = [];
88
+ let finalized = false;
89
+ const root = await spawnNode({
96
90
  kind: 'general',
97
91
  mode: 'base',
98
92
  lifecycle: 'resident',
99
93
  cwd: meta.cwd,
100
94
  name: 'general',
101
95
  parent: null,
96
+ replaces: nodeId,
102
97
  profile_id: meta.profile_id,
103
98
  launch,
99
+ beforeCreate: async () => {
100
+ try {
101
+ const res = await pushFinal(nodeId, body);
102
+ delivered = res.deliveredTo;
103
+ finalized = true;
104
+ }
105
+ catch { /* recycle the pane even if the report failed */ }
106
+ },
104
107
  });
105
108
  const fresh = getNode(root.node_id);
109
+ await invokeBirthLifecycleHooks(fresh);
106
110
  const inv = buildPiArgv(fresh);
107
111
  // CRTR_SUBTREE groups the fresh root's subtree; FRONT_DOOR is set by the broker
108
112
  // host itself, so it is not added here.
@@ -27,6 +27,7 @@ import { tearDownNode, focusOf, registerViewerFocus, respawnPaneSync, windowOfPa
27
27
  import { viewerSplitEnv, waitForBrokerViewSocket } from './placement-tmux.js';
28
28
  import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
29
29
  import { spawnNode, rootOfSpine } from './nodes.js';
30
+ import { invokeBirthLifecycleHooks } from './spawn.js';
30
31
  // reapDescendants — tear down a root's descendant sub-DAG (shared helper)
31
32
  /** Reap the descendant sub-DAG of `rootId`: mark each **canceled** (the user
32
33
  * moved on — a clean teardown, NOT a fault) + clear intent FIRST, then kill its
@@ -95,17 +96,19 @@ export async function relaunchRoot(oldId, deps = {}) {
95
96
  cwd: old.cwd,
96
97
  profileId: old.profile_id,
97
98
  });
98
- const newMeta = spawnNode({
99
+ const newMeta = await spawnNode({
99
100
  kind: old.kind,
100
101
  mode: 'base',
101
102
  lifecycle: 'resident',
102
103
  cwd: old.cwd,
103
104
  name: old.kind,
104
105
  parent: null,
106
+ replaces: oldId,
105
107
  profile_id: old.profile_id,
106
108
  launch,
107
109
  modelOverride: old.model_override ?? undefined,
108
110
  });
111
+ await invokeBirthLifecycleHooks(newMeta);
109
112
  const inv = buildPiArgv(newMeta, {});
110
113
  // Mirror bootRoot's subtree routing on inv.env (the broker host merges it; it
111
114
  // sets CRTR_FRONT_DOOR itself).
@@ -278,10 +278,6 @@ async function launchRevive(nodeId, meta, opts) {
278
278
  next,
279
279
  });
280
280
  }
281
- // This is the same durable distinction used to decide between a birth prompt
282
- // and a revive kickoff below. Compute it before launch mutations so a stale
283
- // session identity cannot make an ordinary fresh revive look like a birth.
284
- const isBirth = isUnstartedBirth(nodeId);
285
281
  // Lazy host_kind coerce (§C): every launch now uses the broker host. Persist
286
282
  // that identity only after this call has passed the no-op liveness guard.
287
283
  if (meta.host_kind !== 'broker') {
@@ -340,6 +336,16 @@ async function launchRevive(nodeId, meta, opts) {
340
336
  const hasSessionPath = resume.resumeSessionPath !== undefined;
341
337
  const cycling = hasSessionPath && resume.newCycle === true;
342
338
  const resuming = hasSessionPath && !cycling;
339
+ // Never conversed and nothing on disk to rebuild from — relaunch as a birth
340
+ // (see isUnstartedBirth). A clock-driven wake must keep its <crtr-wake>
341
+ // kickoff — the schedule IS the instruction to take a turn, so it never
342
+ // degrades to a birth replay. This one decision selects the kickoff below
343
+ // and is what the node:start hook payload reports as `isBirth`: a hook is
344
+ // told "birth" exactly when the node boots as one.
345
+ const birthReplay = !hasSessionPath
346
+ && (opts.wakeReason === undefined || opts.wakeReason === 'runtime-restart-abort')
347
+ && isUnstartedBirth(nodeId);
348
+ const isBirth = forkBirthPending || birthReplay;
343
349
  // A cycling launch is provisional until session_start confirms it. Once
344
350
  // true, this marker is monotonic across failed retries; only that confirmed
345
351
  // boot may clear it.
@@ -399,14 +405,9 @@ async function launchRevive(nodeId, meta, opts) {
399
405
  kickoffOrigin = { type: 'runtime', kind: 'restart-continuation' };
400
406
  }
401
407
  }
402
- else if (!hasSessionPath &&
403
- // A clock-driven wake must keep its <crtr-wake> kickoff — the schedule IS
404
- // the instruction to take a turn, so it never degrades to a birth replay.
405
- (opts.wakeReason === undefined || opts.wakeReason === 'runtime-restart-abort') &&
406
- isUnstartedBirth(nodeId)) {
407
- // Never conversed and nothing on disk to rebuild from — relaunch as a birth
408
- // (see isUnstartedBirth). A node with a spawn mandate replays it; a bare
409
- // interactive node boots promptless and takes no turn at all.
408
+ else if (birthReplay) {
409
+ // A node with a spawn mandate replays it; a bare interactive node boots
410
+ // promptless and takes no turn at all.
410
411
  const goal = readGoal(nodeId)?.trim();
411
412
  inv = buildPiArgv(meta, goal !== undefined && goal !== '' ? { prompt: goal } : {});
412
413
  if (goal !== undefined && goal !== '')
@@ -127,8 +127,11 @@ export declare function resolveSpawner(parent: string | undefined, ctxNodeId: st
127
127
  * to the spawner (it carries spawned_by=spawner for provenance only), brought
128
128
  * forefront so a human can pick up the conversation directly. */
129
129
  type BeforeBrokerLaunch = (nodeId: string) => void;
130
- export declare function spawnChildPrepared(opts: SpawnChildOpts, beforeBrokerLaunch?: BeforeBrokerLaunch): Promise<SpawnChildResult>;
130
+ type AfterNodeCreated = (nodeId: string) => void;
131
+ export declare function spawnChildPrepared(opts: SpawnChildOpts, beforeBrokerLaunch?: BeforeBrokerLaunch, afterNodeCreated?: AfterNodeCreated): Promise<SpawnChildResult>;
131
132
  export declare function spawnChild(opts: SpawnChildOpts): Promise<SpawnChildResult>;
133
+ /** Invoke the best-effort node-start plan before any cold broker launch. */
134
+ export declare function invokeBirthLifecycleHooks(meta: NodeMeta): Promise<void>;
132
135
  /** Create an independent, idle root from a node's conversation and durable
133
136
  * context. Runtime/graph state is intentionally not inherited. */
134
137
  export declare function forkNode(sourceId: string): Promise<SpawnChildResult>;
@@ -22,7 +22,7 @@ import { appendSituationalContext, formatSituationalProse } from './situational-
22
22
  import { hasRoadmap, seedRoadmap } from './roadmap.js';
23
23
  import { buildWakeBearings } from './bearings.js';
24
24
  import { encodeKickoffOrigin, KICKOFF_ORIGIN_ENV } from './stamp/protocol.js';
25
- import { canonicalSessionFile, contextDir, findNodeBySessionFile, getNode, fullName, recordPid, setFrozen } from '../canvas/index.js';
25
+ import { canonicalSessionFile, contextDir, crtrHome, findNodeBySessionFile, getNode, fullName, jobDir, nodeDir, recordPid, reportsDir, setFrozen } from '../canvas/index.js';
26
26
  import { boundFleet, brokerThresholdsForDaemon } from './fleet.js';
27
27
  import { emitEvent } from '../events/emit.js';
28
28
  import { openViewerWindow, focusOf, windowOfPane, } from './placement.js';
@@ -37,6 +37,8 @@ import { modelRequestFromConfig, unregisteredConcreteModel } from '../model-rout
37
37
  import { openModelRegistry } from './model-registry.js';
38
38
  import { MODEL_SPEC_NEXT } from './model-selection.js';
39
39
  import { initializeForkContextExposure } from '../substrate/injected-store.js';
40
+ import { discoverLifecycleHookRegistry } from '../command-hooks/discovery.js';
41
+ import { invokeLifecycleHooks } from '../command-hooks/transport/exec-lifecycle.js';
40
42
  // Shared broker-launch wrapper — catch launch/preflight failures at the call
41
43
  // site, mark the node crashed, then rethrow a clear error.
42
44
  function launchFailureOutcome(error) {
@@ -210,7 +212,7 @@ export function resolveSpawner(parent, ctxNodeId, root) {
210
212
  }
211
213
  return spawner;
212
214
  }
213
- export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
215
+ export async function spawnChildPrepared(opts, beforeBrokerLaunch, afterNodeCreated) {
214
216
  try {
215
217
  ensureDaemon();
216
218
  }
@@ -276,7 +278,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
276
278
  if (wantsWorktree) {
277
279
  await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd: spawnCwd, profileId });
278
280
  }
279
- const meta = spawnNode({
281
+ const meta = await spawnNode({
280
282
  kind: opts.kind,
281
283
  mode,
282
284
  lifecycle,
@@ -299,6 +301,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
299
301
  ...(opts.outcomeDelivery === undefined ? {} : { outcomeDelivery: opts.outcomeDelivery }),
300
302
  launch,
301
303
  });
304
+ afterNodeCreated?.(meta.node_id);
302
305
  if (opts.forkFrom !== undefined) {
303
306
  try {
304
307
  initializeForkContextExposure(meta.node_id, forkSourceNodeId);
@@ -345,6 +348,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
345
348
  }
346
349
  // (The three scoped long-term memory stores are seeded for EVERY node at birth
347
350
  // in spawnNode — no orchestrator-gated seeding needed here.)
351
+ await invokeBirthLifecycleHooks(meta);
348
352
  const inv = buildPiArgv(meta, { prompt: kickoff, forkFrom });
349
353
  // CRTR_SUBTREE (the spine root) rides the detached broker's env so it can group
350
354
  // the subtree; the host sets CRTR_FRONT_DOOR itself.
@@ -491,6 +495,29 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
491
495
  export async function spawnChild(opts) {
492
496
  return spawnChildPrepared(opts);
493
497
  }
498
+ /** Invoke the best-effort node-start plan before any cold broker launch. */
499
+ export async function invokeBirthLifecycleHooks(meta) {
500
+ const lifecycleHooks = discoverLifecycleHookRegistry(meta.cwd, meta.profile_id).plans.get('node:start') ?? [];
501
+ await invokeLifecycleHooks(lifecycleHooks, {
502
+ node: {
503
+ id: meta.node_id,
504
+ name: meta.name,
505
+ kind: meta.kind,
506
+ mode: meta.mode,
507
+ lifecycle: meta.lifecycle,
508
+ cwd: meta.cwd,
509
+ nodeDir: nodeDir(meta.node_id),
510
+ contextDir: contextDir(meta.node_id),
511
+ jobDir: jobDir(meta.node_id),
512
+ reportsDir: reportsDir(meta.node_id),
513
+ },
514
+ runtime: {
515
+ isBirth: true,
516
+ canvasHome: crtrHome(),
517
+ profile: meta.profile_id ?? null,
518
+ },
519
+ });
520
+ }
494
521
  const FORK_CONTEXT_EXCLUDES = new Set(['initial-prompt.md', 'yield-message.md', 'result.json']);
495
522
  /** Create an independent, idle root from a node's conversation and durable
496
523
  * context. Runtime/graph state is intentionally not inherited. */
@@ -30,8 +30,8 @@ export interface WarmRequest {
30
30
  creator: string | null;
31
31
  }
32
32
  /** Claim a spare for `request`, reshaped onto its persona — or null when the
33
- * pool cannot serve it. The caller falls back to a cold spawn; a claim never
34
- * fails the create. */
33
+ * pool cannot serve it. The caller falls back to a cold spawn; a refused
34
+ * create hook leaves the spare untouched and rejects the create. */
35
35
  export declare function claimWarmNode(request: WarmRequest, birth?: {
36
36
  deadlineAt?: string;
37
37
  outcomeDelivery?: OutcomeDeliveryBinding;
@@ -60,6 +60,7 @@ import { createHash } from 'node:crypto';
60
60
  import { claimWarmSpare, deleteNode, getNode, listNodes, listWarmSpares, registerWarmSpare, unregisterWarmSpare, updateNode, warmSpareCount, } from '../canvas/index.js';
61
61
  import { recordedPidLiveness } from '../canvas/pid.js';
62
62
  import { nowIso } from '../fs-utils.js';
63
+ import { admitNodeCreate } from '../command-hooks/lifecycle-create.js';
63
64
  import { spawnChildPrepared } from './spawn.js';
64
65
  import { buildLaunchSpecAsync } from './launch.js';
65
66
  import { setModelLive } from './model-swap.js';
@@ -110,15 +111,34 @@ async function resolveRecipe(request) {
110
111
  return { key: warmRecipeKey(request.cwd, request.profileId, launch), launch, request };
111
112
  }
112
113
  /** Claim a spare for `request`, reshaped onto its persona — or null when the
113
- * pool cannot serve it. The caller falls back to a cold spawn; a claim never
114
- * fails the create. */
114
+ * pool cannot serve it. The caller falls back to a cold spawn; a refused
115
+ * create hook leaves the spare untouched and rejects the create. */
115
116
  export async function claimWarmNode(request, birth = {}) {
116
117
  const recipe = await resolveRecipe(request);
118
+ if (warmSpareCount(recipe.key) === 0)
119
+ return null;
117
120
  const created = nowIso();
118
121
  // The claim's requested deadline and delivery registration move with its
119
122
  // marker deletion, so the spare becomes caller-owned only as a fully armed
120
123
  // birth. meta.json follows with the same created stamp.
121
- const nodeId = claimWarmSpare(recipe.key, created, birth);
124
+ const releaseCreateGate = await admitNodeCreate({
125
+ kind: request.kind,
126
+ mode: request.mode,
127
+ cwd: request.cwd,
128
+ profile: request.profileId,
129
+ root: true,
130
+ parentId: null,
131
+ requestingNodeId: request.creator,
132
+ lifecycle: 'resident',
133
+ replaces: null,
134
+ });
135
+ let nodeId;
136
+ try {
137
+ nodeId = claimWarmSpare(recipe.key, created, birth);
138
+ }
139
+ finally {
140
+ releaseCreateGate();
141
+ }
122
142
  if (nodeId === null)
123
143
  return null;
124
144
  const spare = getNode(nodeId);
@@ -47,9 +47,9 @@ after(() => {
47
47
  else
48
48
  process.env.CRTR_HOME = priorHome;
49
49
  });
50
- test('birth commits the requested deadline and outcome registration before spawn orchestration can launch a broker', () => {
50
+ test('birth commits the requested deadline and outcome registration before spawn orchestration can launch a broker', async () => {
51
51
  const deadlineAt = '2026-01-01T00:00:00.000Z';
52
- const born = spawnNode({
52
+ const born = await spawnNode({
53
53
  kind: 'general',
54
54
  cwd: home,
55
55
  lifecycle: 'terminal',