@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
@@ -84,9 +84,9 @@ export function profileMemoryDir(profileId) {
84
84
  * the user started them, not in whatever dir was last added to its purview. */
85
85
  function normalizeHome(profileId, manifest) {
86
86
  const stored = typeof manifest.home === 'string' && manifest.home !== '' ? manifest.home : null;
87
- if (profileId === ROOT_PROFILE_ID)
88
- return { ...manifest, home: stored };
89
- return { ...manifest, home: stored ?? manifest.projects[0] ?? null };
87
+ const home = profileId === ROOT_PROFILE_ID ? stored : stored ?? manifest.projects[0] ?? null;
88
+ const pausedAt = typeof manifest.paused_at === 'string' && manifest.paused_at !== '' ? manifest.paused_at : null;
89
+ return { ...manifest, home, paused_at: pausedAt };
90
90
  }
91
91
  function readManifestFile(path, profileId) {
92
92
  if (!existsSync(path))
@@ -282,6 +282,7 @@ export function ensureRootProfile() {
282
282
  name: ROOT_PROFILE_NAME,
283
283
  projects: [],
284
284
  home: null,
285
+ paused_at: null,
285
286
  created_at: nowIso(),
286
287
  last_used_at: nowIso(),
287
288
  };
@@ -290,10 +291,33 @@ export function ensureRootProfile() {
290
291
  return { profileId: ROOT_PROFILE_ID, manifest: next };
291
292
  });
292
293
  }
