@crouton-kit/crouter 0.3.60 → 0.3.62

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 (120) hide show
  1. package/dist/builtin-views/canvas/core.mjs +2 -1
  2. package/dist/clients/attach/__tests__/context-message.test.d.ts +1 -0
  3. package/dist/clients/attach/__tests__/context-message.test.js +46 -0
  4. package/dist/clients/attach/__tests__/transport-relay.test.d.ts +1 -0
  5. package/dist/clients/attach/__tests__/transport-relay.test.js +176 -0
  6. package/dist/clients/attach/attach-cmd.js +747 -743
  7. package/dist/clients/attach/canvas-panels.d.ts +4 -4
  8. package/dist/clients/attach/canvas-panels.js +108 -37
  9. package/dist/clients/attach/chat-view.js +11 -0
  10. package/dist/clients/attach/context-message.d.ts +3 -1
  11. package/dist/clients/attach/context-message.js +30 -21
  12. package/dist/clients/attach/graph-overlay.d.ts +8 -1
  13. package/dist/clients/attach/graph-overlay.js +201 -22
  14. package/dist/clients/attach/transport-relay.d.ts +51 -0
  15. package/dist/clients/attach/transport-relay.js +196 -0
  16. package/dist/clients/attach/transport-socket.d.ts +29 -0
  17. package/dist/clients/attach/transport-socket.js +164 -0
  18. package/dist/clients/attach/transport.d.ts +32 -0
  19. package/dist/clients/attach/transport.js +26 -0
  20. package/dist/clients/attach/view-socket.d.ts +40 -44
  21. package/dist/clients/attach/view-socket.js +94 -197
  22. package/dist/clients/web/server.js +1 -0
  23. package/dist/commands/memory/delete.js +3 -3
  24. package/dist/commands/memory/origin.js +1 -1
  25. package/dist/commands/memory/read.js +3 -3
  26. package/dist/commands/memory/shared.d.ts +8 -6
  27. package/dist/commands/memory/shared.js +23 -9
  28. package/dist/commands/memory/write.js +3 -3
  29. package/dist/commands/node-worktree.js +9 -9
  30. package/dist/commands/node.js +71 -23
  31. package/dist/commands/sys/doctor.js +1 -0
  32. package/dist/core/__tests__/broker-fork-seam.test.js +17 -2
  33. package/dist/core/__tests__/broker-snapshot-history.test.js +12 -2
  34. package/dist/core/__tests__/canvas-inbox-watcher.test.js +79 -0
  35. package/dist/core/__tests__/context-intro.test.js +11 -0
  36. package/dist/core/__tests__/fixtures/fake-engine.d.ts +9 -4
  37. package/dist/core/__tests__/fixtures/fake-engine.js +12 -4
  38. package/dist/core/__tests__/kickoff.test.js +21 -0
  39. package/dist/core/__tests__/spawn-root.test.js +60 -0
  40. package/dist/core/__tests__/worktree.test.js +24 -1
  41. package/dist/core/canvas/browse/app.d.ts +2 -0
  42. package/dist/core/canvas/browse/app.js +83 -48
  43. package/dist/core/canvas/db.js +3 -2
  44. package/dist/core/canvas/index.d.ts +1 -0
  45. package/dist/core/canvas/index.js +1 -0
  46. package/dist/core/canvas/nav-model.d.ts +5 -3
  47. package/dist/core/canvas/nav-model.js +7 -6
  48. package/dist/core/canvas/paths.d.ts +8 -0
  49. package/dist/core/canvas/paths.js +10 -0
  50. package/dist/core/canvas/render.d.ts +6 -0
  51. package/dist/core/canvas/render.js +117 -20
  52. package/dist/core/canvas/source.d.ts +26 -0
  53. package/dist/core/canvas/source.js +29 -0
  54. package/dist/core/canvas/status-glyph.js +1 -0
  55. package/dist/core/canvas/types.d.ts +4 -3
  56. package/dist/core/config.d.ts +18 -2
  57. package/dist/core/config.js +88 -10
  58. package/dist/core/fault-classifier.d.ts +2 -2
  59. package/dist/core/memory-resolver.d.ts +9 -6
  60. package/dist/core/memory-resolver.js +28 -4
  61. package/dist/core/profiles/__tests__/root-profile.test.d.ts +1 -0
  62. package/dist/core/profiles/__tests__/root-profile.test.js +69 -0
  63. package/dist/core/profiles/manifest.d.ts +2 -0
  64. package/dist/core/profiles/manifest.js +40 -7
  65. package/dist/core/profiles/select.d.ts +4 -4
  66. package/dist/core/profiles/select.js +13 -13
  67. package/dist/core/runtime/__tests__/spawn-env.test.d.ts +1 -0
  68. package/dist/core/runtime/__tests__/spawn-env.test.js +268 -0
  69. package/dist/core/runtime/bearings.d.ts +5 -4
  70. package/dist/core/runtime/bearings.js +12 -6
  71. package/dist/core/runtime/broker.js +59 -7
  72. package/dist/core/runtime/fault-recovery.js +4 -0
  73. package/dist/core/runtime/front-door.js +1 -1
  74. package/dist/core/runtime/host.js +43 -3
  75. package/dist/core/runtime/kickoff.js +6 -0
  76. package/dist/core/runtime/launch.d.ts +6 -1
  77. package/dist/core/runtime/launch.js +7 -1
  78. package/dist/core/runtime/nodes.d.ts +7 -6
  79. package/dist/core/runtime/nodes.js +3 -3
  80. package/dist/core/runtime/situational-context.d.ts +12 -0
  81. package/dist/core/runtime/situational-context.js +48 -0
  82. package/dist/core/runtime/spawn-env.d.ts +27 -0
  83. package/dist/core/runtime/spawn-env.js +169 -0
  84. package/dist/core/runtime/spawn.d.ts +16 -7
  85. package/dist/core/runtime/spawn.js +8 -2
  86. package/dist/core/substrate/session-cache.js +7 -1
  87. package/dist/core/substrate/subject.d.ts +1 -1
  88. package/dist/core/view/remote-canvas-target.d.ts +3 -0
  89. package/dist/core/view/remote-canvas-target.js +25 -0
  90. package/dist/core/worktree.d.ts +4 -4
  91. package/dist/core/worktree.js +41 -19
  92. package/dist/daemon/crtrd.js +32 -0
  93. package/dist/daemon/predicate-eval.js +12 -1
  94. package/dist/index.d.ts +1 -0
  95. package/dist/index.js +4 -0
  96. package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -0
  97. package/dist/pi-extensions/canvas-bash-valve.js +238 -0
  98. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +12 -0
  99. package/dist/pi-extensions/canvas-inbox-watcher.js +41 -5
  100. package/dist/types.d.ts +32 -0
  101. package/dist/types.js +7 -0
  102. package/dist/web-client/apple-touch-icon.png +0 -0
  103. package/dist/web-client/assets/index-BnmSLNLa.css +2 -0
  104. package/dist/web-client/assets/index-BvzxXXGU.js +77 -0
  105. package/dist/web-client/assets/workbox-window.prod.es5-Bd17z0YL.js +2 -0
  106. package/dist/web-client/icon-192.png +0 -0
  107. package/dist/web-client/icon-512.png +0 -0
  108. package/dist/web-client/icon-maskable-512.png +0 -0
  109. package/dist/web-client/index.html +6 -3
  110. package/dist/web-client/manifest.webmanifest +1 -0
  111. package/dist/web-client/sw.js +1 -0
  112. package/docs/public-api.md +5 -3
  113. package/package.json +4 -3
  114. package/dist/web-client/assets/fraunces-latin-ext-wght-normal-Ca2vKHc0.woff2 +0 -0
  115. package/dist/web-client/assets/fraunces-latin-wght-normal-ukD16Tqj.woff2 +0 -0
  116. package/dist/web-client/assets/fraunces-vietnamese-wght-normal-CnvboYUG.woff2 +0 -0
  117. package/dist/web-client/assets/index-72YQdji_.js +0 -77
  118. package/dist/web-client/assets/index-bDhImjJy.css +0 -2
  119. package/dist/web-client/assets/instrument-sans-latin-ext-wght-normal-B5bTHO_g.woff2 +0 -0
  120. package/dist/web-client/assets/instrument-sans-latin-wght-normal-BbzFLZTg.woff2 +0 -0
