@north-light/crouter 0.3.214 → 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 (97) 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/design/00-base.md +2 -2
  8. package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +1 -1
  9. package/dist/builtin-memory/05-kinds/developer/00-base.md +2 -2
  10. package/dist/builtin-memory/05-kinds/explore/00-base.md +1 -1
  11. package/dist/builtin-memory/05-kinds/general/00-base.md +2 -0
  12. package/dist/builtin-memory/05-kinds/plan/00-base.md +1 -1
  13. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +1 -1
  14. package/dist/builtin-memory/05-kinds/review/00-base.md +1 -3
  15. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
  16. package/dist/builtin-memory/05-kinds/review/security-findings.md +12 -0
  17. package/dist/builtin-memory/05-kinds/spec/00-base.md +1 -1
  18. package/dist/clients/attach/__tests__/context-message.test.js +28 -1
  19. package/dist/clients/attach/chrome/canvas-panels.d.ts +0 -4
  20. package/dist/clients/attach/chrome/canvas-panels.js +5 -7
  21. package/dist/clients/attach/chrome/inbox-strip.d.ts +31 -0
  22. package/dist/clients/attach/chrome/inbox-strip.js +187 -0
  23. package/dist/clients/attach/chrome/roster.d.ts +6 -2
  24. package/dist/clients/attach/chrome/roster.js +13 -52
  25. package/dist/clients/attach/chrome/ticket-panel.d.ts +29 -0
  26. package/dist/clients/attach/chrome/ticket-panel.js +236 -0
  27. package/dist/clients/attach/render/card-presentation.d.ts +8 -3
  28. package/dist/clients/attach/render/card-presentation.js +48 -10
  29. package/dist/clients/attach/render/context-message.d.ts +5 -0
  30. package/dist/clients/attach/render/context-message.js +20 -14
  31. package/dist/clients/attach/session/context.d.ts +3 -0
  32. package/dist/clients/attach/session/frame.d.ts +4 -0
  33. package/dist/clients/attach/session/frame.js +8 -3
  34. package/dist/clients/attach/session/keys.d.ts +7 -0
  35. package/dist/clients/attach/session/keys.js +7 -0
  36. package/dist/clients/attach/session/layout.js +6 -3
  37. package/dist/clients/attach/session/pane-focus.d.ts +8 -0
  38. package/dist/clients/attach/session/pane-focus.js +42 -0
  39. package/dist/clients/attach/viewer.js +793 -789
  40. package/dist/clients/inbox/controller.d.ts +9 -0
  41. package/dist/clients/inbox/controller.js +57 -2
  42. package/dist/clients/inbox/surface.d.ts +2 -0
  43. package/dist/clients/inbox/surface.js +18 -3
  44. package/dist/clients/inbox/tui.d.ts +2 -0
  45. package/dist/clients/inbox/tui.js +11 -1
  46. package/dist/commands/node/create.js +3 -3
  47. package/dist/commands/profile/kind.d.ts +2 -0
  48. package/dist/commands/profile/kind.js +51 -0
  49. package/dist/commands/profile/list.js +5 -1
  50. package/dist/commands/profile/meta.d.ts +4 -0
  51. package/dist/commands/profile/meta.js +67 -0
  52. package/dist/commands/profile/new.js +33 -3
  53. package/dist/commands/profile/pause.d.ts +2 -0
  54. package/dist/commands/profile/pause.js +60 -0
  55. package/dist/commands/profile/show.js +9 -1
  56. package/dist/commands/profile.js +5 -8
  57. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +40 -0
  58. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +28 -0
  59. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -0
  60. package/dist/core/command.js +23 -23
  61. package/dist/core/help.d.ts +1 -1
  62. package/dist/core/keybindings/attach-control.d.ts +3 -0
  63. package/dist/core/keybindings/attach-control.js +1 -0
  64. package/dist/core/keybindings/catalog.js +1 -0
  65. package/dist/core/profiles/deletion-reservation.d.ts +7 -3
  66. package/dist/core/profiles/deletion-reservation.js +9 -5
  67. package/dist/core/profiles/manifest.d.ts +27 -1
  68. package/dist/core/profiles/manifest.js +122 -4
  69. package/dist/core/runtime/boot-root.js +3 -3
  70. package/dist/core/runtime/close.js +10 -5
  71. package/dist/core/runtime/revive.js +2 -0
  72. package/dist/core/runtime/spawn-env.d.ts +9 -1
  73. package/dist/core/runtime/spawn-env.js +17 -1
  74. package/dist/core/termrender/termrender.d.ts +7 -2
  75. package/dist/core/termrender/termrender.js +11 -6
  76. package/dist/core/termrender/version.d.ts +1 -1
  77. package/dist/core/termrender/version.js +1 -1
  78. package/dist/daemon/api/__tests__/profile-launch-gates.test.d.ts +1 -0
  79. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +111 -0
  80. package/dist/daemon/api/handlers/messages.js +9 -2
  81. package/dist/daemon/api/handlers/nodes.d.ts +2 -0
  82. package/dist/daemon/api/handlers/nodes.js +15 -8
  83. package/dist/daemon/api/handlers/profiles.js +7 -2
  84. package/dist/daemon/api/map.js +3 -0
  85. package/dist/daemon/fleet.js +1 -6
  86. package/dist/daemon/reconcilers/broker-supervision.js +15 -25
  87. package/dist/daemon/reconcilers/dormant-inbox.js +10 -10
  88. package/dist/daemon/reconcilers/live-obligation.d.ts +5 -1
  89. package/dist/daemon/reconcilers/live-obligation.js +11 -19
  90. package/dist/pi-extensions/canvas-inbox-watcher.js +15 -0
  91. package/dist/types.d.ts +9 -0
  92. package/package.json +1 -1
  93. package/runtime.lock.json +2 -2
  94. package/dist/builtin-memory/01-spine/01-no-manager.md +0 -11
  95. package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +0 -8
  96. /package/dist/builtin-memory/05-kinds/advisor/{00-base.md → advice-contract.md} +0 -0
  97. /package/dist/builtin-memory/05-kinds/plan/reviewers/{00-base.md → lens-contract.md} +0 -0
