@north-light/crouter 0.3.263 → 0.3.270

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 (171) hide show
  1. package/dist/api/client.d.ts +5 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/broker.d.ts +11 -2
  4. package/dist/api/dto/broker.js +2 -2
  5. package/dist/api/dto/canvas.d.ts +53 -2
  6. package/dist/api/dto/nodes.d.ts +10 -2
  7. package/dist/api/routes.d.ts +1 -0
  8. package/dist/api/routes.js +1 -0
  9. package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +2 -2
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +50 -0
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +46 -6
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +6 -2
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +7 -2
  14. package/dist/clients/attach/session/whip-achievements.d.ts +4 -0
  15. package/dist/clients/attach/session/whip-achievements.js +145 -0
  16. package/dist/clients/attach/slash/dispatch.js +5 -3
  17. package/dist/clients/attach/viewer.js +607 -606
  18. package/dist/commands/canvas-history/grep.js +3 -22
  19. package/dist/commands/canvas-history/read.js +5 -3
  20. package/dist/commands/canvas-history/search.js +3 -22
  21. package/dist/commands/canvas-history/shared.d.ts +8 -0
  22. package/dist/commands/canvas-history/shared.js +64 -1
  23. package/dist/commands/canvas-history/stats.d.ts +1 -0
  24. package/dist/commands/canvas-history/stats.js +68 -0
  25. package/dist/commands/canvas-history.js +4 -3
  26. package/dist/commands/node/create.js +2 -2
  27. package/dist/commands/pkg/plugin-inspect.js +2 -2
  28. package/dist/commands/pkg/plugin-manage.js +3 -3
  29. package/dist/commands/sys/config.js +19 -2
  30. package/dist/commands/sys/doctor.js +4 -3
  31. package/dist/commands/sys/panels/models-panel.js +3 -1
  32. package/dist/core/__tests__/human-deliver.test.js +1 -1
  33. package/dist/core/__tests__/integration/lifecycle-hooks.test.d.ts +1 -0
  34. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +182 -0
  35. package/dist/core/__tests__/integration/revive.test.js +19 -17
  36. package/dist/core/__tests__/model-pin-durability.test.js +25 -1
  37. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  38. package/dist/core/__tests__/relaunch-root.test.js +17 -0
  39. package/dist/core/__tests__/revive-capacity.test.js +20 -20
  40. package/dist/core/__tests__/revive-parked-fresh.test.js +14 -14
  41. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +2 -2
  42. package/dist/core/__tests__/seam/broker-provider-retry.test.js +19 -4
  43. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
  44. package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +1 -1
  45. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +19 -2
  46. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +2 -2
  47. package/dist/core/broker-client/__tests__/transport-relay.test.js +3 -1
  48. package/dist/core/broker-client/transport-relay.js +5 -1
  49. package/dist/core/canvas/__tests__/history-transcript.test.d.ts +1 -0
  50. package/dist/core/canvas/__tests__/history-transcript.test.js +102 -0
  51. package/dist/core/canvas/browse/app.d.ts +1 -1
  52. package/dist/core/canvas/browse/app.js +13 -10
  53. package/dist/core/canvas/browse/model.js +16 -1
  54. package/dist/core/canvas/browse/render.d.ts +23 -3
  55. package/dist/core/canvas/browse/render.js +429 -64
  56. package/dist/core/canvas/canvas.js +12 -8
  57. package/dist/core/canvas/extensions.d.ts +1 -1
  58. package/dist/core/canvas/extensions.js +7 -1
  59. package/dist/core/canvas/history.d.ts +36 -1
  60. package/dist/core/canvas/history.js +328 -7
  61. package/dist/core/canvas/labels.d.ts +2 -2
  62. package/dist/core/canvas/migrations.js +32 -0
  63. package/dist/core/canvas/node-recap.d.ts +23 -0
  64. package/dist/core/canvas/node-recap.js +67 -0
  65. package/dist/core/canvas/render-source.d.ts +33 -1
  66. package/dist/core/canvas/render-source.js +80 -22
  67. package/dist/core/canvas/types.d.ts +9 -0
  68. package/dist/core/command-hooks/discovery.d.ts +27 -1
  69. package/dist/core/command-hooks/discovery.js +52 -0
  70. package/dist/core/command-hooks/index.d.ts +4 -3
  71. package/dist/core/command-hooks/index.js +2 -1
  72. package/dist/core/command-hooks/lifecycle-catalog.d.ts +3 -0
  73. package/dist/core/command-hooks/lifecycle-catalog.js +4 -0
  74. package/dist/core/command-hooks/report.d.ts +11 -2
  75. package/dist/core/command-hooks/report.js +10 -1
  76. package/dist/core/command-hooks/schema.d.ts +16 -1
  77. package/dist/core/command-hooks/schema.js +128 -40
  78. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +12 -0
  79. package/dist/core/command-hooks/transport/exec-lifecycle.js +152 -0
  80. package/dist/core/config.js +13 -5
  81. package/dist/core/human/feedback-companion.js +1 -1
  82. package/dist/core/keybindings/catalog.d.ts +2 -2
  83. package/dist/core/keybindings/catalog.js +2 -1
  84. package/dist/core/model-routes.js +1 -1
  85. package/dist/core/review/realize.js +1 -1
  86. package/dist/core/runtime/broker/auth-reload.js +4 -1
  87. package/dist/core/runtime/broker/engine-drive.d.ts +14 -3
  88. package/dist/core/runtime/broker/engine-drive.js +29 -1
  89. package/dist/core/runtime/broker/fault-retry.js +7 -2
  90. package/dist/core/runtime/broker/frame-client.js +3 -0
  91. package/dist/core/runtime/broker/frame-dispatch.js +7 -0
  92. package/dist/core/runtime/broker/inbox.d.ts +12 -2
  93. package/dist/core/runtime/broker/inbox.js +68 -38
  94. package/dist/core/runtime/broker/rebind.js +7 -0
  95. package/dist/core/runtime/broker-protocol.d.ts +16 -1
  96. package/dist/core/runtime/broker.d.ts +1 -1
  97. package/dist/core/runtime/broker.js +27 -3
  98. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  99. package/dist/core/runtime/canvas-extensions.js +2 -0
  100. package/dist/core/runtime/interactive-deliver.d.ts +8 -2
  101. package/dist/core/runtime/interactive-deliver.js +12 -3
  102. package/dist/core/runtime/launch-target.js +1 -1
  103. package/dist/core/runtime/launch.d.ts +12 -6
  104. package/dist/core/runtime/launch.js +19 -17
  105. package/dist/core/runtime/model-swap.d.ts +5 -4
  106. package/dist/core/runtime/model-swap.js +7 -5
  107. package/dist/core/runtime/node-read.d.ts +2 -0
  108. package/dist/core/runtime/node-read.js +18 -9
  109. package/dist/core/runtime/promote.js +2 -1
  110. package/dist/core/runtime/reset.js +4 -2
  111. package/dist/core/runtime/revive-all.d.ts +1 -1
  112. package/dist/core/runtime/revive-all.js +2 -2
  113. package/dist/core/runtime/revive.d.ts +2 -2
  114. package/dist/core/runtime/revive.js +71 -13
  115. package/dist/core/runtime/session-visibility.d.ts +10 -14
  116. package/dist/core/runtime/session-visibility.js +43 -30
  117. package/dist/core/runtime/spawn.d.ts +3 -3
  118. package/dist/core/runtime/spawn.js +20 -3
  119. package/dist/core/runtime/stamp/channel.d.ts +22 -0
  120. package/dist/core/runtime/stamp/channel.js +83 -0
  121. package/dist/core/runtime/stamp/protocol.d.ts +90 -0
  122. package/dist/core/runtime/stamp/protocol.js +113 -0
  123. package/dist/core/runtime/tmux-driver.d.ts +11 -1
  124. package/dist/core/runtime/tmux-driver.js +27 -11
  125. package/dist/core/runtime/turn-visibility.d.ts +10 -0
  126. package/dist/core/runtime/turn-visibility.js +31 -0
  127. package/dist/core/self-update.js +2 -5
  128. package/dist/core/tui/draw.d.ts +1 -0
  129. package/dist/core/tui/draw.js +5 -1
  130. package/dist/core/tui/markdown.d.ts +17 -0
  131. package/dist/core/tui/markdown.js +407 -0
  132. package/dist/core/user-settings.d.ts +4 -0
  133. package/dist/core/user-settings.js +1 -0
  134. package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -0
  135. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
  136. package/dist/daemon/api/bridge.d.ts +3 -2
  137. package/dist/daemon/api/bridge.js +34 -9
  138. package/dist/daemon/api/handlers/attach.js +4 -4
  139. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  140. package/dist/daemon/api/handlers/canvas.js +62 -16
  141. package/dist/daemon/api/handlers/messages.js +14 -3
  142. package/dist/daemon/api/handlers/nodes.js +17 -7
  143. package/dist/daemon/api/map.js +2 -0
  144. package/dist/daemon/cron/sinks.js +3 -3
  145. package/dist/daemon/fleet.js +1 -1
  146. package/dist/daemon/messaging/node-message.js +16 -2
  147. package/dist/daemon/profile-delete.js +1 -1
  148. package/dist/daemon/reconcilers/broker-supervision.js +26 -9
  149. package/dist/daemon/reconcilers/live-obligation.d.ts +8 -1
  150. package/dist/daemon/reconcilers/live-obligation.js +17 -0
  151. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +6 -6
  152. package/dist/hook-authoring.d.ts +31 -1
  153. package/dist/hook-authoring.js +42 -5
  154. package/dist/index.d.ts +1 -0
  155. package/dist/index.js +3 -0
  156. package/dist/pi-extensions/canvas-inbox-watcher.js +27 -2
  157. package/dist/pi-extensions/canvas-recap.js +4 -0
  158. package/dist/pi-extensions/canvas-stamp.d.ts +14 -0
  159. package/dist/pi-extensions/canvas-stamp.js +77 -0
  160. package/dist/pi-extensions/canvas-stophook.js +3 -0
  161. package/dist/shared/__tests__/env-boundary.test.js +4 -2
  162. package/dist/shared/crtr-version.d.ts +2 -0
  163. package/dist/shared/crtr-version.js +18 -0
  164. package/dist/shared/env.d.ts +10 -0
  165. package/dist/shared/env.js +19 -4
  166. package/dist/shared/generated-context.d.ts +1 -2
  167. package/dist/shared/generated-context.js +3 -4
  168. package/dist/types.d.ts +10 -0
  169. package/dist/types.js +4 -0
  170. package/package.json +1 -1
  171. package/runtime.lock.json +2 -2
