@north-light/crouter 0.3.214 → 0.3.216

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 (99) hide show
  1. package/dist/api/dto/nodes.d.ts +2 -1
  2. package/dist/api/dto/profiles.d.ts +13 -1
  3. package/dist/builtin-memory/00-runtime-base.md +12 -11
  4. package/dist/builtin-memory/01-spine/00-has-manager.md +1 -11
  5. package/dist/builtin-memory/02-lifecycle/00-terminal.md +4 -10
  6. package/dist/builtin-memory/04-orchestration-kernel.md +9 -35
  7. package/dist/builtin-memory/05-kinds/advisor/{00-base.md → advice-contract.md} +1 -1
  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/{00-base.md → lens-contract.md} +1 -1
  15. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +1 -1
  16. package/dist/builtin-memory/05-kinds/review/00-base.md +1 -3
  17. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
  18. package/dist/builtin-memory/05-kinds/review/security-findings.md +12 -0
  19. package/dist/builtin-memory/05-kinds/spec/00-base.md +1 -1
  20. package/dist/clients/attach/__tests__/context-message.test.js +28 -1
  21. package/dist/clients/attach/chrome/canvas-panels.d.ts +0 -4
  22. package/dist/clients/attach/chrome/canvas-panels.js +5 -7
  23. package/dist/clients/attach/chrome/inbox-strip.d.ts +31 -0
  24. package/dist/clients/attach/chrome/inbox-strip.js +187 -0
  25. package/dist/clients/attach/chrome/roster.d.ts +6 -2
  26. package/dist/clients/attach/chrome/roster.js +13 -52
  27. package/dist/clients/attach/chrome/ticket-panel.d.ts +29 -0
  28. package/dist/clients/attach/chrome/ticket-panel.js +236 -0
  29. package/dist/clients/attach/render/card-presentation.d.ts +8 -3
  30. package/dist/clients/attach/render/card-presentation.js +48 -10
  31. package/dist/clients/attach/render/context-message.d.ts +5 -0
  32. package/dist/clients/attach/render/context-message.js +20 -14
  33. package/dist/clients/attach/session/context.d.ts +3 -0
  34. package/dist/clients/attach/session/frame.d.ts +4 -0
  35. package/dist/clients/attach/session/frame.js +8 -3
  36. package/dist/clients/attach/session/keys.d.ts +7 -0
  37. package/dist/clients/attach/session/keys.js +7 -0
  38. package/dist/clients/attach/session/layout.js +6 -3
  39. package/dist/clients/attach/session/pane-focus.d.ts +8 -0
  40. package/dist/clients/attach/session/pane-focus.js +42 -0
  41. package/dist/clients/attach/viewer.js +793 -789
  42. package/dist/clients/inbox/controller.d.ts +9 -0
  43. package/dist/clients/inbox/controller.js +57 -2
  44. package/dist/clients/inbox/surface.d.ts +2 -0
  45. package/dist/clients/inbox/surface.js +18 -3
  46. package/dist/clients/inbox/tui.d.ts +2 -0
  47. package/dist/clients/inbox/tui.js +11 -1
  48. package/dist/commands/node/create.js +3 -3
  49. package/dist/commands/profile/kind.d.ts +1 -0
  50. package/dist/commands/profile/kind.js +39 -0
  51. package/dist/commands/profile/list.js +5 -1
  52. package/dist/commands/profile/meta.d.ts +4 -0
  53. package/dist/commands/profile/meta.js +69 -0
  54. package/dist/commands/profile/new.js +34 -4
  55. package/dist/commands/profile/pause.d.ts +2 -0
  56. package/dist/commands/profile/pause.js +60 -0
  57. package/dist/commands/profile/show.js +10 -2
  58. package/dist/commands/profile.js +5 -8
  59. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +40 -0
  60. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +28 -0
  61. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -0
  62. package/dist/core/command.js +23 -23
  63. package/dist/core/config.d.ts +5 -0
  64. package/dist/core/config.js +15 -0
  65. package/dist/core/help.d.ts +1 -1
  66. package/dist/core/keybindings/attach-control.d.ts +3 -0
  67. package/dist/core/keybindings/attach-control.js +1 -0
  68. package/dist/core/keybindings/catalog.js +1 -0
  69. package/dist/core/profiles/deletion-reservation.d.ts +7 -3
  70. package/dist/core/profiles/deletion-reservation.js +9 -5
  71. package/dist/core/profiles/manifest.d.ts +39 -1
  72. package/dist/core/profiles/manifest.js +171 -4
  73. package/dist/core/runtime/boot-root.js +3 -3
  74. package/dist/core/runtime/close.js +10 -5
  75. package/dist/core/runtime/revive.js +2 -0
  76. package/dist/core/runtime/spawn-env.d.ts +9 -1
  77. package/dist/core/runtime/spawn-env.js +17 -1
  78. package/dist/core/termrender/termrender.d.ts +7 -2
  79. package/dist/core/termrender/termrender.js +11 -6
  80. package/dist/core/termrender/version.d.ts +1 -1
  81. package/dist/core/termrender/version.js +1 -1
  82. package/dist/daemon/api/__tests__/profile-launch-gates.test.d.ts +1 -0
  83. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +111 -0
  84. package/dist/daemon/api/handlers/messages.js +9 -2
  85. package/dist/daemon/api/handlers/nodes.d.ts +2 -0
  86. package/dist/daemon/api/handlers/nodes.js +15 -8
  87. package/dist/daemon/api/handlers/profiles.js +12 -2
  88. package/dist/daemon/api/map.js +4 -0
  89. package/dist/daemon/fleet.js +1 -6
  90. package/dist/daemon/reconcilers/broker-supervision.js +15 -25
  91. package/dist/daemon/reconcilers/dormant-inbox.js +10 -10
  92. package/dist/daemon/reconcilers/live-obligation.d.ts +5 -1
  93. package/dist/daemon/reconcilers/live-obligation.js +11 -19
  94. package/dist/pi-extensions/canvas-inbox-watcher.js +15 -0
  95. package/dist/types.d.ts +9 -0
  96. package/package.json +1 -1
  97. package/runtime.lock.json +2 -2
  98. package/dist/builtin-memory/01-spine/01-no-manager.md +0 -11
  99. package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +0 -8
