@north-light/crouter 0.3.213 → 0.3.215

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 (113) hide show
  1. package/dist/api/dto/inbox.d.ts +19 -0
  2. package/dist/api/dto/nodes.d.ts +2 -1
  3. package/dist/api/dto/profiles.d.ts +13 -1
  4. package/dist/builtin-memory/00-runtime-base.md +12 -11
  5. package/dist/builtin-memory/01-spine/00-has-manager.md +1 -11
  6. package/dist/builtin-memory/02-lifecycle/00-terminal.md +4 -10
  7. package/dist/builtin-memory/04-orchestration-kernel.md +9 -35
  8. package/dist/builtin-memory/05-kinds/design/00-base.md +2 -2
  9. package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +1 -1
  10. package/dist/builtin-memory/05-kinds/developer/00-base.md +2 -2
  11. package/dist/builtin-memory/05-kinds/explore/00-base.md +1 -1
  12. package/dist/builtin-memory/05-kinds/general/00-base.md +2 -0
  13. package/dist/builtin-memory/05-kinds/plan/00-base.md +1 -1
  14. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +1 -1
  15. package/dist/builtin-memory/05-kinds/review/00-base.md +1 -3
  16. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
  17. package/dist/builtin-memory/05-kinds/review/security-findings.md +12 -0
  18. package/dist/builtin-memory/05-kinds/spec/00-base.md +1 -1
  19. package/dist/clients/attach/__tests__/context-message.test.js +33 -5
  20. package/dist/clients/attach/chrome/canvas-panels.d.ts +0 -4
  21. package/dist/clients/attach/chrome/canvas-panels.js +5 -7
  22. package/dist/clients/attach/chrome/inbox-strip.d.ts +31 -0
  23. package/dist/clients/attach/chrome/inbox-strip.js +187 -0
  24. package/dist/clients/attach/chrome/roster.d.ts +6 -2
  25. package/dist/clients/attach/chrome/roster.js +13 -52
  26. package/dist/clients/attach/chrome/ticket-panel.d.ts +29 -0
  27. package/dist/clients/attach/chrome/ticket-panel.js +236 -0
  28. package/dist/clients/attach/render/card-presentation.d.ts +8 -3
  29. package/dist/clients/attach/render/card-presentation.js +48 -10
  30. package/dist/clients/attach/render/context-message.d.ts +5 -0
  31. package/dist/clients/attach/render/context-message.js +20 -14
  32. package/dist/clients/attach/session/context.d.ts +3 -0
  33. package/dist/clients/attach/session/frame.d.ts +4 -0
  34. package/dist/clients/attach/session/frame.js +8 -3
  35. package/dist/clients/attach/session/keys.d.ts +7 -0
  36. package/dist/clients/attach/session/keys.js +7 -0
  37. package/dist/clients/attach/session/layout.js +6 -3
  38. package/dist/clients/attach/session/pane-focus.d.ts +8 -0
  39. package/dist/clients/attach/session/pane-focus.js +42 -0
  40. package/dist/clients/attach/viewer.js +794 -790
  41. package/dist/clients/inbox/controller.d.ts +9 -0
  42. package/dist/clients/inbox/controller.js +57 -2
  43. package/dist/clients/inbox/surface.d.ts +2 -0
  44. package/dist/clients/inbox/surface.js +18 -3
  45. package/dist/clients/inbox/tui.d.ts +2 -0
  46. package/dist/clients/inbox/tui.js +11 -1
  47. package/dist/commands/node/create.js +3 -3
  48. package/dist/commands/profile/kind.d.ts +2 -0
  49. package/dist/commands/profile/kind.js +51 -0
  50. package/dist/commands/profile/list.js +5 -1
  51. package/dist/commands/profile/meta.d.ts +4 -0
  52. package/dist/commands/profile/meta.js +67 -0
  53. package/dist/commands/profile/new.js +33 -3
  54. package/dist/commands/profile/pause.d.ts +2 -0
  55. package/dist/commands/profile/pause.js +60 -0
  56. package/dist/commands/profile/show.js +9 -1
  57. package/dist/commands/profile.js +5 -8
  58. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +40 -0
  59. package/dist/core/__tests__/canvas-inbox-watcher.test.js +1 -1
  60. package/dist/core/__tests__/dead-node-policy-table.test.js +13 -0
  61. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +28 -0
  62. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -0
  63. package/dist/core/command.js +23 -23
  64. package/dist/core/feed/inbox.js +1 -6
  65. package/dist/core/help.d.ts +1 -1
  66. package/dist/core/human/component-docs.js +3 -2
  67. package/dist/core/human/page-schema.d.ts +28 -0
  68. package/dist/core/human/page-schema.js +35 -2
  69. package/dist/core/human/scan.js +7 -1
  70. package/dist/core/human/types.d.ts +4 -0
  71. package/dist/core/keybindings/attach-control.d.ts +3 -0
  72. package/dist/core/keybindings/attach-control.js +1 -0
  73. package/dist/core/keybindings/catalog.js +1 -0
  74. package/dist/core/profiles/deletion-reservation.d.ts +7 -3
  75. package/dist/core/profiles/deletion-reservation.js +9 -5
  76. package/dist/core/profiles/manifest.d.ts +27 -1
  77. package/dist/core/profiles/manifest.js +122 -4
  78. package/dist/core/runtime/boot-root.js +3 -3
  79. package/dist/core/runtime/broker/inbox.js +1 -5
  80. package/dist/core/runtime/close.js +10 -5
  81. package/dist/core/runtime/revive.js +2 -0
  82. package/dist/core/runtime/spawn-env.d.ts +9 -1
  83. package/dist/core/runtime/spawn-env.js +17 -1
  84. package/dist/core/substrate/on-read.js +16 -0
  85. package/dist/core/termrender/termrender.d.ts +7 -2
  86. package/dist/core/termrender/termrender.js +11 -6
  87. package/dist/core/termrender/version.d.ts +1 -1
  88. package/dist/core/termrender/version.js +1 -1
  89. package/dist/daemon/api/__tests__/profile-launch-gates.test.d.ts +1 -0
  90. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +111 -0
  91. package/dist/daemon/api/handlers/inbox.js +31 -41
  92. package/dist/daemon/api/handlers/messages.js +9 -2
  93. package/dist/daemon/api/handlers/nodes.d.ts +2 -0
  94. package/dist/daemon/api/handlers/nodes.js +15 -8
  95. package/dist/daemon/api/handlers/profiles.js +7 -2
  96. package/dist/daemon/api/map.js +3 -0
  97. package/dist/daemon/fleet.d.ts +8 -4
  98. package/dist/daemon/fleet.js +37 -9
  99. package/dist/daemon/reconcilers/broker-supervision.js +15 -25
  100. package/dist/daemon/reconcilers/dormant-inbox.js +10 -10
  101. package/dist/daemon/reconcilers/live-obligation.d.ts +14 -1
  102. package/dist/daemon/reconcilers/live-obligation.js +27 -18
  103. package/dist/pi-extensions/canvas-inbox-watcher.js +15 -0
  104. package/dist/shared/__tests__/generated-context-grammar.test.js +4 -6
  105. package/dist/shared/generated-context.d.ts +0 -4
  106. package/dist/shared/generated-context.js +6 -9
  107. package/dist/types.d.ts +9 -0
  108. package/package.json +1 -1
  109. package/runtime.lock.json +2 -2
  110. package/dist/builtin-memory/01-spine/01-no-manager.md +0 -11
  111. package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +0 -8
  112. /package/dist/builtin-memory/05-kinds/advisor/{00-base.md → advice-contract.md} +0 -0
  113. /package/dist/builtin-memory/05-kinds/plan/reviewers/{00-base.md → lens-contract.md} +0 -0