@@ -337,7 +337,13 @@ test("picker and Alt+M choices each clear logical routing, and Alt+M / Shift+Tab
337
337
  const parent = h.spawnRoot('model durability parent');
338
338
  seedPool('anthropic', false);
339
339
  seedPool('openai-codex', false);
340
- const pickerNode = await h.spawnHeadlessChild(parent, 'picker durability child', { model: 'openai/strong' });
340
+ const userConfigDir = join(h.agentHome, '.crouter');
341
+ const userConfigPath = join(userConfigDir, 'config.json');
342
+ mkdirSync(userConfigDir, { recursive: true });
343
+ writeFileSync(userConfigPath, JSON.stringify({
344
+ kinds: { 'test/logical-openai': { whenToUse: 'Logical OpenAI routing test.', model: 'openai/strong' } },
345
+ }));
346
+ const pickerNode = await h.spawnHeadlessChild(parent, 'picker durability child', { kind: 'test/logical-openai' });
341
347
  const pickerBefore = h.node(pickerNode);
342
348
  assert.deepEqual(pickerBefore.launch?.modelIntent, { family: 'openai', strength: 'strong' }, 'picker fixture starts from a logical launch preference');
343
349
  assert.equal(pickerBefore.launch?.env.CRTR_MODEL_INTENT, 'openai/strong');
@@ -358,7 +364,7 @@ test("picker and Alt+M choices each clear logical routing, and Alt+M / Shift+Tab
358
364
  // A separate logical launch reaches Alt+M with BOTH intent representations
359
365
  // still populated. This is the regression path: a user-selected ladder rung
360
366
  // must clear them before a restart can resolve the stale route over it.
361
- const ladderNode = await h.spawnHeadlessChild(parent, 'ladder durability child', { model: 'openai/strong' });
367
+ const ladderNode = await h.spawnHeadlessChild(parent, 'ladder durability child', { kind: 'test/logical-openai' });
362
368
  const ladderBefore = h.node(ladderNode);
363
369
  assert.deepEqual(ladderBefore.launch?.modelIntent, { family: 'openai', strength: 'strong' }, 'Alt+M starts with durable logical modelIntent present');
364
370
  assert.equal(ladderBefore.launch?.env.CRTR_MODEL_INTENT, 'openai/strong', 'Alt+M starts with the launch environment intent present');
@@ -388,6 +394,7 @@ test("picker and Alt+M choices each clear logical routing, and Alt+M / Shift+Tab
388
394
  assert.equal(replay.argv[modelArg + 1], expected, 'the replayed model argument carries both choices');
389
395
  assert.equal(replay.env.CRTR_MODEL_INTENT, undefined, 'the stale logical intent is absent from the restarted broker environment');
390
396
  ladderCtrl.close();
397
+ rmSync(userConfigPath, { force: true });
391
398
  });
392
399
  test('reload_auth restores a newly authenticated personal route before the next idle prompt', { timeout: 30_000 }, async () => {
393
400
  const personal = { provider: 'personal-anthropic', id: 'claude-personal' };
@@ -398,6 +405,7 @@ test('reload_auth restores a newly authenticated personal route before the next
398
405
  const unauthenticatedProvider = join(h.home, 'fake-pi.unauthenticated-provider');
399
406
  mkdirSync(userConfigDir, { recursive: true });
400
407
  writeFileSync(userConfigPath, JSON.stringify({
408
+ kinds: { general: { whenToUse: 'General work.', model: 'anthropic/medium' } },
401
409
  modelRoutes: {
402
410
  personal: { family: 'anthropic', credentialSource: 'user', providerId: personal.provider, models: { ultra: personalSpec, strong: personalSpec, medium: personalSpec, light: personalSpec } },
403
411
  funded: { family: 'anthropic', credentialSource: 'northlight', providerId: funded.provider, models: { ultra: `${funded.provider}/${funded.id}:medium`, strong: `${funded.provider}/${funded.id}:medium`, medium: `${funded.provider}/${funded.id}:medium`, light: `${funded.provider}/${funded.id}:medium` } },
@@ -407,7 +415,7 @@ test('reload_auth restores a newly authenticated personal route before the next
407
415
  writeFileSync(unauthenticatedProvider, personal.provider);
408
416
  try {
409
417
  const parent = h.spawnRoot('auth route reconciliation parent');
410
- const child = await h.spawnHeadlessChild(parent, 'auth route reconciliation child', { model: 'anthropic/medium' });
418
+ const child = await h.spawnHeadlessChild(parent, 'auth route reconciliation child');
411
419
  assert.deepEqual(model(child), funded, 'the logical request starts on the funded route while personal auth is absent');
412
420
  const controller = await kit.connectRaw(child, { read: true });
413
421
  controller.send({ type: 'hello', role: 'controller', client_id: 'auth-route-reconciliation' });
@@ -470,10 +478,16 @@ test('reload_auth restores a newly authenticated personal route before the next
470
478
  // subscription-pool occupancy (broker-sdk-wiring's scope).
471
479
  test('login re-drive → list_models self-heals (0a) and reload_auth resolves a model + fires the deferred kickoff', { timeout: 30_000 }, async () => {
472
480
  const noAuthSentinel = join(h.home, 'fake-pi.no-auth');
481
+ const userConfigDir = join(h.agentHome, '.crouter');
482
+ const userConfigPath = join(userConfigDir, 'config.json');
483
+ mkdirSync(userConfigDir, { recursive: true });
484
+ writeFileSync(userConfigPath, JSON.stringify({
485
+ kinds: { general: { whenToUse: 'General work.', model: 'anthropic/medium' } },
486
+ }));
473
487
  writeFileSync(noAuthSentinel, '1');
474
488
  try {
475
489
  const parent = h.spawnRoot('login re-drive parent');
476
- const child = await h.spawnHeadlessChild(parent, 'login re-drive', { model: 'anthropic/medium' });
490
+ const child = await h.spawnHeadlessChild(parent, 'login re-drive');
477
491
  assert.equal(model(child), null, 'boots model-less under zero-auth');
478
492
  assert.equal(agentStartCount(child), 0, 'kickoff deferred at boot');
479
493
  const c = await attachUntil(child, 'controller', 'redrive-ctrl', (a) => a.welcome.role === 'controller', 'redrive controller admitted');
@@ -498,5 +512,6 @@ test('login re-drive → list_models self-heals (0a) and reload_auth resolves a
498
512
  }
499
513
  finally {
500
514
  rmSync(noAuthSentinel, { force: true });
515
+ rmSync(userConfigPath, { force: true });
501
516
  }
502
517
  });
@@ -79,7 +79,7 @@ test('idle-release settles its real watcher before exit; daemon revives exactly
79
79
  },
80
80
  },
81
81
  });
82
- reviveNode(manager, { resume: true, capacity: 'freeze' });
82
+ await reviveNode(manager, { resume: true, capacity: 'freeze' });
83
83
  await h.awaitBoot(manager);
84
84
  // A live managed child makes this unfocused terminal manager legitimately
85
85
  // awaiting, so its real stophook takes the idle-release branch.
@@ -172,7 +172,7 @@ test('an unattended resident with no live obligation completes on the daemon clo
172
172
  },
173
173
  });
174
174
  subscribe(root, nodeId, true);
175
- reviveNode(nodeId, { resume: true, capacity: 'freeze' });
175
+ await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
176
176
  const boot = await h.awaitBoot(nodeId);
177
177
  assert.equal(h.fleet.has(nodeId), true, 'the revived broker is fleet-owned');
178
178
  const seenAt = 10_000;
@@ -223,7 +223,7 @@ test('a non-attending observer does not prevent unattended resident parking', {
223
223
  intent: 'idle-release',
224
224
  });
225
225
  updateNode(nodeId, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
226
- reviveNode(nodeId, { resume: true, capacity: 'freeze' });
226
+ await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
227
227
  const boot = await h.awaitBoot(nodeId);
228
228
  await attach.attach(nodeId, 'observer', 'system-observer-test', { attends: false });
229
229
  assert.equal(JSON.parse(readFileSync(join(h.home, 'nodes', nodeId, 'job', 'attach.json'), 'utf8')).viewers, 0, 'a non-attending hello is not persisted as human attendance');
@@ -268,7 +268,7 @@ test('a connected broker viewer keeps an obligation-free resident live', { timeo
268
268
  env: {},
269
269
  },
270
270
  });
271
- reviveNode(nodeId, { resume: true, capacity: 'freeze' });
271
+ await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
272
272
  const boot = await h.awaitBoot(nodeId);
273
273
  // awaitBoot returns on the boot proof, which is written BEFORE the broker's
274
274
  // own `persistAttachState()` lays down the truthful zero state. Writing our
@@ -311,7 +311,7 @@ test('the unattended park clock cancels an obligation-free terminal node and spa
311
311
  try {
312
312
  const boot = async (id) => {
313
313
  updateNode(id, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
314
- reviveNode(id, { resume: true, capacity: 'freeze' });
314
+ await reviveNode(id, { resume: true, capacity: 'freeze' });
315
315
  return (await h.awaitBoot(id)).pid;
316
316
  };
317
317
  const settled = h.fabricateBrokerNode({ id: 'terminal-no-work', parent: root, status: 'idle', intent: 'idle-release' });
@@ -378,7 +378,7 @@ test('a resident managed child parks without finalizing while a terminal child i
378
378
  subscribe(terminalManager, terminal, true);
379
379
  const boot = async (id) => {
380
380
  updateNode(id, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
381
- reviveNode(id, { resume: true, capacity: 'freeze' });
381
+ await reviveNode(id, { resume: true, capacity: 'freeze' });
382
382
  return (await h.awaitBoot(id)).pid;
383
383
  };
384
384
  const residentPid = await boot(resident);
@@ -439,7 +439,7 @@ test('an unattended resident awaiting a live child remains wakeable', { timeout:
439
439
  env: {},
440
440
  },
441
441
  });
442
- reviveNode(nodeId, { resume: true, capacity: 'freeze' });
442
+ await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
443
443
  const boot = await h.awaitBoot(nodeId);
444
444
  const seenAt = 20_000;
445
445
  await h.tick(seenAt);
@@ -42,7 +42,7 @@ test('a Jiti-loaded watcher prepends its held deferred batch to a native broker
42
42
  },
43
43
  },
44
44
  });
45
- reviveNode(nodeId, { resume: true, capacity: 'freeze' });
45
+ await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
46
46
  await h.awaitBoot(nodeId);
47
47
  const entry = appendInbox(nodeId, {
48
48
  from: 'child',
@@ -115,11 +115,28 @@ test('node-relative memory slash commands omit preview metadata and register inv
115
115
  assert.equal(loadedCommandPreview('/acceptance-independent first evidence', live.commands), undefined);
116
116
  assert.deepEqual(live.notifications, []);
117
117
  const sentContent = Object.fromEntries(live.sent.map((send) => [send.name, send.message.content]));
118
- assert.deepEqual(sentContent, {
118
+ const invokedBody = {
119
119
  [COMMAND_NAMES[0]]: `Store evidence in ${join(home, 'nodes', nodeId, 'context', 'result.md')}.\n`,
120
120
  [COMMAND_NAMES[1]]: `Node ${nodeId} received evidence.\n`,
121
121
  [COMMAND_NAMES[2]]: 'Independent payload: evidence.\n',
122
- });
122
+ };
123
+ // An invoked document is content delivery, so the FIRST invocation carries the
124
+ // same companions `crtr memory read` attaches: the listings that place the doc,
125
+ // plus every corpus doc its memory-read routes match. The doc being delivered is
126
+ // absent from its own listing because its exposure registers before they render.
127
+ const firstSent = sentContent[COMMAND_NAMES[0]];
128
+ assert.ok(firstSent.endsWith(invokedBody[COMMAND_NAMES[0]]), 'the expanded body ends the delivery');
129
+ const companions = firstSent.slice(0, firstSent.length - invokedBody[COMMAND_NAMES[0]].length);
130
+ assert.match(companions, /^<auto-loaded-context>\n[\s\S]*<\/auto-loaded-context>\n\n$/);
131
+ assert.ok(companions.includes('<memory-listing dir="workspace/workspace">\n'
132
+ + `[[workspace/workspace/${COMMAND_LOCALS[2]}]]: Acceptance fixture.\n`
133
+ + `[[workspace/workspace/${COMMAND_LOCALS[1]}]]: Acceptance fixture.\n`
134
+ + '</memory-listing>'), 'the invoked doc\'s own directory listing is attached as a block');
135
+ assert.ok(companions.includes('<memory-listing dir="workspace">\n[[workspace/workspace]]\n</memory-listing>'));
136
+ assert.match(companions, /<memory kind="knowledge" name="memory-read-orientation">/);
137
+ // The later invocations attach nothing: the first delivery already exposed them.
138
+ assert.equal(sentContent[COMMAND_NAMES[1]], invokedBody[COMMAND_NAMES[1]]);
139
+ assert.equal(sentContent[COMMAND_NAMES[2]], invokedBody[COMMAND_NAMES[2]]);
123
140
  for (const send of live.sent) {
124
141
  assert.deepEqual(send.message, {
125
142
  customType: 'memory-slash-command',
@@ -73,11 +73,11 @@ test('yield-message survives one pre-session_start retry and clears on session_s
73
73
  // the retry still roots a fresh cycle carrying the note, never reopening the
74
74
  // old session. Driving reviveNode directly is exactly what focus's revive path
75
75
  // calls, minus a tmux pane this contract does not involve.
76
- const focusRevive = reviveNode(id, { resume: true, capacity: 'freeze' });
76
+ const focusRevive = await reviveNode(id, { resume: true, capacity: 'freeze' });
77
77
  const focusPid = focusRevive.launch?.pid;
78
78
  assert.ok(focusPid != null, 'the competing focus invocation launches the replacement broker');
79
79
  assert.equal(focusRevive.resumed, false, 'refresh intent overrides this invocation\'s strict-resume request');
80
- assert.equal(h.fleet.get(id)?.pid, focusPid, 'the focus invocation synchronously owns the authoritative fleet slot');
80
+ assert.equal(h.fleet.get(id)?.pid, focusPid, 'the focus invocation owns the authoritative fleet slot');
81
81
  // Registering the manual launch cancels the exit policy's deferred recovery.
82
82
  // Its pid must therefore be the successful observed boot, proving these
83
83
  // assertions belong to the contender above rather than an already-queued boot.
@@ -61,7 +61,9 @@ test('RelayTransport dials crtrd attach with Authorization: Bearer <token>', asy
61
61
  const relay = await startMockRelay();
62
62
  const transport = new RelayTransport('node-auth', relay.target);
63
63
  await connectTransport(transport);
64
- assert.equal(relay.requestUrls[0], '/v1/nodes/node-auth/attach');
64
+ // `wakeParked=1` is part of the dial contract: a person attached deliberately,
65
+ // so the bridge wakes an exact parked resident for this caller.
66
+ assert.equal(relay.requestUrls[0], '/v1/nodes/node-auth/attach?wakeParked=1');
65
67
  assert.equal(relay.authHeaders[0], 'Bearer test-token');
66
68
  transport.destroy();
67
69
  await relay.close();
@@ -14,7 +14,11 @@ function relayUrl(target, nodeId) {
14
14
  const url = new URL(target.previewEndpoint);
15
15
  url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:';
16
16
  url.pathname = `/v1/nodes/${encodeURIComponent(nodeId)}/attach`;
17
- url.search = '';
17
+ // A person typed `crtr surface attach` at this node, so an exact parked
18
+ // resident is woken for it — the same thing the LOCAL attach ensure does for
19
+ // a direct attach. The bridge leaves a parked node parked without this, which
20
+ // is what a product page reconnecting its viewport wants.
21
+ url.search = '?wakeParked=1';
18
22
  url.hash = '';
19
23
  return url.toString();
20
24
  }
@@ -0,0 +1,102 @@
1
+ // history-transcript.test.ts — transcript artifacts carry the origin their
2
+ // stamp recorded.
3
+ //
4
+ // The join is the fragile part: a stamp is physically written BEFORE the
5
+ // message it describes, so correlation runs on the message's own epoch-ms
6
+ // `timestamp` and never on tree position. Getting that wrong yields a corpus
7
+ // where every origin silently reads `unknown` — which looks like a stamping
8
+ // regression rather than a reader bug, so it is worth pinning.
9
+ import { test, before, beforeEach, after } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { createNode } from '../canvas.js';
15
+ import { closeDb } from '../db.js';
16
+ import { nodeDir, sessionPtrPath } from '../paths.js';
17
+ import { nodeArtifacts, groupCorpus } from '../history.js';
18
+ const NODE = 'test-transcript-node';
19
+ function meta(id, cwd) {
20
+ return {
21
+ node_id: id,
22
+ name: id,
23
+ created: new Date().toISOString(),
24
+ cwd,
25
+ kind: 'general',
26
+ mode: 'base',
27
+ lifecycle: 'terminal',
28
+ status: 'active',
29
+ };
30
+ }
31
+ function messageEntry(role, text, msgTs) {
32
+ return JSON.stringify({
33
+ type: 'message',
34
+ timestamp: new Date(msgTs).toISOString(),
35
+ message: { role, content: [{ type: 'text', text }], timestamp: msgTs },
36
+ });
37
+ }
38
+ function stampEntry(msgTs, origin) {
39
+ return JSON.stringify({
40
+ type: 'custom',
41
+ customType: 'crtr-stamp',
42
+ data: { v: 1, kind: 'message', payload: { msgTs, origin } },
43
+ });
44
+ }
45
+ /** A session file in the real on-disk shape: stamps precede their messages,
46
+ * one inbound turn is unstamped, and an assistant turn sits between them. */
47
+ function seedSession(home) {
48
+ const dir = nodeDir(NODE);
49
+ mkdirSync(dir, { recursive: true });
50
+ const file = join(home, 'session.jsonl');
51
+ writeFileSync(file, [
52
+ JSON.stringify({ type: 'custom', customType: 'crtr-stamp', data: { v: 1, kind: 'session', payload: { node: NODE, crtr: '0.0.0' } } }),
53
+ stampEntry(1000, { type: 'runtime', kind: 'wake', wake: { kind: 'schedule_revive', ownerId: null, armedAt: 'x' } }),
54
+ messageEntry('user', 'woken by cron', 1000),
55
+ JSON.stringify({ type: 'message', timestamp: new Date(1500).toISOString(), message: { role: 'assistant', content: [{ type: 'thinking', text: 'private' }, { type: 'toolCall', name: 'Bash' }], timestamp: 1500 } }),
56
+ stampEntry(2000, { type: 'runtime', kind: 'inbox', entries: [{ from: 'n1', fromName: 'child-node', kind: 'report', tier: 'normal' }] }),
57
+ messageEntry('user', 'a delivery', 2000),
58
+ messageEntry('user', 'no stamp for this one', 3000),
59
+ ].join('\n'), 'utf8');
60
+ writeFileSync(sessionPtrPath(NODE), file, 'utf8');
61
+ }
62
+ let home;
63
+ let cwd;
64
+ before(() => {
65
+ home = mkdtempSync(join(tmpdir(), 'crtr-history-transcript-'));
66
+ cwd = mkdtempSync(join(tmpdir(), 'crtr-history-transcript-cwd-'));
67
+ process.env['CRTR_HOME'] = home;
68
+ });
69
+ beforeEach(() => {
70
+ closeDb();
71
+ });
72
+ after(() => {
73
+ closeDb();
74
+ for (const d of [home, cwd])
75
+ rmSync(d, { recursive: true, force: true });
76
+ delete process.env['CRTR_HOME'];
77
+ });
78
+ test('a stamped message carries its origin; an unstamped one reads unknown', () => {
79
+ createNode(meta(NODE, cwd));
80
+ seedSession(home);
81
+ const arts = nodeArtifacts(NODE, ['transcript']);
82
+ assert.equal(arts.length, 4, 'every message entry becomes one artifact');
83
+ const origins = arts.map((a) => a.origin);
84
+ assert.deepEqual(origins, ['wake', undefined, 'inbox', 'unknown']);
85
+ // Origin is a property of ARRIVAL — a generated turn must not acquire one,
86
+ // which is what lets `--origin` exclude assistant turns without a role filter.
87
+ const assistant = arts.find((a) => a.role === 'assistant');
88
+ assert.equal(assistant?.origin, undefined);
89
+ // Sender attribution rides the inbox stamp's delivered entries.
90
+ assert.equal(arts[2]?.from, 'child-node');
91
+ // Private model reasoning stays out of the searchable body, matching every
92
+ // other human-facing transcript surface.
93
+ assert.ok(!assistant?.loadBody().includes('private'));
94
+ assert.ok(assistant?.loadBody().includes('Tool: Bash'));
95
+ });
96
+ test('grouping by origin counts arrivals only, and unknown is its own bucket', () => {
97
+ createNode(meta(NODE, cwd));
98
+ seedSession(home);
99
+ const groups = groupCorpus(nodeArtifacts(NODE, ['transcript']), 'origin');
100
+ assert.deepEqual(groups.map((g) => g.key).sort(), ['inbox', 'unknown', 'wake'], 'the assistant turn contributes no bucket');
101
+ assert.equal(groups.reduce((n, g) => n + g.count, 0), 3);
102
+ });
@@ -1,6 +1,6 @@
1
1
  import type { CanvasSource } from '../source.js';
2
2
  import { type BindingId, type BindingResolution, type RawTerminalInput } from '../../keybindings/index.js';
3
- declare const BROWSE_NAV_ACTIONS: readonly ["crtr.browse.quit", "crtr.browse.down", "crtr.browse.up", "crtr.browse.first", "crtr.browse.last", "crtr.browse.expand-or-child", "crtr.browse.collapse-or-parent", "crtr.browse.toggle-fold", "crtr.browse.tab-next", "crtr.browse.tab-previous", "crtr.browse.tab.1", "crtr.browse.tab.2", "crtr.browse.tab.3", "crtr.browse.tab.4", "crtr.browse.tab.5", "crtr.browse.close-subtree", "crtr.browse.sort", "crtr.browse.scope", "crtr.browse.profile", "crtr.browse.residents", "crtr.browse.preview", "crtr.browse.search", "crtr.browse.revive-now", "crtr.browse.focus", "crtr.browse.focus-background", "crtr.browse.yank-id"];
3
+ declare const BROWSE_NAV_ACTIONS: readonly ["crtr.browse.quit", "crtr.browse.down", "crtr.browse.up", "crtr.browse.first", "crtr.browse.last", "crtr.browse.expand-or-child", "crtr.browse.collapse-or-parent", "crtr.browse.toggle-fold", "crtr.browse.tab-next", "crtr.browse.tab-previous", "crtr.browse.tab.1", "crtr.browse.tab.2", "crtr.browse.tab.3", "crtr.browse.tab.4", "crtr.browse.tab.5", "crtr.browse.close-subtree", "crtr.browse.sort", "crtr.browse.scope", "crtr.browse.profile", "crtr.browse.residents", "crtr.browse.preview", "crtr.browse.pin", "crtr.browse.search", "crtr.browse.revive-now", "crtr.browse.focus", "crtr.browse.focus-background", "crtr.browse.yank-id"];
4
4
  declare const BROWSE_SEARCH_ACTIONS: readonly ["crtr.browse.search.cancel", "crtr.browse.search.commit", "crtr.browse.search.delete", "crtr.browse.search.quit"];
5
5
  export declare function resolveBrowseNavAction(bindings: BindingResolution<BindingId>, raw: RawTerminalInput): (typeof BROWSE_NAV_ACTIONS)[number] | null;
6
6
  export declare function resolveBrowseSearchAction(bindings: BindingResolution<BindingId>, raw: RawTerminalInput): (typeof BROWSE_SEARCH_ACTIONS)[number] | null;
@@ -21,7 +21,8 @@ const BROWSE_NAV_ACTIONS = [
21
21
  'crtr.browse.expand-or-child', 'crtr.browse.collapse-or-parent', 'crtr.browse.toggle-fold',
22
22
  'crtr.browse.tab-next', 'crtr.browse.tab-previous', 'crtr.browse.tab.1', 'crtr.browse.tab.2',
23
23
  'crtr.browse.tab.3', 'crtr.browse.tab.4', 'crtr.browse.tab.5', 'crtr.browse.close-subtree', 'crtr.browse.sort',
24
- 'crtr.browse.scope', 'crtr.browse.profile', 'crtr.browse.residents', 'crtr.browse.preview', 'crtr.browse.search',
24
+ 'crtr.browse.scope', 'crtr.browse.profile', 'crtr.browse.residents', 'crtr.browse.preview',
25
+ 'crtr.browse.pin', 'crtr.browse.search',
25
26
  'crtr.browse.revive-now', 'crtr.browse.focus', 'crtr.browse.focus-background', 'crtr.browse.yank-id',
26
27
  ];
27
28
  const BROWSE_SEARCH_ACTIONS = [
@@ -40,14 +41,16 @@ export function resolveBrowseSearchAction(bindings, raw) {
40
41
  // Sort cycle for the `s` key. Starts on the default `attention` ordering; one `s`
41
42
  // press restores the structural `tree` view, then relevance/recency, then back.
42
43
  const SORT_CYCLE = ['attention', 'tree', 'relevance', 'recency'];
43
- import { renderFrame, detectColorCaps, headerHeight, PREVIEW_HEIGHT } from './render.js';
44
+ import { renderFrame, detectColorCaps, headerHeight, previewLayout, PREVIEW_HEIGHT } from './render.js';
44
45
  import { writeClipboardTextEverywhere } from '../../clipboard-text.js';
45
46
  /** Viewport (body) height = total rows minus the header renderFrame draws (see
46
- * render.ts headerHeight), the footer, and the preview panel when shown. Kept
47
- * in lockstep with render.ts via the shared headerHeight/PREVIEW_HEIGHT. */
48
- function viewportHeight(rowsTotal, inputLine, previewOn) {
49
- const rows = Math.max(8, rowsTotal);
50
- const previewH = previewOn ? PREVIEW_HEIGHT : 0;
47
+ * render.ts headerHeight), the footer, and the BOTTOM preview panel when that is
48
+ * the layout in play. The side preview column costs columns, not rows, so it does
49
+ * not shrink the viewport. Kept in lockstep with render.ts via the shared
50
+ * headerHeight/previewLayout/PREVIEW_HEIGHT. */
51
+ function viewportHeight(size, inputLine, previewOn) {
52
+ const rows = Math.max(8, size.rows);
53
+ const previewH = previewLayout(size.cols, previewOn).mode === 'bottom' ? PREVIEW_HEIGHT : 0;
51
54
  return Math.max(1, rows - headerHeight(inputLine) - 1 /* footer */ - previewH);
52
55
  }
53
56
  export async function runBrowse(opts) {
@@ -117,7 +120,7 @@ export async function runBrowse(opts) {
117
120
  cwdScope: null, // default: All dirs
118
121
  profileScope: launchProfileId,
119
122
  sort: 'attention', // default: attention ordering (attached/streaming/live first)
120
- preview: true, // default ON (decision)
123
+ preview: false, // default OFF (decision) — `v` opens it for the hovered row
121
124
  residentsOnly: true, // default ON: hide one-shot workers (decision)
122
125
  pendingClose: null,
123
126
  flash: null,
@@ -218,7 +221,7 @@ export async function runBrowse(opts) {
218
221
  const size = getTerminalSize();
219
222
  const previewOn = state.preview && visible.length > 0;
220
223
  const inputMode = state.search || state.rename || state.message;
221
- const viewport = viewportHeight(size.rows, inputMode, previewOn);
224
+ const viewport = viewportHeight(size, inputMode, previewOn);
222
225
  // Keep the cursor inside the viewport window.
223
226
  if (state.cursor < state.scrollOffset)
224
227
  state.scrollOffset = state.cursor;
@@ -708,7 +711,7 @@ export async function runBrowse(opts) {
708
711
  return;
709
712
  }
710
713
  // Pin or unpin in the local, view-only pin set.
711
- if (input === 'f') {
714
+ if (action === 'crtr.browse.pin') {
712
715
  if (row !== undefined)
713
716
  togglePin(row.id);
714
717
  scheduleFlush();
@@ -201,13 +201,28 @@ export function previewSnippet(query, text, width, maxLines) {
201
201
  // 2. Recompute highlight indices on the FINAL snippet string (no drift), then
202
202
  // word-wrap with offset tracking and split the highlight into per-line cols.
203
203
  const hi = highlightIndices(query, snippet);
204
- return wrapTracked(snippet, width, maxLines).map(({ text: lt, start }) => {
204
+ const wrapped = wrapTracked(snippet, width, maxLines);
205
+ const lines = wrapped.map(({ text: lt, start }) => {
205
206
  const lhi = new Set();
206
207
  for (const idx of hi)
207
208
  if (idx >= start && idx < start + lt.length)
208
209
  lhi.add(idx - start);
209
210
  return { text: lt, hi: lhi };
210
211
  });
212
+ // 3. Mark a truncation. Without this the last line reads as the end of the text,
213
+ // which is exactly the wrong impression for "where did this node leave off?".
214
+ const last = wrapped[wrapped.length - 1];
215
+ const lastLine = lines[lines.length - 1];
216
+ if (last !== undefined && lastLine !== undefined && last.start + last.text.length < snippet.length) {
217
+ const keep = Math.max(0, Math.min(lastLine.text.length, width - 1));
218
+ const text = lastLine.text.slice(0, keep);
219
+ const lhi = new Set();
220
+ for (const idx of lastLine.hi)
221
+ if (idx < keep)
222
+ lhi.add(idx);
223
+ lines[lines.length - 1] = { text: `${text}…`, hi: lhi };
224
+ }
225
+ return lines;
211
226
  }
212
227
  function shortId(id) {
213
228
  return id.slice(0, 8);
@@ -2,10 +2,21 @@ import type { Tab, Tree, VisibleRow, SortMode } from './model.js';
2
2
  import { detectColorCaps, type ColorCaps } from '../../tui/draw.js';
3
3
  export { detectColorCaps };
4
4
  export type { ColorCaps };
5
- export declare const PROMPT_LINES = 3;
6
- export declare const REPLY_LINES = 3;
7
- export declare const PREVIEW_BODY: number;
5
+ export declare const RECAP_LINES = 2;
6
+ export declare const REPORT_LINES = 2;
7
+ export declare const PREVIEW_BODY = 8;
8
8
  export declare const PREVIEW_HEIGHT: number;
9
+ export type PreviewMode = 'none' | 'bottom' | 'side';
10
+ /** Where the preview goes at this terminal width, and how the body splits.
11
+ * `rowsW` is the width the row table draws into (full width unless `side`).
12
+ * Exported so app.ts's viewport math can subtract preview ROWS only in `bottom`
13
+ * mode — the side column costs columns, not rows. */
14
+ export interface PreviewLayout {
15
+ mode: PreviewMode;
16
+ panelW: number;
17
+ rowsW: number;
18
+ }
19
+ export declare function previewLayout(cols: number, previewOn: boolean): PreviewLayout;
9
20
  export declare function headerHeight(inputLine: boolean): number;
10
21
  export interface RenderState {
11
22
  tree: Tree;
@@ -52,6 +63,15 @@ export interface RenderState {
52
63
  * dead action. undefined/false = local, byte-identical footer. */
53
64
  readOnly?: boolean;
54
65
  }
66
+ /** How the fixed PREVIEW_BODY budget is divided for one row: the optional blocks
67
+ * take their two lines only when the node actually has them, and the remainder is
68
+ * split between the ask and the reply (ask keeps the odd line). */
69
+ export declare function previewAllocation(hasRecap: boolean, hasReport: boolean): {
70
+ recap: number;
71
+ ask: number;
72
+ reply: number;
73
+ report: number;
74
+ };
55
75
  /**
56
76
  * Render the whole frame. Returns a single string that, written as-is, repaints
57
77
  * the screen in place. `caps` gates all hue (defaults to a no-color frame so