@crouton-kit/crouter 0.3.65 → 0.3.67

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 (83) hide show
  1. package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -1
  2. package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -0
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +24 -12
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +41 -16
  5. package/dist/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
  6. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
  7. package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
  8. package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
  9. package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
  10. package/dist/clients/attach/attach-cmd.d.ts +30 -2
  11. package/dist/clients/attach/attach-cmd.js +632 -632
  12. package/dist/clients/attach/canvas-panels.js +9 -3
  13. package/dist/clients/attach/graph-overlay.d.ts +5 -0
  14. package/dist/clients/attach/graph-overlay.js +65 -11
  15. package/dist/clients/attach/input-controller.d.ts +9 -0
  16. package/dist/clients/attach/input-controller.js +47 -8
  17. package/dist/clients/attach/slash-commands.d.ts +11 -4
  18. package/dist/clients/attach/slash-commands.js +44 -9
  19. package/dist/clients/attach/transport-relay.d.ts +5 -5
  20. package/dist/clients/attach/transport-relay.js +3 -30
  21. package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
  22. package/dist/commands/__tests__/canvas-config.test.js +107 -0
  23. package/dist/commands/canvas-browse.js +26 -1
  24. package/dist/commands/canvas-config.d.ts +2 -0
  25. package/dist/commands/canvas-config.js +210 -0
  26. package/dist/commands/canvas-use.d.ts +1 -0
  27. package/dist/commands/canvas-use.js +63 -0
  28. package/dist/commands/canvas.js +4 -2
  29. package/dist/commands/node-lifecycle-revive.js +9 -2
  30. package/dist/commands/node.js +41 -11
  31. package/dist/core/__tests__/on-read-crouter-home-fence.test.d.ts +1 -0
  32. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +67 -0
  33. package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
  34. package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
  35. package/dist/core/__tests__/secrets.test.d.ts +1 -0
  36. package/dist/core/__tests__/secrets.test.js +55 -0
  37. package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
  38. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
  39. package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
  40. package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
  41. package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
  42. package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
  43. package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
  44. package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
  45. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
  46. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
  47. package/dist/core/canvas/browse/app.d.ts +14 -0
  48. package/dist/core/canvas/browse/app.js +97 -15
  49. package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
  50. package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
  51. package/dist/core/canvas/browse/render.d.ts +4 -0
  52. package/dist/core/canvas/browse/render.js +3 -1
  53. package/dist/core/canvas/nav-model.d.ts +19 -10
  54. package/dist/core/canvas/nav-model.js +30 -12
  55. package/dist/core/canvas/pid.d.ts +71 -13
  56. package/dist/core/canvas/pid.js +73 -29
  57. package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
  58. package/dist/core/canvas/remote-canvas-source.js +222 -0
  59. package/dist/core/canvas/remote-event-stream.d.ts +24 -0
  60. package/dist/core/canvas/remote-event-stream.js +94 -0
  61. package/dist/core/canvas/render.d.ts +13 -1
  62. package/dist/core/canvas/render.js +56 -37
  63. package/dist/core/canvas/source.d.ts +9 -0
  64. package/dist/core/canvas/source.js +15 -0
  65. package/dist/core/config.js +4 -3
  66. package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
  67. package/dist/core/runtime/__tests__/node-env.test.js +91 -0
  68. package/dist/core/runtime/nodes.js +10 -0
  69. package/dist/core/runtime/revive-all.js +8 -2
  70. package/dist/core/runtime/revive.js +18 -8
  71. package/dist/core/secrets.d.ts +25 -0
  72. package/dist/core/secrets.js +55 -0
  73. package/dist/core/substrate/on-read.js +13 -2
  74. package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
  75. package/dist/core/view/__tests__/transport-remote.test.js +95 -0
  76. package/dist/core/view/remote-canvas-target.d.ts +2 -1
  77. package/dist/core/view/remote-canvas-target.js +12 -7
  78. package/dist/core/view/transport-remote.d.ts +2 -0
  79. package/dist/core/view/transport-remote.js +53 -0
  80. package/dist/daemon/crtrd.js +18 -8
  81. package/dist/types.d.ts +19 -11
  82. package/dist/types.js +1 -1
  83. package/package.json +1 -1
@@ -60,50 +60,92 @@ export function isPidAlive(pid) {
60
60
  }
61
61
  return !isZombie(pid);
62
62
  }
