@north-light/crouter 0.3.310 → 0.3.312

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 (68) hide show
  1. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +3 -3
  2. package/dist/clients/attach/viewer.js +389 -389
  3. package/dist/commands/__tests__/integration/dashboard-snapshot.test.d.ts +1 -0
  4. package/dist/commands/__tests__/integration/dashboard-snapshot.test.js +212 -0
  5. package/dist/commands/api-client.d.ts +1 -0
  6. package/dist/commands/api-client.js +4 -2
  7. package/dist/commands/canvas-roster.js +24 -17
  8. package/dist/commands/dashboard.js +23 -16
  9. package/dist/core/__tests__/context-intro.test.js +4 -4
  10. package/dist/core/__tests__/cron-timeout-descendants.test.d.ts +1 -0
  11. package/dist/core/__tests__/cron-timeout-descendants.test.js +102 -0
  12. package/dist/core/__tests__/human-deliver.test.js +18 -18
  13. package/dist/core/__tests__/integration/human-deliver-e2e.test.js +5 -5
  14. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +299 -1
  15. package/dist/core/__tests__/integration/spawn-root.test.js +11 -11
  16. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +2 -2
  17. package/dist/core/__tests__/on-read-dedup-resume.test.js +6 -6
  18. package/dist/core/__tests__/on-read-nested-store.test.js +6 -6
  19. package/dist/core/__tests__/profile-project-memory-delivery.test.js +31 -31
  20. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -3
  21. package/dist/core/__tests__/warm-claim-preference-snapshot.test.js +1 -1
  22. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +24 -11
  23. package/dist/core/canvas/remote-canvas-source.d.ts +4 -0
  24. package/dist/core/canvas/remote-canvas-source.js +58 -6
  25. package/dist/core/canvas/render-source.d.ts +4 -4
  26. package/dist/core/canvas/render-source.js +49 -48
  27. package/dist/core/canvas/source.d.ts +2 -0
  28. package/dist/core/canvas/source.js +4 -1
  29. package/dist/core/command-hooks/artifact.d.ts +6 -0
  30. package/dist/core/command-hooks/artifact.js +22 -6
  31. package/dist/core/command-hooks/discovery.d.ts +2 -0
  32. package/dist/core/command-hooks/discovery.js +2 -0
  33. package/dist/core/command-hooks/lifecycle-catalog.d.ts +20 -2
  34. package/dist/core/command-hooks/lifecycle-catalog.js +7 -2
  35. package/dist/core/command-hooks/lifecycle-create.d.ts +4 -0
  36. package/dist/core/command-hooks/lifecycle-create.js +51 -0
  37. package/dist/core/command-hooks/schema.d.ts +2 -2
  38. package/dist/core/command-hooks/schema.js +5 -4
  39. package/dist/core/command-hooks/transport/exec-invoke.js +3 -1
  40. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +18 -8
  41. package/dist/core/command-hooks/transport/exec-lifecycle.js +89 -30
  42. package/dist/core/errors.d.ts +1 -0
  43. package/dist/core/errors.js +4 -0
  44. package/dist/core/human/feedback-companion.js +1 -1
  45. package/dist/core/review/companion.d.ts +1 -1
  46. package/dist/core/review/companion.js +2 -2
  47. package/dist/core/review/realize.js +6 -6
  48. package/dist/core/runtime/nodes.d.ts +5 -1
  49. package/dist/core/runtime/nodes.js +26 -11
  50. package/dist/core/runtime/recycle.js +16 -12
  51. package/dist/core/runtime/reset.js +4 -1
  52. package/dist/core/runtime/revive.js +13 -12
  53. package/dist/core/runtime/spawn.d.ts +4 -1
  54. package/dist/core/runtime/spawn.js +30 -3
  55. package/dist/core/runtime/warm-pool.d.ts +2 -2
  56. package/dist/core/runtime/warm-pool.js +23 -3
  57. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +2 -2
  58. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
  59. package/dist/daemon/api/__tests__/seam/profile-delete.test.js +2 -2
  60. package/dist/daemon/api/handlers/human.js +5 -2
  61. package/dist/daemon/api/handlers/nodes.d.ts +1 -0
  62. package/dist/daemon/api/handlers/nodes.js +34 -29
  63. package/dist/daemon/cron-run.js +49 -15
  64. package/dist/hook-authoring.d.ts +28 -2
  65. package/dist/hook-authoring.js +53 -1
  66. package/dist/index.d.ts +1 -1
  67. package/package.json +1 -1
  68. package/runtime.lock.json +5 -5
@@ -350,11 +350,11 @@ after(() => {
350
350
  restore('CRTR_PROFILE_ID', prevProfile);
351
351
  restore('CRTR_NODE_ID', prevNode);
352
352
  });
