@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
|
@@ -10,15 +10,15 @@
|
|
|
10
10
|
// provenance via spawned_by) brought forefront for direct driving.
|
|
11
11
|
import { spawnSync } from 'node:child_process';
|
|
12
12
|
import { FRONT_DOOR_ENV } from './front-door.js';
|
|
13
|
-
import { spawnNode, currentNodeContext, resolveBirthSession } from './nodes.js';
|
|
13
|
+
import { spawnNode, currentNodeContext, resolveBirthSession, nodeSession } from './nodes.js';
|
|
14
14
|
import { buildLaunchSpec, buildPiArgv } from './launch.js';
|
|
15
15
|
import { writeGoal } from './kickoff.js';
|
|
16
16
|
import { hasRoadmap, seedRoadmap } from './roadmap.js';
|
|
17
17
|
import { seedMemory, seedUserMemory, seedProjectMemory } from './memory.js';
|
|
18
18
|
import { generateSessionName } from './naming.js';
|
|
19
|
-
import {
|
|
19
|
+
import { installMenuBinding, installNavBindings } from './tmux-chrome.js';
|
|
20
20
|
import { setPresence, updateNode, getNode, fullName } from '../canvas/index.js';
|
|
21
|
-
import { registerRootFocus } from './placement.js';
|
|
21
|
+
import { registerRootFocus, ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, focusWindow, } from './placement.js';
|
|
22
22
|
import { transition } from './lifecycle.js';
|
|
23
23
|
import { ensureDaemon } from '../../daemon/manage.js';
|
|
24
24
|
/** Create a root node and bring up its pi. Returns the node; for 'inline' this
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { installMenuBinding, installNavBindings, sendKeysEnter } from './tmux.js';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// tmux-chrome.ts — chrome seam (§2.1): stateless keybind/input verbs.
|
|
2
|
+
// The ONLY non-placement module allowed to import the tmux driver, per the
|
|
3
|
+
// §5.1 lint. Re-exports the menu/nav/send-keys verbs callers (spawn, chord) need.
|
|
4
|
+
export { installMenuBinding, installNavBindings, sendKeysEnter } from './tmux.js';
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
/** POSIX single-quote escaping for one shell word. */
|
|
2
2
|
export declare function shellQuote(s: string): string;
|
|
3
3
|
export declare function inTmux(): boolean;
|
|
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
|
-
export declare function nodeSession(): string;
|
|
9
4
|
export interface TmuxLocation {
|
|
10
5
|
session: string;
|
|
11
6
|
window: string;
|
|
@@ -139,7 +134,19 @@ export declare function respawnPaneSync(opts: RespawnPaneOpts): boolean;
|
|
|
139
134
|
* callers stay green while the placement layer migrates onto the explicit
|
|
140
135
|
* sync/detached split. */
|
|
141
136
|
export declare function respawnPane(opts: RespawnPaneOpts): boolean;
|
|
142
|
-
/** Turn a pi argv array into a single shell command string.
|
|
137
|
+
/** Turn a pi argv array into a single shell command string.
|
|
138
|
+
*
|
|
139
|
+
* The binary defaults to `CRTR_PI_BINARY` when that env var is set, else the
|
|
140
|
+
* literal `pi`. This is a TEST-ONLY substitution seam: when CRTR_PI_BINARY is
|
|
141
|
+
* unset (every production path) the behavior is byte-identical to exec'ing
|
|
142
|
+
* `pi`. The integration-test harness points it at a deterministic fake-pi
|
|
143
|
+
* vehicle so a real `crtr node new` reaches the fake instead of the LLM `pi`,
|
|
144
|
+
* without any dependence on tmux/shell PATH inheritance — the substitution is
|
|
145
|
+
* baked into the command string at build time, in the process that calls
|
|
146
|
+
* piCommand. An explicit `binary` arg still overrides the env (no caller passes
|
|
147
|
+
* one today). The value may be a multi-word launcher (e.g. `node --import
|
|
148
|
+
* tsx/esm host.ts`); only the argv entries are shell-quoted, so a multi-word
|
|
149
|
+
* binary is spliced verbatim ahead of them. */
|
|
143
150
|
export declare function piCommand(argv: string[], binary?: string): string;
|
|
144
151
|
/** List all window ids present in `session`. Returns [] if the session does
|
|
145
152
|
* not exist or tmux fails for any reason. Each entry is the raw window id
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// their window; reviving opens a fresh one.
|
|
10
10
|
import { spawn, spawnSync } from 'node:child_process';
|
|
11
11
|
import { readConfig } from '../config.js';
|
|
12
|
+
import { nodeSession } from './nodes.js';
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
// Shell quoting + tmux invocation
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
@@ -27,14 +28,6 @@ function tmux(args) {
|
|
|
27
28
|
export function inTmux() {
|
|
28
29
|
return process.env['TMUX'] !== undefined && process.env['TMUX'] !== '';
|
|
29
30
|
}
|
|
30
|
-
/** The single, shared tmux session that ALL canvas node windows live in.
|
|
31
|
-
* Overridable with CRTR_NODE_SESSION (default `crtr`). Every root and every
|
|
32
|
-
* child opens a window here rather than cluttering the user's own working
|
|
33
|
-
* session — switch to it to browse the whole live graph, ignore it otherwise. */
|
|
34
|
-
export function nodeSession() {
|
|
35
|
-
const v = process.env['CRTR_NODE_SESSION'];
|
|
36
|
-
return v !== undefined && v !== '' ? v : 'crtr';
|
|
37
|
-
}
|
|
38
31
|
/** Where the caller currently is, or null if not inside tmux. */
|
|
39
32
|
export function currentTmux() {
|
|
40
33
|
if (!inTmux())
|
|
@@ -270,8 +263,20 @@ export function respawnPane(opts) {
|
|
|
270
263
|
// ---------------------------------------------------------------------------
|
|
271
264
|
// pi command assembly
|
|
272
265
|
// ---------------------------------------------------------------------------
|
|
273
|
-
/** Turn a pi argv array into a single shell command string.
|
|
274
|
-
|
|
266
|
+
/** Turn a pi argv array into a single shell command string.
|
|
267
|
+
*
|
|
268
|
+
* The binary defaults to `CRTR_PI_BINARY` when that env var is set, else the
|
|
269
|
+
* literal `pi`. This is a TEST-ONLY substitution seam: when CRTR_PI_BINARY is
|
|
270
|
+
* unset (every production path) the behavior is byte-identical to exec'ing
|
|
271
|
+
* `pi`. The integration-test harness points it at a deterministic fake-pi
|
|
272
|
+
* vehicle so a real `crtr node new` reaches the fake instead of the LLM `pi`,
|
|
273
|
+
* without any dependence on tmux/shell PATH inheritance — the substitution is
|
|
274
|
+
* baked into the command string at build time, in the process that calls
|
|
275
|
+
* piCommand. An explicit `binary` arg still overrides the env (no caller passes
|
|
276
|
+
* one today). The value may be a multi-word launcher (e.g. `node --import
|
|
277
|
+
* tsx/esm host.ts`); only the argv entries are shell-quoted, so a multi-word
|
|
278
|
+
* binary is spliced verbatim ahead of them. */
|
|
279
|
+
export function piCommand(argv, binary = process.env['CRTR_PI_BINARY'] ?? 'pi') {
|
|
275
280
|
return [binary, ...argv.map(shellQuote)].join(' ');
|
|
276
281
|
}
|
|
277
282
|
// ---------------------------------------------------------------------------
|
|
@@ -295,7 +300,7 @@ export function windowAlive(session, window) {
|
|
|
295
300
|
return listWindowIds(session).includes(window);
|
|
296
301
|
}
|
|
297
302
|
// ---------------------------------------------------------------------------
|
|
298
|
-
// Focus helpers (used by the
|
|
303
|
+
// Focus helpers (used by the placement layer)
|
|
299
304
|
// ---------------------------------------------------------------------------
|
|
300
305
|
/** Activate a window within its session (same-session navigation). Equivalent
|
|
301
306
|
* to `tmux select-window -t <session>:<window>`. Best-effort; never throws. */
|
|
@@ -351,7 +356,7 @@ export function sendKeysEnter(pane, text) {
|
|
|
351
356
|
// ---------------------------------------------------------------------------
|
|
352
357
|
/** Reserved mnemonic keys owned by the built-in menu items below — a custom
|
|
353
358
|
* `prefixBind` may not claim these (the built-in item wins). */
|
|
354
|
-
const RESERVED_MENU_KEYS = new Set(['o', 'd', 'D', 'x', 'b']);
|
|
359
|
+
const RESERVED_MENU_KEYS = new Set(['o', 'r', 'd', 'D', 'x', 'b']);
|
|
355
360
|
/** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails.
|
|
356
361
|
* The built-in items (promote/demote/detach/close/browse) are static; the canvas-nav
|
|
357
362
|
* chords (graph/manager/expand/report-N + any custom prefixBind) are appended
|
|
@@ -366,6 +371,10 @@ export function installMenuBinding() {
|
|
|
366
371
|
// the slash command delivers the orchestration guidance into the node's
|
|
367
372
|
// context, which a bare `run-shell` (output discarded) could not.
|
|
368
373
|
{ name: 'promote to orchestrator', key: 'o', cmd: `send-keys -t '#{pane_id}' '/promote' Enter` },
|
|
374
|
+
// Resume types `/resume-node` into the agent's pane: the slash command opens
|
|
375
|
+
// a whole-canvas picker (incl. dormant nodes) and revives the choice via
|
|
376
|
+
// `crtr node focus` — the only sync-safe open (routes through reviveNode).
|
|
377
|
+
{ name: 'resume node', key: 'r', cmd: `send-keys -t '#{pane_id}' '/resume-node' Enter` },
|
|
369
378
|
// `d` demotes the agent to TERMINAL in place: no finalize, no kill — it keeps
|
|
370
379
|
// running where it is, and because it is now terminal it is forced to push a
|
|
371
380
|
// final up the spine when it finishes. `D` ALSO detaches it to the background
|
package/dist/daemon/crtrd.js
CHANGED
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
// • Pane gone + intent==='idle-release' → node freed its own pane while
|
|
15
15
|
// dormant; clear the stale window ref and revive (resume) when its inbox
|
|
16
16
|
// gains an unseen entry.
|
|
17
|
-
// • Pane gone + any other intent →
|
|
17
|
+
// • Pane gone + any other intent → route on what the node was doing:
|
|
18
|
+
// - never-booted (pi_session_id null) → crash ('dead') + surface boot fail
|
|
19
|
+
// - mid-generation (busy marker present) → crash ('dead')
|
|
20
|
+
// - finished its turn, still awaiting a live child → crash ('dead'), for now
|
|
21
|
+
// - finished its turn, awaiting nothing live → finalize ('done')
|
|
18
22
|
// • Nodes with no tmux placement (inline roots) are skipped.
|
|
19
23
|
//
|
|
20
24
|
// Single-instance guarantee
|
|
@@ -24,8 +28,9 @@
|
|
|
24
28
|
import { writeFileSync, readFileSync, rmSync, existsSync, mkdirSync, } from 'node:fs';
|
|
25
29
|
import { join } from 'node:path';
|
|
26
30
|
import { crtrHome } from '../core/canvas/paths.js';
|
|
27
|
-
import { listNodes, getRow, setPresence, getNode, } from '../core/canvas/index.js';
|
|
31
|
+
import { listNodes, getRow, setPresence, getNode, hasActiveLiveSubscription, } from '../core/canvas/index.js';
|
|
28
32
|
import { transition } from '../core/runtime/lifecycle.js';
|
|
33
|
+
import { isBusy } from '../core/runtime/busy.js';
|
|
29
34
|
import { isNodePaneAlive, reconcile } from '../core/runtime/placement.js';
|
|
30
35
|
import { reviveNode } from '../core/runtime/revive.js';
|
|
31
36
|
import { pushUrgent } from '../core/feed/feed.js';
|
|
@@ -201,28 +206,45 @@ export async function superviseTick(now = Date.now()) {
|
|
|
201
206
|
setPresence(row.node_id, { tmux_session: row.tmux_session, window: null });
|
|
202
207
|
}
|
|
203
208
|
else {
|
|
204
|
-
// The pane vanished without the node
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
209
|
+
// The pane vanished without the node yielding or releasing. Route on what
|
|
210
|
+
// the node was DOING at pane-kill time — not every gone pane is a death:
|
|
211
|
+
// • never-booted (pi_session_id null) → crash + surface boot failure.
|
|
212
|
+
// A spawn failure the parent was never told about — it had no turn to
|
|
213
|
+
// finish, so it can never be a finalize. (Boot-failed vs crashed turns
|
|
214
|
+
// on pi_session_id, an IDENTITY field — the one place this pass still
|
|
215
|
+
// reads meta; surfaceBootFailure also wants name/kind for its message.)
|
|
216
|
+
// • MID-GENERATION (busy marker present) → crash (→dead). agent_start
|
|
217
|
+
// touched the marker and agent_end never cleared it ⇒ the pane was
|
|
218
|
+
// killed inside a turn: a genuine mid-run death. (The pane is gone, so
|
|
219
|
+
// pi is dead; we read isBusy WITHOUT the usual AND-pidAlive guard on
|
|
220
|
+
// purpose — here a stale marker IS the proof it died mid-turn.)
|
|
221
|
+
// • finished its turn (busy ABSENT) but still awaiting a LIVE child →
|
|
222
|
+
// crash (→dead) for now. (This waiting-on-a-live-child case may later
|
|
223
|
+
// route to a revivable-idle instead of a hard death.)
|
|
224
|
+
// • finished its turn AND awaiting nothing live → finalize (→done): it
|
|
225
|
+
// did its own work and the pane was closed to dismiss it.
|
|
214
226
|
const meta = getNode(row.node_id);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
process.stderr.write(`[crtrd] surfaceBootFailure ${row.node_id} error: ${err.message}\n`);
|
|
222
|
-
}
|
|
227
|
+
const neverBooted = meta !== null && meta.pi_session_id == null;
|
|
228
|
+
if (!neverBooted &&
|
|
229
|
+
!isBusy(row.node_id) &&
|
|
230
|
+
!hasActiveLiveSubscription(row.node_id)) {
|
|
231
|
+
transition(row.node_id, 'finalize');
|
|
232
|
+
process.stderr.write(`[crtrd] done ${row.node_id} (pane gone after turn end, no live child)\n`);
|
|
223
233
|
}
|
|
224
234
|
else {
|
|
225
|
-
|
|
235
|
+
transition(row.node_id, 'crash');
|
|
236
|
+
if (neverBooted) {
|
|
237
|
+
process.stderr.write(`[crtrd] boot-failed ${row.node_id} (pane gone before pi ever started)\n`);
|
|
238
|
+
try {
|
|
239
|
+
await surfaceBootFailure(meta);
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
process.stderr.write(`[crtrd] surfaceBootFailure ${row.node_id} error: ${err.message}\n`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
process.stderr.write(`[crtrd] dead ${row.node_id} (pane gone, intent=${String(row.intent)})\n`);
|
|
247
|
+
}
|
|
226
248
|
}
|
|
227
249
|
}
|
|
228
250
|
}
|
|
@@ -198,28 +198,37 @@ function managerOf(id) {
|
|
|
198
198
|
return undefined;
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
|
-
/**
|
|
202
|
-
|
|
201
|
+
/** A kind:'human' node is a control-plane ASK (a humanloop deck on the human's
|
|
202
|
+
* screen), NOT a pi conversation — it has no session, so focusing/reviving it
|
|
203
|
+
* boots a confused blank "you have been revived" pi. Its pending-ask signal
|
|
204
|
+
* already rides the ⚑ badge on the ASKING node (attention.ts attributes asks by
|
|
205
|
+
* source.nodeId, never to the human node), so the row carries no signal of its
|
|
206
|
+
* own. Drop it from every navigable list (the tree, BASE reports, child counts,
|
|
207
|
+
* subtree expansion) so it can never be selected. */
|
|
208
|
+
function isHumanAsk(id) {
|
|
209
|
+
return getNode(id)?.kind === 'human';
|
|
210
|
+
}
|
|
211
|
+
/** A node's direct children that are navigable conversations — human-ask nodes
|
|
212
|
+
* dropped. The one place the nav chrome enumerates children. */
|
|
213
|
+
function convoChildIds(id) {
|
|
203
214
|
try {
|
|
204
|
-
return subscriptionsOf(id)
|
|
205
|
-
.map((s) => s.node_id)
|
|
206
|
-
.filter((cid) => {
|
|
207
|
-
const st = getNode(cid)?.status;
|
|
208
|
-
return st === 'active' || st === 'idle';
|
|
209
|
-
});
|
|
215
|
+
return subscriptionsOf(id).map((s) => s.node_id).filter((cid) => !isHumanAsk(cid));
|
|
210
216
|
}
|
|
211
217
|
catch {
|
|
212
218
|
return [];
|
|
213
219
|
}
|
|
214
220
|
}
|
|
215
|
-
/**
|
|
221
|
+
/** Live reports (active|idle) of a node — the DOWN set in BASE. */
|
|
222
|
+
function liveReports(id) {
|
|
223
|
+
return convoChildIds(id).filter((cid) => {
|
|
224
|
+
const st = getNode(cid)?.status;
|
|
225
|
+
return st === 'active' || st === 'idle';
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/** Direct navigable children — used for the ⤳ badge and fold counts (human-ask
|
|
229
|
+
* nodes excluded, so the count matches what the tree actually shows). */
|
|
216
230
|
function childCount(id) {
|
|
217
|
-
|
|
218
|
-
return subscriptionsOf(id).length;
|
|
219
|
-
}
|
|
220
|
-
catch {
|
|
221
|
-
return 0;
|
|
222
|
-
}
|
|
231
|
+
return convoChildIds(id).length;
|
|
223
232
|
}
|
|
224
233
|
/** Climb first-manager edges from `self` to the ancestry root (cycle-guarded). */
|
|
225
234
|
function climbRoot(self) {
|
|
@@ -238,16 +247,16 @@ function climbRoot(self) {
|
|
|
238
247
|
function subtreeIds(root) {
|
|
239
248
|
const out = [];
|
|
240
249
|
const seen = new Set([root]);
|
|
241
|
-
const q =
|
|
250
|
+
const q = convoChildIds(root);
|
|
242
251
|
while (q.length > 0) {
|
|
243
252
|
const id = q.shift();
|
|
244
253
|
if (seen.has(id))
|
|
245
254
|
continue;
|
|
246
255
|
seen.add(id);
|
|
247
256
|
out.push(id);
|
|
248
|
-
for (const
|
|
249
|
-
if (!seen.has(
|
|
250
|
-
q.push(
|
|
257
|
+
for (const cid of convoChildIds(id))
|
|
258
|
+
if (!seen.has(cid))
|
|
259
|
+
q.push(cid);
|
|
251
260
|
}
|
|
252
261
|
return out;
|
|
253
262
|
}
|
|
@@ -283,12 +292,7 @@ function statusRank(id) {
|
|
|
283
292
|
* the tree and when stepping into a subtree (`l`). Array.sort is stable, so
|
|
284
293
|
* equal-status siblings keep their creation order. */
|
|
285
294
|
function sortedChildIds(id) {
|
|
286
|
-
|
|
287
|
-
return subscriptionsOf(id).map((s) => s.node_id).sort((a, b) => statusRank(a) - statusRank(b));
|
|
288
|
-
}
|
|
289
|
-
catch {
|
|
290
|
-
return [];
|
|
291
|
-
}
|
|
295
|
+
return convoChildIds(id).sort((a, b) => statusRank(a) - statusRank(b));
|
|
292
296
|
}
|
|
293
297
|
function buildGraphModel(self) {
|
|
294
298
|
const rootId = climbRoot(self);
|
|
@@ -502,11 +506,19 @@ export function registerCanvasNav(pi) {
|
|
|
502
506
|
// Budget WITHIN pi's widget cap (see graphWidgetBudget): reserve 1 line for
|
|
503
507
|
// the footer hint, up to 2 for the ↑/↓ "more" indicators, the rest for tree
|
|
504
508
|
// rows. The window then tracks the cursor, so j/k scrolls through the WHOLE
|
|
505
|
-
// list rather than hitting pi's hard truncation.
|
|
506
|
-
// mutual dependency between "how many rows fit" and "are indicators shown"
|
|
509
|
+
// list rather than hitting pi's hard truncation. The passes settle the
|
|
510
|
+
// mutual dependency between "how many rows fit" and "are indicators shown":
|
|
511
|
+
// each ↑/↓ indicator steals a tree row, which can push the cursor out of
|
|
512
|
+
// view, which moves the window, which changes whether an indicator shows.
|
|
513
|
+
// This needs up to 3 passes to converge (an indicator appearing shrinks the
|
|
514
|
+
// window, the smaller window re-homes scrollTop, that re-home can toggle the
|
|
515
|
+
// *other* indicator). Bailing early (the old 2-pass cap) left the cursor one
|
|
516
|
+
// row off-screen for a single keypress near the bottom — the arrow vanished
|
|
517
|
+
// and only the NEXT press scrolled. 4 passes always settles to a stable,
|
|
518
|
+
// cursor-visible window.
|
|
507
519
|
const treeArea = Math.max(2, graphWidgetBudget() - 1);
|
|
508
520
|
let viewportH = treeArea;
|
|
509
|
-
for (let pass = 0; pass <
|
|
521
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
510
522
|
if (cursorIdx < scrollTop)
|
|
511
523
|
scrollTop = cursorIdx;
|
|
512
524
|
if (cursorIdx >= scrollTop + viewportH)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
interface CommandUI {
|
|
2
|
+
notify(message: string, type?: 'info' | 'warning' | 'error'): void;
|
|
3
|
+
}
|
|
4
|
+
interface CommandCtx {
|
|
5
|
+
mode: string;
|
|
6
|
+
ui: CommandUI;
|
|
7
|
+
}
|
|
8
|
+
interface PiLike {
|
|
9
|
+
registerCommand?(name: string, options: {
|
|
10
|
+
description?: string;
|
|
11
|
+
handler: (args: string, ctx: CommandCtx) => void | Promise<void>;
|
|
12
|
+
}): void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Register the /resume-node command on `pi`.
|
|
16
|
+
*
|
|
17
|
+
* Returns immediately when CRTR_NODE_ID is absent — the extension is fully
|
|
18
|
+
* inert in a non-canvas pi session.
|
|
19
|
+
*/
|
|
20
|
+
export declare function registerCanvasResume(pi: PiLike): void;
|
|
21
|
+
export default registerCanvasResume;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// canvas-resume.ts — pi extension registering the /resume-node canvas command.
|
|
2
|
+
//
|
|
3
|
+
// /resume-node — open the full-screen canvas navigator (`crtr canvas browse`)
|
|
4
|
+
// as a tmux popup. The navigator owns the screen (tabs / auto-collapsed tree
|
|
5
|
+
// / `/` super-search / cwd scope / sort / preview) and, on Enter, focuses the
|
|
6
|
+
// chosen node back INTO this pane via `crtr node focus --pane`. The popup is
|
|
7
|
+
// scoped to THIS node's cwd by default (pass --cwd) so you see the nodes from
|
|
8
|
+
// the dir you're working in first; toggle to All dirs inside with `c`.
|
|
9
|
+
//
|
|
10
|
+
// The name is literally `resume-node`, NOT `resume`, to avoid clashing with
|
|
11
|
+
// pi's built-in /resume.
|
|
12
|
+
//
|
|
13
|
+
// crtr ONLY runs inside tmux (see crouter/CLAUDE.md) — there is no non-tmux
|
|
14
|
+
// fallback picker. Outside tmux the command notifies and no-ops.
|
|
15
|
+
//
|
|
16
|
+
// ⚠ DESYNC — why `crtr node focus` is the ONLY sanctioned open
|
|
17
|
+
// `crtr node focus <id>` (which `canvas browse` shells on Enter) routes through
|
|
18
|
+
// reviveNode() (src/core/runtime/revive.ts), the ONLY sanctioned launcher of
|
|
19
|
+
// `pi --session <file>`: it sets CRTR_NODE_ID + the `-e` canvas extensions and
|
|
20
|
+
// runs transition('revive'). A RAW `pi --session <file>` has NEITHER → every
|
|
21
|
+
// canvas hook is inert and the daemon can DOUBLE-SPAWN onto the same .jsonl.
|
|
22
|
+
// A UI must therefore NEVER spawn `pi --session` directly.
|
|
23
|
+
//
|
|
24
|
+
// INERT when CRTR_NODE_ID is absent (a plain pi session, not a canvas node).
|
|
25
|
+
//
|
|
26
|
+
// Plain TS-with-types — no imports from @earendil-works/* so this compiles
|
|
27
|
+
// inside crouter's own tsc build without a dep on the pi packages (mirrors
|
|
28
|
+
// canvas-nav.ts / canvas-commands.ts).
|
|
29
|
+
import { execFile } from 'node:child_process';
|
|
30
|
+
/** Single-quote a string for safe interpolation into a `sh -c` command line —
|
|
31
|
+
* tmux runs the display-popup trailing string through the shell, so a cwd with
|
|
32
|
+
* spaces or quotes must be escaped. */
|
|
33
|
+
function shellQuote(s) {
|
|
34
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Register the /resume-node command on `pi`.
|
|
38
|
+
*
|
|
39
|
+
* Returns immediately when CRTR_NODE_ID is absent — the extension is fully
|
|
40
|
+
* inert in a non-canvas pi session.
|
|
41
|
+
*/
|
|
42
|
+
export function registerCanvasResume(pi) {
|
|
43
|
+
const nodeId = process.env['CRTR_NODE_ID'];
|
|
44
|
+
if (nodeId === undefined || nodeId.trim() === '')
|
|
45
|
+
return; // not a canvas node
|
|
46
|
+
if (typeof pi.registerCommand !== 'function')
|
|
47
|
+
return;
|
|
48
|
+
pi.registerCommand('resume-node', {
|
|
49
|
+
description: 'Open the canvas navigator (search/scope/sort/tree) and resume the chosen node',
|
|
50
|
+
handler: async (_args, ctx) => {
|
|
51
|
+
// The popup is terminal-only — guard the run mode before opening it.
|
|
52
|
+
if (ctx.mode !== 'tui') {
|
|
53
|
+
try {
|
|
54
|
+
ctx.ui.notify('/resume-node needs the interactive TUI', 'warning');
|
|
55
|
+
}
|
|
56
|
+
catch { /* best-effort */ }
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const origPane = process.env['TMUX_PANE'];
|
|
60
|
+
// crtr only runs in tmux: open the full-screen canvas navigator as a popup.
|
|
61
|
+
// It owns the screen and, on Enter, focuses the chosen node back INTO this
|
|
62
|
+
// pane via `crtr node focus --pane`. Fire-and-forget: tmux runs the trailing
|
|
63
|
+
// string through sh -c, and the popup closes itself when browse exits.
|
|
64
|
+
if (process.env['TMUX'] !== undefined && origPane !== undefined && origPane !== '') {
|
|
65
|
+
// Scope the navigator to this node's cwd by default (the dir pi runs in).
|
|
66
|
+
const cwd = shellQuote(process.cwd());
|
|
67
|
+
const cmd = `crtr canvas browse --return-pane ${origPane} --cwd ${cwd}`;
|
|
68
|
+
try {
|
|
69
|
+
execFile('tmux', ['display-popup', '-E', '-w', '90%', '-h', '85%', cmd], () => { });
|
|
70
|
+
}
|
|
71
|
+
catch { /* best-effort */ }
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Not in tmux → crtr is tmux-only, so there is nothing to fall back to.
|
|
75
|
+
try {
|
|
76
|
+
ctx.ui.notify('/resume-node needs tmux', 'warning');
|
|
77
|
+
}
|
|
78
|
+
catch { /* best-effort */ }
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
export default registerCanvasResume;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
type PiEvents = 'turn_end' | 'agent_end' | 'session_shutdown' | 'session_start';
|
|
1
|
+
type PiEvents = 'agent_start' | 'turn_end' | 'agent_end' | 'session_shutdown' | 'session_start';
|
|
2
2
|
interface PiLike {
|
|
3
3
|
on: (event: PiEvents, handler: (event: any, ctx: any) => void | Promise<void>) => void;
|
|
4
4
|
sendUserMessage: (content: string, options?: {
|
|
@@ -28,15 +28,14 @@
|
|
|
28
28
|
// crouter's own tsc build without a dep on the pi packages.
|
|
29
29
|
import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
|
|
30
30
|
import { join } from 'node:path';
|
|
31
|
-
import { getNode, jobDir, updateNode, recordPid, subscribersOf,
|
|
31
|
+
import { getNode, jobDir, updateNode, recordPid, subscribersOf, setPresence } from '../core/canvas/index.js';
|
|
32
32
|
import { transition } from '../core/runtime/lifecycle.js';
|
|
33
|
+
import { markBusy, clearBusy } from '../core/runtime/busy.js';
|
|
33
34
|
import { evaluateStop } from '../core/runtime/stop-guard.js';
|
|
34
35
|
import { personaDrift, commitPersonaAck } from '../core/runtime/persona.js';
|
|
35
36
|
import { reviveInPlace } from '../core/runtime/revive.js';
|
|
36
37
|
import { handleNewSession, markCleanExitDone } from '../core/runtime/reset.js';
|
|
37
|
-
import {
|
|
38
|
-
import { focusOf, handFocusToManager, tearDownNode } from '../core/runtime/placement.js';
|
|
39
|
-
import { setRemainOnExit } from '../core/runtime/tmux.js';
|
|
38
|
+
import { focusOf, handFocusToManager, tearDownNode, closeFocusToShell } from '../core/runtime/placement.js';
|
|
40
39
|
/**
|
|
41
40
|
* Merge accumulated token counts into nodes/<nodeId>/job/telemetry.json.
|
|
42
41
|
* Creates the directory when it doesn't yet exist. Best-effort; never throws.
|
|
@@ -284,6 +283,17 @@ export function registerCanvasStophook(pi) {
|
|
|
284
283
|
}
|
|
285
284
|
});
|
|
286
285
|
// ---------------------------------------------------------------------------
|
|
286
|
+
// agent_start — pi entered a turn. Mark the node mid-turn (busy) so a
|
|
287
|
+
// focus-away while it is genuinely working backstages it (Invariant F2)
|
|
288
|
+
// rather than reaping it. Cleared at the top of agent_end (turn over).
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
pi.on('agent_start', () => {
|
|
291
|
+
try {
|
|
292
|
+
markBusy(nodeId);
|
|
293
|
+
}
|
|
294
|
+
catch { /* best-effort */ }
|
|
295
|
+
});
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
287
297
|
// session_shutdown — clean exit → done.
|
|
288
298
|
//
|
|
289
299
|
// pi hands us a reason as a session tears down. Only 'quit' is a node-ending
|
|
@@ -298,6 +308,7 @@ export function registerCanvasStophook(pi) {
|
|
|
298
308
|
// ---------------------------------------------------------------------------
|
|
299
309
|
pi.on('session_shutdown', (event, _ctx) => {
|
|
300
310
|
try {
|
|
311
|
+
clearBusy(nodeId); // turn marker is meaningless once pi is exiting
|
|
301
312
|
// Clean /quit (reason='quit') resolves the node to done; if it held the
|
|
302
313
|
// user's viewport, Q1-close it (tearDownNode kills the frozen focus pane +
|
|
303
314
|
// closes the focus row → returns the user to a shell, §1.5/flow (e)). pi is
|
|
@@ -388,6 +399,9 @@ export function registerCanvasStophook(pi) {
|
|
|
388
399
|
// steering). The stop/yield auto-pushes that needed `await push(...)` were
|
|
389
400
|
// removed, so the handler no longer needs to be async — the node reaches its
|
|
390
401
|
// subscribers ONLY through its own explicit `crtr push` calls.
|
|
402
|
+
// The turn has ended regardless of how it routes below — clear the mid-turn
|
|
403
|
+
// marker FIRST so a focus-away from this now-parked node despawns it.
|
|
404
|
+
clearBusy(nodeId);
|
|
391
405
|
try {
|
|
392
406
|
const messages = Array.isArray(event?.messages) ? event.messages : [];
|
|
393
407
|
// Accumulate tokens from the final batch (edge case: a turn that fired
|
|
@@ -423,11 +437,9 @@ export function registerCanvasStophook(pi) {
|
|
|
423
437
|
if (f !== null) {
|
|
424
438
|
const managerId = node.parent ?? subscribersOf(nodeId)[0]?.node_id ?? null;
|
|
425
439
|
if (!handFocusToManager(f.focus_id, managerId)) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (win)
|
|
430
|
-
setRemainOnExit(win, false); // Q1 return-to-shell
|
|
440
|
+
// Q1 return-to-shell, self-saw-safe: close the focus row + disarm the
|
|
441
|
+
// pane's freeze so it reaps on exit (we can't closePane our own pane).
|
|
442
|
+
closeFocusToShell(f.focus_id, nodeId);
|
|
431
443
|
}
|
|
432
444
|
}
|
|
433
445
|
setPresence(nodeId, { pane: null, tmux_session: null, window: null }); // M done → owns no pane
|
package/dist/prompts/skill.js
CHANGED
|
@@ -38,7 +38,12 @@ is still chosen by what the agent does after reading, not by the script.
|
|
|
38
38
|
assets; nested dirs are their own skills.
|
|
39
39
|
- Required frontmatter: \`name\`, \`type\`, \`description\`. \`name\` must equal the
|
|
40
40
|
dir path under \`skills/\` — slashes nest (\`web/frontend/design\`).
|
|
41
|
-
- \`description\`: one sentence, front-load "Use when…" — it
|
|
41
|
+
- \`description\`: one sentence, front-load "Use when…" — it is the **only**
|
|
42
|
+
text an agent reads before choosing the skill, so every "when to reach for
|
|
43
|
+
this" / selection cue lives *here*, never in the body. By the time anyone
|
|
44
|
+
reads the body they have already picked the skill; a body line about
|
|
45
|
+
when-to-use is wasted. The body is purely the workflow or knowledge they
|
|
46
|
+
came for.
|
|
42
47
|
- Budget ~150 lines per \`SKILL.md\`; spill deeper material into sibling files.
|
|
43
48
|
- \`crtr skill author scaffold <n> --type <t> --scope <s> --description "<d>"\` writes correct frontmatter for you; \`crtr sys doctor\` validates.
|
|
44
49
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crouton-kit/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.17",
|
|
4
4
|
"description": "crtr — fast access to skills, plugins, and marketplaces",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"license": "MIT",
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@crouton-kit/humanloop": "^0.3.
|
|
39
|
+
"@crouton-kit/humanloop": "^0.3.15",
|
|
40
40
|
"commander": "^13.0.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|