@crouton-kit/crouter 0.3.66 → 0.3.68
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.
- package/dist/build-root.js +1 -0
- package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -1
- package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
- package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
- package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
- package/dist/clients/attach/attach-cmd.d.ts +30 -2
- package/dist/clients/attach/attach-cmd.js +624 -624
- package/dist/clients/attach/canvas-panels.js +9 -3
- package/dist/clients/attach/graph-overlay.d.ts +5 -0
- package/dist/clients/attach/graph-overlay.js +40 -7
- package/dist/clients/attach/input-controller.d.ts +9 -0
- package/dist/clients/attach/input-controller.js +47 -8
- package/dist/clients/attach/slash-commands.d.ts +11 -4
- package/dist/clients/attach/slash-commands.js +44 -9
- package/dist/clients/attach/transport-relay.d.ts +5 -5
- package/dist/clients/attach/transport-relay.js +3 -30
- package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
- package/dist/commands/__tests__/canvas-config.test.js +107 -0
- package/dist/commands/canvas-browse.js +26 -1
- package/dist/commands/canvas-config.d.ts +2 -0
- package/dist/commands/canvas-config.js +210 -0
- package/dist/commands/canvas-use.d.ts +1 -0
- package/dist/commands/canvas-use.js +63 -0
- package/dist/commands/canvas.js +4 -2
- package/dist/commands/capture.d.ts +2 -0
- package/dist/commands/capture.js +28 -0
- package/dist/commands/node.js +41 -11
- package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
- package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
- package/dist/core/__tests__/revive.test.js +70 -1
- package/dist/core/__tests__/secrets.test.d.ts +1 -0
- package/dist/core/__tests__/secrets.test.js +55 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
- package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
- package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
- package/dist/core/canvas/browse/app.d.ts +14 -0
- package/dist/core/canvas/browse/app.js +97 -15
- package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
- package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
- package/dist/core/canvas/browse/render.d.ts +4 -0
- package/dist/core/canvas/browse/render.js +3 -1
- package/dist/core/canvas/nav-model.d.ts +19 -10
- package/dist/core/canvas/nav-model.js +30 -12
- package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
- package/dist/core/canvas/remote-canvas-source.js +222 -0
- package/dist/core/canvas/remote-event-stream.d.ts +24 -0
- package/dist/core/canvas/remote-event-stream.js +94 -0
- package/dist/core/canvas/render.d.ts +13 -1
- package/dist/core/canvas/render.js +56 -37
- package/dist/core/canvas/source.d.ts +9 -0
- package/dist/core/canvas/source.js +15 -0
- package/dist/core/command.d.ts +15 -0
- package/dist/core/command.js +72 -0
- package/dist/core/config.js +4 -3
- package/dist/core/profiles/select.d.ts +4 -1
- package/dist/core/profiles/select.js +356 -81
- package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
- package/dist/core/runtime/__tests__/node-env.test.js +91 -0
- package/dist/core/runtime/nodes.js +10 -0
- package/dist/core/runtime/revive.js +176 -139
- package/dist/core/runtime/tmux.js +4 -1
- package/dist/core/secrets.d.ts +25 -0
- package/dist/core/secrets.js +55 -0
- package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
- package/dist/core/view/__tests__/transport-remote.test.js +95 -0
- package/dist/core/view/remote-canvas-target.d.ts +2 -1
- package/dist/core/view/remote-canvas-target.js +12 -7
- package/dist/core/view/transport-local.js +12 -3
- package/dist/core/view/transport-remote.d.ts +2 -0
- package/dist/core/view/transport-remote.js +53 -0
- package/dist/types.d.ts +19 -11
- package/dist/types.js +1 -1
- package/dist/web-client/assets/{index-BvzxXXGU.js → index-CnF5r8ky.js} +18 -18
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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>;
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
16
16
|
import { join } from 'node:path';
|
|
17
17
|
import { getNode, listNodes, subscriptionsOf, view } from './canvas.js';
|
|
18
|
+
import { RemoteCanvasSource } from './remote-canvas-source.js';
|
|
18
19
|
import { fullName } from './labels.js';
|
|
19
20
|
import { jobDir, contextDir } from './paths.js';
|
|
20
21
|
import { countAsks, asksForNodes } from './attention.js';
|
|
@@ -174,25 +175,24 @@ export function renderForest() {
|
|
|
174
175
|
}
|
|
175
176
|
return parts.join('\n\n');
|
|
176
177
|
}
|
|
177
|
-
async function nodeLineFromSource(source, nodeId, indent, connector) {
|
|
178
|
+
async function nodeLineFromSource(source, nodeId, indent, connector, remote) {
|
|
178
179
|
const node = await source.getNode(nodeId);
|
|
179
180
|
if (node === null)
|
|
180
181
|
return `${indent}${connector}? <missing meta: ${nodeId}>`;
|
|
181
|
-
const hanging = hangingFor(node);
|
|
182
|
+
const hanging = remote ? null : hangingFor(node);
|
|
182
183
|
const glyph = resolveNodeVisual(node.status, { hanging }).glyph;
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
const asks = countAsks(nodeId);
|
|
184
|
+
const ctx = remote ? '0k' : fmtCtx(readNodeTelemetry(nodeId).tokens_in);
|
|
185
|
+
const asks = remote ? 0 : countAsks(nodeId);
|
|
186
186
|
const askSuffix = asks > 0 ? ` ⚑${asks}` : '';
|
|
187
187
|
const faultSuffix = hanging !== null ? ` · ${faultSummary(hanging)}` : '';
|
|
188
188
|
return `${indent}${connector}${glyph} ${fullName(node)} [${node.kind}/${node.mode}] ctx ${ctx}${askSuffix}${faultSuffix}`;
|
|
189
189
|
}
|
|
190
|
-
async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out) {
|
|
190
|
+
async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out, remote) {
|
|
191
191
|
if (visited.has(nodeId))
|
|
192
192
|
return;
|
|
193
193
|
visited.add(nodeId);
|
|
194
194
|
const connector = isLast ? '└─ ' : '├─ ';
|
|
195
|
-
out.push(await nodeLineFromSource(source, nodeId, indent, connector));
|
|
195
|
+
out.push(await nodeLineFromSource(source, nodeId, indent, connector, remote));
|
|
196
196
|
const children = await source.subscriptionsOf(nodeId).catch(() => []);
|
|
197
197
|
const childIndent = indent + (isLast ? ' ' : '│ ');
|
|
198
198
|
for (let i = 0; i < children.length; i++) {
|
|
@@ -203,18 +203,18 @@ async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out)
|
|
|
203
203
|
out.push(`${childIndent}${cycleConnector}↺ <cycle: ${child.node_id}>`);
|
|
204
204
|
continue;
|
|
205
205
|
}
|
|
206
|
-
await walkTreeFromSource(source, child.node_id, childIndent, childIsLast, visited, out);
|
|
206
|
+
await walkTreeFromSource(source, child.node_id, childIndent, childIsLast, visited, out, remote);
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
209
|
export async function renderTreeFromSource(source, rootId) {
|
|
210
|
+
const remote = source instanceof RemoteCanvasSource;
|
|
210
211
|
const node = await source.getNode(rootId);
|
|
211
212
|
if (node === null)
|
|
212
213
|
return `? <missing node: ${rootId}>`;
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
const asks = countAsks(rootId);
|
|
214
|
+
const ctx = remote ? '0k' : fmtCtx(readNodeTelemetry(rootId).tokens_in);
|
|
215
|
+
const asks = remote ? 0 : countAsks(rootId);
|
|
216
216
|
const askSuffix = asks > 0 ? ` ⚑${asks}` : '';
|
|
217
|
-
const hanging = hangingFor(node);
|
|
217
|
+
const hanging = remote ? null : hangingFor(node);
|
|
218
218
|
const glyph = resolveNodeVisual(node.status, { hanging }).glyph;
|
|
219
219
|
const faultSuffix = hanging !== null ? ` · ${faultSummary(hanging)}` : '';
|
|
220
220
|
const out = [];
|
|
@@ -224,7 +224,7 @@ export async function renderTreeFromSource(source, rootId) {
|
|
|
224
224
|
for (let i = 0; i < children.length; i++) {
|
|
225
225
|
const child = children[i];
|
|
226
226
|
const isLast = i === children.length - 1;
|
|
227
|
-
await walkTreeFromSource(source, child.node_id, '', isLast, visited, out);
|
|
227
|
+
await walkTreeFromSource(source, child.node_id, '', isLast, visited, out, remote);
|
|
228
228
|
}
|
|
229
229
|
return out.join('\n');
|
|
230
230
|
}
|
|
@@ -468,14 +468,16 @@ export function dashboardRows(rootId) {
|
|
|
468
468
|
* a deterministic canvas-home path, so it tracks last-message recency without a meta
|
|
469
469
|
* read. Falls back to `created`'s epoch when a node never ran a turn (no telemetry).
|
|
470
470
|
* Never throws. */
|
|
471
|
-
function sessionMtime(nodeId, created) {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
471
|
+
function sessionMtime(nodeId, created, remote = false) {
|
|
472
|
+
if (!remote) {
|
|
473
|
+
try {
|
|
474
|
+
const st = statSync(join(jobDir(nodeId), 'telemetry.json'), { throwIfNoEntry: false });
|
|
475
|
+
if (st !== undefined)
|
|
476
|
+
return st.mtimeMs;
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
/* fall through to created */
|
|
480
|
+
}
|
|
479
481
|
}
|
|
480
482
|
const t = Date.parse(created);
|
|
481
483
|
return Number.isNaN(t) ? 0 : t;
|
|
@@ -500,11 +502,11 @@ function sessionMtime(nodeId, created) {
|
|
|
500
502
|
*/
|
|
501
503
|
export function dashboardRowsAll() {
|
|
502
504
|
const focusedNodeIds = new Set(listFocuses().map((f) => f.node_id));
|
|
503
|
-
return listNodes().map((row) => dashboardRowFromNodeRow(row, focusedNodeIds));
|
|
505
|
+
return listNodes().map((row) => dashboardRowFromNodeRow(row, focusedNodeIds, false));
|
|
504
506
|
}
|
|
505
|
-
function dashboardRowFromNodeRow(row, focusedNodeIds) {
|
|
507
|
+
function dashboardRowFromNodeRow(row, focusedNodeIds, remote) {
|
|
506
508
|
const live = row.status === 'active' || row.status === 'idle';
|
|
507
|
-
const hanging = activeFaultForDisplay(row, readFault(row.node_id));
|
|
509
|
+
const hanging = remote ? null : activeFaultForDisplay(row, readFault(row.node_id));
|
|
508
510
|
return {
|
|
509
511
|
node_id: row.node_id,
|
|
510
512
|
name: row.name, // handle only; enrichRow upgrades to fullName (meta.description)
|
|
@@ -516,15 +518,20 @@ function dashboardRowFromNodeRow(row, focusedNodeIds) {
|
|
|
516
518
|
cwd: row.cwd,
|
|
517
519
|
created: row.created,
|
|
518
520
|
lifecycle: row.lifecycle,
|
|
519
|
-
mtimeMs: sessionMtime(row.node_id, row.created),
|
|
520
|
-
streaming: hanging === null && live ? isStreaming(row.node_id, row.pi_pid) : false,
|
|
521
|
+
mtimeMs: sessionMtime(row.node_id, row.created, remote),
|
|
522
|
+
streaming: !remote && hanging === null && live ? isStreaming(row.node_id, row.pi_pid) : false,
|
|
521
523
|
hanging,
|
|
522
|
-
viewed: focusedNodeIds.has(row.node_id),
|
|
524
|
+
viewed: remote ? false : focusedNodeIds.has(row.node_id),
|
|
523
525
|
};
|
|
524
526
|
}
|
|
527
|
+
/** A remote source provides only graph structure (the 8 CanvasSource methods);
|
|
528
|
+
* every other per-node field below is a local disk/SQLite read and is suppressed
|
|
529
|
+
* to a safe default for a remote source — `listFocuses()` (SQLite) is skipped
|
|
530
|
+
* entirely rather than queried and discarded. */
|
|
525
531
|
export async function dashboardRowsAllFromSource(source) {
|
|
526
|
-
const
|
|
527
|
-
|
|
532
|
+
const remote = source instanceof RemoteCanvasSource;
|
|
533
|
+
const focusedNodeIds = remote ? new Set() : new Set(listFocuses().map((f) => f.node_id));
|
|
534
|
+
return (await source.listNodes()).map((row) => dashboardRowFromNodeRow(row, focusedNodeIds, remote));
|
|
528
535
|
}
|
|
529
536
|
// ---------------------------------------------------------------------------
|
|
530
537
|
// Lazy enrichment API — fold the expensive per-node reads into rows on demand,
|
|
@@ -565,16 +572,23 @@ export function enrichRows(rows) {
|
|
|
565
572
|
row.enriched = true;
|
|
566
573
|
}
|
|
567
574
|
}
|
|
568
|
-
|
|
575
|
+
/** Telemetry (ctx tokens) is a local disk read, so it is suppressed to 0 for a
|
|
576
|
+
* remote source. Ask counts are suppressed to 0 UNLESS the caller supplies
|
|
577
|
+
* `remoteAsks` — a pre-fetched `askCountsForView` map (one call per forest
|
|
578
|
+
* root; the caller's job, since the right root set is a browse/snapshot
|
|
579
|
+
* concern, not this per-row enrichment loop's). See `runBrowse`'s
|
|
580
|
+
* `remoteAskCounts`, fetched once per snapshot rebuild, not per flush. */
|
|
581
|
+
export async function enrichRowsFromSource(source, rows, remoteAsks) {
|
|
569
582
|
const todo = rows.filter((r) => r.enriched !== true);
|
|
570
583
|
if (todo.length === 0)
|
|
571
584
|
return;
|
|
572
|
-
const
|
|
585
|
+
const remote = source instanceof RemoteCanvasSource;
|
|
586
|
+
const asks = remote ? (remoteAsks ?? {}) : asksForNodes(todo.map((r) => r.node_id));
|
|
573
587
|
await Promise.all(todo.map(async (row) => {
|
|
574
588
|
const meta = await source.getNode(row.node_id);
|
|
575
589
|
if (meta !== null)
|
|
576
590
|
row.name = fullName(meta);
|
|
577
|
-
row.ctx_tokens = readNodeTelemetry(row.node_id).tokens_in ?? 0;
|
|
591
|
+
row.ctx_tokens = remote ? 0 : (readNodeTelemetry(row.node_id).tokens_in ?? 0);
|
|
578
592
|
row.asks = asks[row.node_id] ?? 0;
|
|
579
593
|
row.enriched = true;
|
|
580
594
|
}));
|
|
@@ -595,14 +609,19 @@ export function loadPreview(row) {
|
|
|
595
609
|
row.previewLoaded = true;
|
|
596
610
|
return row;
|
|
597
611
|
}
|
|
612
|
+
/** goal (initial-prompt.md) and session parts are local disk reads — suppressed
|
|
613
|
+
* to undefined for a remote source. */
|
|
598
614
|
export async function loadPreviewFromSource(source, row) {
|
|
599
615
|
if (row.previewLoaded === true)
|
|
600
616
|
return row;
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
617
|
+
const remote = source instanceof RemoteCanvasSource;
|
|
618
|
+
row.goal = remote ? undefined : readGoalText(row.node_id);
|
|
619
|
+
if (!remote) {
|
|
620
|
+
const meta = await source.getNode(row.node_id);
|
|
621
|
+
const { prompts, lastAssistant } = readSessionParts(meta?.pi_session_file);
|
|
622
|
+
row.prompts = prompts;
|
|
623
|
+
row.lastAssistant = lastAssistant;
|
|
624
|
+
}
|
|
606
625
|
row.previewLoaded = true;
|
|
607
626
|
return row;
|
|
608
627
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { NodeMeta, NodeRow, NodeStatus, SubscriptionRef } from './types.js';
|
|
2
|
+
import type { Scope } from '../../types.js';
|
|
2
3
|
export interface CanvasSource {
|
|
3
4
|
getNode(nodeId: string): Promise<NodeMeta | null>;
|
|
4
5
|
getRow(nodeId: string): Promise<NodeRow | null>;
|
|
@@ -24,3 +25,11 @@ export declare class LocalCanvasSource implements CanvasSource {
|
|
|
24
25
|
hasActiveLiveSubscription(nodeId: string): Promise<boolean>;
|
|
25
26
|
}
|
|
26
27
|
export declare const localCanvasSource: CanvasSource;
|
|
28
|
+
/** The active read source for a command: an explicit `--canvas <name>` wins,
|
|
29
|
+
* else the durable `crtr canvas use` selection (`ScopeState.activeCanvas`),
|
|
30
|
+
* else local — byte-for-byte the same `localCanvasSource` singleton so a
|
|
31
|
+
* caller with neither set sees zero behavior change. */
|
|
32
|
+
export declare function resolveCanvasSource(opts?: {
|
|
33
|
+
canvasName?: string;
|
|
34
|
+
scope?: Scope;
|
|
35
|
+
}): CanvasSource;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { getNode, getRow, listNodes, subscriptionsOf, subscribersOf, view, hasActiveLiveSubscription, } from './canvas.js';
|
|
2
2
|
import { asksForNodes } from './attention.js';
|
|
3
|
+
import { readState } from '../config.js';
|
|
4
|
+
import { resolveRemoteCanvasTarget } from '../view/remote-canvas-target.js';
|
|
5
|
+
import { RemoteCanvasSource } from './remote-canvas-source.js';
|
|
3
6
|
export class LocalCanvasSource {
|
|
4
7
|
async getNode(nodeId) {
|
|
5
8
|
return getNode(nodeId);
|
|
@@ -27,3 +30,15 @@ export class LocalCanvasSource {
|
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
32
|
export const localCanvasSource = new LocalCanvasSource();
|
|
33
|
+
/** The active read source for a command: an explicit `--canvas <name>` wins,
|
|
34
|
+
* else the durable `crtr canvas use` selection (`ScopeState.activeCanvas`),
|
|
35
|
+
* else local — byte-for-byte the same `localCanvasSource` singleton so a
|
|
36
|
+
* caller with neither set sees zero behavior change. */
|
|
37
|
+
export function resolveCanvasSource(opts) {
|
|
38
|
+
const scope = opts?.scope ?? 'user';
|
|
39
|
+
const name = opts?.canvasName ?? readState(scope).activeCanvas ?? null;
|
|
40
|
+
if (!name)
|
|
41
|
+
return localCanvasSource;
|
|
42
|
+
const { previewEndpoint, relayToken } = resolveRemoteCanvasTarget(name, scope);
|
|
43
|
+
return new RemoteCanvasSource(previewEndpoint, relayToken);
|
|
44
|
+
}
|
package/dist/core/command.d.ts
CHANGED
|
@@ -56,6 +56,17 @@ export interface BranchDef {
|
|
|
56
56
|
rootEntry?: RootEntry;
|
|
57
57
|
/** Opt into editor slash-command exposure (see SlashSpec). */
|
|
58
58
|
slash?: SlashSpec;
|
|
59
|
+
/** Opt this branch out of the tree model entirely: every token after this
|
|
60
|
+
* branch's name is forwarded VERBATIM (raw argv, not the `--json`-filtered
|
|
61
|
+
* tokens) to an external binary via spawn, with stdio inherited and the
|
|
62
|
+
* child's exit code propagated. A deliberate, documented exception for
|
|
63
|
+
* wrapping an external CLI whose own schema crtr cannot and must not
|
|
64
|
+
* duplicate (see `crtr capture`) — not a general escape hatch. A
|
|
65
|
+
* passthrough branch should declare no children. */
|
|
66
|
+
passthrough?: {
|
|
67
|
+
bin: string;
|
|
68
|
+
installHint: string;
|
|
69
|
+
};
|
|
59
70
|
children: (LeafDef | BranchDef)[];
|
|
60
71
|
}
|
|
61
72
|
export interface RootDef {
|
|
@@ -81,6 +92,10 @@ export declare function defineBranch(opts: {
|
|
|
81
92
|
help: BranchHelp;
|
|
82
93
|
rootEntry?: RootEntry;
|
|
83
94
|
slash?: SlashSpec;
|
|
95
|
+
passthrough?: {
|
|
96
|
+
bin: string;
|
|
97
|
+
installHint: string;
|
|
98
|
+
};
|
|
84
99
|
children: (LeafDef | BranchDef)[];
|
|
85
100
|
}): BranchDef;
|
|
86
101
|
/** Walk the whole tree and collect every node's SlashSpec (depth-first). Used
|