@@ -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 { 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[`CRTR_PROFILE_META_${key.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`] = 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)
@@ -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
  }
@@ -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;
@@ -1,9 +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';
5
6
  import { focusOf } from '../../core/runtime/placement.js';
6
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';
7
10
  /** Durable work that requires a node to remain automatically wakeable. */
8
11
  export function hasLiveObligation(nodeId) {
9
12
  return hasActiveLiveSubscription(nodeId)
@@ -27,24 +30,6 @@ export function hasPendingWake(nodeId) {
27
30
  return true;
28
31
  return focusOf(nodeId) !== null || hasAttachedBrokerViewers(nodeId);
29
32
  }
30
- /** What parking an unattended node actually amounts to — the one rule both the
31
- * live-broker park clock (broker-supervision) and the already-parked
32
- * reconciliation (dormant-inbox) apply.
33
- *
34
- * `release` — something can still wake it, so parking is a pause.
35
- * `finish` — a resident with nothing left to wake it has completed its
36
- * conversation, and the row becomes prunable history.
37
- * `cancel` — a terminal node that never pushed a final was torn down before
38
- * finishing, which is what `canceled` records; it stays revivable
39
- * and becomes prunable. A live subscriber still awaiting its
40
- * report is an outstanding deliverable, so such a node keeps its
41
- * wakeable idle row instead of being written off.
42
- *
43
- * Undelivered mail of ANY tier also holds the row. A deferred-only unseen set
44
- * never wakes a node on its own — it rides the next natural cycle — but a
45
- * terminalized row no longer takes that cycle, so writing one off would drop
46
- * content the sender already handed over.
47
- */
48
33
  export function parkEvent(nodeId, lifecycle) {
49
34
  if (hasLiveObligation(nodeId))
50
35
  return 'release';
@@ -54,3 +39,10 @@ export function parkEvent(nodeId, lifecycle) {
54
39
  return 'finish';
55
40
  return hasLiveSubscriber(nodeId) ? 'release' : 'cancel';
56
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) {
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
  }