@@ -46,11 +46,16 @@ import { appendPassive } from '../feed/passive.js';
46
46
  * crash path (revive.ts). */
47
47
  export function fanDoctrineWake(fromId, subscribers, label, data) {
48
48
  for (const sub of subscribers) {
49
- const notice = { from: fromId, tier: 'normal', kind: 'message', label, data };
50
- if (sub.active)
51
- appendInbox(sub.node_id, notice);
52
- else
53
- appendPassive(sub.node_id, notice);
49
+ try {
50
+ const notice = { from: fromId, tier: 'normal', kind: 'message', label, data };
51
+ if (sub.active)
52
+ appendInbox(sub.node_id, notice);
53
+ else
54
+ appendPassive(sub.node_id, notice);
55
+ }
56
+ catch {
57
+ /* one unavailable subscriber never blocks the rest */
58
+ }
54
59
  }
55
60
  }
56
61
  /** The set of nodes to close: the root plus every descendant reachable down the
@@ -36,6 +36,7 @@ import { clearFault, beginBootFaultAttempt } from './fault.js';
36
36
  import { clearInjectedDocs } from '../substrate/injected-store.js';
37
37
  import { rootOfSpine } from './nodes.js';
38
38
  import { isReviewCompanionBound } from '../review/companion.js';
39
+ import { assertProfileActive } from '../profiles/manifest.js';
39
40
  // ---------------------------------------------------------------------------
40
41
  // resumeArgs — which session source a revive resumes from
41
42
  // ---------------------------------------------------------------------------
@@ -108,6 +109,7 @@ export function reviveNode(nodeId, opts) {
108
109
  if (meta.final_report !== null) {
109
110
  throw new Error(`reviveNode: refusing to revive ${nodeId} — its finalization latch is set (final_report=${meta.final_report}). A final landed between the caller's reopen-gate check and this revive; re-run with --reopen if this retask is still wanted.`);
110
111
  }
112
+ assertProfileActive(meta.profile_id);
111
113
  // Double-launch guard: a fleet entry IS liveness (design D-1). reviveNode
112
114
  // runs fully SYNCHRONOUSLY on the daemon thread — this check, the launch,
113
115
  // and the fleet registration inside `headlessBrokerHost.launch` complete
@@ -22,6 +22,12 @@ export declare function buildOperationalEnvBase(opts: {
22
22
  targetProfileId: string | null;
23
23
  host?: NodeJS.ProcessEnv;
24
24
  }): NodeJS.ProcessEnv;
25
+ /** Source F — the TARGET profile's manifest `metadata`, each entry surfaced
26
+ * as `CRTR_PROFILE_META_<KEY>` (key uppercased, every non-alphanumeric run
27
+ * → `_`). Identity facts rather than secrets — unlike the env store these
28
+ * are readable back (`profile show`, ProfileDTO). Read straight off the
29
+ * manifest at every launch; never throws (a bad profile id yields `{}`). */
30
+ export declare function profileMetadataEnv(profileId: string | null): NodeJS.ProcessEnv;
25
31
  /** The one authoritative broker child env: sources A–C (the operational
26
32
  * base, resolved from the TARGET node's own cwd/profile — `inv.env`'s
27
33
  * `CRTR_NODE_CWD`/`CRTR_PROFILE_ID`, never this process's ambient
@@ -30,7 +36,9 @@ export declare function buildOperationalEnvBase(opts: {
30
36
  * own `profile env` store (`readProfileEnvVars`, `core/profiles/env-store.ts`)
31
37
  * read directly off disk and injected regardless of the host env or any
32
38
  * `spawnEnv.allow` entry (setting a value there IS the consent to cross this
33
- * boundary), plus D — the trusted, crtr-constructed `inv.env` overlay and the
39
+ * boundary), F — the TARGET profile's manifest metadata as
40
+ * `CRTR_PROFILE_META_*` (`profileMetadataEnv` above), plus D — the trusted,
41
+ * crtr-constructed `inv.env` overlay and the
34
42
  * fork-bomb recursion guard `FRONT_DOOR_ENV=1`. D is layered last so crtr's
35
43
  * own constructed env always wins a name collision with a stored profile
36
44
  * value. Every broker launch (front-door root, managed child, `--root`,
@@ -33,6 +33,7 @@ import { statSync } from 'node:fs';
33
33
  import { delimiter, resolve } from 'node:path';
34
34
  import { readMergedLaunchConfig } from '../config.js';
35
35
  import { readProfileEnvVars } from '../profiles/env-store.js';
36
+ import { metadataEnvName, readProfileMetadata } from '../profiles/manifest.js';
36
37
  import { binShimDirFor } from './bin-contributions.js';
37
38
  import { FRONT_DOOR_ENV } from './front-door-env.js';
38
39
  /** A1 — literal env-var names copied verbatim (no value inspection): a login
@@ -211,6 +212,18 @@ export function buildOperationalEnvBase(opts) {
211
212
  }
212
213
  return out;
213
214
  }
215
+ /** Source F — the TARGET profile's manifest `metadata`, each entry surfaced
216
+ * as `CRTR_PROFILE_META_<KEY>` (key uppercased, every non-alphanumeric run
217
+ * → `_`). Identity facts rather than secrets — unlike the env store these
218
+ * are readable back (`profile show`, ProfileDTO). Read straight off the
219
+ * manifest at every launch; never throws (a bad profile id yields `{}`). */
220
+ export function profileMetadataEnv(profileId) {
221
+ const out = {};
222
+ for (const [key, value] of Object.entries(readProfileMetadata(profileId))) {
223
+ out[metadataEnvName(key)] = value;
224
+ }
225
+ return out;
226
+ }
214
227
  /** The one authoritative broker child env: sources A–C (the operational
215
228
  * base, resolved from the TARGET node's own cwd/profile — `inv.env`'s
216
229
  * `CRTR_NODE_CWD`/`CRTR_PROFILE_ID`, never this process's ambient
@@ -219,7 +232,9 @@ export function buildOperationalEnvBase(opts) {
219
232
  * own `profile env` store (`readProfileEnvVars`, `core/profiles/env-store.ts`)
220
233
  * read directly off disk and injected regardless of the host env or any
221
234
  * `spawnEnv.allow` entry (setting a value there IS the consent to cross this
222
- * boundary), plus D — the trusted, crtr-constructed `inv.env` overlay and the
235
+ * boundary), F — the TARGET profile's manifest metadata as
236
+ * `CRTR_PROFILE_META_*` (`profileMetadataEnv` above), plus D — the trusted,
237
+ * crtr-constructed `inv.env` overlay and the
223
238
  * fork-bomb recursion guard `FRONT_DOOR_ENV=1`. D is layered last so crtr's
224
239
  * own constructed env always wins a name collision with a stored profile
225
240
  * value. Every broker launch (front-door root, managed child, `--root`,
@@ -231,6 +246,7 @@ export function buildBrokerEnv(inv) {
231
246
  const operational = buildOperationalEnvBase({ targetCwd, targetProfileId });
232
247
  return {
233
248
  ...operational,
249
+ ...profileMetadataEnv(targetProfileId),
234
250
  ...readProfileEnvVars(targetProfileId),
235
251
  ...inv.env,
236
252
  [ADMITTED_HOST_PATH_ENV]: operational[ADMITTED_HOST_PATH_ENV],
@@ -15,7 +15,9 @@
15
15
  export declare function ensureRenderer(): void;
16
16
  /** Cheap predicate — true when the pinned managed binary is verified ready. Does not install or spawn. */