@@ -12,6 +12,7 @@ import { nodeDir } from '../canvas/paths.js';
12
12
  import { spawnNode } from '../runtime/nodes.js';
13
13
  import { spawnChild, resolveSpawner } from '../runtime/spawn.js';
14
14
  import { headlessBrokerHost } from '../runtime/host.js';
15
+ import { ROOT_PROFILE_ID } from '../profiles/manifest.js';
15
16
  let home;
16
17
  function spawner(id) {
17
18
  return {
@@ -104,3 +105,62 @@ test('spawnChild crashes the node when launch/preflight throws', async () => {
104
105
  rmSync(home, { recursive: true, force: true });
105
106
  }
106
107
  });
108
+ test('spawnChild: a root from a shell gets the stable root profile id', async () => {
109
+ const previousHome = process.env.HOME;
110
+ const previousNodeId = process.env.CRTR_NODE_ID;
111
+ const previousTmux = process.env.TMUX;
112
+ const previousTmuxPane = process.env.TMUX_PANE;
113
+ const env = process.env;
114
+ const localHome = mkdtempSync(join(tmpdir(), 'crtr-spawn-root-home-'));
115
+ const localCwd = mkdtempSync(join(tmpdir(), 'crtr-spawn-root-cwd-'));
116
+ const originalLaunch = headlessBrokerHost.launch;
117
+ const previousStdinTty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
118
+ const previousStdoutTty = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY');
119
+ try {
120
+ process.env.HOME = localHome;
121
+ Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true, writable: true });
122
+ Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true, writable: true });
123
+ Reflect.deleteProperty(env, 'CRTR_NODE_ID');
124
+ delete process.env.TMUX;
125
+ delete process.env.TMUX_PANE;
126
+ headlessBrokerHost.launch = (_nodeId, _inv, _opts) => ({
127
+ pid: 4242,
128
+ exited: Promise.resolve(),
129
+ });
130
+ const result = await spawnChild({ kind: 'developer', cwd: localCwd, root: true, prompt: 'task' });
131
+ const node = getNode(result.node.node_id);
132
+ assert.equal(node.profile_id, ROOT_PROFILE_ID, 'the root spawn persists the stable root profile id');
133
+ assert.equal(node.parent, null, 'a shell root has no spine parent');
134
+ assert.equal(node.lifecycle, 'resident', 'a shell root stays resident');
135
+ assert.equal(result.node.profile_id, ROOT_PROFILE_ID, 'the returned node view carries the stable root profile id');
136
+ }
137
+ finally {
138
+ headlessBrokerHost.launch = originalLaunch;
139
+ if (previousHome === undefined)
140
+ delete process.env.HOME;
141
+ else
142
+ process.env.HOME = previousHome;
143
+ if (previousNodeId === undefined)
144
+ Reflect.deleteProperty(env, 'CRTR_NODE_ID');
145
+ else
146
+ env.CRTR_NODE_ID = previousNodeId;
147
+ if (previousTmux === undefined)
148
+ delete process.env.TMUX;
149
+ else
150
+ process.env.TMUX = previousTmux;
151
+ if (previousTmuxPane === undefined)
152
+ delete process.env.TMUX_PANE;
153
+ else
154
+ process.env.TMUX_PANE = previousTmuxPane;
155
+ if (previousStdinTty === undefined)
156
+ Reflect.deleteProperty(process.stdin, 'isTTY');
157
+ else
158
+ Object.defineProperty(process.stdin, 'isTTY', previousStdinTty);
159
+ if (previousStdoutTty === undefined)
160
+ Reflect.deleteProperty(process.stdout, 'isTTY');
161
+ else
162
+ Object.defineProperty(process.stdout, 'isTTY', previousStdoutTty);
163
+ rmSync(localHome, { recursive: true, force: true });
164
+ rmSync(localCwd, { recursive: true, force: true });
165
+ }
166
+ });
@@ -32,7 +32,8 @@ function node(id, over = {}) {
32
32
  before(() => {
33
33
  home = mkdtempSync(join(tmpdir(), 'crtr-worktree-home-'));
34
34
  process.env['CRTR_HOME'] = home;
35
- // Bare "remote" the source repo pushes to, so `origin/main` resolves.
35
+ // Bare "remote" wired up as origin so the test can prove landing NEVER pushes
36
+ // to it (managed worktrees are based on and land onto local `main` only).
36
37
  remoteDir = mkdtempSync(join(tmpdir(), 'crtr-worktree-remote-'));
37
38
  run(['init', '--bare'], remoteDir);
38
39
  sourceDir = mkdtempSync(join(tmpdir(), 'crtr-worktree-source-'));
@@ -83,3 +84,25 @@ test('closeManagedWorktree recreates the worktree path as a tombstone dir so the
83
84
  const meta = getNode(nodeId);
84
85
  assert.equal(meta?.managed_worktree?.state, 'closed');
85
86
  });
87
+ // Landing is purely local: a commit made in the managed worktree must advance
88
+ // the source repo's LOCAL main to that commit, and origin/main must stay put
89
+ // (close never pushes to origin).
90
+ test('closeManagedWorktree fast-forwards local main to the worktree tip without touching origin', () => {
91
+ const nodeId = 'wt-close-2';
92
+ const originMainBefore = gitSync(['rev-parse', 'origin/main'], sourceDir).stdout.trim();
93
+ const wt = createManagedWorktree(sourceDir, nodeId);
94
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
95
+ // Commit real work inside the managed worktree.
96
+ writeFileSync(join(wt.path, 'feature.txt'), 'work\n');
97
+ run(['add', '.'], wt.path);
98
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'feature'], wt.path);
99
+ const workSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
100
+ const result = closeManagedWorktree(nodeId);
101
+ assert.equal(result.landed_sha, workSha, 'landed_sha should be the committed work');
102
+ // Local main in the source (primary) checkout advanced to the work.
103
+ const localMainAfter = gitSync(['rev-parse', 'main'], sourceDir).stdout.trim();
104
+ assert.equal(localMainAfter, workSha, 'local main must fast-forward to the worktree tip');
105
+ // origin/main is untouched — nothing was pushed.
106
+ const originMainAfter = gitSync(['rev-parse', 'origin/main'], sourceDir).stdout.trim();
107
+ assert.equal(originMainAfter, originMainBefore, 'origin/main must not move — landing is local-only');
108
+ });
@@ -1,4 +1,6 @@
1
+ import { type CanvasSource } from '../source.js';
1
2
  export declare function runBrowse(opts?: {
2
3
  returnPane?: string;
3
4
  cwd?: string;
5
+ source?: CanvasSource;
4
6
  }): Promise<void>;