63
- /** Like `isPidAlive`, but guards a RECORDED node pid against PID REUSE using the
64
- * launch-time identity baseline (`pi_pid_identity`, captured by `recordPid`).
65
- * On a heavily-forking host — esp. Linux, where low pids recycle fast a
66
- * broker's recorded pid can be reused by an unrelated process after the broker
67
- * dies; a bare `isPidAlive` then reports the STRANGER as "our broker alive",
68
- * wedging every daemon supervise/revive decision (the daemon never revives a
69
- * row that looks alive; `reviveNode`'s double-launch guard never relaunches).
70
- * Returns false the instant the pid's CURRENT identity is POSITIVELY known to
71
- * differ from `expectedIdentity` (the recycle case). Fail-open everywhere else
72
- * no baseline (legacy row / capture failed at launch), `ps` probe failure, or
73
- * a pid present-but-unreadable — exactly matching `captureTeardownSnapshot`'s
74
- * discipline: a MISMATCH is the ONLY thing that reads dead, never an absence of
75
- * evidence. */
76
- export function isRecordedPidAlive(pid, expectedIdentity) {
63
+ /** Split a `composeIdentity` fingerprint (`lstart` or `lstart#ticks`) into its
64
+ * coarse (`lstart`, second-granular, portable) and fine (`ticks`, Linux-only
65
+ * jiffies, best-effort) parts. `#` never appears in `lstart` itself (a `ps`
66
+ * timestamp), so the first `#` unambiguously separates them. */
67
+ function splitIdentity(identity) {
68
+ const i = identity.indexOf('#');
69
+ return i === -1 ? { base: identity, ticks: undefined } : { base: identity.slice(0, i), ticks: identity.slice(i + 1) };
70
+ }
71
+ /** THE single precision-aware identity compare, used EVERYWHERE two
72
+ * `composeIdentity` fingerprints are compared (this module's own
73
+ * `recordedPidLiveness`, `captureTeardownSnapshot`'s reuse check, and
74
+ * `killProcessTreePids`'s reuse check) centralized so no call site can
75
+ * drift from this rule (review Major #1, Round 2 of the pid-reuse-liveness
76
+ * fix).
77
+ *
78
+ * The hazard this closes: `composeIdentity` appends the `#<ticks>` Linux
79
+ * discriminator only when `/proc/<pid>/stat` happens to be readable at
80
+ * capture time — so the SAME live process can be recorded plain `lstart` on
81
+ * one capture and `lstart#ticks` on another (a transient `/proc` read
82
+ * failure, or capturing on a platform/container without procfs). A naive
83
+ * `===` then reads that as a reuse (different string) and reports `dead` for
84
+ * a broker that never actually died — the false-DEAD direction, which can
85
+ * make `reviveNode`'s double-launch guard wrongly relaunch a SECOND broker
86
+ * onto a still-live session.
87
+ *
88
+ * Rule: split both on `#`. Bases (`lstart`) differ → definitely NOT a match
89
+ * (different process, or the same pid reused in a different second). Bases
90
+ * match and BOTH carry a `#ticks` suffix → match iff the suffixes are equal
91
+ * (this still catches genuine same-second reuse, the case `procStartTicks`
92
+ * exists for). Bases match and EITHER lacks the suffix → coarse-vs-fine,
93
+ * can't distinguish → MATCH (fail-open to same-process, matching this
94
+ * module's universal discipline: only a POSITIVE mismatch counts as reuse,
95
+ * never an absence of evidence). */
96
+ export function identitiesMatch(a, b) {
97
+ const ia = splitIdentity(a);
98
+ const ib = splitIdentity(b);
99
+ if (ia.base !== ib.base)
100
+ return false;
101
+ if (ia.ticks !== undefined && ib.ticks !== undefined)
102
+ return ia.ticks === ib.ticks;
103
+ return true;
104
+ }
105
+ export function recordedPidLiveness(pid, expectedIdentity) {
77
106
  const debug = Boolean(process.env.CRTR_DEBUG_PID_LIVENESS);
78
107
  const alive = isPidAlive(pid);
79
108
  if (!alive) {
80
109
  if (debug)
81
- logPidLivenessDecision(pid, alive, expectedIdentity, 'no-probe', 'isPidAlive=false → dead', false);
82
- return false; // gone/zombie → dead (unchanged path)
110
+ logPidLivenessDecision(pid, alive, expectedIdentity, 'no-probe', 'isPidAlive=false → dead', 'dead');
111
+ return 'dead'; // gone/zombie → dead (unchanged path)
83
112
  }
84
113
  if (expectedIdentity == null) {
85
114
  if (debug)
86
- logPidLivenessDecision(pid, alive, expectedIdentity, 'no-probe', 'no baseline → trust isPidAlive', true);
87
- return true; // no baseline → trust isPidAlive (legacy behavior)
115
+ logPidLivenessDecision(pid, alive, expectedIdentity, 'no-probe', 'no baseline → trust isPidAlive', 'alive');
116
+ return 'alive'; // no baseline → trust isPidAlive (legacy behavior)
88
117
  }
89
118
  const current = capturePidIdentities([pid]);
90
119
  if (current == null) {
91
120
  if (debug)
92
- logPidLivenessDecision(pid, alive, expectedIdentity, 'probe-null', 'ps probe failed → fail-open alive', true);
93
- return true; // probe itself failed → fail-open
121
+ logPidLivenessDecision(pid, alive, expectedIdentity, 'probe-null', 'ps probe failed → indeterminate', 'indeterminate');
122
+ return 'indeterminate'; // probe itself failed → we don't know (THE fix: was fail-open alive)
94
123
  }
95
124
  const actual = current.get(pid);
96
125
  if (actual === undefined) {
97
126
  if (debug)
98
- logPidLivenessDecision(pid, alive, expectedIdentity, 'no-entry', 'no identity row for pid (race) → fail-open alive', true);
99
- return true; // present but no identity row (race) fail-open
127
+ logPidLivenessDecision(pid, alive, expectedIdentity, 'no-entry', 'no identity row for pid (race) → dead', 'dead');
128
+ return 'dead'; // isPidAlive said alive but it's gone nowdead
100
129
  }
101
- const result = actual === expectedIdentity; // different identity → REUSED → dead
130
+ const matches = identitiesMatch(actual, expectedIdentity);
102
131
  if (debug)
103
- logPidLivenessDecision(pid, alive, expectedIdentity, 'value', result ? 'identity matches expected → alive' : 'identity differs from expected → REUSED → dead', result, actual);
104
- return result;
132
+ logPidLivenessDecision(pid, alive, expectedIdentity, 'value', matches ? 'identity matches expected → alive' : 'identity differs from expected → REUSED → dead', matches ? 'alive' : 'dead', actual);
133
+ return matches ? 'alive' : 'dead';
134
+ }
135
+ /** Boolean teardown-direction adapter over `recordedPidLiveness`, kept so
136
+ * every pre-existing signalling caller stays behavior-identical: `dead` is
137
+ * the ONLY thing that reads false. `indeterminate` (can't confirm either
138
+ * way) reads true alongside `alive` — never signal a maybe-ours pid on mere
139
+ * uncertainty, matching this module's universal discipline (a POSITIVE
140
+ * mismatch is the only thing that changes behavior, never an absence of
141
+ * evidence). Callers that need to distinguish "confirmed alive" from
142
+ * "can't confirm" (e.g. a double-launch guard, where treating uncertainty as
143
+ * alive risks silently skipping a needed relaunch) must call
144
+ * `recordedPidLiveness` directly instead of this adapter. */
145
+ export function isRecordedPidAlive(pid, expectedIdentity) {
146
+ return recordedPidLiveness(pid, expectedIdentity) !== 'dead';
105
147
  }
106
- /** Diagnostic-only decision trace for `isRecordedPidAlive`, gated entirely
148
+ /** Diagnostic-only decision trace for `recordedPidLiveness`, gated entirely
107
149
  * behind `CRTR_DEBUG_PID_LIVENESS` — callers must check the env var
108
150
  * themselves before calling this so the (tiny) formatting cost is paid ONLY
109
151
  * when the env var is set; this function does no env check of its own.
@@ -129,7 +171,7 @@ function logPidLivenessDecision(pid, isPidAliveResult, expectedIdentity, probeOu
129
171
  `branch=${JSON.stringify(branch)}`,
130
172
  `returned=${returned}`,
131
173
  ];
132
- process.stderr.write(`[isRecordedPidAlive] ${parts.join(' ')}\n`);
174
+ process.stderr.write(`[recordedPidLiveness] ${parts.join(' ')}\n`);
133
175
  }
134
176
  /** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
135
177
  * swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
@@ -339,7 +381,9 @@ export function captureTeardownSnapshot(rootPid, expectedIdentity) {
339
381
  if (table === null)
340
382
  return { tree: [rootPid], identities: null, reused: false };
341
383
  const currentRootIdentity = table.identities.get(rootPid);
342
- const reused = expectedIdentity != null && currentRootIdentity !== undefined && currentRootIdentity !== expectedIdentity;
384
+ const reused = expectedIdentity != null &&
385
+ currentRootIdentity !== undefined &&
386
+ !identitiesMatch(currentRootIdentity, expectedIdentity);
343
387
  if (reused)
344
388
  return { tree: [], identities: null, reused: true };
345
389
  const tree = [rootPid, ...descendantPidsFrom(rootPid, table.childrenOf)];
@@ -407,7 +451,7 @@ export function killProcessTreePids(pids, signal = 'SIGTERM', identities) {
407
451
  if (identities != null && current != null) {
408
452
  const expected = identities.get(pid);
409
453
  const actual = current.get(pid);
410
- const reused = expected !== undefined && actual !== undefined && actual !== expected;
454
+ const reused = expected !== undefined && actual !== undefined && !identitiesMatch(actual, expected);
411
455
  if (reused)
412
456
  continue;
413
457
  }
@@ -0,0 +1,62 @@
1
+ import type { CanvasSource } from './source.js';
2
+ import type { NodeMeta, NodeRow, NodeStatus, SubscriptionRef } from './types.js';
3
+ /** The exact JSON shape `crtr --json node inspect show <id>` emits (see
4
+ * `nodeShow`'s `run`, `src/commands/node.ts`) — mirrored here, not imported,
5
+ * to avoid a commands→canvas value dependency. */
6
+ export interface RemoteShowResponse {
7
+ node: NodeMeta;
8
+ reports: {
9
+ node_id: string;
10
+ name: string;
11
+ kind: string;
12
+ status: string;
13
+ active: boolean;
14
+ }[];
15
+ managers: {
16
+ node_id: string;
17
+ name: string;
18
+ kind: string;
19
+ status: string;
20
+ active: boolean;
21
+ }[];
22
+ artifacts: {
23
+ report: number;
24
+ doc: number;
25
+ roadmap: number;
26
+ };
27
+ pending_triggers: number;
28
+ paths: {
29
+ context_dir: string;
30
+ reports_dir: string;
31
+ session_file: string | null;
32
+ };
33
+ follow_up: string;
34
+ }
35
+ export declare class RemoteCanvasSource implements CanvasSource {
36
+ private readonly previewEndpoint;
37
+ private readonly relayToken;
38
+ private readonly exec;
39
+ private nodeCache;
40
+ private rosterCache;
41
+ private viewCache;
42
+ constructor(previewEndpoint: string, relayToken: string);
43
+ /** Drops every cached entry — called on a `RemoteEventStream` `'nodes'` (or
44
+ * `'inbox'`) event so the next read re-fetches fresh state. */
45
+ invalidate(): void;
46
+ private execJson;
47
+ private fetchShow;
48
+ /** The full passthrough object `node inspect show` produces — used by
49
+ * `nodeShow`'s remote branch (`src/commands/node.ts`) to return the exact
50
+ * same shape a local read would, without re-deriving it. */
51
+ rawShow(id: string): Promise<RemoteShowResponse | null>;
52
+ getNode(id: string): Promise<NodeMeta | null>;
53
+ getRow(id: string): Promise<NodeRow | null>;
54
+ listNodes(filter?: {
55
+ status?: NodeStatus | NodeStatus[];
56
+ }): Promise<NodeRow[]>;
57
+ subscriptionsOf(id: string): Promise<SubscriptionRef[]>;
58
+ subscribersOf(id: string): Promise<SubscriptionRef[]>;
59
+ view(root: string): Promise<string[]>;
60
+ askCountsForView(root: string): Promise<Record<string, number>>;
61
+ hasActiveLiveSubscription(id: string): Promise<boolean>;
62
+ }
@@ -0,0 +1,222 @@
1
+ // remote-canvas-source.ts — a CanvasSource backed by a remote canvas over the
2
+ // relay-token-authed `/__crtr/source` transport (see `transport-remote.ts`).
3
+ // Every method shells out to an EXISTING `crtr --json <subcommand>` in-guest —
4
+ // no new in-guest command, no remote SQLite read, no drive-plane verb. A
5
+ // short-TTL cache coalesces the fan-out a single browse/inspect frame would
6
+ // otherwise make into the same node.
7
+ import { createRemoteExec } from '../view/transport-remote.js';
8
+ const CACHE_TTL_MS = 1000;
9
+ // ---------------------------------------------------------------------------
10
+ // Remote JSON shape validation.
11
+ //
12
+ // `execJson<T>` parses stdout and casts it to T with NO structural check — a
13
+ // remote subprocess that exits 0 with a wrong shape (e.g. `{}`, `{node:null}`)
14
+ // would otherwise pass through as a "valid" T. `fetchShow`/`listNodes`/
15
+ // `askCountsForView` validate before caching/returning; on a shape mismatch
16
+ // they degrade exactly like the existing network-failure path (null/[]/{}),
17
+ // never a malformed object a caller might dereference.
18
+ // ---------------------------------------------------------------------------
19
+ function isPlainObject(x) {
20
+ return typeof x === 'object' && x !== null && !Array.isArray(x);
21
+ }
22
+ function isValidShowResponse(x) {
23
+ if (!isPlainObject(x))
24
+ return false;
25
+ const node = x['node'];
26
+ if (!isPlainObject(node) || typeof node['node_id'] !== 'string')
27
+ return false;
28
+ if (!Array.isArray(x['reports']) || !Array.isArray(x['managers']))
29
+ return false;
30
+ const artifacts = x['artifacts'];
31
+ if (!isPlainObject(artifacts) ||
32
+ typeof artifacts['report'] !== 'number' ||
33
+ typeof artifacts['doc'] !== 'number' ||
34
+ typeof artifacts['roadmap'] !== 'number')
35
+ return false;
36
+ if (typeof x['pending_triggers'] !== 'number')
37
+ return false;
38
+ const paths = x['paths'];
39
+ if (!isPlainObject(paths) ||
40
+ typeof paths['context_dir'] !== 'string' ||
41
+ typeof paths['reports_dir'] !== 'string' ||
42
+ (paths['session_file'] !== null && typeof paths['session_file'] !== 'string'))
43
+ return false;
44
+ if (typeof x['follow_up'] !== 'string')
45
+ return false;
46
+ return true;
47
+ }
48
+ /** A `reports`/`managers` entry (`RemoteShowResponse`) — validated per-element
49
+ * because `isValidShowResponse` only checks the array wrapper. Without this,
50
+ * a malformed neighbor (`null`, `{}`) survives into `subscriptionsOf`/
51
+ * `subscribersOf`'s `.map()` and node.ts's remote `show` passthrough render,
52
+ * both of which dereference `.node_id`/`.active` unguarded. */
53
+ function isValidNeighborEntry(x) {
54
+ return (isPlainObject(x) &&
55
+ typeof x['node_id'] === 'string' &&
56
+ typeof x['name'] === 'string' &&
57
+ typeof x['kind'] === 'string' &&
58
+ typeof x['status'] === 'string' &&
59
+ typeof x['active'] === 'boolean');
60
+ }
61
+ /** A `NodeRow` element from `node inspect list`'s `nodes` array — validated
62
+ * per-element because a remote success payload can be `{nodes:[null]}` (the
63
+ * outer array check alone lets that through). Requires the string identity/
64
+ * lifecycle fields browse's tree-building code dereferences unguarded
65
+ * (`browse/app.ts`'s `rebuildSnapshot`); the remaining nullable runtime
66
+ * fields (host_kind, parent, pi_pid, ...) are left to cast through as-is. */
67
+ function isValidNodeRow(x) {
68
+ if (!isPlainObject(x))
69
+ return false;
70
+ return (typeof x['node_id'] === 'string' &&
71
+ typeof x['name'] === 'string' &&
72
+ typeof x['kind'] === 'string' &&
73
+ typeof x['mode'] === 'string' &&
74
+ typeof x['lifecycle'] === 'string' &&
75
+ typeof x['status'] === 'string' &&
76
+ typeof x['cwd'] === 'string' &&
77
+ typeof x['created'] === 'string');
78
+ }
79
+ export class RemoteCanvasSource {
80
+ previewEndpoint;
81
+ relayToken;
82
+ exec;
83
+ nodeCache = new Map();
84
+ rosterCache = new Map();
85
+ viewCache = new Map();
86
+ constructor(previewEndpoint, relayToken) {
87
+ this.previewEndpoint = previewEndpoint;
88
+ this.relayToken = relayToken;
89
+ this.exec = createRemoteExec(previewEndpoint, relayToken);
90
+ }
91
+ /** Drops every cached entry — called on a `RemoteEventStream` `'nodes'` (or
92
+ * `'inbox'`) event so the next read re-fetches fresh state. */
93
+ invalidate() {
94
+ this.nodeCache.clear();
95
+ this.rosterCache.clear();
96
+ this.viewCache.clear();
97
+ }
98
+ async execJson(args) {
99
+ let raw;
100
+ try {
101
+ raw = await this.exec('crtr', ['--json', ...args]);
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ if (!raw.ok)
107
+ return null;
108
+ if (raw.exitCode !== undefined && raw.exitCode !== 0)
109
+ return null;
110
+ try {
111
+ return JSON.parse(raw.stdout.trim());
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ async fetchShow(id) {
118
+ const cached = this.nodeCache.get(id);
119
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS)
120
+ return cached.value;
121
+ const raw = await this.execJson(['node', 'inspect', 'show', id]);
122
+ // Sanitize reports/managers elements here (not just the outer array shape)
123
+ // so both consumers — subscriptionsOf/subscribersOf's `.map()` below AND
124
+ // node.ts's remote `show` passthrough render — see only well-formed
125
+ // neighbor entries, never a malformed one they'd dereference unguarded.
126
+ const res = isValidShowResponse(raw)
127
+ ? { ...raw, reports: raw.reports.filter(isValidNeighborEntry), managers: raw.managers.filter(isValidNeighborEntry) }
128
+ : null;
129
+ this.nodeCache.set(id, { value: res, at: Date.now() });
130
+ return res;
131
+ }
132
+ /** The full passthrough object `node inspect show` produces — used by
133
+ * `nodeShow`'s remote branch (`src/commands/node.ts`) to return the exact
134
+ * same shape a local read would, without re-deriving it. */
135
+ async rawShow(id) {
136
+ return this.fetchShow(id);
137
+ }
138
+ async getNode(id) {
139
+ return (await this.fetchShow(id))?.node ?? null;
140
+ }
141
+ async getRow(id) {
142
+ const n = (await this.fetchShow(id))?.node;
143
+ if (!n)
144
+ return null;
145
+ return {
146
+ node_id: n.node_id,
147
+ name: n.name,
148
+ kind: n.kind,
149
+ mode: n.mode,
150
+ lifecycle: n.lifecycle,
151
+ status: n.status,
152
+ cwd: n.cwd,
153
+ host_kind: n.host_kind ?? null,
154
+ profile_id: n.profile_id ?? null,
155
+ parent: n.parent ?? null,
156
+ created: n.created,
157
+ intent: n.intent ?? null,
158
+ pi_pid: n.pi_pid ?? null,
159
+ pi_pid_identity: n.pi_pid_identity ?? null,
160
+ window: n.window ?? null,
161
+ tmux_session: n.tmux_session ?? null,
162
+ pane: n.pane ?? null,
163
+ };
164
+ }
165
+ async listNodes(filter) {
166
+ const key = JSON.stringify(filter?.status ?? null);
167
+ const cached = this.rosterCache.get(key);
168
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS)
169
+ return cached.value;
170
+ const args = ['node', 'inspect', 'list'];
171
+ if (filter?.status) {
172
+ args.push('--status', (Array.isArray(filter.status) ? filter.status : [filter.status]).join(','));
173
+ }
174
+ const res = await this.execJson(args);
175
+ const rawNodes = isPlainObject(res) && Array.isArray(res['nodes']) ? res['nodes'] : [];
176
+ const nodes = rawNodes.filter(isValidNodeRow);
177
+ this.rosterCache.set(key, { value: nodes, at: Date.now() });
178
+ return nodes;
179
+ }
180
+ async subscriptionsOf(id) {
181
+ const reports = (await this.fetchShow(id))?.reports ?? [];
182
+ return reports.map((r) => ({ node_id: r.node_id, active: r.active, created: '' }));
183
+ }
184
+ async subscribersOf(id) {
185
+ const managers = (await this.fetchShow(id))?.managers ?? [];
186
+ return managers.map((r) => ({ node_id: r.node_id, active: r.active, created: '' }));
187
+ }
188
+ async view(root) {
189
+ const cached = this.viewCache.get(root);
190
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS)
191
+ return cached.value;
192
+ const seen = new Set([root]);
193
+ const out = [];
194
+ const queue = (await this.subscriptionsOf(root)).map((s) => s.node_id);
195
+ while (queue.length > 0) {
196
+ const id = queue.shift();
197
+ if (seen.has(id))
198
+ continue;
199
+ seen.add(id);
200
+ out.push(id);
201
+ for (const s of await this.subscriptionsOf(id)) {
202
+ if (!seen.has(s.node_id))
203
+ queue.push(s.node_id);
204
+ }
205
+ }
206
+ this.viewCache.set(root, { value: out, at: Date.now() });
207
+ return out;
208
+ }
209
+ async askCountsForView(root) {
210
+ const res = await this.execJson(['canvas', 'attention', 'map', '--view', root]);
211
+ return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
212
+ }
213
+ async hasActiveLiveSubscription(id) {
214
+ const subs = (await this.subscriptionsOf(id)).filter((s) => s.active);
215
+ for (const s of subs) {
216
+ const row = await this.getRow(s.node_id);
217
+ if (row && (row.status === 'active' || row.status === 'idle'))
218
+ return true;
219
+ }
220
+ return false;
221
+ }
222
+ }
@@ -0,0 +1,24 @@
1
+ export type RemoteEventKind = 'nodes' | 'inbox';
2
+ export interface RemoteEventStreamOptions {
3
+ /** Initial reconnect backoff (ms). Default 1000; injectable so tests don't
4
+ * have to wait out the production backoff window. */
5
+ initialBackoffMs?: number;
6
+ /** Reconnect backoff cap (ms). Default 30_000. */
7
+ backoffCapMs?: number;
8
+ }
9
+ export declare class RemoteEventStream {
10
+ private readonly previewEndpoint;
11
+ private readonly relayToken;
12
+ private controller;
13
+ private stopped;
14
+ private readonly listeners;
15
+ private readonly initialBackoffMs;
16
+ private backoffMs;
17
+ private readonly BACKOFF_CAP_MS;
18
+ private reconnectTimer;
19
+ constructor(previewEndpoint: string, relayToken: string, opts?: RemoteEventStreamOptions);
20
+ subscribe(fn: (kind: RemoteEventKind) => void): () => void;
21
+ start(): void;
22
+ stop(): void;
23
+ private loop;
24
+ }
@@ -0,0 +1,94 @@
1
+ // remote-event-stream.ts — a Node SSE client over a remote canvas's
2
+ // `/__crtr/events` endpoint, driving cache invalidation + re-render for a
3
+ // remote-backed `canvas browse`/viewer. No new npm dependency: Node 18+
4
+ // `fetch` returns a web-standard `ReadableStream` body, read manually with
5
+ // `getReader()` + `TextDecoder` and split on the SSE `\n\n` event delimiter.
6
+ export class RemoteEventStream {
7
+ previewEndpoint;
8
+ relayToken;
9
+ controller;
10
+ stopped = false;
11
+ listeners = new Set();
12
+ initialBackoffMs;
13
+ backoffMs;
14
+ BACKOFF_CAP_MS;
15
+ reconnectTimer;
16
+ constructor(previewEndpoint, relayToken, opts = {}) {
17
+ this.previewEndpoint = previewEndpoint;
18
+ this.relayToken = relayToken;
19
+ this.initialBackoffMs = opts.initialBackoffMs ?? 1000;
20
+ this.backoffMs = this.initialBackoffMs;
21
+ this.BACKOFF_CAP_MS = opts.backoffCapMs ?? 30_000;
22
+ }
23
+ subscribe(fn) {
24
+ this.listeners.add(fn);
25
+ return () => this.listeners.delete(fn);
26
+ }
27
+ start() {
28
+ this.stopped = false;
29
+ void this.loop();
30
+ }
31
+ stop() {
32
+ this.stopped = true;
33
+ this.controller?.abort();
34
+ if (this.reconnectTimer !== undefined) {
35
+ clearTimeout(this.reconnectTimer);
36
+ this.reconnectTimer = undefined;
37
+ }
38
+ }
39
+ async loop() {
40
+ while (!this.stopped) {
41
+ this.controller = new AbortController();
42
+ try {
43
+ const res = await fetch(new URL('/__crtr/events', this.previewEndpoint), {
44
+ headers: { authorization: `Bearer ${this.relayToken}` },
45
+ signal: this.controller.signal,
46
+ });
47
+ if (!res.ok || !res.body)
48
+ throw new Error(`events ${res.status}`);
49
+ this.backoffMs = this.initialBackoffMs; // reset on a successful connect
50
+ const reader = res.body.getReader();
51
+ const decoder = new TextDecoder();
52
+ let buf = '';
53
+ while (true) {
54
+ const { done, value } = await reader.read();
55
+ if (done)
56
+ break;
57
+ buf += decoder.decode(value, { stream: true });
58
+ let idx;
59
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
60
+ const rawEvent = buf.slice(0, idx);
61
+ buf = buf.slice(idx + 2);
62
+ for (const line of rawEvent.split('\n')) {
63
+ if (!line.startsWith('data:'))
64
+ continue; // skip comments (: ping, : connected)
65
+ try {
66
+ const payload = JSON.parse(line.slice(5).trim());
67
+ if (payload.kind === 'nodes' || payload.kind === 'inbox') {
68
+ for (const l of this.listeners)
69
+ l(payload.kind);
70
+ }
71
+ // unknown kind → silently ignored (forward-compat)
72
+ }
73
+ catch {
74
+ // malformed data line — ignore
75
+ }
76
+ }
77
+ }
78
+ }
79
+ }
80
+ catch {
81
+ // connection error/abort — fall through to backoff+retry unless stopped
82
+ }
83
+ if (this.stopped)
84
+ return;
85
+ await new Promise((resolve) => {
86
+ this.reconnectTimer = setTimeout(() => {
87
+ this.reconnectTimer = undefined;
88
+ resolve();
89
+ }, this.backoffMs);
90
+ });
91
+ this.backoffMs = Math.min(this.backoffMs * 2, this.BACKOFF_CAP_MS);
92
+ }
93
+ }
94
+ }
@@ -119,6 +119,10 @@ export declare function dashboardRows(rootId: string): DashboardRow[];
119
119
  * viewport on demand. See {@link enrichRow}, {@link enrichRows}, {@link loadPreview}.
