@crouton-kit/crouter 0.3.15 → 0.3.17
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/developer/orchestrator.md +1 -1
- package/dist/builtin-personas/orchestration-kernel.md +6 -6
- package/dist/builtin-personas/plan/base.md +1 -1
- package/dist/builtin-personas/plan/orchestrator.md +1 -1
- package/dist/builtin-personas/spec/base.md +1 -1
- package/dist/commands/canvas-browse.d.ts +2 -0
- package/dist/commands/canvas-browse.js +45 -0
- package/dist/commands/canvas-prune.js +11 -2
- package/dist/commands/canvas.js +3 -2
- package/dist/commands/chord.js +1 -1
- package/dist/commands/human/shared.js +1 -1
- package/dist/commands/node.js +14 -2
- package/dist/commands/skill/author.js +2 -2
- package/dist/commands/tmux-spread.js +2 -3
- package/dist/core/__tests__/cascade-close.test.js +199 -0
- package/dist/core/__tests__/close.test.js +2 -2
- package/dist/core/__tests__/daemon-boot.test.js +7 -0
- package/dist/core/__tests__/daemon-liveness.test.js +59 -4
- package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
- package/dist/core/__tests__/focuses.test.js +5 -68
- package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
- package/dist/core/__tests__/grace-clock.test.js +115 -0
- package/dist/core/__tests__/helpers/harness.d.ts +78 -0
- package/dist/core/__tests__/helpers/harness.js +406 -0
- package/dist/core/__tests__/home-session.test.js +1 -1
- package/dist/core/__tests__/lifecycle.test.js +6 -13
- package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation.test.js +341 -0
- package/dist/core/__tests__/placement-focus.test.js +106 -46
- package/dist/core/__tests__/placement-teardown.test.js +4 -9
- package/dist/core/__tests__/relaunch.test.js +22 -16
- package/dist/core/__tests__/reset.test.js +11 -6
- package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
- package/dist/core/__tests__/spike-harness.test.js +241 -0
- package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/subscription-delivery.test.js +233 -0
- package/dist/core/__tests__/tmux-surface.test.js +8 -9
- package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
- package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +4 -0
- package/dist/core/canvas/browse/app.js +349 -0
- package/dist/core/canvas/browse/model.d.ts +97 -0
- package/dist/core/canvas/browse/model.js +258 -0
- package/dist/core/canvas/browse/render.d.ts +41 -0
- package/dist/core/canvas/browse/render.js +387 -0
- package/dist/core/canvas/browse/terminal.d.ts +23 -0
- package/dist/core/canvas/browse/terminal.js +100 -0
- package/dist/core/canvas/canvas.d.ts +9 -2
- package/dist/core/canvas/canvas.js +41 -3
- package/dist/core/canvas/db.js +2 -3
- package/dist/core/canvas/focuses.d.ts +2 -2
- package/dist/core/canvas/focuses.js +4 -3
- package/dist/core/canvas/render.d.ts +10 -0
- package/dist/core/canvas/render.js +25 -1
- package/dist/core/canvas/types.d.ts +1 -1
- package/dist/core/feed/inbox.d.ts +0 -3
- package/dist/core/feed/inbox.js +1 -5
- package/dist/core/runtime/busy.d.ts +8 -0
- package/dist/core/runtime/busy.js +46 -0
- package/dist/core/runtime/close.js +2 -2
- package/dist/core/runtime/demote.js +2 -7
- package/dist/core/runtime/launch.d.ts +3 -1
- package/dist/core/runtime/launch.js +4 -1
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +12 -4
- package/dist/core/runtime/naming.d.ts +3 -3
- package/dist/core/runtime/naming.js +6 -6
- package/dist/core/runtime/nodes.d.ts +7 -0
- package/dist/core/runtime/nodes.js +10 -1
- package/dist/core/runtime/placement.d.ts +39 -10
- package/dist/core/runtime/placement.js +100 -44
- package/dist/core/runtime/reset.d.ts +11 -8
- package/dist/core/runtime/reset.js +36 -31
- package/dist/core/runtime/revive.d.ts +1 -1
- package/dist/core/runtime/revive.js +2 -2
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/runtime/tmux-chrome.d.ts +1 -0
- package/dist/core/runtime/tmux-chrome.js +4 -0
- package/dist/core/runtime/tmux.d.ts +13 -6
- package/dist/core/runtime/tmux.js +21 -12
- package/dist/daemon/crtrd.js +43 -21
- package/dist/pi-extensions/canvas-nav.js +40 -28
- package/dist/pi-extensions/canvas-resume.d.ts +21 -0
- package/dist/pi-extensions/canvas-resume.js +82 -0
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +21 -9
- package/dist/prompts/skill.js +6 -1
- package/package.json +2 -2
- package/dist/commands/__tests__/skill.test.js +0 -290
- package/dist/core/__tests__/pkg.test.js +0 -218
- package/dist/core/__tests__/sys.test.js +0 -208
- package/dist/core/runtime/presence.d.ts +0 -30
- package/dist/core/runtime/presence.js +0 -178
- /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
- /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface Key {
|
|
2
|
+
upArrow: boolean;
|
|
3
|
+
downArrow: boolean;
|
|
4
|
+
leftArrow: boolean;
|
|
5
|
+
rightArrow: boolean;
|
|
6
|
+
return: boolean;
|
|
7
|
+
escape: boolean;
|
|
8
|
+
ctrl: boolean;
|
|
9
|
+
meta: boolean;
|
|
10
|
+
tab: boolean;
|
|
11
|
+
shiftTab: boolean;
|
|
12
|
+
backspace: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function parseKeypress(data: Buffer): {
|
|
15
|
+
input: string;
|
|
16
|
+
key: Key;
|
|
17
|
+
};
|
|
18
|
+
export declare function setupTerminal(): void;
|
|
19
|
+
export declare function restoreTerminal(): void;
|
|
20
|
+
export declare function getTerminalSize(): {
|
|
21
|
+
cols: number;
|
|
22
|
+
rows: number;
|
|
23
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// terminal.ts — raw-mode helpers for the `crtr canvas browse` TUI.
|
|
2
|
+
//
|
|
3
|
+
// Hand-rolled (no deps), mirroring humanloop's src/tui/terminal.ts. Extends its
|
|
4
|
+
// key parsing beyond up/down/return/escape/tab/backspace/ctrl + printable input:
|
|
5
|
+
// adds leftArrow/rightArrow (cursor keys) and shiftTab (`\x1b[Z`) so the browser
|
|
6
|
+
// can drive tree expand/collapse and tab cycling.
|
|
7
|
+
function emptyKey() {
|
|
8
|
+
return {
|
|
9
|
+
upArrow: false,
|
|
10
|
+
downArrow: false,
|
|
11
|
+
leftArrow: false,
|
|
12
|
+
rightArrow: false,
|
|
13
|
+
return: false,
|
|
14
|
+
escape: false,
|
|
15
|
+
ctrl: false,
|
|
16
|
+
meta: false,
|
|
17
|
+
tab: false,
|
|
18
|
+
shiftTab: false,
|
|
19
|
+
backspace: false,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function parseKeypress(data) {
|
|
23
|
+
const str = data.toString('utf8');
|
|
24
|
+
const key = emptyKey();
|
|
25
|
+
if (str === '\x1b[A') {
|
|
26
|
+
key.upArrow = true;
|
|
27
|
+
return { input: '', key };
|
|
28
|
+
}
|
|
29
|
+
if (str === '\x1b[B') {
|
|
30
|
+
key.downArrow = true;
|
|
31
|
+
return { input: '', key };
|
|
32
|
+
}
|
|
33
|
+
if (str === '\x1b[C') {
|
|
34
|
+
key.rightArrow = true;
|
|
35
|
+
return { input: '', key };
|
|
36
|
+
}
|
|
37
|
+
if (str === '\x1b[D') {
|
|
38
|
+
key.leftArrow = true;
|
|
39
|
+
return { input: '', key };
|
|
40
|
+
}
|
|
41
|
+
if (str === '\x1b[Z') {
|
|
42
|
+
key.shiftTab = true;
|
|
43
|
+
return { input: '', key };
|
|
44
|
+
}
|
|
45
|
+
if (str === '\r' || str === '\n') {
|
|
46
|
+
key.return = true;
|
|
47
|
+
return { input: '', key };
|
|
48
|
+
}
|
|
49
|
+
// Alt+Backspace: terminals send ESC followed by DEL/BS. Must precede the
|
|
50
|
+
// bare-ESC check so the two-byte sequence isn't swallowed as plain escape.
|
|
51
|
+
if (str === '\x1b\x7f' || str === '\x1b\b') {
|
|
52
|
+
key.meta = true;
|
|
53
|
+
key.backspace = true;
|
|
54
|
+
return { input: '', key };
|
|
55
|
+
}
|
|
56
|
+
if (str === '\x1b') {
|
|
57
|
+
key.escape = true;
|
|
58
|
+
return { input: '', key };
|
|
59
|
+
}
|
|
60
|
+
if (str === '\t') {
|
|
61
|
+
key.tab = true;
|
|
62
|
+
return { input: '', key };
|
|
63
|
+
}
|
|
64
|
+
if (str === '\x7f' || str === '\b') {
|
|
65
|
+
key.backspace = true;
|
|
66
|
+
return { input: '', key };
|
|
67
|
+
}
|
|
68
|
+
if (str.length === 1 && str.charCodeAt(0) < 32) {
|
|
69
|
+
key.ctrl = true;
|
|
70
|
+
const ch = String.fromCharCode(str.charCodeAt(0) + 64).toLowerCase();
|
|
71
|
+
return { input: ch, key };
|
|
72
|
+
}
|
|
73
|
+
// Multi-byte chunks (paste, multi-byte UTF-8, unknown escape sequences) are
|
|
74
|
+
// returned as-is in `input`; the input-mode handler sanitises them before
|
|
75
|
+
// appending to its buffer. Top-level handlers ignore strings of length > 1.
|
|
76
|
+
return { input: str, key };
|
|
77
|
+
}
|
|
78
|
+
export function setupTerminal() {
|
|
79
|
+
if (!process.stdin.isTTY) {
|
|
80
|
+
throw new Error('crtr canvas browse requires an interactive terminal (TTY)');
|
|
81
|
+
}
|
|
82
|
+
process.stdin.setRawMode(true);
|
|
83
|
+
process.stdin.resume();
|
|
84
|
+
process.stdin.setEncoding('utf8');
|
|
85
|
+
process.stdout.write('\x1b[?25l'); // hide cursor
|
|
86
|
+
process.stdout.write('\x1b[?1049h'); // alt screen
|
|
87
|
+
process.stdout.write('\x1b[2J\x1b[H'); // clear
|
|
88
|
+
}
|
|
89
|
+
export function restoreTerminal() {
|
|
90
|
+
process.stdout.write('\x1b[?25h'); // show cursor
|
|
91
|
+
process.stdout.write('\x1b[?1049l'); // restore screen
|
|
92
|
+
process.stdin.setRawMode(false);
|
|
93
|
+
process.stdin.pause();
|
|
94
|
+
}
|
|
95
|
+
export function getTerminalSize() {
|
|
96
|
+
return {
|
|
97
|
+
cols: process.stdout.columns || 80,
|
|
98
|
+
rows: process.stdout.rows || 24,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -86,8 +86,14 @@ export interface PruneResult {
|
|
|
86
86
|
* `created` is older than `ttlDays`, bounding the otherwise-unbounded growth of
|
|
87
87
|
* node rows + dirs. The edges→nodes FK (`ON DELETE CASCADE`, migration v4) GCs
|
|
88
88
|
* each pruned node's edges automatically; the on-disk `nodes/<id>/` dir is
|
|
89
|
-
* removed too.
|
|
90
|
-
*
|
|
89
|
+
* removed too.
|
|
90
|
+
*
|
|
91
|
+
* With `includeStale`, ALSO prunes nominally-live (active | idle) nodes past the
|
|
92
|
+
* TTL whose process is provably gone — `pi_pid` is NULL or no longer alive. This
|
|
93
|
+
* reaps stale roots (a bare `crtr` whose pi died without the row transitioning),
|
|
94
|
+
* which the daemon's supervision never reconciled. A genuinely-running node keeps
|
|
95
|
+
* a live `pi_pid`, so it is protected, as is the CALLER ($CRTR_NODE_ID). Without
|
|
96
|
+
* the flag, active | idle are NEVER touched (the daemon's domain).
|
|
91
97
|
*
|
|
92
98
|
* The row deletes run in ONE transaction (so the sweep is all-or-nothing); the
|
|
93
99
|
* dir removals follow after COMMIT — the fs isn't transactional, and by then the
|
|
@@ -96,4 +102,5 @@ export interface PruneResult {
|
|
|
96
102
|
export declare function pruneNodes(opts: {
|
|
97
103
|
ttlDays: number;
|
|
98
104
|
dryRun?: boolean;
|
|
105
|
+
includeStale?: boolean;
|
|
99
106
|
}): PruneResult;
|
|
@@ -352,12 +352,29 @@ export function rebuildIndex() {
|
|
|
352
352
|
recordSpawn(meta.node_id, prov);
|
|
353
353
|
}
|
|
354
354
|
}
|
|
355
|
+
/** Is `pid` a live process? `kill(pid, 0)` sends no signal — it only probes
|
|
356
|
+
* existence/permission. ESRCH ⇒ gone; EPERM ⇒ alive but not ours (still alive). */
|
|
357
|
+
function pidAlive(pid) {
|
|
358
|
+
try {
|
|
359
|
+
process.kill(pid, 0);
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
catch (e) {
|
|
363
|
+
return e.code === 'EPERM';
|
|
364
|
+
}
|
|
365
|
+
}
|
|
355
366
|
/** Retention sweep: remove TERMINAL nodes (status dead | done | canceled) whose
|
|
356
367
|
* `created` is older than `ttlDays`, bounding the otherwise-unbounded growth of
|
|
357
368
|
* node rows + dirs. The edges→nodes FK (`ON DELETE CASCADE`, migration v4) GCs
|
|
358
369
|
* each pruned node's edges automatically; the on-disk `nodes/<id>/` dir is
|
|
359
|
-
* removed too.
|
|
360
|
-
*
|
|
370
|
+
* removed too.
|
|
371
|
+
*
|
|
372
|
+
* With `includeStale`, ALSO prunes nominally-live (active | idle) nodes past the
|
|
373
|
+
* TTL whose process is provably gone — `pi_pid` is NULL or no longer alive. This
|
|
374
|
+
* reaps stale roots (a bare `crtr` whose pi died without the row transitioning),
|
|
375
|
+
* which the daemon's supervision never reconciled. A genuinely-running node keeps
|
|
376
|
+
* a live `pi_pid`, so it is protected, as is the CALLER ($CRTR_NODE_ID). Without
|
|
377
|
+
* the flag, active | idle are NEVER touched (the daemon's domain).
|
|
361
378
|
*
|
|
362
379
|
* The row deletes run in ONE transaction (so the sweep is all-or-nothing); the
|
|
363
380
|
* dir removals follow after COMMIT — the fs isn't transactional, and by then the
|
|
@@ -365,9 +382,11 @@ export function rebuildIndex() {
|
|
|
365
382
|
* reports the candidate set and deletes NOTHING. */
|
|
366
383
|
export function pruneNodes(opts) {
|
|
367
384
|
const dryRun = opts.dryRun ?? false;
|
|
385
|
+
const includeStale = opts.includeStale ?? false;
|
|
368
386
|
const cutoff = new Date(Date.now() - opts.ttlDays * 86_400_000).toISOString();
|
|
387
|
+
const selfId = process.env['CRTR_NODE_ID'] ?? '';
|
|
369
388
|
const db = openDb();
|
|
370
|
-
const
|
|
389
|
+
const terminal = db
|
|
371
390
|
.prepare(`SELECT node_id, status, created FROM nodes
|
|
372
391
|
WHERE status IN ('dead', 'done', 'canceled') AND created < ?
|
|
373
392
|
ORDER BY created`)
|
|
@@ -376,6 +395,25 @@ export function pruneNodes(opts) {
|
|
|
376
395
|
status: r['status'],
|
|
377
396
|
created: r['created'],
|
|
378
397
|
}));
|
|
398
|
+
// Stale non-terminal sweep (opt-in): active | idle past the TTL whose process
|
|
399
|
+
// is provably gone (pi_pid NULL or not alive). Never the caller itself.
|
|
400
|
+
const stale = !includeStale ? [] : db
|
|
401
|
+
.prepare(`SELECT node_id, status, created, pi_pid FROM nodes
|
|
402
|
+
WHERE status IN ('active', 'idle') AND created < ?
|
|
403
|
+
ORDER BY created`)
|
|
404
|
+
.all(cutoff)
|
|
405
|
+
.filter((r) => {
|
|
406
|
+
if (r['node_id'] === selfId)
|
|
407
|
+
return false;
|
|
408
|
+
const pid = r['pi_pid'];
|
|
409
|
+
return pid === null || !pidAlive(pid);
|
|
410
|
+
})
|
|
411
|
+
.map((r) => ({
|
|
412
|
+
node_id: r['node_id'],
|
|
413
|
+
status: r['status'],
|
|
414
|
+
created: r['created'],
|
|
415
|
+
}));
|
|
416
|
+
const candidates = [...terminal, ...stale];
|
|
379
417
|
if (dryRun || candidates.length === 0)
|
|
380
418
|
return { pruned: candidates, dryRun };
|
|
381
419
|
// One transactioned sweep — delete the rows; the FK cascades their edges.
|
package/dist/core/canvas/db.js
CHANGED
|
@@ -196,9 +196,8 @@ function addPaneColumn(db) {
|
|
|
196
196
|
* viewport (Q7 widens canvas.db from "topology" to "topology + focuses"). Each
|
|
197
197
|
* row is anchored on the durable tmux `%pane_id`; `session` is a derived cache
|
|
198
198
|
* reconciled from the pane; `node_id` is UNIQUE so a node occupies at most one
|
|
199
|
-
* focus (Q5).
|
|
200
|
-
*
|
|
201
|
-
* dual-write; the switch to table-as-authority lands in Step 6). */
|
|
199
|
+
* focus (Q5). The focuses table is the CANONICAL focus store — there is no
|
|
200
|
+
* focus.ptr file and no dual-write bridge; placement writes focus rows directly. */
|
|
202
201
|
function addFocusesTable(db) {
|
|
203
202
|
db.exec(`
|
|
204
203
|
CREATE TABLE IF NOT EXISTS focuses (
|
|
@@ -15,8 +15,8 @@ export declare function closeFocusRow(focus_id: string): void;
|
|
|
15
15
|
export declare function getFocusByNode(node_id: string): FocusRow | null;
|
|
16
16
|
/** The focus realized by a given pane (`%id`), or null. */
|
|
17
17
|
export declare function getFocusByPane(pane: string): FocusRow | null;
|
|
18
|
-
/** A focus by its stable id, or null.
|
|
19
|
-
*
|
|
18
|
+
/** A focus by its stable id, or null. Used by placement to read a row back by id
|
|
19
|
+
* (handFocusToManager / retargetFocus / registerRootFocus). */
|
|
20
20
|
export declare function getFocusById(focus_id: string): FocusRow | null;
|
|
21
21
|
/** Every focus row, ordered by id. */
|
|
22
22
|
export declare function listFocuses(): FocusRow[];
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// "topology + focuses", so the focus-row SQL lives here beside the node+edge
|
|
5
5
|
// model, never in the runtime layer. A FOCUS is one durable on-screen viewport
|
|
6
6
|
// bound to one node; the table is PLURAL (many focuses across windows/sessions),
|
|
7
|
-
// the generalization of the old single
|
|
7
|
+
// the generalization of the old single focus pointer. It is the CANONICAL focus
|
|
8
|
+
// store — there is no focus.ptr file and no dual-write bridge.
|
|
8
9
|
//
|
|
9
10
|
// placement.ts COMPOSES over these atomic setters/reads (the same way it calls
|
|
10
11
|
// setPresence) — it never runs raw focus SQL itself.
|
|
@@ -66,8 +67,8 @@ export function getFocusByPane(pane) {
|
|
|
66
67
|
.get(pane);
|
|
67
68
|
return r ? focusFrom(r) : null;
|
|
68
69
|
}
|
|
69
|
-
/** A focus by its stable id, or null.
|
|
70
|
-
*
|
|
70
|
+
/** A focus by its stable id, or null. Used by placement to read a row back by id
|
|
71
|
+
* (handFocusToManager / retargetFocus / registerRootFocus). */
|
|
71
72
|
export function getFocusById(focus_id) {
|
|
72
73
|
const r = openDb()
|
|
73
74
|
.prepare('SELECT * FROM focuses WHERE focus_id = ?')
|
|
@@ -23,6 +23,16 @@ export interface DashboardRow {
|
|
|
23
23
|
mode: string;
|
|
24
24
|
ctx_tokens: number;
|
|
25
25
|
asks: number;
|
|
26
|
+
/** The dir the node is pinned to (its cwd). Drives the browser's cwd-scope
|
|
27
|
+
* filter + the All-dirs basename cue. */
|
|
28
|
+
cwd: string;
|
|
29
|
+
/** ISO 8601 birth timestamp — drives the recency sort + the relative-age cue. */
|
|
30
|
+
created: string;
|
|
31
|
+
/** The node's spawn prompt (context/initial-prompt.md), trimmed + capped. Only
|
|
32
|
+
* populated by dashboardRowsAll (the browser snapshot) — the dashboard leaf
|
|
33
|
+
* leaves it undefined to avoid a file read per node. Indexed by super-search
|
|
34
|
+
* and shown in the preview panel. */
|
|
35
|
+
goal?: string;
|
|
26
36
|
}
|
|
27
37
|
/** One row per node visible in the sub-DAG of `rootId` (including root). */
|
|
28
38
|
export declare function dashboardRows(rootId: string): DashboardRow[];
|
|
@@ -16,7 +16,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
16
16
|
import { join } from 'node:path';
|
|
17
17
|
import { getNode, listNodes, subscriptionsOf, view } from './canvas.js';
|
|
18
18
|
import { fullName } from './labels.js';
|
|
19
|
-
import { jobDir } from './paths.js';
|
|
19
|
+
import { jobDir, contextDir } from './paths.js';
|
|
20
20
|
import { countAsks } from './attention.js';
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
22
22
|
// Glyphs
|
|
@@ -162,6 +162,25 @@ export function renderForest() {
|
|
|
162
162
|
}
|
|
163
163
|
return parts.join('\n\n');
|
|
164
164
|
}
|
|
165
|
+
/** The spawn prompt, read straight off disk (canvas-home state) and capped so a
|
|
166
|
+
* giant initial-prompt.md can't bloat the snapshot. Mirrors how telemetry is
|
|
167
|
+
* read here directly rather than via the runtime layer (which would invert the
|
|
168
|
+
* canvas→runtime dependency). Never throws. */
|
|
169
|
+
const GOAL_CAP = 4096;
|
|
170
|
+
function readGoalText(nodeId) {
|
|
171
|
+
try {
|
|
172
|
+
const p = join(contextDir(nodeId), 'initial-prompt.md');
|
|
173
|
+
if (!existsSync(p))
|
|
174
|
+
return undefined;
|
|
175
|
+
const body = readFileSync(p, 'utf8').trim();
|
|
176
|
+
if (body === '')
|
|
177
|
+
return undefined;
|
|
178
|
+
return body.length > GOAL_CAP ? body.slice(0, GOAL_CAP) : body;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
165
184
|
/** One row per node visible in the sub-DAG of `rootId` (including root). */
|
|
166
185
|
export function dashboardRows(rootId) {
|
|
167
186
|
const ids = [rootId, ...view(rootId)];
|
|
@@ -178,6 +197,8 @@ export function dashboardRows(rootId) {
|
|
|
178
197
|
mode: node.mode,
|
|
179
198
|
ctx_tokens: tel.tokens_in ?? 0,
|
|
180
199
|
asks: countAsks(id),
|
|
200
|
+
cwd: node.cwd,
|
|
201
|
+
created: node.created,
|
|
181
202
|
}];
|
|
182
203
|
});
|
|
183
204
|
}
|
|
@@ -196,6 +217,9 @@ export function dashboardRowsAll() {
|
|
|
196
217
|
mode: row.mode,
|
|
197
218
|
ctx_tokens: tel.tokens_in ?? 0,
|
|
198
219
|
asks: countAsks(row.node_id),
|
|
220
|
+
cwd: row.cwd,
|
|
221
|
+
created: row.created,
|
|
222
|
+
goal: readGoalText(row.node_id),
|
|
199
223
|
}];
|
|
200
224
|
});
|
|
201
225
|
}
|
|
@@ -168,7 +168,7 @@ export interface Edge {
|
|
|
168
168
|
/** A FOCUS row as stored in the `focuses` table (canvas.db, migration v6): one
|
|
169
169
|
* durable on-screen viewport the user looks at, bound to one node. Plural —
|
|
170
170
|
* many focuses live at once across windows and sessions (the plural
|
|
171
|
-
* generalization of the old single
|
|
171
|
+
* generalization of the old single focus pointer). Anchored on the durable tmux
|
|
172
172
|
* `%pane_id`; `session` is a derived cache reconciled from the pane. `node_id`
|
|
173
173
|
* is UNIQUE — a node occupies at most one focus (Q5). */
|
|
174
174
|
export interface FocusRow {
|
|
@@ -45,8 +45,5 @@ export declare function writeCursor(nodeId: string, iso: string): void;
|
|
|
45
45
|
* [<kind>] ← ref-less msg: full body inlined
|
|
46
46
|
* <body line>
|
|
47
47
|
* …
|
|
48
|
-
*
|
|
49
|
-
* A header line announces the total count and instructs the receiver to
|
|
50
|
-
* dereference only what matters.
|
|
51
48
|
*/
|
|
52
49
|
export declare function coalesce(entries: InboxEntry[]): string;
|
package/dist/core/feed/inbox.js
CHANGED
|
@@ -139,14 +139,10 @@ function renderEntry(e) {
|
|
|
139
139
|
* [<kind>] ← ref-less msg: full body inlined
|
|
140
140
|
* <body line>
|
|
141
141
|
* …
|
|
142
|
-
*
|
|
143
|
-
* A header line announces the total count and instructs the receiver to
|
|
144
|
-
* dereference only what matters.
|
|
145
142
|
*/
|
|
146
143
|
export function coalesce(entries) {
|
|
147
144
|
if (entries.length === 0)
|
|
148
145
|
return '(inbox empty)';
|
|
149
|
-
const header = `${entries.length} update${entries.length === 1 ? '' : 's'} since last read — dereference what matters.\n`;
|
|
150
146
|
// Group by `from` (null → 'system').
|
|
151
147
|
const groups = new Map();
|
|
152
148
|
for (const e of entries) {
|
|
@@ -160,5 +156,5 @@ export function coalesce(entries) {
|
|
|
160
156
|
const lines = items.map(renderEntry);
|
|
161
157
|
sections.push(`From ${sender} — ${items.length} update${items.length === 1 ? '' : 's'}:\n${lines.join('\n')}`);
|
|
162
158
|
}
|
|
163
|
-
return
|
|
159
|
+
return sections.join('\n\n');
|
|
164
160
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Mark a node mid-turn (pi entered a turn). Best-effort. */
|
|
2
|
+
export declare function markBusy(nodeId: string): void;
|
|
3
|
+
/** Clear the mid-turn marker (the turn ended, however it routed). Best-effort. */
|
|
4
|
+
export declare function clearBusy(nodeId: string): void;
|
|
5
|
+
/** Is the node currently inside a turn? AND this with `pidAlive` at the call
|
|
6
|
+
* site — a stale marker from a crashed pi is harmless because the dead pid
|
|
7
|
+
* fails the AND. */
|
|
8
|
+
export declare function isBusy(nodeId: string): boolean;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// busy.ts — the "is pi actually mid-turn" signal (a marker file, no db column).
|
|
2
|
+
//
|
|
3
|
+
// The disposition of a focus's OUTGOING node on a hot-swap (placement.ts
|
|
4
|
+
// `outgoingDisposition`) must distinguish a terminal worker that is GENUINELY
|
|
5
|
+
// mid-turn (keep it running off-screen, Invariant F2) from one merely PARKED at
|
|
6
|
+
// its prompt with a live pi (a viewer revived for inspection — despawn it back to
|
|
7
|
+
// dormant on focus-away). A live pid is NOT that signal: a parked node has a live
|
|
8
|
+
// pid too. This marker is.
|
|
9
|
+
//
|
|
10
|
+
// `<jobDir>/busy` exists for exactly the span pi is inside a turn: the stophook
|
|
11
|
+
// touches it on `agent_start` and unlinks it at the top of `agent_end` (and
|
|
12
|
+
// defensively on `session_shutdown`). It is always AND-ed with `pidAlive` at the
|
|
13
|
+
// read site, so a stale marker (process crashed mid-turn without firing
|
|
14
|
+
// agent_end) is harmless — the dead pid fails the AND and the node is reaped.
|
|
15
|
+
// No db migration, atomic touch/unlink, best-effort (never throws).
|
|
16
|
+
import { existsSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { jobDir } from '../canvas/index.js';
|
|
19
|
+
function busyPath(nodeId) {
|
|
20
|
+
return join(jobDir(nodeId), 'busy');
|
|
21
|
+
}
|
|
22
|
+
/** Mark a node mid-turn (pi entered a turn). Best-effort. */
|
|
23
|
+
export function markBusy(nodeId) {
|
|
24
|
+
try {
|
|
25
|
+
mkdirSync(jobDir(nodeId), { recursive: true });
|
|
26
|
+
writeFileSync(busyPath(nodeId), '');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* best-effort */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Clear the mid-turn marker (the turn ended, however it routed). Best-effort. */
|
|
33
|
+
export function clearBusy(nodeId) {
|
|
34
|
+
try {
|
|
35
|
+
rmSync(busyPath(nodeId), { force: true });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* best-effort */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Is the node currently inside a turn? AND this with `pidAlive` at the call
|
|
42
|
+
* site — a stale marker from a crashed pi is harmless because the dead pid
|
|
43
|
+
* fails the AND. */
|
|
44
|
+
export function isBusy(nodeId) {
|
|
45
|
+
return existsSync(busyPath(nodeId));
|
|
46
|
+
}
|
|
@@ -130,8 +130,8 @@ export function closeNode(rootId) {
|
|
|
130
130
|
transition(id, 'cancel');
|
|
131
131
|
// 2) Tear the node off its placement (pane-keyed): close any focus row it
|
|
132
132
|
// occupies, kill its PANE (the window closes once its last pane goes, so
|
|
133
|
-
// sibling nodes the user co-located in one window survive), null its
|
|
134
|
-
// LOCATION
|
|
133
|
+
// sibling nodes the user co-located in one window survive), and null its
|
|
134
|
+
// LOCATION (closing the focus row is the record — no pointer to clear).
|
|
135
135
|
tearDownNode(id);
|
|
136
136
|
// 3) Leave the resume notice AFTER the watcher is gone, so it survives.
|
|
137
137
|
appendInbox(id, {
|
|
@@ -16,12 +16,10 @@ import { join } from 'node:path';
|
|
|
16
16
|
import { getNode, setPresence, updateNode, setFocusOccupant, fullName } from '../canvas/index.js';
|
|
17
17
|
import { reportsDir } from '../canvas/paths.js';
|
|
18
18
|
import { pushFinal } from '../feed/feed.js';
|
|
19
|
-
import { spawnNode } from './nodes.js';
|
|
19
|
+
import { spawnNode, nodeSession } from './nodes.js';
|
|
20
20
|
import { buildLaunchSpec, buildPiArgv } from './launch.js';
|
|
21
|
-
import { piCommand, paneLocation, nodeSession } from './tmux.js';
|
|
22
21
|
import { FRONT_DOOR_ENV } from './front-door.js';
|
|
23
|
-
import {
|
|
24
|
-
import { focusOf, recycleFocusPane } from './placement.js';
|
|
22
|
+
import { focusOf, recycleFocusPane, piCommand, paneLocation } from './placement.js';
|
|
25
23
|
import { ensureDaemon } from '../../daemon/manage.js';
|
|
26
24
|
/** The agent's most recent surfaced message: the newest reports/*.md body with
|
|
27
25
|
* its YAML frontmatter stripped. Empty string when the node never reported. */
|
|
@@ -104,12 +102,9 @@ export async function demoteNode(nodeId, callerPane) {
|
|
|
104
102
|
if (f !== null) {
|
|
105
103
|
try {
|
|
106
104
|
setFocusOccupant(f.focus_id, root.node_id);
|
|
107
|
-
setFocus(root.node_id);
|
|
108
105
|
}
|
|
109
106
|
catch { /* best-effort */ }
|
|
110
107
|
}
|
|
111
|
-
else if (getFocus() === nodeId)
|
|
112
|
-
setFocus('');
|
|
113
108
|
const fresh = getNode(root.node_id);
|
|
114
109
|
const inv = buildPiArgv(fresh);
|
|
115
110
|
const env = { ...inv.env, CRTR_ROOT_SESSION: nodeSession(), [FRONT_DOOR_ENV]: '1' };
|
|
@@ -6,12 +6,14 @@ export declare const CANVAS_GOAL_CAPTURE_PATH: string;
|
|
|
6
6
|
export declare const CANVAS_PASSIVE_CONTEXT_PATH: string;
|
|
7
7
|
export declare const CANVAS_CONTEXT_INTRO_PATH: string;
|
|
8
8
|
export declare const CANVAS_COMMANDS_PATH: string;
|
|
9
|
+
export declare const CANVAS_RESUME_PATH: string;
|
|
9
10
|
/** The canvas extensions every node loads, in order: stophook (routing +
|
|
10
11
|
* telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
|
|
11
12
|
* graph chrome), goal-capture (persist the first user message as the goal),
|
|
12
13
|
* passive-context (drain passive backlog as pre-text on the next message),
|
|
13
14
|
* context-intro (inject the <crtr-context> bearings block as its own session
|
|
14
|
-
* message, once per brand-new chat), commands (the /promote slash-command)
|
|
15
|
+
* message, once per brand-new chat), commands (the /promote slash-command),
|
|
16
|
+
* resume (the /resume-node whole-canvas picker → `crtr node focus`).
|
|
15
17
|
* All self-gate on CRTR_NODE_ID. goal-capture precedes passive-context so it
|
|
16
18
|
* reads the raw user text. */
|
|
17
19
|
export declare const CANVAS_EXTENSIONS: string[];
|
|
@@ -33,12 +33,14 @@ export const CANVAS_GOAL_CAPTURE_PATH = resolveExtension('canvas-goal-capture');
|
|
|
33
33
|
export const CANVAS_PASSIVE_CONTEXT_PATH = resolveExtension('canvas-passive-context');
|
|
34
34
|
export const CANVAS_CONTEXT_INTRO_PATH = resolveExtension('canvas-context-intro');
|
|
35
35
|
export const CANVAS_COMMANDS_PATH = resolveExtension('canvas-commands');
|
|
36
|
+
export const CANVAS_RESUME_PATH = resolveExtension('canvas-resume');
|
|
36
37
|
/** The canvas extensions every node loads, in order: stophook (routing +
|
|
37
38
|
* telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
|
|
38
39
|
* graph chrome), goal-capture (persist the first user message as the goal),
|
|
39
40
|
* passive-context (drain passive backlog as pre-text on the next message),
|
|
40
41
|
* context-intro (inject the <crtr-context> bearings block as its own session
|
|
41
|
-
* message, once per brand-new chat), commands (the /promote slash-command)
|
|
42
|
+
* message, once per brand-new chat), commands (the /promote slash-command),
|
|
43
|
+
* resume (the /resume-node whole-canvas picker → `crtr node focus`).
|
|
42
44
|
* All self-gate on CRTR_NODE_ID. goal-capture precedes passive-context so it
|
|
43
45
|
* reads the raw user text. */
|
|
44
46
|
export const CANVAS_EXTENSIONS = [
|
|
@@ -49,6 +51,7 @@ export const CANVAS_EXTENSIONS = [
|
|
|
49
51
|
CANVAS_PASSIVE_CONTEXT_PATH,
|
|
50
52
|
CANVAS_CONTEXT_INTRO_PATH,
|
|
51
53
|
CANVAS_COMMANDS_PATH,
|
|
54
|
+
CANVAS_RESUME_PATH,
|
|
52
55
|
];
|
|
53
56
|
/** Bare model aliases resolve to the anthropic provider under pi (avoids the
|
|
54
57
|
* bedrock default). Anything with a `/` or an unknown name passes through. */
|
|
@@ -2,7 +2,7 @@ import type { NodeMeta } from '../canvas/types.js';
|
|
|
2
2
|
/** The lifecycle events — the only vocabulary for moving a node's status/intent.
|
|
3
3
|
* Each maps (in the table below) to a target status and/or intent plus the set
|
|
4
4
|
* of from-statuses it is legal from. */
|
|
5
|
-
export type LifecycleEvent = 'finalize' | '
|
|
5
|
+
export type LifecycleEvent = 'finalize' | 'cancel' | 'crash' | 'yield' | 'release' | 'revive' | 'boot';
|
|
6
6
|
/** Enact a lifecycle event on a node: validate the from-status against the
|
|
7
7
|
* table, then write status+intent in ONE atomic statement (so they can never
|
|
8
8
|
* disagree). Returns the hydrated node view after the write.
|
|
@@ -17,9 +17,17 @@
|
|
|
17
17
|
// "flip status to a non-supervised value + clear intent BEFORE killing the
|
|
18
18
|
// window" — the daemon only ever revives active|idle nodes, so a teardown must
|
|
19
19
|
// leave the node done/canceled first to close the revive race. That invariant is
|
|
20
|
-
// now the DEFINITION of the `
|
|
20
|
+
// now the DEFINITION of the `cancel` event: callers flip via transition()
|
|
21
21
|
// and only THEN kill the window.
|
|
22
22
|
//
|
|
23
|
+
// Unification (A5, human-confirmed 2026-06-06): an externally-reaped node — torn
|
|
24
|
+
// down because the user moved on (close cascade) OR because a root reset/relaunch
|
|
25
|
+
// superseded it — ends `canceled`, NOT `done`. `done` is reserved for a node that
|
|
26
|
+
// finished its OWN work (finalize). The old `reap` event (→ done) was identical to
|
|
27
|
+
// `cancel` in every field and side effect once unified on status, so it was
|
|
28
|
+
// COLLAPSED into `cancel`; reset.ts's reapDescendants + relaunchRoot park-old now
|
|
29
|
+
// route through `cancel`.
|
|
30
|
+
//
|
|
23
31
|
// Layering note: lifecycle.ts is runtime, but it is the canvas write surface's
|
|
24
32
|
// `transition` verb (the only writer of status+intent), so it owns its atomic
|
|
25
33
|
// row UPDATE directly via openDb — the one sanctioned exception to "only
|
|
@@ -35,9 +43,9 @@ const LIVE = ['active', 'idle'];
|
|
|
35
43
|
const TRANSITIONS = {
|
|
36
44
|
// feed.push(final) · queue.cancelJob · markCleanExitDone (clean quit).
|
|
37
45
|
finalize: { status: 'done', intent: 'done', from: LIVE },
|
|
38
|
-
// reapDescendants · relaunchRoot park-old. Forced teardown
|
|
39
|
-
|
|
40
|
-
//
|
|
46
|
+
// closeNode cascade · reapDescendants · relaunchRoot park-old. Forced teardown
|
|
47
|
+
// of a node that did NOT finish its own work → canceled, intent cleared. (A5:
|
|
48
|
+
// done is reserved for finalize; every external reap unifies on canceled.)
|
|
41
49
|
cancel: { status: 'canceled', intent: null, from: ANY },
|
|
42
50
|
// daemon superviseTick: window gone with no yield/release intent. Intent KEPT
|
|
43
51
|
// (the dead log line still reports it).
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { NodeMeta } from '../canvas/index.js';
|
|
2
|
-
/** Coerce arbitrary text into a 3-
|
|
2
|
+
/** Coerce arbitrary text into a 3-8 word kebab-case name, or '' if nothing
|
|
3
3
|
* usable survives. Lowercases, keeps [a-z0-9], collapses everything else to a
|
|
4
|
-
* single hyphen, and clamps to the first
|
|
4
|
+
* single hyphen, and clamps to the first 8 words. */
|
|
5
5
|
export declare function sanitizeSessionName(raw: string): string;
|
|
6
6
|
/** Local fallback: derive a name straight from the prompt (no pi call). Drops
|
|
7
7
|
* stop-words, takes the first few content words. */
|
|
8
8
|
export declare function slugFromPrompt(prompt: string): string;
|
|
9
|
-
/** Synchronously ask pi for a
|
|
9
|
+
/** Synchronously ask pi for a 3-8 word kebab name for `prompt`. Blocks up to
|
|
10
10
|
* NAME_TIMEOUT_MS; on any failure (non-zero exit, timeout, empty/garbled
|
|
11
11
|
* output) falls back to a local slug. Returns '' only for an empty prompt. */
|
|
12
12
|
export declare function generateSessionName(prompt: string): string;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// handle for the editor label.
|
|
3
3
|
//
|
|
4
4
|
// A node's editor label is `<kind> (<mode>) <name> <cycle>` (see editorLabel in
|
|
5
|
-
// launch.ts). The `<name>` is a 3-
|
|
5
|
+
// launch.ts). The `<name>` is a 3-8 word kebab-case "description" derived from
|
|
6
6
|
// the first prompt by asking pi headlessly (`pi -p`), persisted on the node's
|
|
7
7
|
// meta so it survives revives and shows in every cycle.
|
|
8
8
|
//
|
|
@@ -26,7 +26,7 @@ const PROMPT_CAP = 2000;
|
|
|
26
26
|
const NAME_TIMEOUT_MS = 20_000;
|
|
27
27
|
const NAME_SYSTEM_PROMPT = 'You name coding-agent work sessions. This name is a label used to identify the ' +
|
|
28
28
|
'session at a glance among many other concurrent programming sessions, so it must ' +
|
|
29
|
-
'describe what the task is about. Reply with ONLY a concise 3-
|
|
29
|
+
'describe what the task is about. Reply with ONLY a concise 3-8 word name in ' +
|
|
30
30
|
'kebab-case: lowercase words joined by single hyphens (e.g. `refactor-auth-token-flow`, ' +
|
|
31
31
|
'`add-csv-export-endpoint`). No punctuation, quotes, prose, or trailing text. ' +
|
|
32
32
|
'Output JUST the name, nothing else.';
|
|
@@ -43,9 +43,9 @@ const STOPWORDS = new Set([
|
|
|
43
43
|
'is', 'are', 'be', 'this', 'that', 'it', 'as', 'at', 'by', 'from', 'into',
|
|
44
44
|
'please', 'can', 'you', 'i', 'we', 'my', 'our', 'me', 'so', 'then',
|
|
45
45
|
]);
|
|
46
|
-
/** Coerce arbitrary text into a 3-
|
|
46
|
+
/** Coerce arbitrary text into a 3-8 word kebab-case name, or '' if nothing
|
|
47
47
|
* usable survives. Lowercases, keeps [a-z0-9], collapses everything else to a
|
|
48
|
-
* single hyphen, and clamps to the first
|
|
48
|
+
* single hyphen, and clamps to the first 8 words. */
|
|
49
49
|
export function sanitizeSessionName(raw) {
|
|
50
50
|
const firstLine = (raw ?? '').split('\n').map((l) => l.trim()).find((l) => l !== '') ?? '';
|
|
51
51
|
const words = firstLine
|
|
@@ -53,7 +53,7 @@ export function sanitizeSessionName(raw) {
|
|
|
53
53
|
.replace(/[^a-z0-9]+/g, '-')
|
|
54
54
|
.split('-')
|
|
55
55
|
.filter((w) => w !== '');
|
|
56
|
-
return words.slice(0,
|
|
56
|
+
return words.slice(0, 8).join('-');
|
|
57
57
|
}
|
|
58
58
|
/** Local fallback: derive a name straight from the prompt (no pi call). Drops
|
|
59
59
|
* stop-words, takes the first few content words. */
|
|
@@ -97,7 +97,7 @@ function nameArgs(prompt) {
|
|
|
97
97
|
argv.push(nameUserPrompt(prompt));
|
|
98
98
|
return argv;
|
|
99
99
|
}
|
|
100
|
-
/** Synchronously ask pi for a
|
|
100
|
+
/** Synchronously ask pi for a 3-8 word kebab name for `prompt`. Blocks up to
|
|
101
101
|
* NAME_TIMEOUT_MS; on any failure (non-zero exit, timeout, empty/garbled
|
|
102
102
|
* output) falls back to a local slug. Returns '' only for an empty prompt. */
|
|
103
103
|
export function generateSessionName(prompt) {
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { type NodeMeta, type Mode, type Lifecycle, type LaunchSpec } from '../canvas/index.js';
|
|
2
2
|
/** Generate a node id in the same shape as job ids (time-sortable + random). */
|
|
3
3
|
export declare function newNodeId(): string;
|
|
4
|
+
/** The single, shared tmux session that ALL canvas node windows live in.
|
|
5
|
+
* Overridable with CRTR_NODE_SESSION (default `crtr`). Every root and every
|
|
6
|
+
* child opens a window here rather than cluttering the user's own working
|
|
7
|
+
* session — switch to it to browse the whole live graph, ignore it otherwise.
|
|
8
|
+
* Pure policy (env only, no tmux call), so it lives in the node layer, not the
|
|
9
|
+
* driver; the tmux driver imports it from here for installMenuBinding's use. */
|
|
10
|
+
export declare function nodeSession(): string;
|
|
4
11
|
/** Resolve the tmux session a freshly-born node's window/pane opens into — and
|
|
5
12
|
* thus its durable REVIVE-HOME (`home_session`). Pure so the birth decision is
|
|
6
13
|
* unit-testable without a live tmux:
|