@crouton-kit/crouter 0.3.27 → 0.3.28
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/builtin-personas/runtime-base.md +3 -0
- package/dist/clients/attach/__tests__/reconnect-giveup.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
- package/dist/clients/attach/attach-cmd.js +187 -19
- package/dist/clients/attach/canvas-panels.d.ts +10 -0
- package/dist/clients/attach/canvas-panels.js +50 -0
- package/dist/clients/attach/graph-overlay.d.ts +34 -0
- package/dist/clients/attach/graph-overlay.js +266 -0
- package/dist/clients/attach/input-controller.d.ts +6 -0
- package/dist/clients/attach/input-controller.js +2 -0
- package/dist/clients/attach/slash-commands.d.ts +22 -1
- package/dist/clients/attach/slash-commands.js +160 -3
- package/dist/clients/attach/view-socket.d.ts +19 -1
- package/dist/clients/attach/view-socket.js +61 -6
- package/dist/commands/human/prompts.js +3 -3
- package/dist/commands/human/queue.d.ts +17 -0
- package/dist/commands/human/queue.js +111 -4
- package/dist/commands/memory/__tests__/lint-schema.test.js +24 -1
- package/dist/commands/memory/lint.d.ts +5 -4
- package/dist/commands/memory/lint.js +9 -5
- package/dist/commands/memory/write.js +12 -3
- package/dist/commands/sys/feedback.d.ts +1 -0
- package/dist/commands/sys/feedback.js +163 -0
- package/dist/commands/sys.js +3 -2
- package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
- package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +7 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +10 -0
- package/dist/core/__tests__/full/placement-teardown.test.js +76 -0
- package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
- package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
- package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
- package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
- package/dist/core/canvas/nav-model.d.ts +162 -0
- package/dist/core/canvas/nav-model.js +486 -0
- package/dist/core/canvas/paths.d.ts +7 -0
- package/dist/core/canvas/paths.js +9 -0
- package/dist/core/runtime/broker-sdk.d.ts +0 -12
- package/dist/core/runtime/broker-sdk.js +77 -6
- package/dist/core/runtime/broker.d.ts +2 -1
- package/dist/core/runtime/broker.js +26 -1
- package/dist/core/runtime/front-door.js +23 -8
- package/dist/core/runtime/placement.d.ts +7 -6
- package/dist/core/runtime/placement.js +24 -12
- package/dist/core/runtime/revive.js +9 -0
- package/dist/core/runtime/spawn.d.ts +5 -0
- package/dist/core/runtime/spawn.js +62 -1
- package/dist/core/runtime/tmux.d.ts +9 -0
- package/dist/core/runtime/tmux.js +12 -0
- package/dist/core/spawn.d.ts +14 -0
- package/dist/core/spawn.js +29 -9
- package/dist/core/substrate/index.d.ts +1 -1
- package/dist/core/substrate/index.js +6 -6
- package/dist/core/substrate/injected-store.d.ts +10 -0
- package/dist/core/substrate/injected-store.js +55 -0
- package/dist/core/substrate/schema.d.ts +6 -8
- package/dist/core/substrate/schema.js +26 -28
- package/dist/pi-extensions/canvas-doc-substrate.js +16 -7
- package/dist/pi-extensions/canvas-nav.js +30 -385
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +32 -2
- package/package.json +1 -1
|
@@ -167,6 +167,16 @@ class FakeSession {
|
|
|
167
167
|
get messages() {
|
|
168
168
|
return this.messageLog;
|
|
169
169
|
}
|
|
170
|
+
// The broker's catch-up snapshot reconstructs history from the persisted
|
|
171
|
+
// session tree (sessionManager.buildSessionContext) — the single source of
|
|
172
|
+
// truth, since `session.messages` can be sliced by pi's retry/overflow/
|
|
173
|
+
// compaction recovery. Mirror that surface so the fake's snapshot still serves
|
|
174
|
+
// the accrued history (G2/G3). The fake's persisted analog IS messageLog.
|
|
175
|
+
get sessionManager() {
|
|
176
|
+
return {
|
|
177
|
+
buildSessionContext: () => ({ messages: this.messageLog, thinkingLevel: 'off', model: null }),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
170
180
|
get sessionId() {
|
|
171
181
|
return this.sm.getSessionId();
|
|
172
182
|
}
|
|
@@ -145,6 +145,82 @@ test('handFocusToManager: LIVE backstage manager → swaps its pane INTO the foc
|
|
|
145
145
|
spawnSync('tmux', ['kill-session', '-t', back], { stdio: 'ignore' });
|
|
146
146
|
}
|
|
147
147
|
});
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// BUG REGRESSION (lost focus pane on the push-final race): a focused child's
|
|
150
|
+
// `crtr push final` seeds its manager's inbox MID-TURN; the daemon's second pass
|
|
151
|
+
// can revive the dormant manager BACKSTAGE (the focus row still points at the
|
|
152
|
+
// child) before the child's agent_end runs this handoff. At that point the
|
|
153
|
+
// manager is status='active' with a real backstage pane whose pi is still
|
|
154
|
+
// BOOTING — its recorded pi_pid is the OLD dead pid (the fresh pi records its
|
|
155
|
+
// pid only at session_start). Pre-fix, MAJOR 1 gated on isPidAlive(pi_pid), so
|
|
156
|
+
// it missed the booting manager; the dormant branch missed it too (no longer
|
|
157
|
+
// idle-release) → handFocusToManager returned false → the stophook disarmed the
|
|
158
|
+
// freeze and the USER'S VIEWPORT PANE REAPED while the manager ran on invisibly
|
|
159
|
+
// backstage — the "pane gets lost" bug. The gate is now the PANE itself
|
|
160
|
+
// (paneRunning: exists + command running), which a booting pi satisfies.
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
test('handFocusToManager (BUG REGRESSION): just-revived manager (RUNNING pane, STALE dead pi_pid) → swap, not reap', { skip: !hasTmux() }, () => {
|
|
163
|
+
const user = `crtr-hfm-race-user-${process.pid}`;
|
|
164
|
+
const back = `crtr-hfm-race-back-${process.pid}`;
|
|
165
|
+
spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600']);
|
|
166
|
+
spawnSync('tmux', ['new-session', '-d', '-s', back, '-c', '/tmp', 'sleep 600']);
|
|
167
|
+
try {
|
|
168
|
+
const userWindow = tmuxOut(['list-windows', '-t', user, '-F', '#{window_id}']).split('\n')[0];
|
|
169
|
+
const backWindow = tmuxOut(['list-windows', '-t', back, '-F', '#{window_id}']).split('\n')[0];
|
|
170
|
+
const focusPane = tmuxOut(['display-message', '-p', '-t', `${user}:${userWindow}`, '#{pane_id}']);
|
|
171
|
+
// The manager's backstage pane: a RUNNING command (the booting pi stand-in).
|
|
172
|
+
const mgrPane = tmuxOut(['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${back}:${backWindow}`, 'sleep 600']);
|
|
173
|
+
createNode(node('M', { pane: focusPane, tmux_session: user, window: userWindow, status: 'done' }));
|
|
174
|
+
// The just-revived state: active (transition 'revive'), intent cleared, but
|
|
175
|
+
// pi_pid NOT yet re-recorded — dead/stale (revive deliberately leaves it).
|
|
176
|
+
createNode(node('mgr', { pane: mgrPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: null }));
|
|
177
|
+
openFocusRow('f', focusPane, user, 'M');
|
|
178
|
+
assert.equal(handFocusToManager('f', 'mgr'), true, 'a booting just-revived manager takes the focus — the pane is what matters, not the stale pid');
|
|
179
|
+
assert.equal(getFocusByNode('mgr')?.focus_id, 'f', 'the manager occupies the focus row');
|
|
180
|
+
assert.equal(getFocusById('f')?.pane, mgrPane, 'focus row re-anchored to the manager pane (swap happened)');
|
|
181
|
+
assert.equal(paneSession(mgrPane), user, 'the booting manager pane physically moved into the user viewport');
|
|
182
|
+
// Non-vacuous: the pre-fix isPidAlive(mgr.pi_pid) gate fails on pi_pid=null →
|
|
183
|
+
// returns false without repointing → every assert above fails, and the caller
|
|
184
|
+
// would have reaped the user's pane.
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
|
|
188
|
+
spawnSync('tmux', ['kill-session', '-t', back], { stdio: 'ignore' });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
test('handFocusToManager: manager pane is a FROZEN remain-on-exit corpse (pane_dead=1) → false, no swap (dead-focus-pane guarantee holds under the pane gate)', { skip: !hasTmux() }, async () => {
|
|
192
|
+
const user = `crtr-hfm-corpse-user-${process.pid}`;
|
|
193
|
+
const back = `crtr-hfm-corpse-back-${process.pid}`;
|
|
194
|
+
spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600']);
|
|
195
|
+
spawnSync('tmux', ['new-session', '-d', '-s', back, '-c', '/tmp', 'sleep 600']);
|
|
196
|
+
try {
|
|
197
|
+
const userWindow = tmuxOut(['list-windows', '-t', user, '-F', '#{window_id}']).split('\n')[0];
|
|
198
|
+
const backWindow = tmuxOut(['list-windows', '-t', back, '-F', '#{window_id}']).split('\n')[0];
|
|
199
|
+
const focusPane = tmuxOut(['display-message', '-p', '-t', `${user}:${userWindow}`, '#{pane_id}']);
|
|
200
|
+
// A short-lived command frozen by remain-on-exit → pane EXISTS but pane_dead=1.
|
|
201
|
+
const mgrPane = tmuxOut(['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${back}:${backWindow}`, 'sleep 0.2']);
|
|
202
|
+
spawnSync('tmux', ['set-option', '-p', '-t', mgrPane, 'remain-on-exit', 'on'], { stdio: 'ignore' });
|
|
203
|
+
// Wait until tmux reports the pane dead (frozen corpse), bounded.
|
|
204
|
+
for (let i = 0; i < 50; i++) {
|
|
205
|
+
if (tmuxOut(['display-message', '-p', '-t', mgrPane, '#{pane_dead}']) === '1')
|
|
206
|
+
break;
|
|
207
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
208
|
+
}
|
|
209
|
+
assert.equal(tmuxOut(['display-message', '-p', '-t', mgrPane, '#{pane_dead}']), '1', 'precondition: the manager pane is a frozen corpse');
|
|
210
|
+
createNode(node('M', { pane: focusPane, tmux_session: user, window: userWindow, status: 'done' }));
|
|
211
|
+
createNode(node('mgr', { pane: mgrPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: null }));
|
|
212
|
+
openFocusRow('f', focusPane, user, 'M');
|
|
213
|
+
assert.equal(handFocusToManager('f', 'mgr'), false, 'a corpse pane never claims the viewport — caller disarms + reaps');
|
|
214
|
+
assert.equal(getFocusByNode('mgr'), null, 'occupant NOT repointed (no stranded frozen focus)');
|
|
215
|
+
assert.equal(getFocusById('f')?.node_id, 'M', 'M still occupies the focus → caller runs closeFocusToShell');
|
|
216
|
+
// Non-vacuous: a gate of bare paneExists (existence without pane_dead) would
|
|
217
|
+
// swap the corpse into the viewport and return true — both asserts fail.
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
|
|
221
|
+
spawnSync('tmux', ['kill-session', '-t', back], { stdio: 'ignore' });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
148
224
|
test('handFocusToManager: DORMANT idle-release manager (dead pane) → occupant repointed, NO swap, focus pane UNCHANGED', () => {
|
|
149
225
|
openFocusRow('f', '%focus', 'Suser', 'M');
|
|
150
226
|
// The manager is dormant + idle-release (the ONLY dormant manager the daemon
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/human-stranded-deliver.test.ts
|
|
2
|
+
//
|
|
3
|
+
// BUG REGRESSION (human-ask broker round-trip): a headless-broker asker strips
|
|
4
|
+
// $TMUX, so the detached `crtr human _run` worker — whose `pushFinal` is the SOLE
|
|
5
|
+
// deliver-back + reap step — never spawned. Worse, the `crtr human inbox` drain
|
|
6
|
+
// path writes response.json directly (humanloop's inbox()) and ALSO never calls
|
|
7
|
+
// pushFinal, so an answered deck strands on disk: the asker never learns the
|
|
8
|
+
// answer and the bridge node leaks active forever.
|
|
9
|
+
//
|
|
10
|
+
// This locks the SECONDARY heal: finalizeResolvedInteraction must, for a still-
|
|
11
|
+
// LIVE bridge node whose interaction is resolved on disk, deliver the answer to
|
|
12
|
+
// the asker (parent auto-subscribed) and reap the bridge node — exactly what the
|
|
13
|
+
// _run worker's pushFinal would have done. Idempotent (no double-deliver once
|
|
14
|
+
// done); a canceled-on-disk response reaps without delivering a result.
|
|
15
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { createNode, subscribe, getNode } from '../canvas/canvas.js';
|
|
21
|
+
import { closeDb } from '../canvas/db.js';
|
|
22
|
+
import { readInboxSince } from '../feed/inbox.js';
|
|
23
|
+
import { interactionDir, interactionsRoot } from '../artifact.js';
|
|
24
|
+
import { atomicWriteJson, deckPath } from '@crouton-kit/humanloop';
|
|
25
|
+
import { finalizeResolvedInteraction } from '../../commands/human/queue.js';
|
|
26
|
+
let home;
|
|
27
|
+
// A unique cwd so interactionDir (keyed off the REAL homedir + mangled cwd, NOT
|
|
28
|
+
// CRTR_HOME) lands in an isolated, cleanable subtree.
|
|
29
|
+
let workCwd;
|
|
30
|
+
function node(id, parent) {
|
|
31
|
+
return {
|
|
32
|
+
node_id: id,
|
|
33
|
+
name: id,
|
|
34
|
+
created: new Date().toISOString(),
|
|
35
|
+
cwd: workCwd,
|
|
36
|
+
kind: 'developer',
|
|
37
|
+
mode: 'base',
|
|
38
|
+
lifecycle: 'terminal',
|
|
39
|
+
status: 'active',
|
|
40
|
+
parent,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Seed an interaction dir on disk the way the kickoff + a resolution would. */
|
|
44
|
+
function seed(jobId, run, response) {
|
|
45
|
+
const idir = interactionDir(jobId, workCwd);
|
|
46
|
+
mkdirSync(idir, { recursive: true });
|
|
47
|
+
atomicWriteJson(deckPath(idir), { interactions: [] });
|
|
48
|
+
atomicWriteJson(join(idir, 'run.json'), run);
|
|
49
|
+
atomicWriteJson(join(idir, 'response.json'), response);
|
|
50
|
+
}
|
|
51
|
+
before(() => {
|
|
52
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-stranded-'));
|
|
53
|
+
process.env['CRTR_HOME'] = home;
|
|
54
|
+
workCwd = mkdtempSync(join(tmpdir(), 'crtr-stranded-cwd-'));
|
|
55
|
+
});
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
closeDb();
|
|
58
|
+
rmSync(home, { recursive: true, force: true });
|
|
59
|
+
rmSync(interactionsRoot(workCwd), { recursive: true, force: true });
|
|
60
|
+
});
|
|
61
|
+
after(() => {
|
|
62
|
+
closeDb();
|
|
63
|
+
rmSync(home, { recursive: true, force: true });
|
|
64
|
+
rmSync(interactionsRoot(workCwd), { recursive: true, force: true });
|
|
65
|
+
rmSync(workCwd, { recursive: true, force: true });
|
|
66
|
+
delete process.env['CRTR_HOME'];
|
|
67
|
+
});
|
|
68
|
+
test('answered-but-undelivered ask delivers back to the asker and reaps the bridge', async () => {
|
|
69
|
+
// Asker A, bridge B under A (parent auto-subscribes to its child bridge).
|
|
70
|
+
createNode(node('A', null));
|
|
71
|
+
createNode(node('B', 'A'));
|
|
72
|
+
subscribe('A', 'B');
|
|
73
|
+
seed('B', { mode: 'ask', job_id: 'B' }, { responses: [{ id: 'q', selectedOptionId: 'resync', freetext: 'go ahead' }], completedAt: '2026-06-10T22:27:07.000Z' });
|
|
74
|
+
const acted = await finalizeResolvedInteraction('B');
|
|
75
|
+
assert.equal(acted, true, 'should have delivered + reaped');
|
|
76
|
+
// Bridge reaped.
|
|
77
|
+
assert.equal(getNode('B')?.status, 'done', 'bridge node must be reaped to done');
|
|
78
|
+
// Answer fanned into the asker's inbox, carrying the choice.
|
|
79
|
+
const inbox = readInboxSince('A');
|
|
80
|
+
assert.equal(inbox.length >= 1, true, 'asker inbox must receive the answer pointer');
|
|
81
|
+
const blob = JSON.stringify(inbox);
|
|
82
|
+
assert.match(blob, /resync/, 'delivered answer should carry the human selection');
|
|
83
|
+
// Idempotent: a second sweep is a no-op (node already done — no double-deliver).
|
|
84
|
+
const again = await finalizeResolvedInteraction('B');
|
|
85
|
+
assert.equal(again, false, 'must not re-deliver an already-finalized interaction');
|
|
86
|
+
});
|
|
87
|
+
test('a canceled-on-disk response reaps the bridge without delivering a result', async () => {
|
|
88
|
+
createNode(node('A', null));
|
|
89
|
+
createNode(node('B', 'A'));
|
|
90
|
+
subscribe('A', 'B');
|
|
91
|
+
seed('B', { mode: 'ask', job_id: 'B' }, { canceled: true, canceledAt: '2026-06-10T22:42:04.000Z', reason: 'stale' });
|
|
92
|
+
const acted = await finalizeResolvedInteraction('B');
|
|
93
|
+
assert.equal(acted, true);
|
|
94
|
+
assert.equal(getNode('B')?.status, 'done', 'a canceled stranded bridge must still be reaped');
|
|
95
|
+
// The asker gets a quiet "no answer is coming" note, not a result.
|
|
96
|
+
const blob = JSON.stringify(readInboxSince('A'));
|
|
97
|
+
assert.match(blob, /no answer is coming/);
|
|
98
|
+
});
|
|
99
|
+
test('an unresolved (no response.json) live bridge is left untouched', async () => {
|
|
100
|
+
createNode(node('A', null));
|
|
101
|
+
createNode(node('B', 'A'));
|
|
102
|
+
subscribe('A', 'B');
|
|
103
|
+
const idir = interactionDir('B', workCwd);
|
|
104
|
+
mkdirSync(idir, { recursive: true });
|
|
105
|
+
atomicWriteJson(join(idir, 'run.json'), { mode: 'ask', job_id: 'B' });
|
|
106
|
+
assert.equal(await finalizeResolvedInteraction('B'), false);
|
|
107
|
+
assert.equal(getNode('B')?.status, 'active', 'still-pending bridge must stay live');
|
|
108
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Bug-regression: on-read doc injection "fires a second time per session".
|
|
2
|
+
//
|
|
3
|
+
// The on-read substrate hook dedups so a doc surfaces at most once per
|
|
4
|
+
// conversation. That dedup set USED to live only in the pi process heap (cleared
|
|
5
|
+
// on session_start). But a node's logical session — the .jsonl transcript —
|
|
6
|
+
// spans MULTIPLE pi processes: a dormancy → revive(resume) cycle exits the old
|
|
7
|
+
// process and launches a fresh `pi --session` that REUSES the same transcript.
|
|
8
|
+
// The fresh process started with an empty set, so any doc already injected
|
|
9
|
+
// before dormancy got injected AGAIN on the next read.
|
|
10
|
+
//
|
|
11
|
+
// The fix persists the set to nodes/<id>/injected-docs.json: a resume rehydrates
|
|
12
|
+
// it (dedup holds), and only the FRESH-transcript launch paths clear it. These
|
|
13
|
+
// tests lock in both halves: the store round-trip, and the dedup surviving a
|
|
14
|
+
// simulated revive(resume) while re-surfacing after a fresh launch.
|
|
15
|
+
//
|
|
16
|
+
// Run: node --import tsx/esm --test src/core/__tests__/on-read-dedup-resume.test.ts
|
|
17
|
+
import { test, before, beforeEach, after } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { closeDb } from '../canvas/db.js';
|
|
23
|
+
import { injectedDocsPath } from '../canvas/paths.js';
|
|
24
|
+
import { spawnNode } from '../runtime/nodes.js';
|
|
25
|
+
import { renderOnReadDocs } from '../substrate/on-read.js';
|
|
26
|
+
import { loadInjectedDocs, saveInjectedDocs, clearInjectedDocs, } from '../substrate/injected-store.js';
|
|
27
|
+
const FIXTURE_BODY = 'Fixture body that must surface exactly once.';
|
|
28
|
+
let home;
|
|
29
|
+
let work;
|
|
30
|
+
before(() => {
|
|
31
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-onread-home-'));
|
|
32
|
+
process.env['CRTR_HOME'] = home;
|
|
33
|
+
});
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
closeDb();
|
|
36
|
+
rmSync(home, { recursive: true, force: true });
|
|
37
|
+
work = mkdtempSync(join(tmpdir(), 'crtr-onread-work-'));
|
|
38
|
+
});
|
|
39
|
+
after(() => {
|
|
40
|
+
closeDb();
|
|
41
|
+
rmSync(home, { recursive: true, force: true });
|
|
42
|
+
rmSync(work, { recursive: true, force: true });
|
|
43
|
+
delete process.env['CRTR_HOME'];
|
|
44
|
+
});
|
|
45
|
+
test('injected-store round-trips and clears', () => {
|
|
46
|
+
const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
|
|
47
|
+
assert.deepEqual([...loadInjectedDocs(node)], [], 'cold load is empty');
|
|
48
|
+
saveInjectedDocs(node, new Set(['/a', '/b']));
|
|
49
|
+
assert.deepEqual([...loadInjectedDocs(node)].sort(), ['/a', '/b'], 'save then load round-trips');
|
|
50
|
+
clearInjectedDocs(node);
|
|
51
|
+
assert.ok(!existsSync(injectedDocsPath(node)), 'clear removes the file');
|
|
52
|
+
assert.deepEqual([...loadInjectedDocs(node)], [], 'load after clear is empty');
|
|
53
|
+
});
|
|
54
|
+
test('on-read doc surfaces once, stays deduped across a revive(resume), re-surfaces after a fresh launch', () => {
|
|
55
|
+
const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
|
|
56
|
+
// A positional substrate doc in a `.crouter/memory/` ancestor of the read file.
|
|
57
|
+
const memDir = join(work, '.crouter', 'memory');
|
|
58
|
+
mkdirSync(memDir, { recursive: true });
|
|
59
|
+
writeFileSync(join(memDir, 'onread-fixture.md'), '---\nkind: reference\n' +
|
|
60
|
+
'when-and-why-to-read: When reading work files, this reference should be read because it is the on-read regression fixture\n' +
|
|
61
|
+
'file-read-visibility: content\n---\n' +
|
|
62
|
+
`${FIXTURE_BODY}\n`);
|
|
63
|
+
const readFile = join(work, 'src', 'file.ts');
|
|
64
|
+
mkdirSync(join(work, 'src'), { recursive: true });
|
|
65
|
+
writeFileSync(readFile, 'export const x = 1;\n');
|
|
66
|
+
// --- pi process #1: cold load; first read surfaces the doc ---
|
|
67
|
+
const seen = loadInjectedDocs(node);
|
|
68
|
+
const first = renderOnReadDocs(node, readFile, seen);
|
|
69
|
+
assert.match(first, new RegExp(FIXTURE_BODY.replace(/[.]/g, '\\.')), 'first read surfaces the doc body');
|
|
70
|
+
saveInjectedDocs(node, seen);
|
|
71
|
+
// Same process, repeat read of the same file → deduped in-memory.
|
|
72
|
+
assert.ok(!renderOnReadDocs(node, readFile, seen).includes(FIXTURE_BODY), 'same-process repeat read is deduped');
|
|
73
|
+
// --- dormancy → revive(resume): NEW process rehydrates the set from disk ---
|
|
74
|
+
const seenAfterRevive = loadInjectedDocs(node);
|
|
75
|
+
assert.deepEqual(seenAfterRevive, seen, 'revive(resume) rehydrates the persisted dedup set');
|
|
76
|
+
assert.ok(!renderOnReadDocs(node, readFile, seenAfterRevive).includes(FIXTURE_BODY), 'resumed process does NOT re-inject the doc (the bug)');
|
|
77
|
+
// --- fresh launch (revive resume=false / reviveInPlace) clears the set ---
|
|
78
|
+
clearInjectedDocs(node);
|
|
79
|
+
const seenFresh = loadInjectedDocs(node);
|
|
80
|
+
assert.ok(renderOnReadDocs(node, readFile, seenFresh).includes(FIXTURE_BODY), 'a fresh transcript surfaces the doc again');
|
|
81
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { NodeMeta, SubscriptionRef } from './index.js';
|
|
2
|
+
/** pi's InteractiveMode.MAX_WIDGET_LINES — the hard cap on lines in a string
|
|
3
|
+
* array widget; anything beyond it pi truncates with its own "... (widget
|
|
4
|
+
* truncated)". Our GRAPH viewport stays at/under this and scrolls internally. */
|
|
5
|
+
export declare const PI_MAX_WIDGET_LINES = 10;
|
|
6
|
+
export declare const VIEWPORT_FALLBACK_ROWS = 30;
|
|
7
|
+
export declare const ESC = "\u001B[";
|
|
8
|
+
export declare const RESET = "\u001B[0m";
|
|
9
|
+
export declare const BOLD = "\u001B[1m";
|
|
10
|
+
export declare const DIM = "\u001B[2m";
|
|
11
|
+
export declare const REVERSE = "\u001B[7m";
|
|
12
|
+
/** Dark-green background bar marking an ATTACHED node (a human is currently
|
|
13
|
+
* watching it) — distinct from the cursor's reverse-video bar; chosen so
|
|
14
|
+
* default-fg text stays readable. */
|
|
15
|
+
export declare const BG_ATTACHED = "\u001B[48;5;22m";
|
|
16
|
+
export declare const GREEN = "\u001B[32m";
|
|
17
|
+
export declare const RED = "\u001B[31m";
|
|
18
|
+
export declare const YELLOW = "\u001B[33m";
|
|
19
|
+
export declare const CYAN = "\u001B[36m";
|
|
20
|
+
export declare const GRAY = "\u001B[90m";
|
|
21
|
+
/** Status glyph colored by state: active green, idle dim, done cyan, dead red. */
|
|
22
|
+
export declare function coloredGlyph(node: NodeMeta | null): string;
|
|
23
|
+
/** Visible width, ignoring ANSI escapes. */
|
|
24
|
+
export declare function visibleWidth(s: string): number;
|
|
25
|
+
/** Truncate to `max` VISIBLE columns: escape sequences are copied through
|
|
26
|
+
* verbatim (so a cut never lands mid-escape) and the result always ends in
|
|
27
|
+
* RESET, so a clipped style can't bleed into the editor below. */
|
|
28
|
+
export declare function truncate(s: string, max?: number): string;
|
|
29
|
+
/** Visible columns available to ONE widget line — the cap for every line we
|
|
30
|
+
* emit, and the width a full-width reverse-video SELF bar fills to.
|
|
31
|
+
*
|
|
32
|
+
* pi does NOT clip widget lines; it WRAPS them. Each string line is wrapped in
|
|
33
|
+
* a `Text(paddingX = 1)` inside a full-terminal-width container, so the usable
|
|
34
|
+
* content width is `columns - 2` (a 1-col margin on each side). A line wider
|
|
35
|
+
* than that wraps, and the overflow spills onto a second row as a stray
|
|
36
|
+
* reverse-video block (the bug this guards against). Clamp to `columns - 2`. */
|
|
37
|
+
export declare function fillWidth(): number;
|
|
38
|
+
/** Wrap `content` in a full-width background bar opened by `open` (REVERSE for
|
|
39
|
+
* the cursor, BG_ATTACHED for a human-watched node). `open` is re-asserted after every
|
|
40
|
+
* embedded RESET so a coloured cell (the status dot) can't punch a hole in the
|
|
41
|
+
* bar; the visible width is padded out to `width`; the line closes with a real
|
|
42
|
+
* RESET so the style never bleeds into the editor below. */
|
|
43
|
+
export declare function fillBar(content: string, width: number, open: string): string;
|
|
44
|
+
export declare function beginFrame(): void;
|
|
45
|
+
/** getNode memoized for the current frame (meta.json read + sqlite row). */
|
|
46
|
+
export declare function cNode(id: string): NodeMeta | null;
|
|
47
|
+
/** subscriptionsOf (a node's reports) memoized for the current frame. */
|
|
48
|
+
export declare function cSubscriptions(id: string): SubscriptionRef[];
|
|
49
|
+
/** subscribersOf (a node's managers) memoized for the current frame. */
|
|
50
|
+
export declare function cSubscribers(id: string): SubscriptionRef[];
|
|
51
|
+
export interface Telemetry {
|
|
52
|
+
/** Live context-window size (pi's getContextUsage) — the figure pi's own footer
|
|
53
|
+
* shows. This is the node's window fill, NOT a per-turn delta. */
|
|
54
|
+
context_tokens?: number;
|
|
55
|
+
/** One-line "what is it doing" cue (`tool: detail`), written on every tool start. */
|
|
56
|
+
last_activity?: string;
|
|
57
|
+
}
|
|
58
|
+
export declare function readTelemetry(nodeId: string): Telemetry;
|
|
59
|
+
export declare function fmtTokens(n: number): string;
|
|
60
|
+
/** The context-window cell — live window fill rounded to the nearest 1k (the same
|
|
61
|
+
* figure pi's footer shows), NOT a per-turn token delta. */
|
|
62
|
+
export declare function tokensCell(id: string): string;
|
|
63
|
+
/** Dimmed live "what is it doing" cue for an ACTIVE node — the last tool it ran.
|
|
64
|
+
* Empty for non-active rows (stale once a node parks) and when none is recorded. */
|
|
65
|
+
export declare function activityCell(id: string, node: NodeMeta | null): string;
|
|
66
|
+
/** Revive-count badge (meta.cycles). Hidden on the first cycle (0) to cut noise. */
|
|
67
|
+
export declare function cycleBadge(node: NodeMeta | null): string;
|
|
68
|
+
/** Short on-screen label: the explicit handle when set, else the pi-generated
|
|
69
|
+
* description, else the bare name. fullName joins BOTH (`handle description`);
|
|
70
|
+
* the nav chrome shows just the first so rows stay compact. */
|
|
71
|
+
export declare function navLabel(node: NodeMeta | null, id: string): string;
|
|
72
|
+
export declare function shortId(id: string): string;
|
|
73
|
+
/** Node ids currently shown in a tmux focus viewport. Built once per render. */
|
|
74
|
+
export declare function focusedNodeIds(): Set<string>;
|
|
75
|
+
/** True when a human is watching `id` right now (tmux focus or broker viewer). */
|
|
76
|
+
export declare function isAttached(id: string, node: NodeMeta | null, focused: ReadonlySet<string>): boolean;
|
|
77
|
+
export declare function fetchAsksMap(rootId: string): Record<string, number>;
|
|
78
|
+
/** First manager (by created) — the UP step for the ancestry spine. */
|
|
79
|
+
export declare function managerOf(id: string): string | undefined;
|
|
80
|
+
/** A kind:'human' node is a control-plane ASK (a humanloop deck on the human's
|
|
81
|
+
* screen), NOT a pi conversation — it has no session, so focusing/reviving it
|
|
82
|
+
* boots a confused blank "you have been revived" pi. Its pending-ask signal
|
|
83
|
+
* already rides the ⚑ badge on the ASKING node (attention.ts attributes asks by
|
|
84
|
+
* source.nodeId, never to the human node), so the row carries no signal of its
|
|
85
|
+
* own. Drop it from every navigable list (the tree, BASE reports, child counts,
|
|
86
|
+
* subtree expansion) so it can never be selected. */
|
|
87
|
+
export declare function isHumanAsk(id: string): boolean;
|
|
88
|
+
/** A node's direct children that are navigable conversations — human-ask nodes
|
|
89
|
+
* dropped. The one place the nav chrome enumerates children. */
|
|
90
|
+
export declare function convoChildIds(id: string): string[];
|
|
91
|
+
/** Live reports (active|idle) of a node — the DOWN set in BASE. */
|
|
92
|
+
export declare function liveReports(id: string): string[];
|
|
93
|
+
/** Direct navigable children — used for the ⤳ badge and fold counts (human-ask
|
|
94
|
+
* nodes excluded, so the count matches what the tree actually shows). */
|
|
95
|
+
export declare function childCount(id: string): number;
|
|
96
|
+
/** Climb first-manager edges from `self` to the ancestry root (cycle-guarded). */
|
|
97
|
+
export declare function climbRoot(self: string): string;
|
|
98
|
+
/** Space-joined ids of a node's subtree (cursor-relative {subtree} var). */
|
|
99
|
+
export declare function subtreeIds(root: string): string[];
|
|
100
|
+
/** ⤳M direct-children badge — only on orchestrator rows. */
|
|
101
|
+
export declare function childBadge(node: NodeMeta | null): string;
|
|
102
|
+
/** ⚑K pending-asks badge for a node, read from the supplied per-node ask map. */
|
|
103
|
+
export declare function askBadge(id: string, asks: Record<string, number>): string;
|
|
104
|
+
/** "Live work below" badge — green ⇣N when this node is NOT itself active but has
|
|
105
|
+
* N active descendants. The one signal that an idle/parked node isn't dead: real
|
|
106
|
+
* work is still running beneath it. Count comes from the single subtree-activity
|
|
107
|
+
* pass (no per-row walk). Suppressed on active rows (their own ● already says so). */
|
|
108
|
+
export declare function liveBelowBadge(node: NodeMeta | null, activeBelow: ReadonlyMap<string, number>): string;
|
|
109
|
+
/** Sort rank for sibling ordering — live nodes (active, then idle) ahead of
|
|
110
|
+
* terminal ones, so sessions still running surface at the TOP of each child
|
|
111
|
+
* group instead of being buried under finished/failed ones. */
|
|
112
|
+
export declare function statusRank(id: string): number;
|
|
113
|
+
/** Direct children, live-first — the sibling order used both when flattening
|
|
114
|
+
* the tree and when stepping into a subtree (`l`). Array.sort is stable, so
|
|
115
|
+
* equal-status siblings keep their creation order. */
|
|
116
|
+
export declare function sortedChildIds(id: string): string[];
|
|
117
|
+
export interface FlatRow {
|
|
118
|
+
id: string;
|
|
119
|
+
hasKids: boolean;
|
|
120
|
+
isSelf: boolean;
|
|
121
|
+
branch: string;
|
|
122
|
+
cycle: boolean;
|
|
123
|
+
collapsed: boolean;
|
|
124
|
+
}
|
|
125
|
+
/** Manual fold OVERRIDES, keyed by id. `userCollapsed` forces a fold, `userExpanded`
|
|
126
|
+
* forces an unfold; both override the default activity-driven policy. Held by the
|
|
127
|
+
* consumer (the extension / viewer) and threaded into buildGraphModel so this layer
|
|
128
|
+
* carries no mutable singletons. */
|
|
129
|
+
export interface FoldState {
|
|
130
|
+
userExpanded: ReadonlySet<string>;
|
|
131
|
+
userCollapsed: ReadonlySet<string>;
|
|
132
|
+
}
|
|
133
|
+
/** One bottom-up O(N) pass over the local graph, computing TWO things at once so
|
|
134
|
+
* the render path never walks twice:
|
|
135
|
+
* - `expand`: which nodes auto-UNFOLD — a node expands when a child subtree holds
|
|
136
|
+
* a running ('active') agent or self, so the path to any live agent (and to
|
|
137
|
+
* you) is revealed while quiescent branches stay folded.
|
|
138
|
+
* - `activeBelow`: per node, the count of ACTIVE descendants (excluding itself) —
|
|
139
|
+
* drives the ⇣N "live work below" badge on idle/parked nodes.
|
|
140
|
+
* Cycle-guarded (the graph is declared acyclic; a re-seen id contributes only
|
|
141
|
+
* its own status, never its subtree again, so counts can't double or loop). */
|
|
142
|
+
export interface SubtreeActivity {
|
|
143
|
+
expand: Set<string>;
|
|
144
|
+
activeBelow: Map<string, number>;
|
|
145
|
+
}
|
|
146
|
+
export declare function computeSubtreeActivity(root: string, self: string): SubtreeActivity;
|
|
147
|
+
/** Fold policy alone — thin wrapper over computeSubtreeActivity for the keypress
|
|
148
|
+
* path (buildGraphModel without a precomputed set). The render path computes the
|
|
149
|
+
* activity once and threads `expand` in, so it never recomputes here. */
|
|
150
|
+
export declare function computeDefaultExpanded(root: string, self: string): Set<string>;
|
|
151
|
+
export declare function buildGraphModel(self: string, folds: FoldState, expand?: ReadonlySet<string>): FlatRow[];
|
|
152
|
+
/** Render one GRAPH row. CURSOR (selected) → reverse-video bar; an ATTACHED
|
|
153
|
+
* (human-watched) node → a coloured background bar; SELF → bold name. The
|
|
154
|
+
* cursor outranks the attached tint when both land on the same row. Running
|
|
155
|
+
* is signaled by the dot glyph alone (● green = active engine). */
|
|
156
|
+
export declare function renderGraphRow(r: FlatRow, isCursor: boolean, focused: ReadonlySet<string>, activeBelow: ReadonlyMap<string, number>, asks: Record<string, number>): string;
|
|
157
|
+
/** Total lines the GRAPH widget may emit. pi hard-caps extension widgets at
|
|
158
|
+
* MAX_WIDGET_LINES — anything past that pi truncates itself, eating our own
|
|
159
|
+
* scroll chrome — so never exceed it (and shrink on a very short terminal).
|
|
160
|
+
* The viewport scrolls WITHIN this cap as the cursor moves. */
|
|
161
|
+
export declare function graphWidgetBudget(): number;
|
|
162
|
+
export declare const GRAPH_HINT = "\u001B[2mjk move \u00B7 hl fold \u00B7 \u21B5 focus \u00B7 e expand \u00B7 x kill \u00B7 m mgr \u00B7 esc\u001B[0m";
|