@@ -9,8 +9,8 @@
9
9
  // `pi --session` directly (see reviveNode() in revive.ts for the desync hazard).
10
10
  import { execFileSync } from 'node:child_process';
11
11
  import { resolve } from 'node:path';
12
- import { dashboardRowsAll, enrichRows, loadPreview, renderForest } from '../render.js';
13
- import { listNodes, subscriptionsOf, getNode } from '../canvas.js';
12
+ import { dashboardRowsAllFromSource, enrichRowsFromSource, loadPreviewFromSource, renderForestFromSource } from '../render.js';
13
+ import { localCanvasSource } from '../source.js';
14
14
  import { closeNode } from '../../runtime/close.js';
15
15
  import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress, } from '../../tui/terminal.js';
16
16
  import { buildTree, flatten, pruneNode, TABS } from './model.js';
@@ -27,19 +27,25 @@ function viewportHeight(rowsTotal, search, previewOn) {
27
27
  return Math.max(1, rows - headerHeight(search) - 1 /* footer */ - previewH);
28
28
  }
29
29
  export async function runBrowse(opts = {}) {
30
+ const source = opts.source ?? localCanvasSource;
30
31
  // No TTY → print the static forest and exit 0 (no raw mode).
31
32
  if (!process.stdin.isTTY) {
32
- process.stdout.write(renderForest() + '\n');
33
+ process.stdout.write(await renderForestFromSource(source) + '\n');
33
34
  return;
34
35
  }
35
36
  // Snapshot the canvas. Drop kind:'human' control-plane decks — they have no pi
36
37
  // session, so `node focus` refuses them; they are never a navigation/resume
37
38
  // target (mirrors the node focus guard).
38
- const rows = dashboardRowsAll().filter((r) => r.kind !== 'human');
39
- const rootIds = listNodes()
39
+ const rows = (await dashboardRowsAllFromSource(source)).filter((r) => r.kind !== 'human');
40
+ const nodeRows = await source.listNodes();
41
+ const rootIds = nodeRows
40
42
  .filter((n) => n.parent === null && n.kind !== 'human')
41
43
  .map((n) => n.node_id);
42
- const tree = buildTree(rows, rootIds, (id) => subscriptionsOf(id).map((s) => s.node_id));
44
+ const childIds = new Map();
45
+ await Promise.all(nodeRows.map(async (n) => {
46
+ childIds.set(n.node_id, (await source.subscriptionsOf(n.node_id).catch(() => [])).map((s) => s.node_id));
47
+ }));
48
+ const tree = buildTree(rows, rootIds, (id) => childIds.get(id) ?? []);
43
49
  const totalNodes = tree.nodes.size;
44
50
  // Default cwd scope = the dir browse was launched from (the request). The popup
45
51
  // / command passes --cwd; resolve it so it compares cleanly against stored cwds.
@@ -80,6 +86,9 @@ export async function runBrowse(opts = {}) {
80
86
  // Safety net: an uncaught throw in the (un-unit-tested) keystroke path must
81
87
  // never strand the tty in raw + alt-screen + hidden-cursor.
82
88
  process.once('exit', cleanup);
89
+ const logAsyncFailure = (label, err) => {
90
+ process.stderr.write(`[crtr browse] ${label}: ${String(err?.message ?? err)}\n`);
91
+ };
83
92
  /** On-demand kick of a hanging node: shell `node lifecycle revive <id> --now`
84
93
  * (which SIGTERMs the live broker — the daemon resumes it). Synchronous +
85
94
  * quiet (stdio ignored) so it never takes over the TUI; best-effort. */
@@ -123,7 +132,7 @@ export async function runBrowse(opts = {}) {
123
132
  if (state.cursor < 0)
124
133
  state.cursor = 0;
125
134
  };
126
- const flush = () => {
135
+ const flush = async () => {
127
136
  const size = getTerminalSize();
128
137
  const previewOn = state.preview && visible.length > 0;
129
138
  const viewport = viewportHeight(size.rows, state.search, previewOn);
@@ -145,12 +154,12 @@ export async function runBrowse(opts = {}) {
145
154
  if (r !== undefined)
146
155
  slice.push(r);
147
156
  }
148
- enrichRows(slice);
157
+ await enrichRowsFromSource(source, slice);
149
158
  const cur = curRow();
150
159
  if (cur !== undefined) {
151
160
  const r = rowOf(cur.id);
152
161
  if (r !== undefined)
153
- loadPreview(r);
162
+ await loadPreviewFromSource(source, r);
154
163
  }
155
164
  const frame = renderFrame({
156
165
  tree, visible, tab: state.tab, cursor: state.cursor, scrollOffset: state.scrollOffset,
@@ -189,7 +198,7 @@ export async function runBrowse(opts = {}) {
189
198
  // subtree, tear down each engine, and detach every attached viewer. Then reflect
190
199
  // the new terminal statuses back into the live snapshot so the row updates in
191
200
  // place (no re-snapshot). Best-effort: a teardown failure never wedges the picker.
192
- const doClose = (id) => {
201
+ const doClose = async (id) => {
193
202
  state.pendingClose = null;
194
203
  let closed = [];
195
204
  try {
@@ -199,7 +208,7 @@ export async function runBrowse(opts = {}) {
199
208
  return; // unknown/already-gone node — nothing to reflect
200
209
  }
201
210
  for (const cid of closed) {
202
- const m = getNode(cid);
211
+ const m = await source.getNode(cid);
203
212
  if (m === null) {
204
213
  // Reaped, not parked: an EMPTY node (0k ctx, no assistant message) is
205
214
  // hard-deleted by closeNode (row + dir gone). Splice it out of the tree so
@@ -258,6 +267,30 @@ export async function runBrowse(opts = {}) {
258
267
  // phase) has appended its chars, so N rapid keys collapse to ONE recompute+paint
259
268
  // and no keystroke is ignored. The sort may still take a moment — that's fine.
260
269
  let searchRepaintScheduled = false;
270
+ let flushInFlight = false;
271
+ let flushQueued = false;
272
+ const scheduleFlush = () => {
273
+ flushQueued = true;
274
+ if (flushInFlight)
275
+ return;
276
+ flushInFlight = true;
277
+ void (async () => {
278
+ try {
279
+ while (flushQueued && !restored) {
280
+ flushQueued = false;
281
+ await flush();
282
+ }
283
+ }
284
+ finally {
285
+ flushInFlight = false;
286
+ if (flushQueued && !restored)
287
+ scheduleFlush();
288
+ }
289
+ })().catch(() => {
290
+ cleanup();
291
+ process.exit(1);
292
+ });
293
+ };
261
294
  const scheduleSearchRepaint = () => {
262
295
  if (searchRepaintScheduled)
263
296
  return;
@@ -265,7 +298,7 @@ export async function runBrowse(opts = {}) {
265
298
  setImmediate(() => {
266
299
  searchRepaintScheduled = false;
267
300
  recompute();
268
- flush();
301
+ scheduleFlush();
269
302
  });
270
303
  };
271
304
  const onKeySearch = (input, key) => {
@@ -276,7 +309,7 @@ export async function runBrowse(opts = {}) {
276
309
  state.query = '';
277
310
  state.sort = 'tree';
278
311
  recompute();
279
- flush();
312
+ scheduleFlush();
280
313
  return;
281
314
  }
282
315
  if (key.return) {
@@ -288,7 +321,7 @@ export async function runBrowse(opts = {}) {
288
321
  const firstMatch = visible.findIndex((v) => v.matched);
289
322
  if (firstMatch >= 0)
290
323
  state.cursor = firstMatch;
291
- flush();
324
+ scheduleFlush();
292
325
  return;
293
326
  }
294
327
  if (key.backspace) {
@@ -325,13 +358,13 @@ export async function runBrowse(opts = {}) {
325
358
  // other key so the confirm is modal.
326
359
  if (state.pendingClose !== null) {
327
360
  if (input === 'y' || input === 'Y') {
328
- doClose(state.pendingClose);
329
- flush();
361
+ const target = state.pendingClose;
362
+ void doClose(target).then(() => scheduleFlush()).catch(() => scheduleFlush());
330
363
  return;
331
364
  }
332
365
  // n / Esc / anything else cancels the close.
333
366
  state.pendingClose = null;
334
- flush();
367
+ scheduleFlush();
335
368
  return;
336
369
  }
337
370
  const row = curRow();
@@ -341,22 +374,22 @@ export async function runBrowse(opts = {}) {
341
374
  // Move.
342
375
  if (key.upArrow || input === 'k') {
343
376
  state.cursor = Math.max(0, state.cursor - 1);
344
- flush();
377
+ scheduleFlush();
345
378
  return;
346
379
  }
347
380
  if (key.downArrow || input === 'j') {
348
381
  state.cursor = Math.max(0, Math.min(visible.length - 1, state.cursor + 1));
349
- flush();
382
+ scheduleFlush();
350
383
  return;
351
384
  }
352
385
  if (input === 'g') {
353
386
  state.cursor = 0;
354
- flush();
387
+ scheduleFlush();
355
388
  return;
356
389
  }
357
390
  if (input === 'G') {
358
391
  state.cursor = Math.max(0, visible.length - 1);
359
- flush();
392
+ scheduleFlush();
360
393
  return;
361
394
  }
362
395
  // Expand / descend.
@@ -370,7 +403,7 @@ export async function runBrowse(opts = {}) {
370
403
  state.cursor += 1; // already expanded → step onto first child
371
404
  }
372
405
  }
373
- flush();
406
+ scheduleFlush();
374
407
  return;
375
408
  }
376
409
  // Collapse / ascend.
@@ -387,7 +420,7 @@ export async function runBrowse(opts = {}) {
387
420
  state.cursor = idx;
388
421
  }
389
422
  }
390
- flush();
423
+ scheduleFlush();
391
424
  return;
392
425
  }
393
426
  // Toggle collapse.
@@ -399,18 +432,18 @@ export async function runBrowse(opts = {}) {
399
432
  state.collapsed.delete(row.id);
400
433
  recompute(row.id);
401
434
  }
402
- flush();
435
+ scheduleFlush();
403
436
  return;
404
437
  }
405
438
  // Tabs.
406
439
  if (key.tab || input === ']') {
407
440
  cycleTab(1);
408
- flush();
441
+ scheduleFlush();
409
442
  return;
410
443
  }
411
444
  if (key.shiftTab || input === '[') {
412
445
  cycleTab(-1);
413
- flush();
446
+ scheduleFlush();
414
447
  return;
415
448
  }
416
449
  if (input >= '1' && input <= '4') {
@@ -421,40 +454,43 @@ export async function runBrowse(opts = {}) {
421
454
  state.scrollOffset = 0;
422
455
  recompute();
423
456
  }
424
- flush();
457
+ scheduleFlush();
425
458
  return;
426
459
  }
427
460
  // Close-out the selected node (+ its exclusive subtree). If anything in that
428
461
  // subtree is actively streaming, confirm first (y/n); otherwise close at once.
429
462
  if (input === 'x') {
430
- if (row !== undefined) {
431
- if (anyStreaming(row.id))
432
- state.pendingClose = row.id;
433
- else
434
- doClose(row.id);
463
+ if (row === undefined) {
464
+ scheduleFlush();
465
+ return;
466
+ }
467
+ if (anyStreaming(row.id)) {
468
+ state.pendingClose = row.id;
469
+ scheduleFlush();
470
+ return;
435
471
  }
436
- flush();
472
+ void doClose(row.id).then(() => scheduleFlush()).catch(() => scheduleFlush());
437
473
  return;
438
474
  }
439
475
  // Sort / scope / residents / preview.
440
476
  if (input === 's') {
441
477
  cycleSort();
442
- flush();
478
+ scheduleFlush();
443
479
  return;
444
480
  }
445
481
  if (input === 'c') {
446
482
  toggleScope();
447
- flush();
483
+ scheduleFlush();
448
484
  return;
449
485
  }
450
486
  if (input === 'r') {
451
487
  toggleResidents();
452
- flush();
488
+ scheduleFlush();
453
489
  return;
454
490
  }
455
491
  if (input === 'p') {
456
492
  state.preview = !state.preview;
457
- flush();
493
+ scheduleFlush();
458
494
  return;
459
495
  }
460
496
  // Search. Starting a search ranks by relevance (decision) so the best prompt/
@@ -466,7 +502,7 @@ export async function runBrowse(opts = {}) {
466
502
  state.cursor = 0;
467
503
  state.scrollOffset = 0;
468
504
  recompute();
469
- flush();
505
+ scheduleFlush();
470
506
  return;
471
507
  }
472
508
  // Revive now (on-demand kick): for a HANGING node, SIGTERM the live broker so
@@ -480,7 +516,7 @@ export async function runBrowse(opts = {}) {
480
516
  if (r?.hanging != null)
481
517
  reviveNow(row.id);
482
518
  }
483
- flush();
519
+ scheduleFlush();
484
520
  return;
485
521
  }
486
522
  // Resume.
@@ -516,7 +552,7 @@ export async function runBrowse(opts = {}) {
516
552
  recompute(selfId);
517
553
  }
518
554
  setupTerminal();
519
- flush();
555
+ await flush();
520
556
  // Background corpus warmer: after the instant first paint, progressively enrich
521
557
  // + load-preview every row (in small chunks, off the event loop) so the prompt
522
558
  // super-search corpus lights up shortly after the instant name/kind/id search.
@@ -525,24 +561,23 @@ export async function runBrowse(opts = {}) {
525
561
  const warmRows = [...tree.nodes.values()].map((n) => n.row);
526
562
  let warmIdx = 0;
527
563
  const WARM_CHUNK = 30;
528
- const warmChunk = () => {
564
+ const warmChunk = async () => {
529
565
  if (restored)
530
566
  return;
531
567
  const end = Math.min(warmIdx + WARM_CHUNK, warmRows.length);
532
568
  const slice = warmRows.slice(warmIdx, end);
533
- enrichRows(slice);
534
- for (const r of slice)
535
- loadPreview(r);
569
+ await enrichRowsFromSource(source, slice);
570
+ await Promise.all(slice.map((r) => loadPreviewFromSource(source, r)));
536
571
  warmIdx = end;
537
572
  // A live search may match the freshly-warmed prompts → recompute + repaint.
538
573
  if (state.search && state.query !== '') {
539
574
  recompute(curRow()?.id);
540
- flush();
575
+ scheduleFlush();
541
576
  }
542
577
  if (warmIdx < warmRows.length)
543
- setImmediate(warmChunk);
578
+ setImmediate(() => { void warmChunk().catch((err) => logAsyncFailure('background warm failed', err)); });
544
579
  };
545
- setImmediate(warmChunk);
580
+ setImmediate(() => { void warmChunk().catch((err) => logAsyncFailure('background warm failed', err)); });
546
581
  await new Promise(() => {
547
582
  const onData = (data) => {
548
583
  try {
@@ -559,6 +594,6 @@ export async function runBrowse(opts = {}) {
559
594
  }
560
595
  };
561
596
  process.stdin.on('data', onData);
562
- process.stdout.on('resize', flush);
597
+ process.stdout.on('resize', scheduleFlush);
563
598
  });
564
599
  }
@@ -353,8 +353,9 @@ CREATE TABLE IF NOT EXISTS canvas_meta (
353
353
  * (`<slug>-<id>`) a node runs under, chosen once at spawn (inherited from the
354
354
  * spawner, or `--profile`). Durable IDENTITY mirrored from meta.json like
355
355
  * `cwd`/`host_kind` (NOT runtime like `pane`), so it survives an index rebuild
356
- * via the `IDENTITY_KEYS`/`upsertRow` path. Defaults NULL (root/no-profile);
357
- * no data backfill. Additive, forward-only. */
356
+ * via the `IDENTITY_KEYS`/`upsertRow` path. Defaults NULL for historical
357
+ * root/no-profile rows; new root conversations use the stable root profile
358
+ * id instead. No data backfill. Additive, forward-only. */
358
359
  function addProfileIdColumn(db) {
359
360
  const columns = nodeColumns(db);
360
361
  addNodeColumnIfMissing(db, columns, 'profile_id', `ALTER TABLE nodes ADD COLUMN profile_id TEXT;`);
@@ -2,6 +2,7 @@ export * from './types.js';
2
2
  export * from './labels.js';
3
3
  export * from './paths.js';
4
4
  export * from './canvas.js';
5
+ export * from './source.js';
5
6
  export * from './history.js';
6
7
  export * from './focuses.js';
7
8
  export * from './triggers.js';
@@ -4,6 +4,7 @@ export * from './types.js';
4
4
  export * from './labels.js';
5
5
  export * from './paths.js';
6
6
  export * from './canvas.js';
7
+ export * from './source.js';
7
8
  export * from './history.js';
8
9
  export * from './focuses.js';
9
10
  export * from './triggers.js';
@@ -91,14 +91,16 @@ export declare function isHumanAsk(id: string): boolean;
91
91
  export declare function convoChildIds(id: string): string[];
92
92
  /** Live reports (active|idle) of a node — the DOWN set in BASE. */
93
93
  export declare function liveReports(id: string): string[];
94
- /** Direct navigable children — used for the ⤳ badge and fold counts (human-ask
95
- * nodes excluded, so the count matches what the tree actually shows). */
94
+ /** Direct navigable children — used for the ⤳ badge (human-ask nodes excluded,
95
+ * so the count matches what the tree actually shows). */
96
96
  export declare function childCount(id: string): number;
97
97
  /** Climb first-manager edges from `self` to the ancestry root (cycle-guarded). */
98
98
  export declare function climbRoot(self: string): string;
99
99
  /** Space-joined ids of a node's subtree (cursor-relative {subtree} var). */
100
100
  export declare function subtreeIds(root: string): string[];
101
- /** ⤳M direct-children badge — only on orchestrator rows. */
101
+ /** ⤳M direct-children badge — the single child-count signal, shown on any row
102
+ * with navigable children (folded or not). In GRAPH the ▸ caret separately marks
103
+ * a collapsed row, so this stays a pure count and never doubles as a fold badge. */
102
104
  export declare function childBadge(node: NodeMeta | null): string;
103
105
  /** ⚑K pending-asks badge for a node, read from the supplied per-node ask map. */
104
106
  export declare function askBadge(id: string, asks: Record<string, number>): string;
@@ -322,8 +322,8 @@ export function liveReports(id) {
322
322
  return st === 'active' || st === 'idle';
323
323
  });
324
324
  }
325
- /** Direct navigable children — used for the ⤳ badge and fold counts (human-ask
326
- * nodes excluded, so the count matches what the tree actually shows). */
325
+ /** Direct navigable children — used for the ⤳ badge (human-ask nodes excluded,
326
+ * so the count matches what the tree actually shows). */
327
327
  export function childCount(id) {
328
328
  return convoChildIds(id).length;
329
329
  }
@@ -360,9 +360,11 @@ export function subtreeIds(root) {
360
360
  // ---------------------------------------------------------------------------
361
361
  // Shared cell builders
362
362
  // ---------------------------------------------------------------------------
363
- /** ⤳M direct-children badge — only on orchestrator rows. */
363
+ /** ⤳M direct-children badge — the single child-count signal, shown on any row
364
+ * with navigable children (folded or not). In GRAPH the ▸ caret separately marks
365
+ * a collapsed row, so this stays a pure count and never doubles as a fold badge. */
364
366
  export function childBadge(node) {
365
- if (node === null || node.mode !== 'orchestrator')
367
+ if (node === null)
366
368
  return '';
367
369
  const m = childCount(node.node_id);
368
370
  return m > 0 ? ` ${DIM}⤳${m}${RESET}` : '';
@@ -484,10 +486,9 @@ export function renderGraphRow(r, isCursor, focused, activeBelow, asks) {
484
486
  // reads purely as "this unfolds".
485
487
  const expandable = r.hasKids && r.collapsed;
486
488
  const caret = !isCursor && expandable ? `${DIM}▸${RESET} ` : ' ';
487
- const fold = expandable ? ` ${DIM}[+${childCount(r.id)}]${RESET}` : '';
488
489
  const fault = node !== null ? activeFaultForDisplay(node, readFault(r.id)) : null;
489
490
  const faultSuffix = fault !== null ? ` · ${faultSummary(fault)}` : '';
490
- const line = `${r.branch}${caret}${dot} ${name} ${kind} ${tokens}${cycleBadge(node)}${childBadge(node)}${liveBelowBadge(node, activeBelow)}${fold}${askBadge(r.id, asks)}${activityCell(r.id, node)}${faultSuffix}`;
491
+ const line = `${r.branch}${caret}${dot} ${name} ${kind} ${tokens}${cycleBadge(node)}${childBadge(node)}${liveBelowBadge(node, activeBelow)}${askBadge(r.id, asks)}${activityCell(r.id, node)}${faultSuffix}`;
491
492
  return wrap(line, isAttached(r.id, node, focused));
492
493
  }
493
494
  /** Total lines the GRAPH widget may emit. pi hard-caps extension widgets at
@@ -45,6 +45,14 @@ export declare function reportsDir(nodeId: string): string;
45
45
  export declare function messagesDir(nodeId: string): string;
46
46
  export declare function nodeMetaPath(nodeId: string): string;
47
47
  export declare function inboxPath(nodeId: string): string;
48
+ /** The node-owned ambient "current situation" sidecar — the latest hidden
49
+ * `<situational-context>` text injected into bearings/kickoff (see
50
+ * `runtime/situational-context.ts`), upserted in place rather than appended so
51
+ * only the newest applet/situation state is ever carried. Lives beside
52
+ * meta.json/inbox.jsonl (node-runtime state) rather than under context/ (the
53
+ * user-authored artifact store), because it is ambient machine state, not a
54
+ * document a human wrote. */
55
+ export declare function situationalContextPath(nodeId: string): string;
48
56
  /** Passive-subscription accumulator. Pushes from publishers this node subscribes
49
57
  * to PASSIVELY land here instead of inbox.jsonl — the inbox-watcher never polls
50
58
  * it, so they never wake the node. Drained as XML pre-text on the next message. */
@@ -103,6 +103,16 @@ export function nodeMetaPath(nodeId) {
103
103
  export function inboxPath(nodeId) {
104
104
  return join(nodeDir(nodeId), 'inbox.jsonl');
105
105
  }
106
+ /** The node-owned ambient "current situation" sidecar — the latest hidden
107
+ * `<situational-context>` text injected into bearings/kickoff (see
108
+ * `runtime/situational-context.ts`), upserted in place rather than appended so
109
+ * only the newest applet/situation state is ever carried. Lives beside
110
+ * meta.json/inbox.jsonl (node-runtime state) rather than under context/ (the
111
+ * user-authored artifact store), because it is ambient machine state, not a
112
+ * document a human wrote. */
113
+ export function situationalContextPath(nodeId) {
114
+ return join(nodeDir(nodeId), 'situational-context.md');
115
+ }
106
116
  /** Passive-subscription accumulator. Pushes from publishers this node subscribes
107
117
  * to PASSIVELY land here instead of inbox.jsonl — the inbox-watcher never polls
108
118
  * it, so they never wake the node. Drained as XML pre-text on the next message. */
@@ -1,3 +1,4 @@
1
+ import type { CanvasSource } from './source.js';
1
2
  import { type Fault } from '../runtime/fault.js';
2
3
  import type { NodeStatus, Lifecycle } from './types.js';
3
4
  export declare function activeFaultForDisplay<T extends {
@@ -20,6 +21,8 @@ export declare function renderTree(rootId: string): string;
20
21
  * If there are no roots on the canvas, returns a placeholder string.
21
22
  */
22
23
  export declare function renderForest(): string;
24
+ export declare function renderTreeFromSource(source: CanvasSource, rootId: string): Promise<string>;
25
+ export declare function renderForestFromSource(source: CanvasSource): Promise<string>;
23
26
  export interface DashboardRow {
24
27
  node_id: string;
25
28
  name: string;
@@ -116,6 +119,7 @@ export declare function dashboardRows(rootId: string): DashboardRow[];
116
119
  * viewport on demand. See {@link enrichRow}, {@link enrichRows}, {@link loadPreview}.
117
120
  */
118
121
  export declare function dashboardRowsAll(): DashboardRow[];
122
+ export declare function dashboardRowsAllFromSource(source: CanvasSource): Promise<DashboardRow[]>;
119
123
  /** Fold the cheap-boot row's deferred fields in for ONE row: the full label
120
124
  * (meta.description → fullName), ctx tokens (telemetry.json), and ⚑ asks. One
121
125
  * meta read + one telemetry read + one ask scan. Idempotent (no-op once enriched).
@@ -125,9 +129,11 @@ export declare function enrichRow(row: DashboardRow): DashboardRow;
125
129
  * distinct cwd's ask inbox exactly ONCE (via `asksForNodes`) instead of per row.
126
130
  * Use this for a whole viewport / the full forest; mutates each row in place. */
127
131
  export declare function enrichRows(rows: DashboardRow[]): void;
132
+ export declare function enrichRowsFromSource(source: CanvasSource, rows: DashboardRow[]): Promise<void>;
128
133
  /** Load the SELECTED row's preview text: the spawn `goal` (initial-prompt.md) plus
129
134
  * the whole-conversation `prompts` and the `lastAssistant` reply — the latter two
130
135
  * folded into ONE session-file read (see {@link readSessionParts}). Mutates the row
131
136
  * in place; idempotent. Call only for the cursor row (and lazily/in-background to
132
137
  * warm the prompt super-search corpus), never on the boot path. */
133
138
  export declare function loadPreview(row: DashboardRow): DashboardRow;
139
+ export declare function loadPreviewFromSource(source: CanvasSource, row: DashboardRow): Promise<DashboardRow>;