353
- function newNode(kind = 'general') {
354
- return spawnNode({ kind, cwd: cwdProject, parent: null, profile_id: profileId }).node_id;
353
+ async function newNode(kind = 'general') {
354
+ return (await spawnNode({ kind, cwd: cwdProject, parent: null, profile_id: profileId })).node_id;
355
355
  }
356
- test('boot delivery caps every profile project at its relationship, including the one used as cwd', () => {
357
- const boot = renderKnowledgeBlock(newNode(), freshTranscriptTarget());
356
+ test('boot delivery caps every profile project at its relationship, including the one used as cwd', async () => {
357
+ const boot = renderKnowledgeBlock(await newNode(), freshTranscriptTarget());
358
358
  // The control: an uncapped relationship still renders its authored content.
359
359
  assert.ok(boot.includes(B.pcontent), `the content-capped project renders its body; got:\n${boot}`);
360
360
  // The reported failure. cwd reaches this root before the manifest pointer
@@ -369,22 +369,22 @@ test('boot delivery caps every profile project at its relationship, including th
369
369
  assert.match(boot, /[├└]─ t9-pname-doc$/m, `the name-capped doc discloses as a bare name; got:\n${boot}`);
370
370
  assert.ok(!boot.includes('When the name project delivers'), 'a name-capped doc discloses no preview line');
371
371
  });
372
- test('surface-entry gates select the rung by node kind while the document gate remains absolute', () => {
372
+ test('surface-entry gates select the rung by node kind while the document gate remains absolute', async () => {
373
373
  for (const kind of ['design', 'spec', 'plan', 'developer']) {
374
- const boot = renderKnowledgeBlock(newNode(kind), freshTranscriptTarget());
374
+ const boot = renderKnowledgeBlock(await newNode(kind), freshTranscriptTarget());
375
375
  assert.ok(boot.includes(B.kindSplit), `${kind} receives the content rung; got:\n${boot}`);
376
376
  }
377
377
  for (const kind of ['general', 'review', 'advisor']) {
378
- const boot = renderKnowledgeBlock(newNode(kind), freshTranscriptTarget());
378
+ const boot = renderKnowledgeBlock(await newNode(kind), freshTranscriptTarget());
379
379
  assert.match(boot, /t9-kind-split {2}# read when: When the node needs this kind-specific guide\./, `${kind} receives the preview rung; got:\n${boot}`);
380
380
  assert.ok(!boot.includes(B.kindSplit), `${kind} does not receive the body`);
381
381
  }
382
382
  for (const kind of ['explore', 'personal-assistant']) {
383
- const boot = renderKnowledgeBlock(newNode(kind), freshTranscriptTarget());
383
+ const boot = renderKnowledgeBlock(await newNode(kind), freshTranscriptTarget());
384
384
  assert.ok(!boot.includes('t9-kind-split'), `${kind} receives no kind-split entry; got:\n${boot}`);
385
385
  assert.ok(!boot.includes(B.kindSplit), `${kind} receives no kind-split body`);
386
386
  }
387
- const developer = renderKnowledgeBlock(newNode('developer'), freshTranscriptTarget());
387
+ const developer = renderKnowledgeBlock(await newNode('developer'), freshTranscriptTarget());
388
388
  assert.ok(!developer.includes('t9-document-gate-wins'), `a failing document gate excludes every entry; got:\n${developer}`);
389
389
  assert.ok(!developer.includes(B.documentGateWins), 'a failing document gate excludes its content');
390
390
  assert.ok(developer.includes(B.codeCraftIndex), 'the directory document delivers its boot content');
@@ -394,8 +394,8 @@ test('surface-entry gates select the rung by node kind while the document gate r
394
394
  // The ancestor listing, not just the doc's own directory.
395
395
  assert.match(developer, /sibling {2}# read when: When the dev sibling applies\./, `content delivery exposes the enclosing directory's members too; got:\n${developer}`);
396
396
  });
397
- test('context docs accounting reports content attachment raises from the renderer', () => {
398
- const node = newNode('developer');
397
+ test('context docs accounting reports content attachment raises from the renderer', async () => {
398
+ const node = await newNode('developer');
399
399
  const subject = assembleNodeSubject(node);
400
400
  assert.ok(subject !== null, 'the fixture node assembles a subject');
401
401
  const target = { cwd: cwdProject, profileId, nodeId: node };
@@ -431,8 +431,8 @@ test('context docs accounting reports content attachment raises from the rendere
431
431
  assert.equal(viewerRecord?.deliveryCause, 'directory listing attached to content delivery');
432
432
  plans.dispose();
433
433
  });
434
- test('context docs accounting adds full-view workspace content attachments', () => {
435
- const node = newNode();
434
+ test('context docs accounting adds full-view workspace content attachments', async () => {
435
+ const node = await newNode();
436
436
  const subject = assembleNodeSubject(node);
437
437
  assert.ok(subject !== null, 'the fixture node assembles a subject');
438
438
  const target = { cwd: outsideProject, profileId, nodeId: node };
@@ -446,8 +446,8 @@ test('context docs accounting adds full-view workspace content attachments', ()
446
446
  assert.equal(companion?.final_rung, 'content');
447
447
  assert.equal(companion?.delivery_cause, 'memory-read companion of content delivery');
448
448
  });
449
- test('boot content triggers memory-read companions to a deduplicated fixpoint', () => {
450
- const boot = renderKnowledgeBlock(newNode(), freshTranscriptTarget());
449
+ test('boot content triggers memory-read companions to a deduplicated fixpoint', async () => {
450
+ const boot = renderKnowledgeBlock(await newNode(), freshTranscriptTarget());
451
451
  for (const body of [B.companionSource, B.companionA, B.companionB]) {
452
452
  assert.equal(boot.split(body).length - 1, 1, `content companion ${body} delivers once; got:\n${boot}`);
453
453
  }
@@ -455,8 +455,8 @@ test('boot content triggers memory-read companions to a deduplicated fixpoint',
455
455
  // discloses its members exactly as the doc that routed to it did.
456
456
  assert.match(boot, /member-1 {2}# read when: When the companion member applies\./, `a content companion exposes its own directory's members; got:\n${boot}`);
457
457
  });
458
- test('one body delivered across both boot partitions renders exactly once', () => {
459
- const node = newNode();
458
+ test('one body delivered across both boot partitions renders exactly once', async () => {
459
+ const node = await newNode();
460
460
  const subject = assembleNodeSubject(node);
461
461
  assert.ok(subject !== null, 'the fixture node assembles a subject');
462
462
  const state = emptyContextExposureState();
@@ -470,8 +470,8 @@ test('one body delivered across both boot partitions renders exactly once', () =
470
470
  const occurrences = (preferences.split(B.crossPartition).length - 1) + (knowledge.split(B.crossPartition).length - 1);
471
471
  assert.equal(occurrences, 1, `the companion body renders once across both partitions; got:\n${preferences}\n---\n${knowledge}`);
472
472
  });
473
- test('a node-local doc floors to name only without boot entries', () => {
474
- const node = newNode('explore');
473
+ test('a node-local doc floors to name only without boot entries', async () => {
474
+ const node = await newNode('explore');
475
475
  writeDoc(memoryDir(node), 't9-node-local-bootless', bootlessDoc('When the node-local fallback applies', 'BODY-T9-NODE-LOCAL-BOOTLESS'));
476
476
  writeDoc(memoryDir(node), 't9-node-local-entry-gated', [
477
477
  '---',
@@ -491,8 +491,8 @@ test('a node-local doc floors to name only without boot entries', () => {
491
491
  assert.ok(!boot.includes('t9-node-local-entry-gated'), `ineligible boot entries keep a node-local doc absent; got:\n${boot}`);
492
492
  assert.ok(!boot.includes('BODY-T9-NODE-LOCAL-ENTRY-GATED'), 'ineligible boot entries disclose no body');
493
493
  });
494
- test('a `none` project contributes nothing to boot — no body, no name, no hidden count, no shadow', () => {
495
- const boot = renderKnowledgeBlock(newNode(), freshTranscriptTarget());
494
+ test('a `none` project contributes nothing to boot — no body, no name, no hidden count, no shadow', async () => {
495
+ const boot = renderKnowledgeBlock(await newNode(), freshTranscriptTarget());
496
496
  assert.ok(!boot.includes(B.pnone), 'a none-capped body must not render');
497
497
  assert.ok(!boot.includes('pnone'), 'a none-capped store must not appear as a name either — not even its namespace row');
498
498
  // Hidden counts are disclosure too: the control project's namespace row proves
@@ -512,7 +512,7 @@ test('a capped nearer candidate yields the shared identity instead of suppressin
512
512
  // physical candidates. Automatic delivery filters candidates by the profile
513
513
  // cap BEFORE dedupe, so the `none`-capped nearer one drops out and the farther
514
514
  // one delivers; a deliberate read stays winner-only on source order.
515
- const boot = renderKnowledgeBlock(newNode(), freshTranscriptTarget());
515
+ const boot = renderKnowledgeBlock(await newNode(), freshTranscriptTarget());
516
516
  assert.ok(boot.includes(B.dupFar), `the farther uncapped candidate delivers the shared identity; got:\n${boot}`);
517
517
  assert.ok(!boot.includes(B.dupNear), 'the none-capped nearer candidate delivers nothing');
518
518
  const read = (await readLeaf.run({
@@ -524,14 +524,14 @@ test('a capped nearer candidate yields the shared identity instead of suppressin
524
524
  }));
525
525
  assert.ok(read.content.includes(B.dupNear) && !read.content.includes(B.dupFar), `a deliberate read resolves the source-order winner; got:\n${read.content}`);
526
526
  });
527
- test('a non-none cap keeps its project precedence and discloses the duplicate at the capped rung', () => {
528
- const boot = renderKnowledgeBlock(newNode(), freshTranscriptTarget());
527
+ test('a non-none cap keeps its project precedence and discloses the duplicate at the capped rung', async () => {
528
+ const boot = renderKnowledgeBlock(await newNode(), freshTranscriptTarget());
529
529
  assert.match(boot, /[├└]─ t9-namedup-doc$/m, `the name-capped project discloses its doc as a bare name; got:\n${boot}`);
530
530
  assert.ok(!boot.includes(B.nameFromPname), 'a name-capped doc discloses at `name`, never its body');
531
531
  assert.ok(boot.includes(B.nameFromPcontent), 'the content project delivers its own distinct identity in full');
532
532
  });
533
- test('workspace-open clamps each project store the same way boot does', () => {
534
- const opened = renderWorkspaceOpenDocs(newNode(), freshTranscriptTarget());
533
+ test('workspace-open clamps each project store the same way boot does', async () => {
534
+ const opened = renderWorkspaceOpenDocs(await newNode(), freshTranscriptTarget());
535
535
  assert.ok(opened.includes(B.pcontent), `the content-capped project delivers its body; got:\n${opened}`);
536
536
  assert.ok(!opened.includes(B.ppreview), `the cwd project must not deliver its body; got:\n${opened}`);
537
537
  assert.match(opened, /name="ppreview\/t9-ppreview-doc" readWhen="When the cwd project delivers\./, `the cwd project delivers its preview line under its canonical name; got:\n${opened}`);
@@ -539,8 +539,8 @@ test('workspace-open clamps each project store the same way boot does', () => {
539
539
  assert.ok(!opened.includes('t9-pnone-doc'), 'the none-capped project delivers nothing at workspace-open');
540
540
  assert.ok(!opened.includes(B.sharedFromPnone), 'nor its side of a name collision');
541
541
  });
542
- test('workspace-open answers its listings from the workspace it opened, not the ambient one', () => {
543
- const node = newNode();
542
+ test('workspace-open answers its listings from the workspace it opened, not the ambient one', async () => {
543
+ const node = await newNode();
544
544
  const subject = assembleNodeSubject(node);
545
545
  assert.ok(subject !== null, 'the fixture node assembles a subject');
546
546
  // The process cwd is `ppreview`; this workspace is outside the profile
@@ -551,7 +551,7 @@ test('workspace-open answers its listings from the workspace it opened, not the
551
551
  assert.match(opened, /\[\[poutside\/t9-ws-guide\/member-1\]\]: When the outside guide member applies\./, `the listing carries that directory's own members; got:\n${opened}`);
552
552
  });
553
553
  test('memory list and find record synthetic pages without crediting matched documents', async () => {
554
- const node = newNode();
554
+ const node = await newNode();
555
555
  process.env['CRTR_NODE_ID'] = node;
556
556
  try {
557
557
  const listed = await listLeaf.run({
@@ -589,7 +589,7 @@ test('memory list and find record synthetic pages without crediting matched docu
589
589
  }
590
590
  });
591
591
  test('an explicit read after boot content adds no duplicate directory attachments', async () => {
592
- const node = newNode('developer');
592
+ const node = await newNode('developer');
593
593
  process.env['CRTR_NODE_ID'] = node;
594
594
  try {
595
595
  const state = loadContextExposureState(node);
@@ -614,7 +614,7 @@ test('an explicit read after boot content adds no duplicate directory attachment
614
614
  }
615
615
  });
616
616
  test('an explicit `crtr memory read` returns and records the full body a cap withheld', async () => {
617
- const node = newNode();
617
+ const node = await newNode();
618
618
  process.env['CRTR_NODE_ID'] = node;
619
619
  const state = loadContextExposureState(node);
620
620
  const transcript = exposureTarget(state, 'transcript');
@@ -50,7 +50,7 @@ after(async () => {
50
50
  });
51
51
  test('a --worktree spawn at an already-existing node id throws and leaves NO worktree registered', async () => {
52
52
  const nodeId = 'duplicate-id';
53
- spawnNode({ kind: 'general', cwd: '/tmp/work', nodeId });
53
+ await spawnNode({ kind: 'general', cwd: '/tmp/work', nodeId });
54
54
  await assert.rejects(() => spawnChild({ kind: 'general', cwd: repoDir, prompt: '', worktree: true, nodeId }));
55
55
  assert.ok(!existsSync(managedWorktreePath(nodeId)), 'the conflicting spawn must not leave a worktree checkout directory');
56
56
  assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/crtr/${nodeId}`], repoDir).status, 1, 'the conflicting spawn must not leave a registered crtr/<id> branch');
@@ -59,12 +59,12 @@ test('a --worktree spawn at an already-existing node id throws and leaves NO wor
59
59
  assert.equal(getNode(nodeId).kind, 'general');
60
60
  assert.equal(getNode(nodeId).managed_worktree, undefined);
61
61
  });
62
- test('spawnNode({ nodeId }): a hyphen-less opaque id spawns at the exact given id', () => {
62
+ test('spawnNode({ nodeId }): a hyphen-less opaque id spawns at the exact given id', async () => {
63
63
  // Regression for the reviewed opaque-id-contract violation: the old validator
64
64
  // required >=2 hyphen segments and rejected a hyphen-less id outright,
65
65
  // contradicting the design's "opaque, never parsed for structure" ID scheme
66
66
  // that remote and cloud spawns depend on.
67
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', nodeId: 'remote42' });
67
+ const meta = await spawnNode({ kind: 'general', cwd: '/tmp/work', nodeId: 'remote42' });
68
68
  assert.equal(meta.node_id, 'remote42');
69
69
  assert.notEqual(getNode('remote42'), null);
70
70
  });
@@ -33,7 +33,7 @@ after(() => {
33
33
  delete process.env['CRTR_HOME'];
34
34
  });
35
35
  test('the claim path persists a frozen preference snapshot', async () => {
36
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
36
+ const meta = await spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
37
37
  assert.equal(loadContextExposureState(meta.node_id).preferenceSnapshot, null, 'a spare mints with nothing frozen');
38
38
  await deliverBearingsLive(meta.node_id);
39
39
  assert.notEqual(loadContextExposureState(meta.node_id).preferenceSnapshot, null, 'claim freezes the snapshot the persona gate requires');
@@ -116,17 +116,19 @@ test('every request POSTs argv leading with --json, and the token appears ONLY i
116
116
  assert.ok(!String(v).includes('secret-relay-token'), `header ${k} must not carry the token`);
117
117
  }
118
118
  });
119
- test('view BFS traverses subscriptionsOf and is cycle-safe', async (t) => {
120
- // root -> a -> b -> a (cycle back to a).
121
- const graph = {
122
- root: [{ node_id: 'a', active: true }],
123
- a: [{ node_id: 'b', active: true }],
124
- b: [{ node_id: 'a', active: true }],
125
- };
119
+ test('view reads the batched roster topology and is cycle-safe', async (t) => {
126
120
  const server = await startMockCrtrServer((args) => {
127
- if (args[3] === 'show') {
128
- const id = args[4];
129
- return { kind: 'json', body: showResponse(id, graph[id] ?? []) };
121
+ if (args[1] === 'canvas' && args[2] === 'roster') {
122
+ return {
123
+ kind: 'json',
124
+ body: {
125
+ edges: [
126
+ { from_id: 'root', to_id: 'a', active: true, created: '' },
127
+ { from_id: 'a', to_id: 'b', active: true, created: '' },
128
+ { from_id: 'b', to_id: 'a', active: true, created: '' },
129
+ ],
130
+ },
131
+ };
130
132
  }
131
133
  return { kind: 'json', status: 500, body: {} };
132
134
  });
@@ -134,8 +136,17 @@ test('view BFS traverses subscriptionsOf and is cycle-safe', async (t) => {
134
136
  const source = new RemoteCanvasSource(server.url, 'tok');
135
137
  const result = await source.view('root');
136
138
  assert.deepEqual([...result].sort(), ['a', 'b']);
139
+ assert.equal(server.requests.length, 1, 'the whole traversal shares one roster request');
140
+ });
141
+ test('topology failures surface instead of rendering an empty remote graph', async (t) => {
142
+ const server = await startMockCrtrServer(() => ({ kind: 'json', status: 500, body: {} }));
143
+ t.after(() => server.close());
144
+ const source = new RemoteCanvasSource(server.url, 'tok');
145
+ await assert.rejects(source.subscriptionsOf('root'), /remote canvas topology is unavailable/);
146
+ await assert.rejects(source.subscriptionsOf('other-root'), /remote canvas topology is unavailable/);
147
+ assert.equal(server.requests.length, 1, 'a failed roster read is shared across roots for the cache TTL');
137
148
  });
138
- test('a 500 response, a non-JSON body, and a non-zero exitCode all degrade gracefully instead of throwing', async (t) => {
149
+ test('a 500 response, a non-JSON body, and a non-zero exitCode degrade optional reads gracefully', async (t) => {
139
150
  const server = await startMockCrtrServer((args) => {
140
151
  if (args.includes('http500'))
141
152
  return { kind: 'json', status: 500, body: {} };
@@ -159,6 +170,8 @@ test('a 500 response, a non-JSON body, and a non-zero exitCode all degrade grace
159
170
  });
160
171
  test('malformed success JSON (exit 0, wrong shape) degrades cleanly instead of returning a shape a consumer would crash on', async (t) => {
161
172
  const server = await startMockCrtrServer((args) => {
173
+ if (args[1] === 'canvas' && args[2] === 'roster')
174
+ return { kind: 'json', body: { edges: [] } };
162
175
  if (args[3] === 'show') {
163
176
  const id = args[4];
164
177
  if (id === 'empty-object')
@@ -46,10 +46,13 @@ export declare class RemoteCanvasSource implements CanvasSource {
46
46
  private readonly exec;
47
47
  private nodeCache;
48
48
  private rosterCache;
49
+ private topologyCache;
49
50
  private viewCache;
50
51
  constructor(previewEndpoint: string, relayToken: string);
51
52
  private execJson;
52
53
  private fetchShow;
54
+ private topology;
55
+ private loadTopology;
53
56
  /** The full passthrough object `node inspect show` produces — used by
54
57
  * `nodeShow`'s remote branch (`src/commands/node/inspect.ts`) to return the exact
55
58
  * same shape a local read would, without re-deriving it. */
@@ -62,6 +65,7 @@ export declare class RemoteCanvasSource implements CanvasSource {
62
65
  subscriptionsOf(id: string): Promise<SubscriptionRef[]>;
63
66
  subscribersOf(id: string): Promise<SubscriptionRef[]>;
64
67
  view(root: string): Promise<string[]>;
68
+ ticketCountsForNodes(nodeIds: readonly string[]): Promise<Record<string, number>>;
65
69
  ticketCountsForView(root: string): Promise<Record<string, number>>;
66
70
  hasActiveLiveSubscription(id: string): Promise<boolean>;
67
71
  }
@@ -76,6 +76,13 @@ function isValidNeighborEntry(x) {
76
76
  * lifecycle fields local/API-backed row renderers dereference unguarded;
77
77
  * the remaining nullable runtime
78
78
  * fields (host_kind, parent, pi_pid, ...) are left to cast through as-is. */
79
+ function isValidRosterEdge(x) {
80
+ return (isPlainObject(x) &&
81
+ typeof x['from_id'] === 'string' &&
82
+ typeof x['to_id'] === 'string' &&
83
+ typeof x['active'] === 'boolean' &&
84
+ typeof x['created'] === 'string');
85
+ }
79
86
  function isValidNodeRow(x) {
80
87
  if (!isPlainObject(x))
81
88
  return false;
@@ -94,6 +101,7 @@ export class RemoteCanvasSource {
94
101
  exec;
95
102
  nodeCache = new Map();
96
103
  rosterCache = new Map();
104
+ topologyCache = null;
97
105
  viewCache = new Map();
98
106
  constructor(previewEndpoint, relayToken) {
99
107
  this.previewEndpoint = previewEndpoint;
@@ -134,6 +142,46 @@ export class RemoteCanvasSource {
134
142
  this.nodeCache.set(id, { value: res, at: Date.now() });
135
143
  return res;
136
144
  }
145
+ topology() {
146
+ if (this.topologyCache !== null && Date.now() - this.topologyCache.at < CACHE_TTL_MS)
147
+ return this.topologyCache.value;
148
+ const request = this.loadTopology();
149
+ this.topologyCache = { value: request, at: Date.now() };
150
+ return request;
151
+ }
152
+ async loadTopology() {
153
+ const response = await this.exec('crtr', ['--json', 'canvas', 'roster']);
154
+ if (!response.ok || (response.exitCode !== undefined && response.exitCode !== 0)) {
155
+ throw new Error('remote canvas topology is unavailable');
156
+ }
157
+ let raw;
158
+ try {
159
+ raw = JSON.parse(response.stdout.trim());
160
+ }
161
+ catch {
162
+ throw new Error('remote canvas topology is invalid');
163
+ }
164
+ if (!isPlainObject(raw) || !Array.isArray(raw['edges']) || !raw['edges'].every(isValidRosterEdge)) {
165
+ throw new Error('remote canvas topology is invalid');
166
+ }
167
+ const edges = raw['edges'];
168
+ const topology = { subscriptions: new Map(), subscribers: new Map() };
169
+ for (const edge of edges) {
170
+ const subscription = { node_id: edge.to_id, active: edge.active, created: edge.created };
171
+ const subscriber = { node_id: edge.from_id, active: edge.active, created: edge.created };
172
+ const subscriptions = topology.subscriptions.get(edge.from_id);
173
+ if (subscriptions === undefined)
174
+ topology.subscriptions.set(edge.from_id, [subscription]);
175
+ else
176
+ subscriptions.push(subscription);
177
+ const subscribers = topology.subscribers.get(edge.to_id);
178
+ if (subscribers === undefined)
179
+ topology.subscribers.set(edge.to_id, [subscriber]);
180
+ else
181
+ subscribers.push(subscriber);
182
+ }
183
+ return topology;
184
+ }
137
185
  /** The full passthrough object `node inspect show` produces — used by
138
186
  * `nodeShow`'s remote branch (`src/commands/node/inspect.ts`) to return the exact
139
187
  * same shape a local read would, without re-deriving it. */
@@ -186,12 +234,10 @@ export class RemoteCanvasSource {
186
234
  return nodes;
187
235
  }
188
236
  async subscriptionsOf(id) {
189
- const reports = (await this.fetchShow(id))?.reports ?? [];
190
- return reports.map((r) => ({ node_id: r.node_id, active: r.active, created: '' }));
237
+ return (await this.topology()).subscriptions.get(id) ?? [];
191
238
  }
192
239
  async subscribersOf(id) {
193
- const managers = (await this.fetchShow(id))?.managers ?? [];
194
- return managers.map((r) => ({ node_id: r.node_id, active: r.active, created: '' }));
240
+ return (await this.topology()).subscribers.get(id) ?? [];
195
241
  }
196
242
  async view(root) {
197
243
  const cached = this.viewCache.get(root);
@@ -214,9 +260,15 @@ export class RemoteCanvasSource {
214
260
  this.viewCache.set(root, { value: out, at: Date.now() });
215
261
  return out;
216
262
  }
263
+ async ticketCountsForNodes(nodeIds) {
264
+ // The `canvas attention map` transport's `counts` values mean all pending
265
+ // human tickets (page/review), not just asks.
266
+ if (nodeIds.length === 0)
267
+ return {};
268
+ const res = await this.execJson(['canvas', 'attention', 'map', '--nodes', nodeIds.join(',')]);
269
+ return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
270
+ }
217
271
  async ticketCountsForView(root) {
218
- // The `canvas attention map` transport is unchanged; its `counts` values now
219
- // mean all pending human tickets (page/review), not just asks.
220
272
  const res = await this.execJson(['canvas', 'attention', 'map', '--view', root]);
221
273
  return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
222
274
  }
@@ -13,8 +13,8 @@ export interface NodeTelemetry {
13
13
  export declare function readNodeTelemetry(nodeId: string): NodeTelemetry;
14
14
  /** Format a token count as `Nk` (rounded down to nearest 1 k). */
15
15
  export declare function fmtCtx(tokensIn: number | undefined): string;
16
- export declare function renderTreeFromSource(source: CanvasSource, rootId: string): Promise<string>;
17
- export declare function renderForestFromSource(source: CanvasSource): Promise<string>;
16
+ export declare function renderTreeFromSource(source: CanvasSource, rootId: string, snapshot?: readonly NodeRow[], ticketSnapshot?: Record<string, number>, ticketUnavailable?: string): Promise<string>;
17
+ export declare function renderForestFromSource(source: CanvasSource, snapshot?: readonly NodeRow[], ticketSnapshot?: Record<string, number>, ticketUnavailable?: string): Promise<string>;
18
18
  export interface DashboardRow {
19
19
  node_id: string;
20
20
  /** Full display label: explicit handle plus generated description. */
@@ -183,13 +183,13 @@ export declare function dashboardRowFromNodeRow(row: NodeRow, focusedNodeIds: Re
183
183
  * entirely through the source — the openDb-free sibling of `dashboardRows`.
184
184
  * Ticket counts come from ONE `source.ticketCountsForView(rootId)` call (the
185
185
  * API/remote attention projection), never `countTickets` (openDb). */
186
- export declare function dashboardRowsFromSource(source: CanvasSource, rootId: string): Promise<DashboardRow[]>;
186
+ export declare function dashboardRowsFromSource(source: CanvasSource, rootId: string, snapshot?: readonly NodeRow[], ticketSnapshot?: Record<string, number>): Promise<DashboardRow[]>;
187
187
  /** CHEAP boot builder over a source — one row per node across the whole canvas,
188
188
  * fields that need no per-node meta/session read. Local API callers may supply
189
189
  * the node ids from one `listFocuses()` query so `viewed` remains visible without
190
190
  * coupling the canvas source to local viewer state. Remote sources ignore that
191
191
  * set. Local disk telemetry/fault/streaming are likewise suppressed remotely. */
192
- export declare function dashboardRowsAllFromSource(source: CanvasSource, localFocusedNodeIds?: ReadonlySet<string>, liveOnly?: boolean): Promise<DashboardRow[]>;
192
+ export declare function dashboardRowsAllFromSource(source: CanvasSource, localFocusedNodeIds?: ReadonlySet<string>, liveOnly?: boolean, snapshot?: readonly NodeRow[]): Promise<DashboardRow[]>;
193
193
  export declare function enrichRowsFromSource(_source: CanvasSource, rows: DashboardRow[], asks?: Record<string, number>): Promise<void>;
194
194
  /** goal (initial-prompt.md) and session parts are local disk reads — suppressed
195
195
  * to undefined for a remote source. */
@@ -60,9 +60,8 @@ export function fmtCtx(tokensIn) {
60
60
  // LOCAL disk reads (telemetry.json / fault.json), valid for a same-host source
61
61
  // but meaningless for a truly remote canvas, so both are suppressed when the
62
62
  // source is a `RemoteCanvasSource`.
63
- async function nodeLineFromSource(source, nodeId, indent, connector, remote, tickets) {
64
- const node = await source.getNode(nodeId);
65
- if (node === null)
63
+ function nodeLineFromSource(node, nodeId, indent, connector, remote, tickets) {
64
+ if (node === undefined)
66
65
  return `${indent}${connector}? <missing meta: ${nodeId}>`;
67
66
  const hanging = remote ? null : hangingFor(node);
68
67
  const glyph = resolveNodeVisual(node.status, { hanging, frozen: isFrozenRow(node) }).glyph;
@@ -72,12 +71,12 @@ async function nodeLineFromSource(source, nodeId, indent, connector, remote, tic
72
71
  const faultSuffix = hanging !== null ? ` · ${faultSummary(hanging)}` : '';
73
72
  return `${indent}${connector}${glyph} ${fullName(node)} [${node.kind}/${node.mode}] ctx ${ctx}${askSuffix}${faultSuffix}`;
74
73
  }
75
- async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out, remote, tickets) {
74
+ async function walkTreeFromSource(source, nodes, nodeId, indent, isLast, visited, out, remote, tickets) {
76
75
  if (visited.has(nodeId))
77
76
  return;
78
77
  visited.add(nodeId);
79
78
  const connector = isLast ? '└─ ' : '├─ ';
80
- out.push(await nodeLineFromSource(source, nodeId, indent, connector, remote, tickets));
79
+ out.push(nodeLineFromSource(nodes.get(nodeId), nodeId, indent, connector, remote, tickets));
81
80
  const childIndent = indent + (isLast ? ' ' : '│ ');
82
81
  let children;
83
82
  try {
@@ -95,22 +94,25 @@ async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out,
95
94
  out.push(`${childIndent}${cycleConnector}↺ <cycle: ${child.node_id}>`);
96
95
  continue;
97
96
  }
98
- await walkTreeFromSource(source, child.node_id, childIndent, childIsLast, visited, out, remote, tickets);
97
+ await walkTreeFromSource(source, nodes, child.node_id, childIndent, childIsLast, visited, out, remote, tickets);
99
98
  }
100
99
  }
101
- export async function renderTreeFromSource(source, rootId) {
100
+ export async function renderTreeFromSource(source, rootId, snapshot, ticketSnapshot, ticketUnavailable) {
102
101
  const remote = source instanceof RemoteCanvasSource;
103
- const node = await source.getNode(rootId);
104
- if (node === null)
102
+ const nodes = new Map((snapshot ?? await source.listNodes()).map((node) => [node.node_id, node]));
103
+ const node = nodes.get(rootId);
104
+ if (node === undefined)
105
105
  return `? <missing node: ${rootId}>`;
106
- let tickets;
107
- let ticketsUnavailable = null;
108
- try {
109
- tickets = await source.ticketCountsForView(rootId);
110
- }
111
- catch (err) {
112
- tickets = {};
113
- ticketsUnavailable = err instanceof Error ? err.message : String(err);
106
+ let tickets = ticketSnapshot;
107
+ let ticketsUnavailable = ticketUnavailable ?? null;
108
+ if (tickets === undefined) {
109
+ try {
110
+ tickets = await source.ticketCountsForView(rootId);
111
+ }
112
+ catch (err) {
113
+ tickets = {};
114
+ ticketsUnavailable = err instanceof Error ? err.message : String(err);
115
+ }
114
116
  }
115
117
  const ctx = remote ? '0k' : fmtCtx(readNodeTelemetry(rootId).tokens_in);
116
118
  const asks = tickets[rootId] ?? 0;
@@ -132,14 +134,14 @@ export async function renderTreeFromSource(source, rootId) {
132
134
  for (let i = 0; i < children.length; i++) {
133
135
  const child = children[i];
134
136
  const isLast = i === children.length - 1;
135
- await walkTreeFromSource(source, child.node_id, '', isLast, visited, out, remote, tickets);
137
+ await walkTreeFromSource(source, nodes, child.node_id, '', isLast, visited, out, remote, tickets);
136
138
  }
137
139
  if (ticketsUnavailable !== null)
138
140
  out.push(`Tickets: unavailable (${ticketsUnavailable}).`);
139
141
  return out.join('\n');
140
142
  }
141
- export async function renderForestFromSource(source) {
142
- const all = await source.listNodes();
143
+ export async function renderForestFromSource(source, snapshot, ticketSnapshot, ticketUnavailable) {
144
+ const all = snapshot ?? await source.listNodes();
143
145
  if (all.length === 0)
144
146
  return '(canvas is empty)';
145
147
  const roots = all.filter((n) => n.parent === null && (n.status === 'active' || n.status === 'idle'));
@@ -147,7 +149,7 @@ export async function renderForestFromSource(source) {
147
149
  return '(no live roots)';
148
150
  const parts = [];
149
151
  for (const r of roots) {
150
- parts.push(await renderTreeFromSource(source, r.node_id));
152
+ parts.push(await renderTreeFromSource(source, r.node_id, all, ticketSnapshot, ticketUnavailable));
151
153
  }
152
154
  return parts.join('\n\n');
153
155
  }
@@ -563,44 +565,43 @@ export function dashboardRowFromNodeRow(row, focusedNodeIds, remote) {
563
565
  * entirely through the source — the openDb-free sibling of `dashboardRows`.
564
566
  * Ticket counts come from ONE `source.ticketCountsForView(rootId)` call (the
565
567
  * API/remote attention projection), never `countTickets` (openDb). */
566
- export async function dashboardRowsFromSource(source, rootId) {
568
+ export async function dashboardRowsFromSource(source, rootId, snapshot, ticketSnapshot) {
567
569
  const remote = source instanceof RemoteCanvasSource;
568
- const tickets = await source.ticketCountsForView(rootId).catch(() => ({}));
570
+ const tickets = ticketSnapshot ?? await source.ticketCountsForView(rootId).catch(() => ({}));
571
+ const nodes = new Map((snapshot ?? await source.listNodes()).map((node) => [node.node_id, node]));
569
572
  const ids = [rootId, ...(await source.view(rootId))];
570
- const rows = [];
571
- for (const id of ids) {
572
- const node = await source.getNode(id);
573
- if (node === null)
574
- continue;
573
+ return ids.flatMap((id) => {
574
+ const node = nodes.get(id);
575
+ if (node === undefined)
576
+ return [];
575
577
  const tel = remote ? {} : readNodeTelemetry(id);
576
- rows.push({
577
- node_id: id,
578
- name: fullName(node),
579
- handle: node.name,
580
- description: node.description,
581
- status: node.status,
582
- frozen_at: node.frozen_at ?? null,
583
- terminal_reason: node.terminal_reason ?? null,
584
- kind: node.kind,
585
- mode: node.mode,
586
- ctx_tokens: tel.tokens_in ?? 0,
587
- asks: tickets[id] ?? 0,
588
- cwd: node.cwd,
589
- profile_id: node.profile_id,
590
- created: node.created,
591
- });
592
- }
593
- return rows;
578
+ return [{
579
+ node_id: id,
580
+ name: fullName(node),
581
+ handle: node.name,
582
+ description: node.description,
583
+ status: node.status,
584
+ frozen_at: node.frozen_at ?? null,
585
+ terminal_reason: node.terminal_reason ?? null,
586
+ kind: node.kind,
587
+ mode: node.mode,
588
+ ctx_tokens: tel.tokens_in ?? 0,
589
+ asks: tickets[id] ?? 0,
590
+ cwd: node.cwd,
591
+ profile_id: node.profile_id,
592
+ created: node.created,
593
+ }];
594
+ });
594
595
  }
595
596
  /** CHEAP boot builder over a source — one row per node across the whole canvas,
596
597
  * fields that need no per-node meta/session read. Local API callers may supply
597
598
  * the node ids from one `listFocuses()` query so `viewed` remains visible without
598
599
  * coupling the canvas source to local viewer state. Remote sources ignore that
599
600
  * set. Local disk telemetry/fault/streaming are likewise suppressed remotely. */
600
- export async function dashboardRowsAllFromSource(source, localFocusedNodeIds = new Set(), liveOnly = false) {
601
+ export async function dashboardRowsAllFromSource(source, localFocusedNodeIds = new Set(), liveOnly = false, snapshot) {
601
602
  const remote = source instanceof RemoteCanvasSource;
602
603
  const focusedNodeIds = remote ? new Set() : localFocusedNodeIds;
603
- const rows = await source.listNodes(liveOnly ? { status: ['active', 'idle'] } : undefined);
604
+ const rows = snapshot ?? await source.listNodes(liveOnly ? { status: ['active', 'idle'] } : undefined);
604
605
  return rows.map((row) => dashboardRowFromNodeRow(row, focusedNodeIds, remote));
605
606
  }
606
607
  // Lazy enrichment (source-driven) — fold the per-node reads the cheap boot skips
@@ -9,6 +9,7 @@ export interface CanvasSource {
9
9
  subscriptionsOf(subscriber: string): Promise<SubscriptionRef[]>;
10
10
  subscribersOf(publisher: string): Promise<SubscriptionRef[]>;
11
11
  view(root: string): Promise<string[]>;
12
+ ticketCountsForNodes(nodeIds: readonly string[]): Promise<Record<string, number>>;
12
13
  ticketCountsForView(root: string): Promise<Record<string, number>>;
13
14
  hasActiveLiveSubscription(nodeId: string): Promise<boolean>;
14
15
  }
@@ -21,6 +22,7 @@ export declare class LocalCanvasSource implements CanvasSource {
21
22
  subscriptionsOf(subscriber: string): Promise<SubscriptionRef[]>;
22
23
  subscribersOf(publisher: string): Promise<SubscriptionRef[]>;
23
24
  view(root: string): Promise<string[]>;
25
+ ticketCountsForNodes(nodeIds: readonly string[]): Promise<Record<string, number>>;
24
26
  ticketCountsForView(root: string): Promise<Record<string, number>>;
25
27
  hasActiveLiveSubscription(nodeId: string): Promise<boolean>;
26
28
  }
@@ -22,8 +22,11 @@ export class LocalCanvasSource {
22
22
  async view(root) {
23
23
  return view(root);
24
24
  }
25
+ async ticketCountsForNodes(nodeIds) {
26
+ return ticketCountsForNodes([...nodeIds]);
27
+ }
25
28
  async ticketCountsForView(root) {
26
- return ticketCountsForNodes([root, ...view(root)]);
29
+ return this.ticketCountsForNodes([root, ...view(root)]);
27
30
  }
28
31
  async hasActiveLiveSubscription(nodeId) {
29
32
  return hasActiveLiveSubscription(nodeId);
@@ -1,9 +1,15 @@
1
1
  import type { PluginManifest } from '../../types.js';
2
2
  import { type HookManifestIssue, type ValidatedHookManifest } from './schema.js';
3
+ export interface LifecycleDeclarationCandidate {
4
+ event: string;
5
+ op: string;
6
+ index: number;
7
+ }
3
8
  export interface HookArtifactValidation {
4
9
  manifestPath?: string;
5
10
  executablePath?: string;
6
11
  manifest?: ValidatedHookManifest;
12
+ lifecycleCandidates: readonly LifecycleDeclarationCandidate[];
7
13
  issues: HookManifestIssue[];
8
14
  }
9
15
  /** Statically validates the local hook artifact a source plugin ships. */