120
120
  */
121
121
  export declare function dashboardRowsAll(): DashboardRow[];
122
+ /** A remote source provides only graph structure (the 8 CanvasSource methods);
123
+ * every other per-node field below is a local disk/SQLite read and is suppressed
124
+ * to a safe default for a remote source — `listFocuses()` (SQLite) is skipped
125
+ * entirely rather than queried and discarded. */
122
126
  export declare function dashboardRowsAllFromSource(source: CanvasSource): Promise<DashboardRow[]>;
123
127
  /** Fold the cheap-boot row's deferred fields in for ONE row: the full label
124
128
  * (meta.description → fullName), ctx tokens (telemetry.json), and ⚑ asks. One
@@ -129,11 +133,19 @@ export declare function enrichRow(row: DashboardRow): DashboardRow;
129
133
  * distinct cwd's ask inbox exactly ONCE (via `asksForNodes`) instead of per row.
130
134
  * Use this for a whole viewport / the full forest; mutates each row in place. */
131
135
  export declare function enrichRows(rows: DashboardRow[]): void;
132
- export declare function enrichRowsFromSource(source: CanvasSource, rows: DashboardRow[]): Promise<void>;
136
+ /** Telemetry (ctx tokens) is a local disk read, so it is suppressed to 0 for a
137
+ * remote source. Ask counts are suppressed to 0 UNLESS the caller supplies
138
+ * `remoteAsks` — a pre-fetched `askCountsForView` map (one call per forest
139
+ * root; the caller's job, since the right root set is a browse/snapshot
140
+ * concern, not this per-row enrichment loop's). See `runBrowse`'s
141
+ * `remoteAskCounts`, fetched once per snapshot rebuild, not per flush. */
142
+ export declare function enrichRowsFromSource(source: CanvasSource, rows: DashboardRow[], remoteAsks?: Record<string, number>): Promise<void>;
133
143
  /** Load the SELECTED row's preview text: the spawn `goal` (initial-prompt.md) plus
134
144
  * the whole-conversation `prompts` and the `lastAssistant` reply — the latter two
135
145
  * folded into ONE session-file read (see {@link readSessionParts}). Mutates the row
136
146
  * in place; idempotent. Call only for the cursor row (and lazily/in-background to
137
147
  * warm the prompt super-search corpus), never on the boot path. */
138
148
  export declare function loadPreview(row: DashboardRow): DashboardRow;
149
+ /** goal (initial-prompt.md) and session parts are local disk reads — suppressed
150
+ * to undefined for a remote source. */
139
151
  export declare function loadPreviewFromSource(source: CanvasSource, row: DashboardRow): Promise<DashboardRow>;