@north-light/crouter 0.3.199 → 0.3.201
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/api/client.d.ts +2 -1
- package/dist/api/client.js +3 -0
- package/dist/api/dto/inbox.d.ts +1 -1
- package/dist/api/dto/nodes.d.ts +16 -0
- package/dist/clients/attach/ansi-cells.d.ts +13 -4
- package/dist/clients/attach/ansi-cells.js +16 -10
- package/dist/clients/attach/overlays/help.js +6 -0
- package/dist/clients/attach/render/transcript-search.d.ts +28 -0
- package/dist/clients/attach/render/transcript-search.js +153 -0
- package/dist/clients/attach/render/viewport.d.ts +10 -1
- package/dist/clients/attach/render/viewport.js +27 -2
- package/dist/clients/attach/session/frame.d.ts +2 -1
- package/dist/clients/attach/session/frame.js +9 -0
- package/dist/clients/attach/session/keys.d.ts +5 -0
- package/dist/clients/attach/session/keys.js +3 -0
- package/dist/clients/attach/session/layout.d.ts +2 -2
- package/dist/clients/attach/session/layout.js +3 -2
- package/dist/clients/attach/session/transcript-search.d.ts +20 -0
- package/dist/clients/attach/session/transcript-search.js +151 -0
- package/dist/clients/attach/viewer.js +742 -740
- package/dist/core/__tests__/helpers/harness.js +17 -55
- package/dist/core/__tests__/seam/broker-crash-teardown.test.js +4 -5
- package/dist/core/__tests__/seam/dormancy-release.test.js +2 -0
- package/dist/core/human/scan.d.ts +1 -3
- package/dist/core/human/scan.js +3 -3
- package/dist/core/human/types.d.ts +1 -0
- package/dist/core/keybindings/attach-control.d.ts +3 -0
- package/dist/core/keybindings/attach-control.js +1 -0
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +6 -0
- package/dist/core/runtime/node-read.d.ts +11 -0
- package/dist/core/runtime/node-read.js +107 -41
- package/dist/daemon/api/handlers/inbox.js +1 -0
- package/dist/daemon/api/handlers/nodes.js +34 -1
- package/package.json +3 -2
- package/runtime.lock.json +2 -2
|
@@ -28,7 +28,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
28
28
|
import { createRequire } from 'node:module';
|
|
29
29
|
import { createNode, getNode, recordPid, subscribersOf, subscriptionsOf, } from '../../canvas/canvas.js';
|
|
30
30
|
import { closeDb } from '../../canvas/db.js';
|
|
31
|
-
import {
|
|
31
|
+
import { spawnChild } from '../../runtime/spawn.js';
|
|
32
32
|
import { superviseTick } from '../../../daemon/crtrd.js';
|
|
33
33
|
import { DaemonFleet } from '../../../daemon/fleet.js';
|
|
34
34
|
import { apiSocketPath } from '../../canvas/paths.js';
|
|
@@ -150,11 +150,11 @@ export async function createHarness(opts = {}) {
|
|
|
150
150
|
const tmpHome = mkdtempSync(join(tmpdir(), 'crtr-harness-HOME-'));
|
|
151
151
|
const session = `${opts.sessionPrefix ?? 'crtr-harness'}-${process.pid}-${Date.now().toString(36)}`;
|
|
152
152
|
// The harness reads/writes the isolated canvas in-process. CRTR_PI_BINARY in
|
|
153
|
-
// OUR env makes in-process
|
|
153
|
+
// OUR env makes in-process launches (spawnChild and superviseTick → reviveNode)
|
|
154
154
|
// bake the fake-pi into the command string.
|
|
155
155
|
process.env['CRTR_HOME'] = home;
|
|
156
156
|
// HOME must be isolated in THIS process too, not just in the subprocess envs
|
|
157
|
-
// below: an in-process launch (spawnHeadlessChild's
|
|
157
|
+
// below: an in-process launch (spawnHeadlessChild's spawnChild, superviseTick
|
|
158
158
|
// pass-2 wakes) builds its broker env from process.env, so a real HOME here
|
|
159
159
|
// makes every broker read the developer's OWN ~/.pi/agent — auth.json and the
|
|
160
160
|
// managed subscription pools — instead of agentHome, and provider tests then
|
|
@@ -202,7 +202,7 @@ export async function createHarness(opts = {}) {
|
|
|
202
202
|
resetEventSourceForTests();
|
|
203
203
|
bindDaemonEventSource();
|
|
204
204
|
// The harness also stands in for the daemon's FLEET: every in-harness broker
|
|
205
|
-
// launch (reviveNode
|
|
205
|
+
// launch (spawnChild, reviveNode, superviseTick pass-2 wakes) registers its
|
|
206
206
|
// ChildProcess handle here, and real child exits enqueue exit-policy jobs on
|
|
207
207
|
// this serial work lane — the same single-lane discipline crtrd runs, but
|
|
208
208
|
// drainable to a deterministic quiescent point. Reset first for the same
|
|
@@ -367,17 +367,9 @@ export async function createHarness(opts = {}) {
|
|
|
367
367
|
}
|
|
368
368
|
function nodeDirs() {
|
|
369
369
|
try {
|
|
370
|
-
// Node ids
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
// opaque and its segment count has already changed once — mirror
|
|
374
|
-
// profiles/manifest.ts's ID_SHAPE rather than hard-coding a segment
|
|
375
|
-
// count). Scope the scan to "one or more lowercase-alnum segments ending
|
|
376
|
-
// in an 8-hex random suffix" so a stray non-node sibling under nodes/ is
|
|
377
|
-
// never miscounted as a new node dir — e.g. a `reports/` dir created for
|
|
378
|
-
// an empty node id (the rare CI flake on compiled-seam run 27459304455 that
|
|
379
|
-
// broke the spawn counters with `expected exactly 1 new node dir, got
|
|
380
|
-
// [<id>, reports]`), or literal `reports` (no hyphen, no hex suffix).
|
|
370
|
+
// Node ids end in an 8-hex random suffix and may have any number of
|
|
371
|
+
// lowercase-alphanumeric prefix segments. Only return those node dirs so
|
|
372
|
+
// fixture cleanup never treats a non-node sibling as a canvas row.
|
|
381
373
|
return readdirSync(join(home, 'nodes')).filter((d) => /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{8}$/.test(d));
|
|
382
374
|
}
|
|
383
375
|
catch {
|
|
@@ -507,46 +499,16 @@ export async function createHarness(opts = {}) {
|
|
|
507
499
|
return id;
|
|
508
500
|
},
|
|
509
501
|
async spawnHeadlessChild(parentId, task, o = {}) {
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
args.push('--kind', o.kind);
|
|
521
|
-
if (o.mode)
|
|
522
|
-
args.push('--mode', o.mode);
|
|
523
|
-
if (o.model)
|
|
524
|
-
args.push('--model', o.model);
|
|
525
|
-
const res = cli(parentId, args);
|
|
526
|
-
if (res.code !== 0) {
|
|
527
|
-
throw new Error(`spawnHeadlessChild(${parentId}) failed (code ${res.code})\n--stdout--\n${res.stdout}\n--stderr--\n${res.stderr}`);
|
|
528
|
-
}
|
|
529
|
-
const added = nodeDirs().filter((d) => !before.has(d));
|
|
530
|
-
if (added.length !== 1) {
|
|
531
|
-
throw new Error(`spawnHeadlessChild: expected exactly 1 new node dir, got [${added.join(', ')}]`);
|
|
532
|
-
}
|
|
533
|
-
const childId = added[0];
|
|
534
|
-
closeDb();
|
|
535
|
-
const fixtureRow = getNode(childId);
|
|
536
|
-
const fixturePid = fixtureRow?.pi_pid ?? null;
|
|
537
|
-
const fixtureIdentity = fixtureRow?.pi_pid_identity ?? null;
|
|
538
|
-
const priorBoots = harness.bootCount(childId);
|
|
539
|
-
if (fixturePid !== null && fixtureIdentity !== null && recordedPidLiveness(fixturePid, fixtureIdentity) === 'alive') {
|
|
540
|
-
process.kill(fixturePid, 'SIGTERM');
|
|
541
|
-
await waitFor(() => recordedPidLiveness(fixturePid, fixtureIdentity) !== 'alive', {
|
|
542
|
-
timeoutMs: 10_000,
|
|
543
|
-
intervalMs: 25,
|
|
544
|
-
label: `API-fixture broker exit for ${childId}`,
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
reviveNode(childId, { resume: false });
|
|
548
|
-
await harness.awaitBoot(childId, { minCount: priorBoots + 1 });
|
|
549
|
-
return childId;
|
|
502
|
+
const result = await spawnChild({
|
|
503
|
+
kind: o.kind ?? 'general',
|
|
504
|
+
cwd: CROUTER,
|
|
505
|
+
prompt: task,
|
|
506
|
+
parent: parentId,
|
|
507
|
+
...(o.mode !== undefined ? { mode: o.mode } : {}),
|
|
508
|
+
...(o.model !== undefined ? { model: o.model } : {}),
|
|
509
|
+
});
|
|
510
|
+
await harness.awaitBoot(result.node.node_id);
|
|
511
|
+
return result.node.node_id;
|
|
550
512
|
},
|
|
551
513
|
cli,
|
|
552
514
|
async stop(nodeId, reason = 'stop', errorMessage) {
|
|
@@ -10,11 +10,10 @@
|
|
|
10
10
|
// lifecycle close` then tears the replacement down (shutdown frame → exit,
|
|
11
11
|
// socket unlinked, status canceled, daemon leaves it).
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// exit drives policy automatically — no synthetic delivery, no tick, no grace.
|
|
13
|
+
// spawnHeadlessChild calls the production birth path in the harness process, so
|
|
14
|
+
// every broker below is harness-fleet-owned from its first and only boot and
|
|
15
|
+
// every SIGKILL's REAL ChildProcess exit drives policy automatically — no
|
|
16
|
+
// synthetic delivery, no tick, no grace.
|
|
18
17
|
// Each crash has its own real broker setup, so either top-level test remains a
|
|
19
18
|
// complete journey under name filtering and pays only its own first respawn
|
|
20
19
|
// backoff.
|
|
@@ -93,6 +93,7 @@ test('idle-release settles its real watcher before exit; daemon revives exactly
|
|
|
93
93
|
// settles its own prompt lifecycle: watcher → engine → stophook → settle →
|
|
94
94
|
// broker exit. No test-driven turn or settlement dispatch is involved.
|
|
95
95
|
await awaitReleased(manager);
|
|
96
|
+
await h.awaitFleetExit(manager);
|
|
96
97
|
assert.equal(readCursor(manager), first.entry_id, 'the exact handed-off row commits before clean shutdown');
|
|
97
98
|
const cyclesAfterRelease = h.node(manager).cycles ?? 0;
|
|
98
99
|
await h.tick();
|
|
@@ -112,6 +113,7 @@ test('idle-release settles its real watcher before exit; daemon revives exactly
|
|
|
112
113
|
await h.awaitBoot(manager, { minCount: 2 });
|
|
113
114
|
await h.awaitWake(manager, { sinceCount: injectedBeforeSecond, match: /second completed/ });
|
|
114
115
|
await awaitReleased(manager);
|
|
116
|
+
await h.awaitFleetExit(manager);
|
|
115
117
|
assert.equal(readCursor(manager), second.entry_id, 'the later row commits at its own natural idle release');
|
|
116
118
|
await h.tick();
|
|
117
119
|
assert.equal(h.node(manager).cycles ?? 0, cyclesAfterRelease + 1, 'the settled later row does not cause a duplicate revival');
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import type { PageTicketSummary, TicketSummary } from './types.js';
|
|
2
2
|
/** Read all unresolved page/review tickets, newest first. Progress never affects visibility. */
|
|
3
3
|
export declare function scanInbox(): TicketSummary[];
|
|
4
|
-
export
|
|
5
|
-
state: 'pending' | 'resolved' | 'canceled' | 'passive';
|
|
6
|
-
}
|
|
4
|
+
export type PageHistoryEntry = PageTicketSummary;
|
|
7
5
|
/**
|
|
8
6
|
* Every page ticket raised by one node — pending, resolved or canceled — oldest
|
|
9
7
|
* first. This is the durable discovery seam for transcript placement, so it is a
|
package/dist/core/human/scan.js
CHANGED
|
@@ -56,6 +56,7 @@ function pageSummary(dir, id) {
|
|
|
56
56
|
awaitsResponse: manifest.delivery.reply,
|
|
57
57
|
source: manifest.source ?? {},
|
|
58
58
|
emittedAt,
|
|
59
|
+
state: pageTicketState(dir),
|
|
59
60
|
claim: claimSummary(dir),
|
|
60
61
|
};
|
|
61
62
|
}
|
|
@@ -112,9 +113,8 @@ export function scanPageHistory(nodeId) {
|
|
|
112
113
|
const summary = pageSummary(dir, basename(dir));
|
|
113
114
|
if (summary === null || summary.source.nodeId !== nodeId)
|
|
114
115
|
continue;
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
items.push({ ...summary, state, ...(result?.kind === 'page' ? { answerDigest: result.summary.replace(/\s+/g, ' ').trim() } : {}) });
|
|
116
|
+
const result = summary.state === 'resolved' ? readTicketResult(dir) : null;
|
|
117
|
+
items.push({ ...summary, ...(result?.kind === 'page' ? { answerDigest: result.summary.replace(/\s+/g, ' ').trim() } : {}) });
|
|
118
118
|
}
|
|
119
119
|
return items.sort((a, b) => Date.parse(a.emittedAt) - Date.parse(b.emittedAt) || a.id.localeCompare(b.id));
|
|
120
120
|
}
|
|
@@ -65,6 +65,7 @@ export interface PageTicketSummary {
|
|
|
65
65
|
steps: number;
|
|
66
66
|
slotKinds: string[];
|
|
67
67
|
awaitsResponse: boolean;
|
|
68
|
+
state: 'pending' | 'resolved' | 'canceled' | 'passive';
|
|
68
69
|
}
|
|
69
70
|
/** The only pending-ticket shape scanners expose. */
|
|
70
71
|
export type TicketSummary = ReviewTicketSummary | PageTicketSummary;
|
|
@@ -28,6 +28,9 @@ export declare const ATTACH_CONTROL_BINDINGS: readonly [{
|
|
|
28
28
|
}, {
|
|
29
29
|
readonly menuId: "crtr.tmux.menu.attach.profile-files";
|
|
30
30
|
readonly actionId: "crtr.attach.profile-files";
|
|
31
|
+
}, {
|
|
32
|
+
readonly menuId: "crtr.tmux.menu.attach.search";
|
|
33
|
+
readonly actionId: "crtr.attach.search";
|
|
31
34
|
}, {
|
|
32
35
|
readonly menuId: "crtr.tmux.menu.attach.whip";
|
|
33
36
|
readonly actionId: "crtr.attach.whip";
|
|
@@ -9,6 +9,7 @@ export const ATTACH_CONTROL_BINDINGS = [
|
|
|
9
9
|
{ menuId: 'crtr.tmux.menu.attach.command.inspect', actionId: 'crtr.attach.command.inspect' },
|
|
10
10
|
{ menuId: 'crtr.tmux.menu.attach.file-review', actionId: 'crtr.attach.file-review' },
|
|
11
11
|
{ menuId: 'crtr.tmux.menu.attach.profile-files', actionId: 'crtr.attach.profile-files' },
|
|
12
|
+
{ menuId: 'crtr.tmux.menu.attach.search', actionId: 'crtr.attach.search' },
|
|
12
13
|
{ menuId: 'crtr.tmux.menu.attach.whip', actionId: 'crtr.attach.whip' },
|
|
13
14
|
];
|
|
14
15
|
const ATTACH_CONTROL_PREFIX = '\x1b_crtr;';
|
|
@@ -8,8 +8,8 @@ type ProfileMenuAction = 'up' | 'down' | 'select' | 'accept-default' | 'abort' |
|
|
|
8
8
|
type SetupAction = 'cancel' | 'tab-next' | 'tab-previous' | 'open' | 'list-down' | 'list-up' | 'toggle' | 'details' | 'advance' | 'cell-previous' | 'cell-next';
|
|
9
9
|
type SetupKeybindingAction = 'edit' | 'commit-edit' | 'cancel-edit' | 'unbind' | 'reset-row' | 'reset-all' | 'save' | 'discard' | 'page-up' | 'page-down' | 'filter' | 'filter-delete' | 'filter-clear';
|
|
10
10
|
type SetupModelsAction = 'open' | 'commit' | 'cancel' | 'scope-next';
|
|
11
|
-
export type BindingId = `crtr.tmux.menu.${TmuxMenuAction}` | `crtr.tmux.menu.issue.${'idea' | 'bug'}` | AttachMenuBindingId | `crtr.tmux.menu-global.${TmuxMenuEntryAction}` | `crtr.tmux.node.${'next' | 'previous'}` | 'crtr.tmux.inbox.toggle' | `crtr.attach.scroll.${'up' | 'down' | 'page-up' | 'page-down' | 'live'}` | 'crtr.attach.mouse.toggle' | 'crtr.attach.copy.message' | 'crtr.attach.help.toggle' | AttachBindingId | `crtr.graph.${GraphAction}` | `crtr.graph.confirm.${'accept' | 'cancel'}` | `crtr.inspector.${'force-quit' | 'down' | 'up' | 'section-next' | 'section-prev' | 'page-down' | 'page-up' | 'toggle-detail' | 'cancel-item' | 'open-log' | 'confirm' | 'dismiss' | 'retarget' | 'refresh' | 'quit'}` | `crtr.browse.${BrowseAction}` | `crtr.browse.search.${'cancel' | 'commit' | 'delete' | 'quit'}` | `crtr.profile.menu.${ProfileMenuAction}` | `crtr.profile.search.${'up' | 'down' | 'pick' | 'back' | 'delete' | 'abort'}` | `crtr.setup.${SetupAction}` | `crtr.setup.confirm.${'accept' | 'back'}` | `crtr.setup.keybindings.${SetupKeybindingAction}` | `crtr.setup.models.${SetupModelsAction}`;
|
|
12
|
-
export declare const BINDING_CATALOG: readonly [BindingDefinition<"crtr.tmux.menu.open">, BindingDefinition<"crtr.tmux.menu.attach">, ...(BindingDefinition<BindingId> | BindingDefinition<"crtr.tmux.node.next"> | BindingDefinition<"crtr.tmux.node.previous"> | BindingDefinition<"crtr.tmux.inbox.toggle"> | BindingDefinition<"crtr.attach.scroll.up"> | BindingDefinition<"crtr.attach.scroll.down"> | BindingDefinition<"crtr.attach.scroll.page-up"> | BindingDefinition<"crtr.attach.scroll.page-down"> | BindingDefinition<"crtr.attach.scroll.live"> | BindingDefinition<"crtr.attach.mouse.toggle"> | BindingDefinition<"crtr.attach.copy.message"> | BindingDefinition<"crtr.graph.down"> | BindingDefinition<"crtr.graph.up"> | BindingDefinition<"crtr.graph.first"> | BindingDefinition<"crtr.graph.last"> | BindingDefinition<"crtr.graph.collapse-or-manager"> | BindingDefinition<"crtr.graph.expand-or-child"> | BindingDefinition<"crtr.graph.focus"> | BindingDefinition<"crtr.graph.revive"> | BindingDefinition<"crtr.graph.focus-manager"> | BindingDefinition<"crtr.graph.close-subtree"> | BindingDefinition<"crtr.graph.dismiss"> | BindingDefinition<"crtr.graph.confirm.accept"> | BindingDefinition<"crtr.graph.confirm.cancel"> | BindingDefinition<"crtr.inspector.force-quit"> | BindingDefinition<"crtr.inspector.down"> | BindingDefinition<"crtr.inspector.up"> | BindingDefinition<"crtr.inspector.section-next"> | BindingDefinition<"crtr.inspector.section-prev"> | BindingDefinition<"crtr.inspector.page-down"> | BindingDefinition<"crtr.inspector.page-up"> | BindingDefinition<"crtr.inspector.toggle-detail"> | BindingDefinition<"crtr.inspector.cancel-item"> | BindingDefinition<"crtr.inspector.open-log"> | BindingDefinition<"crtr.inspector.confirm"> | BindingDefinition<"crtr.inspector.dismiss"> | BindingDefinition<"crtr.inspector.retarget"> | BindingDefinition<"crtr.inspector.refresh"> | BindingDefinition<"crtr.inspector.quit"> | BindingDefinition<"crtr.browse.quit"> | BindingDefinition<"crtr.browse.down"> | BindingDefinition<"crtr.browse.up"> | BindingDefinition<"crtr.browse.first"> | BindingDefinition<"crtr.browse.last"> | BindingDefinition<"crtr.browse.expand-or-child"> | BindingDefinition<"crtr.browse.collapse-or-parent"> | BindingDefinition<"crtr.browse.toggle-fold"> | BindingDefinition<"crtr.browse.tab-next"> | BindingDefinition<"crtr.browse.tab-previous"> | BindingDefinition<"crtr.browse.tab.2" | "crtr.browse.tab.1" | "crtr.browse.tab.3" | "crtr.browse.tab.4" | "crtr.browse.tab.5"> | BindingDefinition<"crtr.browse.close-subtree"> | BindingDefinition<"crtr.browse.confirm-close"> | BindingDefinition<"crtr.browse.sort"> | BindingDefinition<"crtr.browse.scope"> | BindingDefinition<"crtr.browse.profile"> | BindingDefinition<"crtr.browse.residents"> | BindingDefinition<"crtr.browse.preview"> | BindingDefinition<"crtr.browse.search"> | BindingDefinition<"crtr.browse.revive-now"> | BindingDefinition<"crtr.browse.focus"> | BindingDefinition<"crtr.browse.focus-background"> | BindingDefinition<"crtr.browse.yank-id"> | BindingDefinition<"crtr.browse.search.cancel"> | BindingDefinition<"crtr.browse.search.commit"> | BindingDefinition<"crtr.browse.search.delete"> | BindingDefinition<"crtr.browse.search.quit"> | BindingDefinition<"crtr.profile.menu.up"> | BindingDefinition<"crtr.profile.menu.down"> | BindingDefinition<"crtr.profile.menu.select"> | BindingDefinition<"crtr.profile.menu.accept-default"> | BindingDefinition<"crtr.profile.menu.abort"> | BindingDefinition<"crtr.profile.menu.slot.2" | "crtr.profile.menu.slot.1" | "crtr.profile.menu.slot.3" | "crtr.profile.menu.slot.4" | "crtr.profile.menu.slot.5" | "crtr.profile.menu.slot.6" | "crtr.profile.menu.slot.7" | "crtr.profile.menu.slot.8" | "crtr.profile.menu.slot.9">)[], BindingDefinition<"crtr.profile.menu.create">, BindingDefinition<"crtr.profile.menu.search-existing">, BindingDefinition<"crtr.profile.menu.use-root">, BindingDefinition<"crtr.profile.menu.set-default">, BindingDefinition<"crtr.profile.menu.add-directory">, BindingDefinition<"crtr.profile.menu.unpin">, BindingDefinition<"crtr.profile.search.up">, BindingDefinition<"crtr.profile.search.down">, BindingDefinition<"crtr.profile.search.pick">, BindingDefinition<"crtr.profile.search.back">, BindingDefinition<"crtr.profile.search.delete">, BindingDefinition<"crtr.profile.search.abort">, BindingDefinition<"crtr.setup.cancel">, BindingDefinition<"crtr.setup.tab-next">, BindingDefinition<"crtr.setup.tab-previous">, BindingDefinition<"crtr.setup.open">, BindingDefinition<"crtr.setup.list-down">, BindingDefinition<"crtr.setup.list-up">, BindingDefinition<"crtr.setup.toggle">, BindingDefinition<"crtr.setup.details">, BindingDefinition<"crtr.setup.advance">, BindingDefinition<"crtr.setup.confirm.accept">, BindingDefinition<"crtr.setup.confirm.back">, BindingDefinition<"crtr.setup.keybindings.edit">, BindingDefinition<"crtr.setup.keybindings.commit-edit">, BindingDefinition<"crtr.setup.keybindings.cancel-edit">, BindingDefinition<"crtr.setup.keybindings.unbind">, BindingDefinition<"crtr.setup.keybindings.reset-row">, BindingDefinition<"crtr.setup.keybindings.reset-all">, BindingDefinition<"crtr.setup.keybindings.save">, BindingDefinition<"crtr.setup.keybindings.discard">, BindingDefinition<"crtr.setup.keybindings.page-up">, BindingDefinition<"crtr.setup.keybindings.page-down">, BindingDefinition<"crtr.setup.keybindings.filter">, BindingDefinition<"crtr.setup.keybindings.filter-delete">, BindingDefinition<"crtr.setup.keybindings.filter-clear">, BindingDefinition<"crtr.setup.cell-previous">, BindingDefinition<"crtr.setup.cell-next">, BindingDefinition<"crtr.setup.models.open">, BindingDefinition<"crtr.setup.models.scope-next">, BindingDefinition<"crtr.setup.models.commit">, BindingDefinition<"crtr.setup.models.cancel">];
|
|
11
|
+
export type BindingId = `crtr.tmux.menu.${TmuxMenuAction}` | `crtr.tmux.menu.issue.${'idea' | 'bug'}` | AttachMenuBindingId | `crtr.tmux.menu-global.${TmuxMenuEntryAction}` | `crtr.tmux.node.${'next' | 'previous'}` | 'crtr.tmux.inbox.toggle' | `crtr.attach.scroll.${'up' | 'down' | 'page-up' | 'page-down' | 'live'}` | 'crtr.attach.mouse.toggle' | 'crtr.attach.copy.message' | 'crtr.attach.help.toggle' | `crtr.attach.search.${'next' | 'previous' | 'edit' | 'close'}` | AttachBindingId | `crtr.graph.${GraphAction}` | `crtr.graph.confirm.${'accept' | 'cancel'}` | `crtr.inspector.${'force-quit' | 'down' | 'up' | 'section-next' | 'section-prev' | 'page-down' | 'page-up' | 'toggle-detail' | 'cancel-item' | 'open-log' | 'confirm' | 'dismiss' | 'retarget' | 'refresh' | 'quit'}` | `crtr.browse.${BrowseAction}` | `crtr.browse.search.${'cancel' | 'commit' | 'delete' | 'quit'}` | `crtr.profile.menu.${ProfileMenuAction}` | `crtr.profile.search.${'up' | 'down' | 'pick' | 'back' | 'delete' | 'abort'}` | `crtr.setup.${SetupAction}` | `crtr.setup.confirm.${'accept' | 'back'}` | `crtr.setup.keybindings.${SetupKeybindingAction}` | `crtr.setup.models.${SetupModelsAction}`;
|
|
12
|
+
export declare const BINDING_CATALOG: readonly [BindingDefinition<"crtr.tmux.menu.open">, BindingDefinition<"crtr.tmux.menu.attach">, ...(BindingDefinition<BindingId> | BindingDefinition<"crtr.tmux.node.next"> | BindingDefinition<"crtr.tmux.node.previous"> | BindingDefinition<"crtr.tmux.inbox.toggle"> | BindingDefinition<"crtr.attach.scroll.up"> | BindingDefinition<"crtr.attach.scroll.down"> | BindingDefinition<"crtr.attach.scroll.page-up"> | BindingDefinition<"crtr.attach.scroll.page-down"> | BindingDefinition<"crtr.attach.scroll.live"> | BindingDefinition<"crtr.attach.mouse.toggle"> | BindingDefinition<"crtr.attach.copy.message"> | BindingDefinition<"crtr.attach.search.next"> | BindingDefinition<"crtr.attach.search.previous"> | BindingDefinition<"crtr.attach.search.edit"> | BindingDefinition<"crtr.attach.search.close"> | BindingDefinition<"crtr.graph.down"> | BindingDefinition<"crtr.graph.up"> | BindingDefinition<"crtr.graph.first"> | BindingDefinition<"crtr.graph.last"> | BindingDefinition<"crtr.graph.collapse-or-manager"> | BindingDefinition<"crtr.graph.expand-or-child"> | BindingDefinition<"crtr.graph.focus"> | BindingDefinition<"crtr.graph.revive"> | BindingDefinition<"crtr.graph.focus-manager"> | BindingDefinition<"crtr.graph.close-subtree"> | BindingDefinition<"crtr.graph.dismiss"> | BindingDefinition<"crtr.graph.confirm.accept"> | BindingDefinition<"crtr.graph.confirm.cancel"> | BindingDefinition<"crtr.inspector.force-quit"> | BindingDefinition<"crtr.inspector.down"> | BindingDefinition<"crtr.inspector.up"> | BindingDefinition<"crtr.inspector.section-next"> | BindingDefinition<"crtr.inspector.section-prev"> | BindingDefinition<"crtr.inspector.page-down"> | BindingDefinition<"crtr.inspector.page-up"> | BindingDefinition<"crtr.inspector.toggle-detail"> | BindingDefinition<"crtr.inspector.cancel-item"> | BindingDefinition<"crtr.inspector.open-log"> | BindingDefinition<"crtr.inspector.confirm"> | BindingDefinition<"crtr.inspector.dismiss"> | BindingDefinition<"crtr.inspector.retarget"> | BindingDefinition<"crtr.inspector.refresh"> | BindingDefinition<"crtr.inspector.quit"> | BindingDefinition<"crtr.browse.quit"> | BindingDefinition<"crtr.browse.down"> | BindingDefinition<"crtr.browse.up"> | BindingDefinition<"crtr.browse.first"> | BindingDefinition<"crtr.browse.last"> | BindingDefinition<"crtr.browse.expand-or-child"> | BindingDefinition<"crtr.browse.collapse-or-parent"> | BindingDefinition<"crtr.browse.toggle-fold"> | BindingDefinition<"crtr.browse.tab-next"> | BindingDefinition<"crtr.browse.tab-previous"> | BindingDefinition<"crtr.browse.tab.2" | "crtr.browse.tab.1" | "crtr.browse.tab.3" | "crtr.browse.tab.4" | "crtr.browse.tab.5"> | BindingDefinition<"crtr.browse.close-subtree"> | BindingDefinition<"crtr.browse.confirm-close"> | BindingDefinition<"crtr.browse.sort"> | BindingDefinition<"crtr.browse.scope"> | BindingDefinition<"crtr.browse.profile"> | BindingDefinition<"crtr.browse.residents"> | BindingDefinition<"crtr.browse.preview"> | BindingDefinition<"crtr.browse.search"> | BindingDefinition<"crtr.browse.revive-now"> | BindingDefinition<"crtr.browse.focus"> | BindingDefinition<"crtr.browse.focus-background"> | BindingDefinition<"crtr.browse.yank-id"> | BindingDefinition<"crtr.browse.search.cancel"> | BindingDefinition<"crtr.browse.search.commit"> | BindingDefinition<"crtr.browse.search.delete"> | BindingDefinition<"crtr.browse.search.quit"> | BindingDefinition<"crtr.profile.menu.up"> | BindingDefinition<"crtr.profile.menu.down"> | BindingDefinition<"crtr.profile.menu.select"> | BindingDefinition<"crtr.profile.menu.accept-default"> | BindingDefinition<"crtr.profile.menu.abort"> | BindingDefinition<"crtr.profile.menu.slot.2" | "crtr.profile.menu.slot.1" | "crtr.profile.menu.slot.3" | "crtr.profile.menu.slot.4" | "crtr.profile.menu.slot.5" | "crtr.profile.menu.slot.6" | "crtr.profile.menu.slot.7" | "crtr.profile.menu.slot.8" | "crtr.profile.menu.slot.9">)[], BindingDefinition<"crtr.profile.menu.create">, BindingDefinition<"crtr.profile.menu.search-existing">, BindingDefinition<"crtr.profile.menu.use-root">, BindingDefinition<"crtr.profile.menu.set-default">, BindingDefinition<"crtr.profile.menu.add-directory">, BindingDefinition<"crtr.profile.menu.unpin">, BindingDefinition<"crtr.profile.search.up">, BindingDefinition<"crtr.profile.search.down">, BindingDefinition<"crtr.profile.search.pick">, BindingDefinition<"crtr.profile.search.back">, BindingDefinition<"crtr.profile.search.delete">, BindingDefinition<"crtr.profile.search.abort">, BindingDefinition<"crtr.setup.cancel">, BindingDefinition<"crtr.setup.tab-next">, BindingDefinition<"crtr.setup.tab-previous">, BindingDefinition<"crtr.setup.open">, BindingDefinition<"crtr.setup.list-down">, BindingDefinition<"crtr.setup.list-up">, BindingDefinition<"crtr.setup.toggle">, BindingDefinition<"crtr.setup.details">, BindingDefinition<"crtr.setup.advance">, BindingDefinition<"crtr.setup.confirm.accept">, BindingDefinition<"crtr.setup.confirm.back">, BindingDefinition<"crtr.setup.keybindings.edit">, BindingDefinition<"crtr.setup.keybindings.commit-edit">, BindingDefinition<"crtr.setup.keybindings.cancel-edit">, BindingDefinition<"crtr.setup.keybindings.unbind">, BindingDefinition<"crtr.setup.keybindings.reset-row">, BindingDefinition<"crtr.setup.keybindings.reset-all">, BindingDefinition<"crtr.setup.keybindings.save">, BindingDefinition<"crtr.setup.keybindings.discard">, BindingDefinition<"crtr.setup.keybindings.page-up">, BindingDefinition<"crtr.setup.keybindings.page-down">, BindingDefinition<"crtr.setup.keybindings.filter">, BindingDefinition<"crtr.setup.keybindings.filter-delete">, BindingDefinition<"crtr.setup.keybindings.filter-clear">, BindingDefinition<"crtr.setup.cell-previous">, BindingDefinition<"crtr.setup.cell-next">, BindingDefinition<"crtr.setup.models.open">, BindingDefinition<"crtr.setup.models.scope-next">, BindingDefinition<"crtr.setup.models.commit">, BindingDefinition<"crtr.setup.models.cancel">];
|
|
13
13
|
export declare const BINDING_IDS: ReadonlySet<string>;
|
|
14
14
|
/** True for an action whose direct gesture is handled inside an attach viewer.
|
|
15
15
|
* Tmux menu selectors and root shortcuts are chrome, not pane input. */
|
|
@@ -77,6 +77,7 @@ export const BINDING_CATALOG = Object.freeze([
|
|
|
77
77
|
...attachEntry('crtr.tmux.menu.attach.command.inspect', 'crtr.attach.command.inspect', 'Inspect loaded command', 'Open the loaded slash command’s expanded prompt.', ['h'], ['alt+shift+h']),
|
|
78
78
|
...attachEntry('crtr.tmux.menu.attach.file-review', 'crtr.attach.file-review', 'Review a file', 'Open a line-anchored review of a file named in the transcript.', [], ['alt+shift+r']),
|
|
79
79
|
...attachEntry('crtr.tmux.menu.attach.profile-files', 'crtr.attach.profile-files', 'Search profile files', 'Search files across the node’s profile projects, profile memory, cwd, and ancestor crouter memory stores.', ['f'], ['ctrl+f']),
|
|
80
|
+
...attachEntry('crtr.tmux.menu.attach.search', 'crtr.attach.search', 'Search transcript', 'Search the transcript as it is currently displayed — folded tool output is not searched until it is unfolded.', ['/'], ['alt+/']),
|
|
80
81
|
...attachEntry('crtr.tmux.menu.attach.whip', 'crtr.attach.whip', 'Whip agent', 'Interrupt the agent and send a playful command.', ['w'], []),
|
|
81
82
|
b('crtr.attach.scroll.up', 'Attach', 'Scroll up', 'Scroll the transcript one line up.', ['shift+up'], 'terminal', ATTACH_CONTEXTS),
|
|
82
83
|
b('crtr.attach.scroll.down', 'Attach', 'Scroll down', 'Scroll the transcript one line down.', ['shift+down'], 'terminal', ATTACH_CONTEXTS),
|
|
@@ -85,6 +86,10 @@ export const BINDING_CATALOG = Object.freeze([
|
|
|
85
86
|
b('crtr.attach.scroll.live', 'Attach', 'Return to live', 'Return the transcript to the live bottom.', ['shift+end'], 'terminal', ATTACH_CONTEXTS),
|
|
86
87
|
b('crtr.attach.mouse.toggle', 'Attach', 'Toggle mouse wheel scrolling', 'Toggle mouse wheel scrolling on or off.', ['alt+shift+o'], 'terminal', ATTACH_CONTEXTS),
|
|
87
88
|
b('crtr.attach.copy.message', 'Attach', 'Copy visible message', 'Copy the newest message visible in the transcript viewport.', ['alt+shift+y'], 'terminal', ATTACH_CONTEXTS),
|
|
89
|
+
b('crtr.attach.search.next', 'Attach', 'Next match', 'Move to the next transcript search match.', ['n'], 'terminal', ['attach.search']),
|
|
90
|
+
b('crtr.attach.search.previous', 'Attach', 'Previous match', 'Move to the previous transcript search match.', ['shift+n'], 'terminal', ['attach.search']),
|
|
91
|
+
b('crtr.attach.search.edit', 'Attach', 'Edit query', 'Return to editing the transcript search query.', ['/'], 'terminal', ['attach.search']),
|
|
92
|
+
b('crtr.attach.search.close', 'Attach', 'Close search', 'Leave transcript search and clear its highlights.', ['escape'], 'terminal', ['attach.search'], 'dismiss-modal'),
|
|
88
93
|
b('crtr.graph.down', 'GRAPH', 'Move down', 'Move to the next graph row.', ['j'], 'terminal', ['graph']),
|
|
89
94
|
b('crtr.graph.up', 'GRAPH', 'Move up', 'Move to the previous graph row.', ['k'], 'terminal', ['graph']),
|
|
90
95
|
b('crtr.graph.first', 'GRAPH', 'First row', 'Move to the first graph row.', ['g'], 'terminal', ['graph']),
|
|
@@ -219,6 +224,7 @@ export const SAFETY_REQUIREMENTS = Object.freeze([
|
|
|
219
224
|
{ context: 'graph.confirm', capability: 'dismiss-modal' },
|
|
220
225
|
{ context: 'inspector', capability: 'leave-screen' },
|
|
221
226
|
{ context: 'inspector', capability: 'dismiss-modal' },
|
|
227
|
+
{ context: 'attach.search', capability: 'dismiss-modal' },
|
|
222
228
|
{ context: 'browse', capability: 'leave-screen' },
|
|
223
229
|
{ context: 'browse.search', capability: 'dismiss-modal' },
|
|
224
230
|
{ context: 'profile.menu', capability: 'leave-screen' },
|
|
@@ -28,9 +28,20 @@ export interface NodeSnapshotRead {
|
|
|
28
28
|
source: 'builtin';
|
|
29
29
|
}>;
|
|
30
30
|
}
|
|
31
|
+
export interface NodeMessagesPageRead {
|
|
32
|
+
messages: unknown[];
|
|
33
|
+
nextCursor: string | null;
|
|
34
|
+
}
|
|
35
|
+
export interface NodeMessagesPageOptions {
|
|
36
|
+
cursor?: string;
|
|
37
|
+
limit?: number;
|
|
38
|
+
}
|
|
31
39
|
/** Reconstruct a persisted session without launching its broker. Backs the
|
|
32
40
|
* machine-readable snapshot endpoint and the human transcript render. */
|
|
33
41
|
export declare function readNodeSnapshot(nodeId: string): Promise<NodeSnapshotRead>;
|
|
42
|
+
/** A backward-paged, chronological window over the same visible history as a
|
|
43
|
+
* snapshot. `nextCursor` walks toward older messages. */
|
|
44
|
+
export declare function readNodeMessagesPage(nodeId: string, { cursor, limit }: NodeMessagesPageOptions): Promise<NodeMessagesPageRead>;
|
|
34
45
|
/** Human-readable history only: text messages plus terse tool records, never
|
|
35
46
|
* private model reasoning. */
|
|
36
47
|
export declare function transcriptMarkdown(nodeId: string): Promise<string>;
|
|
@@ -135,9 +135,10 @@ function readToolGroupSummaries(nodeId) {
|
|
|
135
135
|
return {};
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
|
-
/** Reconstruct
|
|
139
|
-
*
|
|
140
|
-
|
|
138
|
+
/** Reconstruct the visible message history from a stable at-rest session copy.
|
|
139
|
+
* Both snapshot and paged reads use this one path so their histories cannot
|
|
140
|
+
* diverge. */
|
|
141
|
+
async function reconstructVisibleMessages(nodeId) {
|
|
141
142
|
const node = getNode(nodeId);
|
|
142
143
|
if (node === null) {
|
|
143
144
|
throw new InputError({ error: 'not_found', message: `no node: ${nodeId}`, field: 'id', next: 'List nodes with `crtr node inspect list`.' });
|
|
@@ -146,57 +147,122 @@ export async function readNodeSnapshot(nodeId) {
|
|
|
146
147
|
if (sessionFile === null) {
|
|
147
148
|
throw new InputError({ error: 'no_session', message: `node has no readable session file: ${nodeId}`, field: 'id', next: 'Choose a node with captured conversation history.' });
|
|
148
149
|
}
|
|
149
|
-
const tempDir = mkdtempSync(join(tmpdir(), 'crtr-node-
|
|
150
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'crtr-node-session-'));
|
|
150
151
|
try {
|
|
151
152
|
const copy = join(tempDir, basename(sessionFile));
|
|
152
153
|
copyFileSync(sessionFile, copy);
|
|
153
154
|
const manager = SessionManager.open(copy);
|
|
154
155
|
const context = manager.buildSessionContext();
|
|
155
156
|
const messages = visibleMessages(cycleAwareMessages(manager), { boundaryReviewId: node.review_binding?.review_id });
|
|
156
|
-
const sessionId = manager.getSessionId();
|
|
157
|
-
const sessionName = manager.getSessionName();
|
|
158
|
-
const snapshot = {
|
|
159
|
-
messages,
|
|
160
|
-
stats: statsFromMessages(messages, sessionId, sessionFile),
|
|
161
|
-
// pi's `get_state` shape (RpcSessionState), reconstructed offline: the
|
|
162
|
-
// live-only fields report their at-rest values (nothing is streaming or
|
|
163
|
-
// queued for a node with no broker).
|
|
164
|
-
state: {
|
|
165
|
-
model: await resolveRecordedModel(context.model),
|
|
166
|
-
thinkingLevel: context.thinkingLevel,
|
|
167
|
-
isStreaming: false,
|
|
168
|
-
isCompacting: false,
|
|
169
|
-
steeringMode: 'one-at-a-time',
|
|
170
|
-
followUpMode: 'one-at-a-time',
|
|
171
|
-
sessionFile,
|
|
172
|
-
sessionId,
|
|
173
|
-
sessionName,
|
|
174
|
-
autoCompactionEnabled: true,
|
|
175
|
-
messageCount: messages.length,
|
|
176
|
-
pendingMessageCount: 0,
|
|
177
|
-
},
|
|
178
|
-
// A snapshot reconstructed from the .jsonl alone: display state is broker
|
|
179
|
-
// process state (last setStatus/setWidget/setTitle) and is not persisted,
|
|
180
|
-
// so an offline read has none.
|
|
181
|
-
display: { statuses: {}, widgets: {}, title: undefined },
|
|
182
|
-
// The unrun queue lives in the engine's memory; a dormant node has no
|
|
183
|
-
// engine, so nothing is waiting to run.
|
|
184
|
-
queued: { steering: [], followUp: [] },
|
|
185
|
-
// Sidecar summaries survive broker exit so a dormant transcript receives
|
|
186
|
-
// the same completed-group data as a live welcome snapshot. Malformed or
|
|
187
|
-
// absent sidecars are deliberately ignored: tool calls remain visible.
|
|
188
|
-
toolGroupSummaries: readToolGroupSummaries(nodeId),
|
|
189
|
-
};
|
|
190
157
|
return {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
158
|
+
sessionFile,
|
|
159
|
+
messages,
|
|
160
|
+
model: context.model,
|
|
161
|
+
thinkingLevel: context.thinkingLevel,
|
|
162
|
+
sessionId: manager.getSessionId(),
|
|
163
|
+
sessionName: manager.getSessionName(),
|
|
194
164
|
};
|
|
195
165
|
}
|
|
196
166
|
finally {
|
|
197
167
|
rmSync(tempDir, { recursive: true, force: true });
|
|
198
168
|
}
|
|
199
169
|
}
|
|
170
|
+
function invalidCursor() {
|
|
171
|
+
throw new InputError({
|
|
172
|
+
error: 'invalid_cursor',
|
|
173
|
+
message: 'cursor must be a base64url-encoded messages cursor.',
|
|
174
|
+
field: 'cursor',
|
|
175
|
+
next: 'Omit cursor to start from the newest messages.',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function decodeMessagesCursor(cursor, messagesLength) {
|
|
179
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(cursor))
|
|
180
|
+
invalidCursor();
|
|
181
|
+
let parsed;
|
|
182
|
+
try {
|
|
183
|
+
const json = Buffer.from(cursor, 'base64url').toString('utf8');
|
|
184
|
+
if (Buffer.from(json, 'utf8').toString('base64url') !== cursor)
|
|
185
|
+
invalidCursor();
|
|
186
|
+
parsed = JSON.parse(json);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
invalidCursor();
|
|
190
|
+
}
|
|
191
|
+
if (typeof parsed !== 'object'
|
|
192
|
+
|| parsed === null
|
|
193
|
+
|| parsed.v !== 1
|
|
194
|
+
|| !Number.isSafeInteger(parsed.before)) {
|
|
195
|
+
invalidCursor();
|
|
196
|
+
}
|
|
197
|
+
const before = parsed.before;
|
|
198
|
+
if (before < 0 || before > messagesLength)
|
|
199
|
+
invalidCursor();
|
|
200
|
+
return before;
|
|
201
|
+
}
|
|
202
|
+
function encodeMessagesCursor(before) {
|
|
203
|
+
return Buffer.from(JSON.stringify({ v: 1, before }), 'utf8').toString('base64url');
|
|
204
|
+
}
|
|
205
|
+
/** Reconstruct a persisted session without launching its broker. Backs the
|
|
206
|
+
* machine-readable snapshot endpoint and the human transcript render. */
|
|
207
|
+
export async function readNodeSnapshot(nodeId) {
|
|
208
|
+
const read = await reconstructVisibleMessages(nodeId);
|
|
209
|
+
const snapshot = {
|
|
210
|
+
messages: read.messages,
|
|
211
|
+
stats: statsFromMessages(read.messages, read.sessionId, read.sessionFile),
|
|
212
|
+
// pi's `get_state` shape (RpcSessionState), reconstructed offline: the
|
|
213
|
+
// live-only fields report their at-rest values (nothing is streaming or
|
|
214
|
+
// queued for a node with no broker).
|
|
215
|
+
state: {
|
|
216
|
+
model: await resolveRecordedModel(read.model),
|
|
217
|
+
thinkingLevel: read.thinkingLevel,
|
|
218
|
+
isStreaming: false,
|
|
219
|
+
isCompacting: false,
|
|
220
|
+
steeringMode: 'one-at-a-time',
|
|
221
|
+
followUpMode: 'one-at-a-time',
|
|
222
|
+
sessionFile: read.sessionFile,
|
|
223
|
+
sessionId: read.sessionId,
|
|
224
|
+
sessionName: read.sessionName,
|
|
225
|
+
autoCompactionEnabled: true,
|
|
226
|
+
messageCount: read.messages.length,
|
|
227
|
+
pendingMessageCount: 0,
|
|
228
|
+
},
|
|
229
|
+
// A snapshot reconstructed from the .jsonl alone: display state is broker
|
|
230
|
+
// process state (last setStatus/setWidget/setTitle) and is not persisted,
|
|
231
|
+
// so an offline read has none.
|
|
232
|
+
display: { statuses: {}, widgets: {}, title: undefined },
|
|
233
|
+
// The unrun queue lives in the engine's memory; a dormant node has no
|
|
234
|
+
// engine, so nothing is waiting to run.
|
|
235
|
+
queued: { steering: [], followUp: [] },
|
|
236
|
+
// Sidecar summaries survive broker exit so a dormant transcript receives
|
|
237
|
+
// the same completed-group data as a live welcome snapshot. Malformed or
|
|
238
|
+
// absent sidecars are deliberately ignored: tool calls remain visible.
|
|
239
|
+
toolGroupSummaries: readToolGroupSummaries(nodeId),
|
|
240
|
+
};
|
|
241
|
+
return {
|
|
242
|
+
nodeId,
|
|
243
|
+
snapshot,
|
|
244
|
+
commands: BUILTIN_SLASH_COMMANDS.map((c) => ({ ...c, source: 'builtin' })),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/** A backward-paged, chronological window over the same visible history as a
|
|
248
|
+
* snapshot. `nextCursor` walks toward older messages. */
|
|
249
|
+
export async function readNodeMessagesPage(nodeId, { cursor, limit = 200 }) {
|
|
250
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) {
|
|
251
|
+
throw new InputError({
|
|
252
|
+
error: 'invalid_limit',
|
|
253
|
+
message: 'limit must be an integer from 1 through 500.',
|
|
254
|
+
field: 'limit',
|
|
255
|
+
next: 'Use a limit from 1 through 500.',
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
const { messages } = await reconstructVisibleMessages(nodeId);
|
|
259
|
+
const before = cursor === undefined ? messages.length : decodeMessagesCursor(cursor, messages.length);
|
|
260
|
+
const start = Math.max(0, before - limit);
|
|
261
|
+
return {
|
|
262
|
+
messages: messages.slice(start, before),
|
|
263
|
+
nextCursor: start === 0 ? null : encodeMessagesCursor(start),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
200
266
|
function textFromContent(content) {
|
|
201
267
|
if (typeof content === 'string')
|
|
202
268
|
return content;
|
|
@@ -14,7 +14,7 @@ import { childrenOf, subscribersOf, subscriptionsOf, setMessageWait, updateNode
|
|
|
14
14
|
import { subtreeIds } from '../../../core/canvas/nav-model.js';
|
|
15
15
|
import { contextDir, reportsDir } from '../../../core/canvas/paths.js';
|
|
16
16
|
import { nodeArtifacts } from '../../../core/canvas/history.js';
|
|
17
|
-
import { readNodeSession, readNodeSnapshot, transcriptMarkdown } from '../../../core/runtime/node-read.js';
|
|
17
|
+
import { readNodeMessagesPage, readNodeSession, readNodeSnapshot, transcriptMarkdown } from '../../../core/runtime/node-read.js';
|
|
18
18
|
import { reviveAll } from '../../../core/runtime/revive-all.js';
|
|
19
19
|
import { promote, requestYield, reshapeNode } from '../../../core/runtime/promote.js';
|
|
20
20
|
import { recycleNode } from '../../../core/runtime/recycle.js';
|
|
@@ -301,6 +301,38 @@ async function handleSnapshot(ctx) {
|
|
|
301
301
|
};
|
|
302
302
|
return { status: 200, body };
|
|
303
303
|
}
|
|
304
|
+
function parseMessagesQuery(ctx) {
|
|
305
|
+
for (const [field] of ctx.query) {
|
|
306
|
+
if (field !== 'cursor' && field !== 'limit')
|
|
307
|
+
throw usage(`unknown messages query parameter: ${field}`);
|
|
308
|
+
}
|
|
309
|
+
const cursors = ctx.query.getAll('cursor');
|
|
310
|
+
const limits = ctx.query.getAll('limit');
|
|
311
|
+
if (cursors.length > 1)
|
|
312
|
+
throw usage('cursor must appear at most once');
|
|
313
|
+
if (limits.length > 1)
|
|
314
|
+
throw usage('limit must appear at most once');
|
|
315
|
+
if (limits.length === 0)
|
|
316
|
+
return cursors.length === 0 ? {} : { cursor: cursors[0] };
|
|
317
|
+
const rawLimit = limits[0];
|
|
318
|
+
const limit = Number(rawLimit);
|
|
319
|
+
if (!Number.isSafeInteger(limit) || rawLimit === '') {
|
|
320
|
+
throw usage('limit must be an integer from 1 through 500');
|
|
321
|
+
}
|
|
322
|
+
return cursors.length === 0 ? { limit } : { cursor: cursors[0], limit };
|
|
323
|
+
}
|
|
324
|
+
/** Offline, backward-paged read of the cycle-flattened visible history. */
|
|
325
|
+
async function handleMessages(ctx) {
|
|
326
|
+
const id = ctx.params['id'];
|
|
327
|
+
const read = await readNodeMessagesPage(id, parseMessagesQuery(ctx));
|
|
328
|
+
const body = {
|
|
329
|
+
node_id: id,
|
|
330
|
+
messages: read.messages,
|
|
331
|
+
next_cursor: read.nextCursor,
|
|
332
|
+
captured_at: nowIso(),
|
|
333
|
+
};
|
|
334
|
+
return { status: 200, body };
|
|
335
|
+
}
|
|
304
336
|
/** The faithful export read: raw `.jsonl` bytes + assembled system prompt.
|
|
305
337
|
* `/snapshot` reconstructs a cycle-flattened `AgentMessage[]` for renderers and
|
|
306
338
|
* drops what a renderer cannot show; this drops nothing. */
|
|
@@ -598,6 +630,7 @@ export const nodeRoutes = [
|
|
|
598
630
|
{ method: 'POST', pattern: '/v1/nodes/revive-all', handler: () => handleReviveAll() },
|
|
599
631
|
{ method: 'GET', pattern: '/v1/nodes/:id', handler: handleDetail },
|
|
600
632
|
{ method: 'GET', pattern: '/v1/nodes/:id/snapshot', handler: handleSnapshot },
|
|
633
|
+
{ method: 'GET', pattern: '/v1/nodes/:id/messages', handler: handleMessages },
|
|
601
634
|
{ method: 'GET', pattern: '/v1/nodes/:id/session', handler: handleSession },
|
|
602
635
|
{ method: 'GET', pattern: '/v1/nodes/:id/transcript', handler: handleTranscript },
|
|
603
636
|
{ method: 'GET', pattern: '/v1/nodes/:id/context', handler: handleContext },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.201",
|
|
4
4
|
"description": "crtr — agent runtime with memory, plugins, and marketplaces",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -83,7 +83,8 @@
|
|
|
83
83
|
"prepublishOnly": "npm run build",
|
|
84
84
|
"test": "node --conditions=crtr-src --import tsx/esm --test --test-concurrency=4 --test-timeout=10000 'src/**/__tests__/*.test.ts'",
|
|
85
85
|
"test:serial": "node --conditions=crtr-src --import tsx/esm --test --test-concurrency=1 --test-timeout=60000 'src/**/__tests__/serial/*.test.ts'",
|
|
86
|
-
"test:seam": "npm run build &&
|
|
86
|
+
"test:seam": "npm run build && npm run test:seam:built",
|
|
87
|
+
"test:seam:built": "node --test --test-concurrency=2 --test-timeout=180000 'dist/**/__tests__/seam/*.test.js'"
|
|
87
88
|
},
|
|
88
89
|
"overrides": {
|
|
89
90
|
"@earendil-works/pi-ai": "0.84.1",
|
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.201",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.201",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|