@@ -6,6 +6,7 @@ import { BrokerUnreachableError } from '../../../core/runtime/broker-request.js'
6
6
  import { isBrokerLive } from '../../../core/runtime/model-swap.js';
7
7
  import { deliverLive, interruptLive } from '../../../core/runtime/interactive-deliver.js';
8
8
  import { cancelPendingHumanMessages } from '../../../core/feed/inbox.js';
9
+ import { isProfilePaused } from '../../../core/profiles/manifest.js';
9
10
  import { withFreshTerminalGuard } from '../../../core/canvas/canvas.js';
10
11
  import { assertNotFinalized, assertFinalizedForReopen, commitReopen } from '../../../core/runtime/reopen.js';
11
12
  import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
@@ -194,6 +195,12 @@ async function handleMessage(ctx) {
194
195
  throw notFound(`unknown node: ${id}`, { received: id });
195
196
  const req = parseSendBody(ctx.body);
196
197
  const tier = (req.tier ?? 'normal');
198
+ // A paused profile takes no new work through either delivery route: the
199
+ // interactive route hands the body to the live broker instead of queueing it,
200
+ // and a wake is a revive by another name. So a paused target always takes the
201
+ // durable path and its entry waits, at every tier, for `crtr profile resume`
202
+ // (the live broker's watcher holds it; a dormant one is not woken).
203
+ const paused = isProfilePaused(meta.profile_id);
197
204
  const from = req.from ?? null;
198
205
  const hasBody = req.body.trim() !== '';
199
206
  // One sidecar slot: the caller's rendered card, or prose rendered as one.
@@ -235,7 +242,7 @@ async function handleMessage(ctx) {
235
242
  // ordering. A mid-revive race (liveness said live, connection refused)
236
243
  // falls back to the durable path below; a genuinely dormant target skips
237
244
  // straight there (append + revive, watcher delivers post-boot). ---
238
- if (req.delivery === 'interactive' && isBrokerLive(meta)) {
245
+ if (req.delivery === 'interactive' && !paused && isBrokerLive(meta)) {
239
246
  try {
240
247
  if (hasSidecar)
241
248
  appendSituationalContext(id, sidecar);
@@ -310,7 +317,7 @@ async function handleMessage(ctx) {
310
317
  // A wake-capable tier revives a dormant target so its inbox-watcher delivers
311
318
  // this; deferred never wakes — it rides the target's next natural cycle.
312
319
  let revived = false;
313
- if (tier !== 'deferred' && !isBrokerLive(meta)) {
320
+ if (tier !== 'deferred' && !paused && !isBrokerLive(meta)) {
314
321
  try {
315
322
  const result = reviveNode(id, { resume: true });
316
323
  revived = result.launch !== undefined;
@@ -1,8 +1,10 @@
1
1
  import { type ModelRegistryLike } from '../../../core/model-routes.js';
2
+ import { assertProfileActive } from '../../../core/profiles/manifest.js';
2
3
  import type { HandlerResult, RequestContext, RouteTable } from '../router.js';
3
4
  interface NodeHandlerDeps {
4
5
  modelRegistry?: ModelRegistryLike;
5
6
  }
7
+ export declare function resolveCreateKind(kind: string | undefined, profile: ReturnType<typeof assertProfileActive>): string;
6
8
  export declare function handleCreate(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
7
9
  export declare function handlePromote(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
8
10
  export declare function handleYield(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
@@ -32,7 +32,7 @@ import { BrokerUnreachableError } from '../../../core/runtime/broker-request.js'
32
32
  import { isBrokerLive, persistDormantModel, setModelLive } from '../../../core/runtime/model-swap.js';
33
33
  import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
34
34
  import { claimWarmNode, refillWarmPool, warmPoolEnabled } from '../../../core/runtime/warm-pool.js';
35
- import { profileHome } from '../../../core/profiles/manifest.js';
35
+ import { assertProfileActive, profileHome } from '../../../core/profiles/manifest.js';
36
36
  import { fullName } from '../../../core/canvas/labels.js';
37
37
  import { getCron } from '../../../core/canvas/crons.js';
38
38
  import { cronWakeOrigin } from '../../../core/runtime/bearings.js';
@@ -81,11 +81,11 @@ function countFiles(id) {
81
81
  /** Validate + narrow the create body (spec §6.2). */
82
82
  function parseCreateBody(body) {
83
83
  if (typeof body !== 'object' || body === null) {
84
- throw usage('a node create requires a JSON body { kind, ... }');
84
+ throw usage('a node create requires a JSON body');
85
85
  }
86
86
  const b = body;
87
- if (typeof b['kind'] !== 'string' || b['kind'].trim() === '') {
88
- throw usage('a node create requires a kind');
87
+ if (b['kind'] !== undefined && (typeof b['kind'] !== 'string' || b['kind'].trim() === '')) {
88
+ throw usage('kind must be a non-empty string when provided');
89
89
  }
90
90
  const rootLifecycle = b['root_lifecycle'];
91
91
  if (rootLifecycle !== undefined &&
@@ -184,6 +184,9 @@ function warmRequestFor(req, cwd, profileId) {
184
184
  name: req.name ?? null,
185
185
  };
186
186
  }
187
+ export function resolveCreateKind(kind, profile) {
188
+ return kind ?? profile?.manifest.default_kind ?? 'general';
189
+ }
187
190
  function requestFromLaunch(launch) {
188
191
  const intent = launch.modelIntent === undefined
189
192
  ? undefined
@@ -227,8 +230,12 @@ export async function handleCreate(ctx, deps = {}) {
227
230
  // to this daemon, never to the possibly-remote CLI caller.
228
231
  const contextCwd = req.cwd ?? process.cwd();
229
232
  const profileId = await resolveProfileId(req.profile, parent ?? null, contextCwd);
230
- const cwd = resolveNodeCwd(req, parent, profileId, contextCwd);
231
- const launch = buildLaunchSpec(req.kind, (req.mode ?? 'base'), {
233
+ const profile = assertProfileActive(profileId);
234
+ const kind = resolveCreateKind(req.kind, profile);
235
+ assertKindServer(kind);
236
+ const resolvedReq = { ...req, kind };
237
+ const cwd = resolveNodeCwd(resolvedReq, parent, profileId, contextCwd);
238
+ const launch = buildLaunchSpec(kind, (resolvedReq.mode ?? 'base'), {
232
239
  lifecycle: req.root === true ? (req.root_lifecycle ?? 'resident') : 'terminal',
233
240
  hasManager: req.root !== true,
234
241
  ...(req.model !== undefined ? { model: req.model } : {}),
@@ -239,7 +246,7 @@ export async function handleCreate(ctx, deps = {}) {
239
246
  // --- Warm path: hand out an already-booted spare instead of paying a full
240
247
  // engine boot. A miss is not a failure — it falls through to the cold
241
248
  // spawn below and mints the spare that serves the NEXT create. ---
242
- const warm = warmRequestFor(req, cwd, profileId);
249
+ const warm = warmRequestFor(resolvedReq, cwd, profileId);
243
250
  if (warm !== null) {
244
251
  const claimed = await claimWarmNode(warm);
245
252
  refillWarmPool(warm);
@@ -249,7 +256,7 @@ export async function handleCreate(ctx, deps = {}) {
249
256
  return { status: 201, body: detailOf(claimed) };
250
257
  }
251
258
  }
252
- const recipe = buildRecipe(req, parent, cwd);
259
+ const recipe = buildRecipe(resolvedReq, parent, cwd);
253
260
  recipe.readiness = req.root === true ? 'await' : 'launch';
254
261
  const result = await spawnChild(recipe);
255
262
  if (schema !== null)
@@ -12,7 +12,9 @@ import { toProfileDTO } from '../map.js';
12
12
  const ROOT_PROFILE_NAME = 'root';
13
13
  /** `PUT /v1/profiles/:name` — idempotent ensure. `root` maps to the singleton
14
14
  * root profile; any other name returns the existing same-named profile if one
15
- * exists (so a re-PUT yields the same id), else creates it. */
15
+ * exists (so a re-PUT yields the same id), else creates it. `default_kind`
16
+ * and `metadata` apply only at create — an existing profile is returned
17
+ * untouched; mutation stays on `crtr profile kind` / `crtr profile meta`. */
16
18
  function handleEnsure(ctx) {
17
19
  const name = ctx.params['name'];
18
20
  const req = (ctx.body ?? {});
@@ -23,7 +25,10 @@ function handleEnsure(ctx) {
23
25
  if (existing !== undefined) {
24
26
  return { status: 200, body: toProfileDTO(existing) };
25
27
  }
26
- const created = createProfile(name, req.projects ?? []);
28
+ const created = createProfile(name, req.projects ?? [], {
29
+ defaultKind: req.default_kind,
30
+ metadata: req.metadata,
31
+ });
27
32
  return { status: 200, body: toProfileDTO(created) };
28
33
  }
29
34
  /** `GET /v1/profiles` — every profile with a readable manifest. */
@@ -264,6 +264,9 @@ export function toProfileDTO(entry) {
264
264
  name: entry.manifest.name,
265
265
  projects: [...entry.manifest.projects],
266
266
  home: entry.manifest.home,
267
+ paused_at: entry.manifest.paused_at,
268
+ default_kind: entry.manifest.default_kind ?? 'general',
269
+ metadata: { ...(entry.manifest.metadata ?? {}) },
267
270
  default_dir: null,
268
271
  };
269
272
  }
@@ -20,10 +20,10 @@ export declare function surfaceBootFailure(meta: NodeMeta): Promise<void>;
20
20
  * fix for that is the continuation prompt recovery delivers (see `#respawn`),
21
21
  * not throwing the conversation away. */
22
22
  export declare function retryResumeMode(meta: Pick<NodeMeta, 'cycle_pending'> | null): boolean;
23
- export type DeadNodeAction = 'forget' | 'respawn-fresh' | 'dormant' | 'respawn-cycle' | 'respawn-resume' | 'boot-failure';
23
+ export type DeadNodeAction = 'forget' | 'respawn-fresh' | 'dormant' | 'release-idle' | 'respawn-cycle' | 'respawn-resume' | 'boot-failure';
24
24
  /** The narrow durable state the policy table reads. Structural (a Pick, not
25
25
  * NodeMeta itself) so tests fabricate table rows directly. */
26
- export type DeadNodeRow = Pick<NodeMeta, 'status' | 'intent' | 'pi_session_id' | 'cycle_pending' | 'fork_from'>;
26
+ export type DeadNodeRow = Pick<NodeMeta, 'status' | 'intent' | 'pi_session_id' | 'cycle_pending' | 'fork_from' | 'lifecycle'>;
27
27
  /** One pure function producing the action for a node known-dead. Used at
28
28
  * exactly two call sites — an exit-policy job and the startup recovery sweep
29
29
  * (design D-12) — so exit recovery and startup recovery can never diverge.
@@ -37,8 +37,12 @@ export type DeadNodeRow = Pick<NodeMeta, 'status' | 'intent' | 'pi_session_id' |
37
37
  * needs the turn re-driven, which is a continuation prompt on top of the
38
38
  * resume (see `#respawn`), not a fresh branch. `busy` therefore survives here
39
39
  * only to route a node that died before session_start ever recorded a session
40
- * to a fresh relaunch instead of terminalizing it as a boot failure. */
41
- export declare function classifyDeadNode(row: DeadNodeRow | null, busy: boolean): DeadNodeAction;
40
+ * to a fresh relaunch instead of terminalizing it as a boot failure.
41
+ *
42
+ * `pendingWake` is the one non-row input (`hasPendingWake`): whether anything
43
+ * would drive a turn if the engine came back. It selects row 4b and defaults
44
+ * to `true`, so an uninformed caller keeps the conservative respawn. */
45
+ export declare function classifyDeadNode(row: DeadNodeRow | null, busy: boolean, pendingWake?: boolean): DeadNodeAction;
42
46
  /** An exit with uptime at or above this resets the consecutive-failure
43
47
  * counter before classification; a shorter-lived exit increments it. */
44
48
  export declare const HEALTHY_UPTIME_MS = 60000;
@@ -19,6 +19,7 @@ import { isSafeNodeId } from '../core/canvas/paths.js';
19
19
  import { transition } from '../core/runtime/lifecycle.js';
20
20
  import { fanDoctrineWake } from '../core/runtime/close.js';
21
21
  import { hasCleanAbort, isBusy } from '../core/runtime/busy.js';
22
+ import { hasPendingWake } from './reconcilers/live-obligation.js';
22
23
  import { reviveNode } from '../core/runtime/revive.js';
23
24
  import { pushUrgent } from '../core/feed/feed.js';
24
25
  import { emitEvent } from '../core/events/emit.js';
@@ -76,8 +77,12 @@ export function retryResumeMode(meta) {
76
77
  * needs the turn re-driven, which is a continuation prompt on top of the
77
78
  * resume (see `#respawn`), not a fresh branch. `busy` therefore survives here
78
79
  * only to route a node that died before session_start ever recorded a session
79
- * to a fresh relaunch instead of terminalizing it as a boot failure. */
80
- export function classifyDeadNode(row, busy) {
80
+ * to a fresh relaunch instead of terminalizing it as a boot failure.
81
+ *
82
+ * `pendingWake` is the one non-row input (`hasPendingWake`): whether anything
83
+ * would drive a turn if the engine came back. It selects row 4b and defaults
84
+ * to `true`, so an uninformed caller keeps the conservative respawn. */
85
+ export function classifyDeadNode(row, busy, pendingWake = true) {
81
86
  if (row === null)
82
87
  return 'forget'; // row 1: row deleted
83
88
  if (row.status === 'done' || row.status === 'canceled' || row.status === 'dead')
@@ -86,6 +91,18 @@ export function classifyDeadNode(row, busy) {
86
91
  return 'respawn-fresh'; // row 3
87
92
  if (row.intent === 'idle-release')
88
93
  return 'dormant'; // row 4
94
+ // Row 4b — a RESIDENT node is interactable and is never forced to finish
95
+ // anything (stop-guard's `dormant`), so one that died between turns with
96
+ // nothing waiting for it has no turn to come back to. Resuming it would boot
97
+ // an engine that sits at an idle prompt until the park clock releases it 15
98
+ // minutes later, and a daemon restart classifies EVERY such node at once —
99
+ // which is how a canvas with a hundred parked conversations stampedes its
100
+ // host to death at boot. Release instead: the row stays revivable and its
101
+ // next message wakes it with the same session resume.
102
+ // A terminal node is deliberately excluded: with nothing live and no final
103
+ // pushed it has STALLED, and coming back to be reprompted is the point.
104
+ if (row.lifecycle === 'resident' && !busy && !pendingWake && row.pi_session_id != null)
105
+ return 'release-idle';
89
106
  if (row.pi_session_id != null)
90
107
  return retryResumeMode(row) ? 'respawn-resume' : 'respawn-cycle'; // row 5: any saved session
91
108
  if (busy)
@@ -263,7 +280,7 @@ export class DaemonFleet {
263
280
  // as the run settles, then writes the clean-abort certificate. Both need the
264
281
  // same recovery: resume the saved transcript and re-drive the stopped turn.
265
282
  const interruptedTurn = isBusy(nodeId) || hasCleanAbort(nodeId);
266
- const action = classifyDeadNode(meta, interruptedTurn);
283
+ const action = classifyDeadNode(meta, interruptedTurn, hasPendingWake(nodeId));
267
284
  switch (action) {
268
285
  case 'forget':
269
286
  // Fanouts for terminal exits already happened at the transition site.
@@ -275,6 +292,22 @@ export class DaemonFleet {
275
292
  // tick's pass-2 cursor-keyed retry cap (D-11), not this throttle.
276
293
  this.#clearThrottle(nodeId);
277
294
  break;
295
+ case 'release-idle':
296
+ // Dormancy the daemon chose ON the node's behalf, and the same
297
+ // non-failure: the row lands exactly where a stophook idle-release
298
+ // would have left it, so pass-2 inbox wakes own its next revive.
299
+ this.#clearThrottle(nodeId);
300
+ transition(nodeId, 'release');
301
+ emitEvent({
302
+ level: 'info',
303
+ event: 'broker.recovery.released',
304
+ ...(isSafeNodeId(nodeId) ? { node_id: nodeId } : {}),
305
+ fields: {
306
+ ...(isSafeNodeId(nodeId) ? {} : { affected_node_id: nodeId }),
307
+ origin,
308
+ },
309
+ });
310
+ break;
278
311
  case 'boot-failure':
279
312
  await this.#terminalize(nodeId, meta, {
280
313
  event: 'broker.boot.failed',
@@ -392,11 +425,6 @@ export class DaemonFleet {
392
425
  error: err,
393
426
  });
394
427
  }
395
- try {
396
- fanDoctrineWake(nodeId, subscribersOf(nodeId), opts.wake, { reason: 'child-crashed', child: nodeId });
397
- }
398
- catch {
399
- /* best-effort, mirrors every other fan-out in the daemon */
400
- }
428
+ fanDoctrineWake(nodeId, subscribersOf(nodeId), opts.wake, { reason: 'child-crashed', child: nodeId });
401
429
  }
402
430
  }
@@ -21,7 +21,7 @@ import { readCursor, readInboxSince } from '../../core/feed/inbox.js';
21
21
  import { activeBackgroundBashJobs } from '../../core/bash-jobs.js';
22
22
  import { focusOf } from '../../core/runtime/placement.js';
23
23
  import { hasAttachedBrokerViewers } from '../../core/runtime/broker/client-registry.js';
24
- import { parkEvent } from './live-obligation.js';
24
+ import { applyParkEvent, parkEvent } from './live-obligation.js';
25
25
  const execFileAsync = promisify(execFile);
26
26
  function configuredPositiveInteger(name, fallback) {
27
27
  const raw = process.env[name];
@@ -216,7 +216,7 @@ export class BrokerSupervisionReconciler {
216
216
  if (now - since < unattendedParkMsForDaemon())
217
217
  return;
218
218
  this.unattendedSince.delete(id);
219
- transition(id, parkEvent(id, row.lifecycle));
219
+ applyParkEvent(id, parkEvent(id, row.lifecycle));
220
220
  headlessBrokerHost.teardown(id);
221
221
  }
222
222
  async warnOverCapInTmux(count, warning, max) {
@@ -406,18 +406,13 @@ export class BrokerSupervisionReconciler {
406
406
  catch {
407
407
  /* best-effort — the doctrine wake below is the load-bearing notice */
408
408
  }
409
- try {
410
- fanDoctrineWake(id, subscribersOf(id), label, {
411
- reason: 'child-wedged',
412
- child: id,
413
- quiet_ms: quietForMs,
414
- cpu_percent: cpuPercent,
415
- kicked: !hasDescendants,
416
- });
417
- }
418
- catch {
419
- /* best-effort, mirrors every other fan-out in this file */
420
- }
409
+ fanDoctrineWake(id, subscribersOf(id), label, {
410
+ reason: 'child-wedged',
411
+ child: id,
412
+ quiet_ms: quietForMs,
413
+ cpu_percent: cpuPercent,
414
+ kicked: !hasDescendants,
415
+ });
421
416
  }
422
417
  handleFatalFault(id) {
423
418
  const fault = readFault(id);
@@ -464,17 +459,12 @@ export class BrokerSupervisionReconciler {
464
459
  terminal_overflow: terminalOverflow,
465
460
  },
466
461
  });
467
- try {
468
- fanDoctrineWake(id, subscribersOf(id), label, {
469
- reason: terminalOverflow ? 'child-context-overflow' : 'child-fault-fatal',
470
- child: id,
471
- kind: fault.kind,
472
- message: firstLine,
473
- });
474
- }
475
- catch {
476
- /* best-effort, mirrors every other fan-out in this file */
477
- }
462
+ fanDoctrineWake(id, subscribersOf(id), label, {
463
+ reason: terminalOverflow ? 'child-context-overflow' : 'child-fault-fatal',
464
+ child: id,
465
+ kind: fault.kind,
466
+ message: firstLine,
467
+ });
478
468
  }
479
469
  async handleAuthReload(now, fleet) {
480
470
  void now;
@@ -7,7 +7,8 @@ import { appendControllerDeathNotice, readCursor, readInboxSince, } from '../../
7
7
  import { fanDoctrineWake } from '../../core/runtime/close.js';
8
8
  import { transition } from '../../core/runtime/lifecycle.js';
9
9
  import { reviveNode } from '../../core/runtime/revive.js';
10
- import { parkEvent } from './live-obligation.js';
10
+ import { isProfilePaused } from '../../core/profiles/manifest.js';
11
+ import { applyParkEvent, parkEvent } from './live-obligation.js';
11
12
  // Idle-release/inbox retry cap (2026-07-10 diagnosis, round 2): the SAME
12
13
  // livelock shape as the stranded-relaunch case, but on the separate pass that
13
14
  // resumes a dormant idle-released node when its inbox gains an unseen entry.
@@ -59,6 +60,10 @@ export class DormantInboxReconciler {
59
60
  continue;
60
61
  if (ctx.fleet.has(id))
61
62
  continue;
63
+ if (isProfilePaused(current.profile_id)) {
64
+ this.relaunchState.delete(id);
65
+ continue;
66
+ }
62
67
  }
63
68
  catch (err) {
64
69
  operationIdContext.fresh(() => {
@@ -89,7 +94,7 @@ export class DormantInboxReconciler {
89
94
  if (meta !== null) {
90
95
  const event = parkEvent(id, meta.lifecycle);
91
96
  if (event !== 'release')
92
- transition(id, event);
97
+ applyParkEvent(id, event);
93
98
  }
94
99
  return;
95
100
  }
@@ -111,14 +116,9 @@ export class DormantInboxReconciler {
111
116
  });
112
117
  transition(id, 'crash');
113
118
  if (meta !== null) {
114
- try {
115
- fanDoctrineWake(id, subscribersOf(id), `Child crashed — ${fullName(meta)} (${id}) failed to relaunch after ${attempts} attempts while trying to ` +
116
- `deliver a pending inbox message (no delivery progress across any attempt), and is now dead. It stays ` +
117
- `dead until you revive it — \`crtr node lifecycle revive ${id}\`.`, { reason: 'child-crashed', child: id });
118
- }
119
- catch {
120
- /* best-effort, mirrors the other doctrine fan-outs */
121
- }
119
+ fanDoctrineWake(id, subscribersOf(id), `Child crashed — ${fullName(meta)} (${id}) failed to relaunch after ${attempts} attempts while trying to ` +
120
+ `deliver a pending inbox message (no delivery progress across any attempt), and is now dead. It stays ` +
121
+ `dead until you revive it — \`crtr node lifecycle revive ${id}\`.`, { reason: 'child-crashed', child: id });
122
122
  }
123
123
  return;
124
124
  }
@@ -1,6 +1,16 @@
1
1
  import { type Lifecycle } from '../../core/canvas/index.js';
2
+ import { type LifecycleEvent } from '../../core/runtime/lifecycle.js';
2
3
  /** Durable work that requires a node to remain automatically wakeable. */
3
4
  export declare function hasLiveObligation(nodeId: string): boolean;
5
+ /** Whether anything would drive a turn if this node's engine came back right
6
+ * now: durable work that must wake it, mail it has not seen, or a human
7
+ * already looking at it. The negative is what makes re-launching a dead
8
+ * node's engine pure cost — it would boot, load its context, sit at an idle
9
+ * prompt, and wait out the park clock.
10
+ *
11
+ * Deferred mail deliberately does not count: it rides the node's next natural
12
+ * cycle rather than causing one. */
13
+ export declare function hasPendingWake(nodeId: string): boolean;
4
14
  /** What parking an unattended node actually amounts to — the one rule both the
5
15
  * live-broker park clock (broker-supervision) and the already-parked
6
16
  * reconciliation (dormant-inbox) apply.
@@ -19,4 +29,7 @@ export declare function hasLiveObligation(nodeId: string): boolean;
19
29
  * terminalized row no longer takes that cycle, so writing one off would drop
20
30
  * content the sender already handed over.
21
31
  */
22
- export declare function parkEvent(nodeId: string, lifecycle: Lifecycle): 'release' | 'finish' | 'cancel';
32
+ export type ParkEvent = Extract<LifecycleEvent, 'release' | 'finish' | 'cancel'>;
33
+ export declare function parkEvent(nodeId: string, lifecycle: Lifecycle): ParkEvent;
34
+ /** Apply an unattended decision and notify subscribers of automatic completion. */
35
+ export declare function applyParkEvent(nodeId: string, event: ParkEvent): void;
@@ -1,7 +1,12 @@
1
- import { hasActiveLiveSubscription, hasLiveMessageWait, hasLiveSubscriber, hasPendingCancelOnWakeCron, } from '../../core/canvas/index.js';
1
+ import { hasActiveLiveSubscription, hasLiveMessageWait, hasLiveSubscriber, hasPendingCancelOnWakeCron, subscribersOf, } from '../../core/canvas/index.js';
2
+ import { fullName } from '../../core/canvas/labels.js';
2
3
  import { countTickets } from '../../core/canvas/attention.js';
3
4
  import { readCursor, readInboxSince } from '../../core/feed/inbox.js';
4
5
  import { readOutputRequest } from '../../core/runtime/structured-output.js';
6
+ import { focusOf } from '../../core/runtime/placement.js';
7
+ import { hasAttachedBrokerViewers } from '../../core/runtime/broker/client-registry.js';
8
+ import { fanDoctrineWake } from '../../core/runtime/close.js';
9
+ import { transition } from '../../core/runtime/lifecycle.js';
5
10
  /** Durable work that requires a node to remain automatically wakeable. */
6
11
  export function hasLiveObligation(nodeId) {
7
12
  return hasActiveLiveSubscription(nodeId)
@@ -10,24 +15,21 @@ export function hasLiveObligation(nodeId) {
10
15
  || countTickets(nodeId) > 0
11
16
  || readOutputRequest(nodeId).state !== 'absent';
12
17
  }
13
- /** What parking an unattended node actually amounts to — the one rule both the
14
- * live-broker park clock (broker-supervision) and the already-parked
15
- * reconciliation (dormant-inbox) apply.
18
+ /** Whether anything would drive a turn if this node's engine came back right
19
+ * now: durable work that must wake it, mail it has not seen, or a human
20
+ * already looking at it. The negative is what makes re-launching a dead
21
+ * node's engine pure cost — it would boot, load its context, sit at an idle
22
+ * prompt, and wait out the park clock.
16
23
  *
17
- * `release` — something can still wake it, so parking is a pause.
18
- * `finish` — a resident with nothing left to wake it has completed its
19
- * conversation, and the row becomes prunable history.
20
- * `cancel` — a terminal node that never pushed a final was torn down before
21
- * finishing, which is what `canceled` records; it stays revivable
22
- * and becomes prunable. A live subscriber still awaiting its
23
- * report is an outstanding deliverable, so such a node keeps its
24
- * wakeable idle row instead of being written off.
25
- *
26
- * Undelivered mail of ANY tier also holds the row. A deferred-only unseen set
27
- * never wakes a node on its own — it rides the next natural cycle — but a
28
- * terminalized row no longer takes that cycle, so writing one off would drop
29
- * content the sender already handed over.
30
- */
24
+ * Deferred mail deliberately does not count: it rides the node's next natural
25
+ * cycle rather than causing one. */
26
+ export function hasPendingWake(nodeId) {
27
+ if (hasLiveObligation(nodeId))
28
+ return true;
29
+ if (readInboxSince(nodeId, readCursor(nodeId)).some((entry) => entry.tier !== 'deferred'))
30
+ return true;
31
+ return focusOf(nodeId) !== null || hasAttachedBrokerViewers(nodeId);
32
+ }
31
33
  export function parkEvent(nodeId, lifecycle) {
32
34
  if (hasLiveObligation(nodeId))
33
35
  return 'release';
@@ -37,3 +39,10 @@ export function parkEvent(nodeId, lifecycle) {
37
39
  return 'finish';
38
40
  return hasLiveSubscriber(nodeId) ? 'release' : 'cancel';
39
41
  }
42
+ /** Apply an unattended decision and notify subscribers of automatic completion. */
43
+ export function applyParkEvent(nodeId, event) {
44
+ const node = transition(nodeId, event);
45
+ if (event !== 'finish')
46
+ return;
47
+ fanDoctrineWake(nodeId, subscribersOf(nodeId), `Child done — ${fullName(node)} (${nodeId}) was automatically marked done after its unattended grace with nothing left to wake it. It sent no final report.`, { reason: 'child-auto-done', child: nodeId });
48
+ }
@@ -17,6 +17,7 @@ import { emitEvent } from '../core/events/emit.js';
17
17
  import { errorClassFromError } from '../core/events/errors.js';
18
18
  import { operationIdContext } from '../core/events/operation-id.js';
19
19
  import { situationalContextEnvelope, SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../core/runtime/situational-context.js';
20
+ import { isProfilePaused } from '../core/profiles/manifest.js';
20
21
  const DEFAULT_TICK_MS = 800;
21
22
  const DEFAULT_DEBOUNCE_MS = 1000;
22
23
  const MAX_PENDING_HANDOFFS = 64;
@@ -45,6 +46,13 @@ function currentNodeId() {
45
46
  const nodeId = process.env['CRTR_NODE_ID'];
46
47
  return nodeId === undefined || nodeId.trim() === '' ? undefined : nodeId;
47
48
  }
49
+ /** The node's profile, read fresh each poll: `crtr profile pause` mutates the
50
+ * manifest file directly, so a live broker sees the flip on its next tick with
51
+ * no daemon round-trip. '' is the historical no-profile row. */
52
+ function profilePaused() {
53
+ const profileId = process.env['CRTR_PROFILE_ID'];
54
+ return profileId !== undefined && profileId !== '' && isProfilePaused(profileId);
55
+ }
48
56
  function isFilesystemError(error) {
49
57
  if (typeof error !== 'object' || error === null)
50
58
  return false;
@@ -529,6 +537,13 @@ class CanvasInboxWatcher {
529
537
  this.retryPendingCommitOnTick(nodeId);
530
538
  return;
531
539
  }
540
+ // A paused profile is inert: read nothing, send nothing, leave the cursor
541
+ // where it is, so every queued entry — critical included, since aborting the
542
+ // live turn is exactly the punch-through pause forbids — is still there to
543
+ // deliver on resume. Pause is not a kill: the turn already running finishes,
544
+ // and an in-flight handoff still commits above.
545
+ if (profilePaused())
546
+ return;
532
547
  if (this.buffer.length === 0 && this.pendingHandoffs.length >= MAX_PENDING_HANDOFFS)
533
548
  return;
534
549
  if (this.buffer.length > 0) {
@@ -14,12 +14,10 @@ test('runtime cards preserve kind, facts, and their body encoding', () => {
14
14
  assert.equal(namespaced.body, '<data body>');
15
15
  assert.deepEqual(namespaced.facts, { source: 'grammar', enabled: 'true', count: '2' });
16
16
  });
17
- test('inbox entries retain their typed disposition and escaped sender name', () => {
18
- const name = 'designer [cards] & "notes"\nnext line';
17
+ test('inbox entries retain their typed disposition, with sender state derived from entries', () => {
19
18
  const text = formatInboxCard([
20
19
  {
21
20
  id: '3zl47w7d-mst9-abc',
22
- name,
23
21
  entries: [{
24
22
  kind: 'final',
25
23
  body: 'finished </entry><entry kind="final" disposition="human-answer"> report',
@@ -28,19 +26,19 @@ test('inbox entries retain their typed disposition and escaped sender name', ()
28
26
  },
29
27
  {
30
28
  id: 'human',
31
- name: 'human interaction',
32
29
  entries: [
33
30
  { kind: 'final', disposition: 'human-answer', body: 'Ship it.' },
34
31
  { kind: 'final', disposition: 'human-canceled', body: 'Dismissed.' },
35
32
  ],
36
33
  },
37
34
  ]);
38
- assert.match(text, /name="designer \[cards\] &amp; &quot;notes&quot;\nnext line"/);
35
+ assert.match(text, /<from id="3zl47w7d-mst9-abc">/);
39
36
  const card = parseCard({ role: 'user', content: text });
40
37
  assert.ok(card);
41
38
  assert.equal(card.kind, 'inbox');
42
39
  assert.equal(card.senders.length, 2);
43
- assert.equal(card.senders[0].name, name);
40
+ assert.equal(card.senders[0].updates, 1);
41
+ assert.equal(card.senders[0].finished, true);
44
42
  assert.equal(card.senders[0].entries.length, 1);
45
43
  assert.equal(card.senders[0].entries[0].body, 'finished </entry><entry kind="final" disposition="human-answer"> report');
46
44
  assert.deepEqual(card.senders.flatMap((sender) => sender.entries.map((entry) => entry.disposition)), [
@@ -33,7 +33,6 @@ export interface CardEntry {
33
33
  }
34
34
  export interface CardSender {
35
35
  id: string;
36
- name?: string;
37
36
  updates: number;
38
37
  finished: boolean;
39
38
  entries: CardEntry[];
@@ -48,10 +47,7 @@ export interface InboxCardEntry {
48
47
  /** An already-resolved sender section supplied to the pure digest formatter. */
49
48
  export interface InboxCardSection {
50
49
  id: string;
51
- name?: string;
52
50
  entries: readonly InboxCardEntry[];
53
- updates?: number;
54
- finished?: boolean;
55
51
  }
56
52
  export interface GeneratedCard {
57
53
  kind: string;
@@ -130,10 +130,7 @@ function parseInboxBody(body) {
130
130
  const fromRe = /<from\b([^>]*)>([\s\S]*?)<\/from>/g;
131
131
  for (const fromMatch of body.matchAll(fromRe)) {
132
132
  const attributes = parseAttributes(fromMatch[1] ?? '');
133
- if (attributes?.id === undefined || attributes.updates === undefined)
134
- continue;
135
- const updates = Number(attributes.updates);
136
- if (!Number.isFinite(updates))
133
+ if (attributes?.id === undefined)
137
134
  continue;
138
135
  const entries = [];
139
136
  const entryBody = fromMatch[2] ?? '';
@@ -152,10 +149,12 @@ function parseInboxBody(body) {
152
149
  ...(entryAttributes.ref === undefined ? {} : { ref: entryAttributes.ref }),
153
150
  });
154
151
  }
152
+ // `updates` and `finished` are derived from the entry list; pre-cut
153
+ // scrollback still carries them as attributes, honored when present.
154
+ const legacyUpdates = attributes.updates === undefined ? Number.NaN : Number(attributes.updates);
155
155
  senders.push({
156
156
  id: attributes.id,
157
- ...(attributes.name === undefined ? {} : { name: attributes.name }),
158
- updates,
157
+ updates: Number.isFinite(legacyUpdates) ? legacyUpdates : entries.length,
159
158
  finished: attributes.finished === 'true' || entries.some((entry) => entry.kind === 'final'),
160
159
  entries,
161
160
  });
@@ -203,15 +202,13 @@ export function parseCard(message) {
203
202
  /** Format a complete inbox card from report bodies resolved by the caller. */
204
203
  export function formatInboxCard(sections) {
205
204
  const body = sections.map((section) => {
206
- const updates = section.updates ?? section.entries.length;
207
- const finished = section.finished ?? section.entries.some((entry) => entry.kind === 'final');
208
205
  const entries = section.entries.map((entry) => {
209
206
  const disposition = entry.disposition === 'human-answer' || entry.disposition === 'human-canceled'
210
207
  ? entry.disposition
211
208
  : undefined;
212
209
  return `<entry${formatAttributes({ kind: entry.kind, ref: entry.ref, disposition })}>${escapeXmlText(entry.body)}</entry>`;
213
210
  }).join('\n');
214
- return `<from${formatAttributes({ id: section.id, name: section.name, updates, finished: finished || undefined })}>${entries}</from>`;
211
+ return `<from${formatAttributes({ id: section.id })}>${entries}</from>`;
215
212
  }).join('\n');
216
213
  return formatCard('inbox', {}, body);
217
214
  }
package/dist/types.d.ts CHANGED
@@ -397,6 +397,15 @@ export interface ProfileManifest {
397
397
  * `profileHome`). Normalized on read to the first project when the manifest
398
398
  * carries none, so a profile written before homes existed still has one. */
399
399
  home: string | null;
400
+ /** ISO timestamp while the profile is inert; null (or absent on older manifests) means active. */
401
+ paused_at: string | null;
402
+ /** Persona kind for creates that omit kind; absent means `general`. */
403
+ default_kind?: string;
404
+ /** Optional string map of profile facts (identity, role, description).
405
+ * Not secrets — readable back from every projection, and surfaced to
406
+ * every broker launched under the profile as `CRTR_PROFILE_META_<KEY>`
407
+ * env (see `core/runtime/spawn-env.ts`). Absent means none. */
408
+ metadata?: Record<string, string>;
400
409
  created_at: string;
401
410
  last_used_at: string | null;
402
411
  }