17
17
  export declare function isRendererReady(): boolean;
18
- /** Render markdown to terminal lines via the pinned binary; plaintext fallback. */
18
+ /** Render markdown to terminal lines via the pinned binary; plaintext fallback.
19
+ * Callers own the returned array — every return is a fresh copy, so a caller
20
+ * that appends to it cannot grow the cached render on the next hit. */
19
21
  export declare function renderMarkdown(md: string, width: number): string[];
20
22
  export interface RenderedDoc {
21
23
  /** Rendered ANSI rows — byte-identical to the plain `doc render` output. */
@@ -50,7 +52,10 @@ export declare function renderMarkdownWithMap(md: string, width: number): Render
50
52
  * unchanged.
51
53
  */
52
54
  export declare function renderMarkdownBlockAware(md: string, proseWidth: number, paneWidth: number): RenderedDoc;
53
- /** Block-aware render as plain rows, for surfaces that do not anchor. */
55
+ /** Block-aware render as plain rows, for surfaces that do not anchor. The rows
56
+ * are the caller's own array: the block-aware render can pass the mapped
57
+ * cache's line array straight through, and a caller appending to it would
58
+ * otherwise poison every later render of the same document. */
54
59
  export declare function renderMarkdownBlockAwareLines(md: string, proseWidth: number, paneWidth: number): string[];
55
60
  /** Validate markdown via `termrender doc check`. */
56
61
  export declare function checkMarkdown(md: string): {
@@ -410,12 +410,14 @@ class BoundedLRUCache {
410
410
  }
411
411
  }
412
412
  const _bodyCache = new BoundedLRUCache(20);
413
- /** Render markdown to terminal lines via the pinned binary; plaintext fallback. */
413
+ /** Render markdown to terminal lines via the pinned binary; plaintext fallback.
414
+ * Callers own the returned array — every return is a fresh copy, so a caller
415
+ * that appends to it cannot grow the cached render on the next hit. */
414
416
  export function renderMarkdown(md, width) {
415
417
  const key = `${md}\0${width}`;
416
418
  const cached = _bodyCache.get(key);
417
419
  if (cached)
418
- return cached;
420
+ return [...cached];
419
421
  ensureRenderer();
420
422
  if (rendererState === 'ready') {
421
423
  try {
@@ -429,7 +431,7 @@ export function renderMarkdown(md, width) {
429
431
  if (lines.length > 0 && lines[lines.length - 1] === '')
430
432
  lines.pop();
431
433
  _bodyCache.set(key, lines);
432
- return lines;
434
+ return [...lines];
433
435
  }
434
436
  catch (err) {
435
437
  // `doc render` is best-effort, so a non-timeout failure implicates the
@@ -441,7 +443,7 @@ export function renderMarkdown(md, width) {
441
443
  }
442
444
  const fallback = wrap(sanitize(md), width);
443
445
  _bodyCache.set(key, fallback);
444
- return fallback;
446
+ return [...fallback];
445
447
  }
446
448
  /** Block types allowed to use the full pane width instead of the prose cap:
447
449
  * a diagram is a picture, not prose, so a readability column limit only
@@ -657,9 +659,12 @@ export function renderMarkdownBlockAware(md, proseWidth, paneWidth) {
657
659
  }
658
660
  return { lines, rows, spans, blocks: base.blocks };
659
661
  }
660
- /** Block-aware render as plain rows, for surfaces that do not anchor. */
662
+ /** Block-aware render as plain rows, for surfaces that do not anchor. The rows
663
+ * are the caller's own array: the block-aware render can pass the mapped
664
+ * cache's line array straight through, and a caller appending to it would
665
+ * otherwise poison every later render of the same document. */
661
666
  export function renderMarkdownBlockAwareLines(md, proseWidth, paneWidth) {
662
- return renderMarkdownBlockAware(md, proseWidth, paneWidth).lines;
667
+ return [...renderMarkdownBlockAware(md, proseWidth, paneWidth).lines];
663
668
  }
664
669
  /** Validate markdown via `termrender doc check`. */
665
670
  export function checkMarkdown(md) {
@@ -1 +1 @@
1
- export declare const TERMRENDER_VERSION = "4.12.7";
1
+ export declare const TERMRENDER_VERSION = "4.12.8";
@@ -1 +1 @@
1
- export const TERMRENDER_VERSION = '4.12.7';
1
+ export const TERMRENDER_VERSION = '4.12.8';
@@ -0,0 +1,111 @@
1
+ import { after, afterEach, beforeEach, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { createNode, getNode, listNodes } from '../../../core/canvas/canvas.js';
7
+ import { closeDb } from '../../../core/canvas/db.js';
8
+ import { CrtrError } from '../../../core/errors.js';
9
+ import { createProfile, loadProfileManifest, pauseProfile, setProfileDefaultKind } from '../../../core/profiles/manifest.js';
10
+ import { resetScopeCache } from '../../../core/scope.js';
11
+ import { reviveNode } from '../../../core/runtime/revive.js';
12
+ import { spawnNode } from '../../../core/runtime/nodes.js';
13
+ import { handleCreate, resolveCreateKind } from '../handlers/nodes.js';
14
+ const previousCrtrHome = process.env['CRTR_HOME'];
15
+ const previousHome = process.env.HOME;
16
+ let home = '';
17
+ let cwd = '';
18
+ beforeEach(() => {
19
+ closeDb();
20
+ home = mkdtempSync(join(tmpdir(), 'crtr-profile-launch-home-'));
21
+ cwd = mkdtempSync(join(tmpdir(), 'crtr-profile-launch-cwd-'));
22
+ process.env.HOME = home;
23
+ process.env['CRTR_HOME'] = join(home, 'canvas');
24
+ mkdirSync(process.env['CRTR_HOME'], { recursive: true });
25
+ resetScopeCache();
26
+ });
27
+ afterEach(() => {
28
+ closeDb();
29
+ rmSync(home, { recursive: true, force: true });
30
+ rmSync(cwd, { recursive: true, force: true });
31
+ resetScopeCache();
32
+ });
33
+ after(() => {
34
+ if (previousHome === undefined)
35
+ delete process.env.HOME;
36
+ else
37
+ process.env.HOME = previousHome;
38
+ if (previousCrtrHome === undefined)
39
+ delete process.env['CRTR_HOME'];
40
+ else
41
+ process.env['CRTR_HOME'] = previousCrtrHome;
42
+ resetScopeCache();
43
+ });
44
+ test('create rejects a paused profile before a node exists', async () => {
45
+ const profile = createProfile('paused profile', [cwd]);
46
+ pauseProfile(profile.profileId);
47
+ const ctx = {
48
+ method: 'POST',
49
+ path: '/v1/nodes',
50
+ params: {},
51
+ query: new URLSearchParams(),
52
+ body: { root: true, profile: profile.profileId, cwd, prompt: 'must not launch' },
53
+ };
54
+ const beforeRows = listNodes().length;
55
+ await assert.rejects(() => handleCreate(ctx), (error) => {
56
+ assert.ok(error instanceof CrtrError);
57
+ assert.equal(error.code, 'usage');
58
+ assert.match(error.message, /paused profile/);
59
+ assert.match(error.details?.['next'], new RegExp(`crtr profile resume ${profile.profileId}`));
60
+ return true;
61
+ });
62
+ assert.equal(listNodes().length, beforeRows);
63
+ });
64
+ test('revive refuses a paused profile before launching a broker', () => {
65
+ const profile = createProfile('paused revive', [cwd]);
66
+ pauseProfile(profile.profileId);
67
+ const nodeId = 'paused-revive-node';
68
+ createNode({
69
+ node_id: nodeId,
70
+ name: nodeId,
71
+ created: new Date().toISOString(),
72
+ cwd,
73
+ kind: 'developer',
74
+ mode: 'base',
75
+ lifecycle: 'resident',
76
+ status: 'active',
77
+ parent: null,
78
+ profile_id: profile.profileId,
79
+ });
80
+ assert.throws(() => reviveNode(nodeId, { resume: true }), (error) => {
81
+ assert.ok(error instanceof CrtrError);
82
+ assert.equal(error.code, 'usage');
83
+ assert.match(error.details?.['next'], new RegExp(`crtr profile resume ${profile.profileId}`));
84
+ return true;
85
+ });
86
+ assert.equal(getNode(nodeId)?.status, 'active');
87
+ });
88
+ // The API create gate phrases the refusal; this one is what every OTHER birth
89
+ // (fork, cron spawn, warm-pool mint, `/new` relaunch, recycle) actually hits,
90
+ // because they all reach the canvas through spawnNode and none of them call
91
+ // handleCreate.
92
+ test('the birth gate refuses a paused profile for a daemon-side spawn', () => {
93
+ const profile = createProfile('paused spawn', [cwd]);
94
+ pauseProfile(profile.profileId);
95
+ const beforeRows = listNodes().length;
96
+ assert.throws(() => spawnNode({ kind: 'general', cwd, parent: null, profile_id: profile.profileId }), (error) => {
97
+ assert.ok(error instanceof CrtrError);
98
+ assert.equal(error.code, 'usage');
99
+ assert.match(error.details?.['next'], new RegExp(`crtr profile resume ${profile.profileId}`));
100
+ return true;
101
+ });
102
+ assert.equal(listNodes().length, beforeRows);
103
+ });
104
+ test('create kind resolves from the profile default when omitted', () => {
105
+ const profile = createProfile('default kind', [cwd]);
106
+ setProfileDefaultKind(profile.profileId, 'developer');
107
+ const entry = loadProfileManifest(profile.profileId);
108
+ assert.equal(resolveCreateKind(undefined, entry), 'developer');
109
+ assert.equal(resolveCreateKind('general', entry), 'general');
110
+ assert.equal(resolveCreateKind(undefined, null), 'general');
111
+ });
@@ -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)
@@ -6,13 +6,16 @@
6
6
  // GET /v1/profiles list all profiles
7
7
  // GET /v1/profiles/:name resolve one profile by name or id
8
8
  // DELETE /v1/profiles/:name required-force cascade or detach
9
+ import { assertInstalledKind } from '../../../core/config.js';
9
10
  import { createProfile, ensureRootProfile, listProfiles, loadProfileManifest, } from '../../../core/profiles/manifest.js';
10
11
  import { requestProfileDeletion } from '../../profile-delete.js';
11
12
  import { toProfileDTO } from '../map.js';
12
13
  const ROOT_PROFILE_NAME = 'root';
13
14
  /** `PUT /v1/profiles/:name` — idempotent ensure. `root` maps to the singleton
14
15
  * 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. */
16
+ * exists (so a re-PUT yields the same id), else creates it. `default_kind`
17
+ * and `metadata` apply only at create — an existing profile is returned
18
+ * untouched; mutation stays on `crtr profile kind` / `crtr profile meta`. */
16
19
  function handleEnsure(ctx) {
17
20
  const name = ctx.params['name'];
18
21
  const req = (ctx.body ?? {});
@@ -23,7 +26,14 @@ function handleEnsure(ctx) {
23
26
  if (existing !== undefined) {
24
27
  return { status: 200, body: toProfileDTO(existing) };
25
28
  }
26
- const created = createProfile(name, req.projects ?? []);
29
+ // Same gate as `profile new`/`profile kind`: a persisted unknown kind would
30
+ // poison every future omitted-kind create under this profile.
31
+ if (req.default_kind !== undefined)
32
+ assertInstalledKind(req.default_kind);
33
+ const created = createProfile(name, req.projects ?? [], {
34
+ defaultKind: req.default_kind,
35
+ metadata: req.metadata,
36
+ });
27
37
  return { status: 200, body: toProfileDTO(created) };
28
38
  }
29
39
  /** `GET /v1/profiles` — every profile with a readable manifest. */
@@ -10,6 +10,7 @@
10
10
  // reasonable projections; this module is what makes them concrete, so any DTO
11
11
  // field that projection proves wrong is corrected here at the source.
12
12
  import { ReviewOperationError as ReviewOperationErrorClass } from '../../core/review/types.js';
13
+ import { sanitizeProfileMetadata } from '../../core/profiles/manifest.js';
13
14
  import { contextDir, inboxPath, nodeDir, nodeMetaPath, reportsDir, reviewResultPath, transcriptPath, viewSocketPath, } from '../../core/canvas/paths.js';
14
15
  import { FinalizationError } from '../../core/feed/feed.js';
15
16
  import { NodeIdConflictError } from '../../core/runtime/nodes.js';
@@ -264,6 +265,9 @@ export function toProfileDTO(entry) {
264
265
  name: entry.manifest.name,
265
266
  projects: [...entry.manifest.projects],
266
267
  home: entry.manifest.home,
268
+ paused_at: entry.manifest.paused_at,
269
+ default_kind: entry.manifest.default_kind ?? 'general',
270
+ metadata: sanitizeProfileMetadata(entry.manifest.metadata),
267
271
  default_dir: null,
268
272
  };
269
273
  }
@@ -425,11 +425,6 @@ export class DaemonFleet {
425
425
  error: err,
426
426
  });
427
427
  }
428
- try {
429
- fanDoctrineWake(nodeId, subscribersOf(nodeId), opts.wake, { reason: 'child-crashed', child: nodeId });
430
- }
431
- catch {
432
- /* best-effort, mirrors every other fan-out in the daemon */
433
- }
428
+ fanDoctrineWake(nodeId, subscribersOf(nodeId), opts.wake, { reason: 'child-crashed', child: nodeId });
434
429
  }
435
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,4 +1,5 @@
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;
4
5
  /** Whether anything would drive a turn if this node's engine came back right
@@ -28,4 +29,7 @@ export declare function hasPendingWake(nodeId: string): boolean;
28
29
  * terminalized row no longer takes that cycle, so writing one off would drop
29
30
  * content the sender already handed over.
30
31
  */
31
- 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;