293
- export function createProfile(name, projects = []) {
294
+ /** Key shape every stored metadata entry must satisfy — kept env-mappable
295
+ * (`CRTR_PROFILE_META_<KEY>`, see `core/runtime/spawn-env.ts`) by
296
+ * construction. */
297
+ const METADATA_KEY_SHAPE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
298
+ export function assertProfileMetadata(entries) {
299
+ for (const [key, value] of Object.entries(entries)) {
300
+ if (!METADATA_KEY_SHAPE.test(key)) {
301
+ throw usage(`invalid metadata key: ${JSON.stringify(key)}`, {
302
+ received: key,
303
+ field: 'metadata',
304
+ next: 'Keys start with a letter or digit, followed by letters, digits, `_`, or `-`.',
305
+ });
306
+ }
307
+ if (value.includes('\0')) {
308
+ throw usage(`metadata value for ${key} contains a NUL byte, which no OS accepts in an environment variable`, {
309
+ field: 'metadata',
310
+ next: 'Remove NUL bytes from the value and retry.',
311
+ });
312
+ }
313
+ }
314
+ }
315
+ export function createProfile(name, projects = [], opts = {}) {
294
316
  const trimmed = name.trim();
295
317
  if (trimmed === '')
296
318
  throw usage('profile name must not be empty');
319
+ if (opts.metadata !== undefined)
320
+ assertProfileMetadata(opts.metadata);
297
321
  const resolvedProjects = dedupeOrdered(projects.map(resolveExistingProjectDir));
298
322
  const profileId = generateProfileId(trimmed);
299
323
  return withProfileManifestLock(profileId, () => {
@@ -302,6 +326,9 @@ export function createProfile(name, projects = []) {
302
326
  name: trimmed,
303
327
  projects: resolvedProjects,
304
328
  home: resolvedProjects[0] ?? null,
329
+ paused_at: null,
330
+ ...(opts.defaultKind !== undefined ? { default_kind: opts.defaultKind } : {}),
331
+ ...(opts.metadata !== undefined && Object.keys(opts.metadata).length > 0 ? { metadata: { ...opts.metadata } } : {}),
305
332
  created_at: nowIso(),
306
333
  last_used_at: null,
307
334
  };
@@ -335,6 +362,97 @@ function mutateManifest(profileId, mutate) {
335
362
  export function updateProfileLastUsed(profileId) {
336
363
  return mutateManifest(profileId, (m) => ({ ...m, last_used_at: nowIso() }));
337
364
  }
365
+ /** Resolve an existing profile for a launch gate without making stale durable
366
+ * profile ids a new failure mode on a hot path. */
367
+ function loadProfileManifestOrNull(profileId) {
368
+ if (profileId === null || profileId === undefined || profileId === '')
369
+ return null;
370
+ try {
371
+ return loadProfileManifest(profileId);
372
+ }
373
+ catch {
374
+ return null;
375
+ }
376
+ }
377
+ /** Paused check for an ALREADY-RESOLVED durable id (a canvas row's `profile_id`,
378
+ * `CRTR_PROFILE_ID`) — exact-id only, no name matching, so it costs one small
379
+ * read on the hot delivery paths that call it every poll. Fails OPEN: an id
380
+ * whose manifest is missing or corrupt is not paused, because treating it as
381
+ * paused would strand that node's queued inbox forever. */
382
+ export function isProfilePaused(profileId) {
383
+ if (profileId === null || profileId === undefined || profileId === '')
384
+ return false;
385
+ try {
386
+ return loadExactProfileManifest(profileId).manifest.paused_at !== null;
387
+ }
388
+ catch {
389
+ return false;
390
+ }
391
+ }
392
+ /** Refuse a launch under a paused profile, naming the resume command. Shared by
393
+ * every gate that already holds a loaded manifest. */
394
+ export function assertProfileEntryActive(entry) {
395
+ if (entry.manifest.paused_at === null)
396
+ return;
397
+ throw usage(`profile "${entry.manifest.name}" is paused and cannot start nodes`, {
398
+ received: entry.profileId,
399
+ next: `Resume it with \`crtr profile resume ${entry.profileId}\`.`,
400
+ });
401
+ }
402
+ export function assertProfileActive(profileId) {
403
+ const entry = loadProfileManifestOrNull(profileId);
404
+ if (entry === null)
405
+ return null;
406
+ assertProfileEntryActive(entry);
407
+ return entry;
408
+ }
409
+ export function pauseProfile(profileId) {
410
+ return mutateManifest(profileId, (m) => ({ ...m, paused_at: m.paused_at ?? nowIso() }));
411
+ }
412
+ export function resumeProfile(profileId) {
413
+ return mutateManifest(profileId, (m) => ({ ...m, paused_at: null }));
414
+ }
415
+ export function setProfileDefaultKind(profileId, defaultKind) {
416
+ return mutateManifest(profileId, (m) => ({ ...m, default_kind: defaultKind }));
417
+ }
418
+ /** Merge `set` entries over the stored map and drop `unset` keys; the
419
+ * `metadata` field is omitted entirely when the result is empty. */
420
+ export function updateProfileMetadata(profileId, set, unset = []) {
421
+ assertProfileMetadata(set);
422
+ return mutateManifest(profileId, (m) => {
423
+ const next = { ...(m.metadata ?? {}), ...set };
424
+ for (const key of unset)
425
+ delete next[key];
426
+ const { metadata: _dropped, ...rest } = m;
427
+ return { ...rest, ...(Object.keys(next).length > 0 ? { metadata: next } : {}) };
428
+ });
429
+ }
430
+ /** A profile's stored metadata for broker-env injection — sanitized entry by
431
+ * entry (a manifest is hand-editable, so a bad key/value is dropped rather
432
+ * than trusted) and never throwing: a stale/invalid/absent profile id is a
433
+ * hot-path no-op, because broker-env resolution must never fail a launch
434
+ * over a bad profile id (mirrors `readProfileEnvVars`). */
435
+ export function readProfileMetadata(profileId) {
436
+ if (profileId === null || profileId === '')
437
+ return {};
438
+ try {
439
+ const stored = loadExactProfileManifest(profileId).manifest.metadata ?? {};
440
+ const out = {};
441
+ for (const [key, value] of Object.entries(stored)) {
442
+ if (typeof value !== 'string')
443
+ continue;
444
+ if (!METADATA_KEY_SHAPE.test(key))
445
+ continue;
446
+ if (value.includes('\0'))
447
+ continue;
448
+ out[key] = value;
449
+ }
450
+ return out;
451
+ }
452
+ catch {
453
+ return {};
454
+ }
455
+ }
338
456
  export function renameProfile(profileId, name) {
339
457
  const trimmed = name.trim();
340
458
  if (trimmed === '')
@@ -30,7 +30,7 @@ export async function bootRoot(opts) {
30
30
  if (!inTmux()) {
31
31
  throw new Error('crtr must be started from inside a tmux session — start tmux first (e.g. `tmux new -s work`), then run `crtr` there.');
32
32
  }
33
- const kind = opts.kind ?? 'general';
33
+ const kind = opts.kind;
34
34
  // The front door's only source of profile identity — a root has no spawner to
35
35
  // inherit from. Runs BEFORE the create call: explicit --profile > MRU profile
36
36
  // covering cwd > a synchronous create-or-root-profile prompt (the stable root
@@ -47,12 +47,12 @@ export async function bootRoot(opts) {
47
47
  // viewer paints the crouton banner the instant the engine attaches (~1s),
48
48
  // which is the real "we're up" signal.
49
49
  const detail = await cliClient().createNode({
50
- kind,
50
+ ...(kind !== undefined ? { kind } : {}),
51
51
  mode: 'base',
52
52
  prompt: opts.prompt,
53
53
  profile: profileId,
54
54
  cwd: opts.cwd,
55
- name: opts.name ?? kind,
55
+ ...(opts.name !== undefined ? { name: opts.name } : kind !== undefined ? { name: kind } : {}),
56
56
  root: true,
57
57
  // The front door is exactly the wait a warm spare exists to remove: a bare
58
58
  // resident root with no kickoff. A miss costs nothing (cold spawn) and
@@ -198,11 +198,7 @@ export function coalesceBrokerInbox(entries, reportNodes) {
198
198
  cardSlot = deliveries.length;
199
199
  deliveries.push({ kind: 'card', text: '', cards: [], entries: [] });
200
200
  }
201
- // The fallback to a CURRENT projected name is correct only for legacy
202
- // entries, which predate the producer's own `from_name` snapshot.
203
- const name = items.map((item) => item.from_name).filter((value) => value !== undefined && value !== '').at(-1)
204
- ?? reportNodes.get(sender)?.name;
205
- sections.push({ id: sender, ...(name === undefined ? {} : { name }), entries: items.map((entry) => cardEntry(entry, reportNodes)) });
201
+ sections.push({ id: sender, entries: items.map((entry) => cardEntry(entry, reportNodes)) });
206
202
  }
207
203
  if (cardSlot >= 0) {
208
204
  // Card sections group by sender, so its entry list comes from the input
@@ -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 { 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],
@@ -26,6 +26,7 @@
26
26
  // so a doc delivered by one never re-delivers at the same or lower rung
27
27
  // through another. Each candidate renders at its matched rung — the highest
28
28
  // `at` over its matching entries for the event.
29
+ import { createHash } from 'node:crypto';
29
30
  import { homedir } from 'node:os';
30
31
  import { dirname, parse, relative, sep } from 'node:path';
31
32
  import { CRTR_DIR_NAME } from '../../types.js';
@@ -172,6 +173,14 @@ function readFileFrontmatter(absReadFile) {
172
173
  return {};
173
174
  }
174
175
  }
176
+ /** Content-identity dedup key, or null for an empty body (name-only docs
177
+ * must not collide with each other). */
178
+ function docContentKey(doc) {
179
+ const body = doc.body.trim();
180
+ if (body === '')
181
+ return null;
182
+ return `content-sha256:${createHash('sha256').update(body).digest('hex')}`;
183
+ }
175
184
  function renderDocEnvelope(doc, rung) {
176
185
  if (rung === 'none')
177
186
  return null;
@@ -191,8 +200,13 @@ export function renderCandidateBlocks(subject, candidates, seen) {
191
200
  const rendered = [];
192
201
  for (const { doc, rung } of candidates) {
193
202
  const real = realpathOrSelf(doc.path);
203
+ // Byte-identical bodies at different paths (a cloned repo's store) inform
204
+ // once; the content key shares the realpath map and its rung semantics.
205
+ const contentKey = docContentKey(doc);
194
206
  if (deliveredAtOrAbove(seen, real, rung))
195
207
  continue;
208
+ if (contentKey !== null && deliveredAtOrAbove(seen, contentKey, rung))
209
+ continue;
196
210
  try {
197
211
  if (subject === null ? doc.gate !== undefined : !gatePasses(doc, subject))
198
212
  continue;
@@ -200,6 +214,8 @@ export function renderCandidateBlocks(subject, candidates, seen) {
200
214
  if (block === null)
201
215
  continue;
202
216
  recordDelivery(seen, real, rung);
217
+ if (contentKey !== null)
218
+ recordDelivery(seen, contentKey, rung);
203
219
  rendered.push(block);
204
220
  }
205
221
  catch {
@@ -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
+ });
@@ -169,33 +169,44 @@ function isSlotValidationError(err) {
169
169
  // ===========================================================================
170
170
  // GET /v1/human/inbox — enumerate
171
171
  // ===========================================================================
172
+ /** The one page-summary projection: the pending list and the per-node history differ only in which tickets they walk. */
173
+ function pageSummaryToDTO(item, canonicalRoot) {
174
+ const dto = {
175
+ ticket_id: createHash('sha256').update(`${canonicalRoot}\0${item.id}`, 'utf8').digest('hex'),
176
+ kind: 'page',
177
+ title: item.title,
178
+ placement: item.placement,
179
+ dialect: item.dialect,
180
+ steps: item.steps,
181
+ emitted_at: item.emittedAt,
182
+ source: item.source,
183
+ slot_kinds: item.slotKinds,
184
+ inbox: item.inbox,
185
+ awaits_response: item.awaitsResponse,
186
+ state: item.state,
187
+ };
188
+ if (item.subtitle !== '')
189
+ dto.subtitle = item.subtitle;
190
+ if (item.answerDigest !== undefined)
191
+ dto.answer_digest = item.answerDigest;
192
+ if (item.recommendedOptions.length > 0) {
193
+ dto.recommended_options = item.recommendedOptions.map((option) => ({ slot_id: option.slotId, option_id: option.optionId, label: option.label }));
194
+ }
195
+ if (item.fastAction !== undefined)
196
+ dto.fast_action = { kind: item.fastAction.kind, responses: item.fastAction.responses };
197
+ return dto;
198
+ }
172
199
  function handleList() {
173
200
  const allItems = scanInbox();
174
201
  const filtered = filterTerminalReviewTickets(allItems);
202
+ const canonicalRoot = realpathSync(ticketsRoot());
175
203
  const tickets = filtered.map((item) => {
176
204
  if (item.kind === 'page') {
177
- const ticketId = createHash('sha256').update(`${realpathSync(ticketsRoot())}\0${item.id}`, 'utf8').digest('hex');
178
- const dto = {
179
- ticket_id: ticketId,
180
- kind: 'page',
181
- title: item.title,
182
- placement: item.placement,
183
- dialect: item.dialect,
184
- steps: item.steps,
185
- emitted_at: item.emittedAt,
186
- source: item.source,
187
- slot_kinds: item.slotKinds,
188
- inbox: item.inbox,
189
- awaits_response: item.awaitsResponse,
190
- state: item.state,
191
- };
192
- if (item.subtitle !== '')
193
- dto.subtitle = item.subtitle;
194
- return dto;
205
+ return pageSummaryToDTO(item, canonicalRoot);
195
206
  }
196
207
  else if (item.kind === 'review') {
197
208
  return {
198
- ticket_id: createHash('sha256').update(`${realpathSync(ticketsRoot())}\0${item.id}`, 'utf8').digest('hex'),
209
+ ticket_id: createHash('sha256').update(`${canonicalRoot}\0${item.id}`, 'utf8').digest('hex'),
199
210
  kind: 'review',
200
211
  title: item.title,
201
212
  subtitle: item.subtitle,
@@ -221,28 +232,7 @@ function handleHistory(ctx) {
221
232
  }
222
233
  const history = scanPageHistory(nodeId);
223
234
  const canonicalRoot = realpathSync(ticketsRoot());
224
- const tickets = history.map((item) => {
225
- const ticketId = createHash('sha256').update(`${canonicalRoot}\0${item.id}`, 'utf8').digest('hex');
226
- const dto = {
227
- ticket_id: ticketId,
228
- kind: 'page',
229
- title: item.title,
230
- placement: item.placement,
231
- dialect: item.dialect,
232
- steps: item.steps,
233
- emitted_at: item.emittedAt,
234
- source: item.source,
235
- slot_kinds: item.slotKinds,
236
- inbox: item.inbox,
237
- awaits_response: item.awaitsResponse,
238
- state: item.state,
239
- };
240
- if (item.subtitle !== '')
241
- dto.subtitle = item.subtitle;
242
- if (item.answerDigest !== undefined)
243
- dto.answer_digest = item.answerDigest;
244
- return dto;
245
- });
235
+ const tickets = history.map((item) => pageSummaryToDTO(item, canonicalRoot));
246
236
  return { status: 200, body: { tickets } };
247
237
  }
248
238
  // ===========================================================================