@crouton-kit/crouter 0.3.12 → 0.3.14
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 +2 -2
- package/dist/commands/__tests__/human.test.js +73 -2
- package/dist/commands/human/queue.d.ts +1 -0
- package/dist/commands/human/queue.js +89 -2
- package/dist/commands/human/shared.d.ts +5 -0
- package/dist/commands/human/shared.js +15 -0
- package/dist/commands/human.js +4 -2
- package/dist/commands/node.js +239 -15
- package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
- package/dist/core/__tests__/passive-subscription.test.js +141 -0
- package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
- package/dist/core/__tests__/subcommand-tier.test.js +97 -0
- package/dist/core/canvas/paths.d.ts +4 -0
- package/dist/core/canvas/paths.js +6 -0
- package/dist/core/command.js +40 -7
- package/dist/core/feed/feed.js +11 -9
- package/dist/core/feed/passive.d.ts +17 -0
- package/dist/core/feed/passive.js +79 -0
- package/dist/core/help.d.ts +45 -12
- package/dist/core/help.js +42 -4
- package/dist/core/runtime/demote.d.ts +14 -0
- package/dist/core/runtime/demote.js +103 -0
- package/dist/core/runtime/kickoff.d.ts +9 -0
- package/dist/core/runtime/kickoff.js +19 -1
- package/dist/core/runtime/launch.d.ts +12 -1
- package/dist/core/runtime/launch.js +18 -2
- package/dist/core/runtime/presence.d.ts +1 -1
- package/dist/core/runtime/presence.js +6 -4
- package/dist/core/runtime/promote.d.ts +4 -0
- package/dist/core/runtime/promote.js +21 -6
- package/dist/core/runtime/revive.js +6 -8
- package/dist/core/runtime/roadmap.d.ts +5 -4
- package/dist/core/runtime/roadmap.js +9 -16
- package/dist/core/runtime/spawn.d.ts +0 -2
- package/dist/core/runtime/spawn.js +26 -16
- package/dist/core/runtime/tmux.d.ts +18 -0
- package/dist/core/runtime/tmux.js +77 -0
- package/dist/pi-extensions/canvas-commands.d.ts +34 -0
- package/dist/pi-extensions/canvas-commands.js +100 -0
- package/dist/pi-extensions/canvas-goal-capture.d.ts +18 -0
- package/dist/pi-extensions/canvas-goal-capture.js +53 -0
- package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
- package/dist/pi-extensions/canvas-passive-context.js +114 -0
- package/dist/pi-extensions/canvas-stophook.js +42 -19
- package/package.json +1 -1
|
@@ -19,7 +19,8 @@ import { loadKernel, loadPersona } from '../personas/index.js';
|
|
|
19
19
|
import { resolveSkill } from '../resolver.js';
|
|
20
20
|
import { readText } from '../fs-utils.js';
|
|
21
21
|
import { parseFrontmatter } from '../frontmatter.js';
|
|
22
|
-
import { hasRoadmap, seedRoadmap, readRoadmap } from './roadmap.js';
|
|
22
|
+
import { hasRoadmap, seedRoadmap, readRoadmap, roadmapPath } from './roadmap.js';
|
|
23
|
+
import { readGoal, goalPath } from './kickoff.js';
|
|
23
24
|
/** Load a skill's body text by name, or null if it can't be resolved. Used to
|
|
24
25
|
* inline a kind's roadmap-shaping skill into the promotion guidance dump. */
|
|
25
26
|
function loadSkillBody(name) {
|
|
@@ -43,16 +44,21 @@ function orchestrationGuidance(nodeId, kind) {
|
|
|
43
44
|
: undefined;
|
|
44
45
|
const skillBody = roadmapSkill ? loadSkillBody(roadmapSkill) : null;
|
|
45
46
|
const roadmap = readRoadmap(nodeId) ?? '(no roadmap yet)';
|
|
47
|
+
const rmPath = roadmapPath(nodeId);
|
|
48
|
+
const goal = readGoal(nodeId);
|
|
46
49
|
const parts = [
|
|
47
50
|
`You are now a RESIDENT ${kind.toUpperCase()} ORCHESTRATOR. Your scarce resource is your own context window.`,
|
|
48
51
|
'Your job is to manage context and delegate — not to do the goal yourself.',
|
|
49
52
|
'',
|
|
50
53
|
kernel,
|
|
51
54
|
];
|
|
55
|
+
if (goal !== null && goal.trim() !== '') {
|
|
56
|
+
parts.push('', `--- Your goal (${goalPath(nodeId)}) ---`, '', goal.trim());
|
|
57
|
+
}
|
|
52
58
|
if (skillBody) {
|
|
53
59
|
parts.push('', `--- How to shape a ${kind} roadmap (skill: ${roadmapSkill}) ---`, '', skillBody);
|
|
54
60
|
}
|
|
55
|
-
parts.push('',
|
|
61
|
+
parts.push('', `Your roadmap scaffold (\`${rmPath}\`) — author it now: state the goal, exit criteria, and the phase skeleton, using the approach above. Current contents:`, '', roadmap, '', 'Then delegate each phase with `crtr node new --kind <kind>`. When your context fills, run `crtr node yield` to refresh against this roadmap.');
|
|
56
62
|
return parts.join('\n');
|
|
57
63
|
}
|
|
58
64
|
/** Promote a node to resident orchestrator, optionally specializing its kind
|
|
@@ -71,15 +77,24 @@ export function promote(nodeId, opts = {}) {
|
|
|
71
77
|
// nodeEnv reads meta.{kind,mode}, so CRTR_KIND/CRTR_MODE flip immediately for
|
|
72
78
|
// the live process's children too.
|
|
73
79
|
const { launch } = buildLaunchSpec(targetKind, 'orchestrator');
|
|
74
|
-
// Seed a roadmap scaffold if absent so the file exists for a
|
|
75
|
-
//
|
|
80
|
+
// Seed a barebones roadmap scaffold if absent so the file exists for a
|
|
81
|
+
// refresh. Pre-fill its Goal from the node's goal doc when present (set at
|
|
82
|
+
// spawn, or captured from the first user message); the node fleshes out the
|
|
83
|
+
// body next, guided by the kind skill dumped below.
|
|
76
84
|
let roadmapWritten = false;
|
|
77
85
|
if (!hasRoadmap(nodeId)) {
|
|
78
|
-
|
|
86
|
+
const goal = readGoal(nodeId);
|
|
87
|
+
seedRoadmap(nodeId, goal !== null && goal.trim() !== '' ? { goal: goal.trim() } : {});
|
|
79
88
|
roadmapWritten = true;
|
|
80
89
|
}
|
|
81
90
|
const meta = updateNode(nodeId, { kind: targetKind, lifecycle: 'resident', mode: 'orchestrator', launch });
|
|
82
|
-
return {
|
|
91
|
+
return {
|
|
92
|
+
meta,
|
|
93
|
+
guidance: orchestrationGuidance(nodeId, targetKind),
|
|
94
|
+
roadmapWritten,
|
|
95
|
+
roadmapPath: roadmapPath(nodeId),
|
|
96
|
+
goalPath: goalPath(nodeId),
|
|
97
|
+
};
|
|
83
98
|
}
|
|
84
99
|
/** Request a refresh-yield: discard in-memory context and revive fresh against
|
|
85
100
|
* the roadmap. A *terminal* node that yields is choosing to persist — it
|
|
@@ -11,8 +11,7 @@
|
|
|
11
11
|
import { getNode, updateNode, } from '../canvas/index.js';
|
|
12
12
|
import { buildPiArgv } from './launch.js';
|
|
13
13
|
import { buildReviveKickoff } from './kickoff.js';
|
|
14
|
-
import { ensureSession, openNodeWindow, piCommand, respawnPane, } from './tmux.js';
|
|
15
|
-
import { rootSessionName } from './spawn.js';
|
|
14
|
+
import { ensureSession, openNodeWindow, piCommand, respawnPane, nodeSession, } from './tmux.js';
|
|
16
15
|
// ---------------------------------------------------------------------------
|
|
17
16
|
// reviveNode
|
|
18
17
|
// ---------------------------------------------------------------------------
|
|
@@ -26,10 +25,10 @@ export function reviveNode(nodeId, opts) {
|
|
|
26
25
|
if (meta === null) {
|
|
27
26
|
throw new Error(`reviveNode: unknown node ${nodeId}`);
|
|
28
27
|
}
|
|
29
|
-
// The node lives in
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
// The node lives in the shared global session. Prefer its stored session
|
|
29
|
+
// (an inline root tracks its own real terminal session); fall back to the
|
|
30
|
+
// shared node session.
|
|
31
|
+
const session = meta.tmux_session ?? nodeSession();
|
|
33
32
|
ensureSession(session, meta.cwd);
|
|
34
33
|
// Decide whether to wake the saved pi conversation or start fresh.
|
|
35
34
|
const resumeId = opts.resume && meta.pi_session_id != null
|
|
@@ -73,8 +72,7 @@ export function reviveInPlace(nodeId, pane) {
|
|
|
73
72
|
if (meta === null) {
|
|
74
73
|
throw new Error(`reviveInPlace: unknown node ${nodeId}`);
|
|
75
74
|
}
|
|
76
|
-
const session = meta.tmux_session ??
|
|
77
|
-
rootSessionName((meta.parent ?? meta.node_id));
|
|
75
|
+
const session = meta.tmux_session ?? nodeSession();
|
|
78
76
|
// Fresh re-exec: same recipe as a no-resume reviveNode, with the kickoff so
|
|
79
77
|
// the node rebuilds its bearings from disk.
|
|
80
78
|
const inv = buildPiArgv(meta, { prompt: buildReviveKickoff(meta) });
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
export declare function roadmapPath(nodeId: string): string;
|
|
2
2
|
export declare function hasRoadmap(nodeId: string): boolean;
|
|
3
3
|
export declare function readRoadmap(nodeId: string): string | null;
|
|
4
|
-
/** Seed a fresh
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
/** Seed a fresh, EXTREMELY BAREBONES roadmap scaffold — just the section
|
|
5
|
+
* skeleton with one-line prompts. Promotion lays this down so the file exists
|
|
6
|
+
* for a refresh; the owner fleshes it out as its next act (guided by its
|
|
7
|
+
* kind's roadmap skill). `goal`/`exitCriteria` pre-fill those sections when
|
|
8
|
+
* known (e.g. from the node's goal doc). Idempotent only if you intend it —
|
|
8
9
|
* call sites guard on hasRoadmap to avoid clobbering an evolved map. */
|
|
9
10
|
export declare function seedRoadmap(nodeId: string, opts?: {
|
|
10
11
|
goal?: string;
|
|
@@ -20,32 +20,25 @@ export function readRoadmap(nodeId) {
|
|
|
20
20
|
const p = roadmapPath(nodeId);
|
|
21
21
|
return existsSync(p) ? readFileSync(p, 'utf8') : null;
|
|
22
22
|
}
|
|
23
|
-
/** Seed a fresh
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
/** Seed a fresh, EXTREMELY BAREBONES roadmap scaffold — just the section
|
|
24
|
+
* skeleton with one-line prompts. Promotion lays this down so the file exists
|
|
25
|
+
* for a refresh; the owner fleshes it out as its next act (guided by its
|
|
26
|
+
* kind's roadmap skill). `goal`/`exitCriteria` pre-fill those sections when
|
|
27
|
+
* known (e.g. from the node's goal doc). Idempotent only if you intend it —
|
|
27
28
|
* call sites guard on hasRoadmap to avoid clobbering an evolved map. */
|
|
28
29
|
export function seedRoadmap(nodeId, opts = {}) {
|
|
29
30
|
const dir = contextDir(nodeId);
|
|
30
31
|
mkdirSync(dir, { recursive: true });
|
|
31
32
|
const body = `# Roadmap
|
|
32
33
|
|
|
33
|
-
<!-- frozen core: set once, rarely changes -->
|
|
34
34
|
## Goal
|
|
35
|
-
${opts.goal?.trim() ?? '
|
|
35
|
+
${opts.goal?.trim() ?? '(the goal you now own)'}
|
|
36
36
|
|
|
37
37
|
## Exit criteria
|
|
38
|
-
${opts.exitCriteria?.trim() ?? '
|
|
38
|
+
${opts.exitCriteria?.trim() ?? '(what "done" looks like)'}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
- (record what's out of scope and what's settled — e.g. "reuse existing auth", "security isn't a concern here" — so children inherit the framing)
|
|
43
|
-
|
|
44
|
-
## Strategy / phases
|
|
45
|
-
- (your high-level shape of how you reach the goal; the ordered phases from here to done, the current one carrying a one-line status. Each phase can become a child whose own roadmap is that phase)
|
|
46
|
-
|
|
47
|
-
## Active context
|
|
48
|
-
- (the context/ files currently relevant to the work, by path; none yet)
|
|
40
|
+
## Phases
|
|
41
|
+
(ordered phases from here to done; the current one carries a one-line status)
|
|
49
42
|
`;
|
|
50
43
|
writeFileSync(roadmapPath(nodeId), body);
|
|
51
44
|
return body;
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { type NodeMeta, type Mode } from '../canvas/index.js';
|
|
2
|
-
/** A root's tmux session name — its home; every descendant is a window in it. */
|
|
3
|
-
export declare function rootSessionName(rootId: string): string;
|
|
4
2
|
export interface BootRootOpts {
|
|
5
3
|
cwd: string;
|
|
6
4
|
kind?: string;
|
|
@@ -11,13 +11,9 @@ import { FRONT_DOOR_ENV } from './front-door.js';
|
|
|
11
11
|
import { spawnNode, currentNodeContext } from './nodes.js';
|
|
12
12
|
import { buildLaunchSpec, buildPiArgv } from './launch.js';
|
|
13
13
|
import { writeGoal } from './kickoff.js';
|
|
14
|
-
import { ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, } from './tmux.js';
|
|
14
|
+
import { ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, nodeSession, installMenuBinding, installNavBindings, } from './tmux.js';
|
|
15
15
|
import { updateNode, getNode } from '../canvas/index.js';
|
|
16
16
|
import { ensureDaemon } from '../../daemon/manage.js';
|
|
17
|
-
/** A root's tmux session name — its home; every descendant is a window in it. */
|
|
18
|
-
export function rootSessionName(rootId) {
|
|
19
|
-
return `crtr-${rootId.replace(/[^a-zA-Z0-9]/g, '').slice(0, 12)}`;
|
|
20
|
-
}
|
|
21
17
|
/** Create a root node and bring up its pi. Returns the node; for 'inline' this
|
|
22
18
|
* only returns after pi exits (it took over the terminal). */
|
|
23
19
|
export function bootRoot(opts) {
|
|
@@ -44,9 +40,22 @@ export function bootRoot(opts) {
|
|
|
44
40
|
// mandate (bare `crtr` has none — writeGoal no-ops on empty).
|
|
45
41
|
if (opts.prompt !== undefined)
|
|
46
42
|
writeGoal(meta.node_id, opts.prompt);
|
|
47
|
-
|
|
43
|
+
// Every node window — root or child — lives in the one shared session.
|
|
44
|
+
const session = nodeSession();
|
|
45
|
+
ensureSession(session, opts.cwd);
|
|
46
|
+
// Make the Alt+C action menu + Alt+] / Alt+[ nav keys live on this server
|
|
47
|
+
// (idempotent, in-tmux only).
|
|
48
|
+
if (inTmux()) {
|
|
49
|
+
try {
|
|
50
|
+
installMenuBinding();
|
|
51
|
+
}
|
|
52
|
+
catch { /* best-effort */ }
|
|
53
|
+
try {
|
|
54
|
+
installNavBindings();
|
|
55
|
+
}
|
|
56
|
+
catch { /* best-effort */ }
|
|
57
|
+
}
|
|
48
58
|
if (opts.placement === 'session') {
|
|
49
|
-
ensureSession(session, opts.cwd);
|
|
50
59
|
updateNode(meta.node_id, { tmux_session: session });
|
|
51
60
|
const withSession = getNode(meta.node_id);
|
|
52
61
|
const inv = buildPiArgv(withSession, { prompt: opts.prompt });
|
|
@@ -61,14 +70,16 @@ export function bootRoot(opts) {
|
|
|
61
70
|
updateNode(meta.node_id, { window: win });
|
|
62
71
|
return getNode(meta.node_id);
|
|
63
72
|
}
|
|
64
|
-
// inline: the root
|
|
65
|
-
//
|
|
73
|
+
// inline: the root's pi takes over THIS terminal, so its own window stays
|
|
74
|
+
// where the user is (its tmux_session tracks that real pane so supervision
|
|
75
|
+
// sees it alive). But its children spawn into the shared global session via
|
|
76
|
+
// CRTR_ROOT_SESSION — they never clutter the user's working session.
|
|
66
77
|
const here = currentTmux();
|
|
67
78
|
const adopted = here?.session ?? session;
|
|
68
79
|
updateNode(meta.node_id, { tmux_session: adopted, window: here?.window ?? null });
|
|
69
80
|
const withSession = getNode(meta.node_id);
|
|
70
81
|
const inv = buildPiArgv(withSession, { prompt: opts.prompt });
|
|
71
|
-
const env = { ...process.env, ...inv.env, CRTR_ROOT_SESSION:
|
|
82
|
+
const env = { ...process.env, ...inv.env, CRTR_ROOT_SESSION: session, [FRONT_DOOR_ENV]: '1' };
|
|
72
83
|
const r = spawnSync('pi', inv.argv, { stdio: 'inherit', env });
|
|
73
84
|
process.exit(r.status ?? 0);
|
|
74
85
|
}
|
|
@@ -97,13 +108,12 @@ export function spawnChild(opts) {
|
|
|
97
108
|
});
|
|
98
109
|
// Persist the task as the child's goal for a fresh revive to re-read.
|
|
99
110
|
writeGoal(meta.node_id, opts.prompt);
|
|
100
|
-
//
|
|
111
|
+
// Children always land in the shared global session: inherited from the
|
|
112
|
+
// parent's CRTR_ROOT_SESSION, else the default node session.
|
|
101
113
|
let session = process.env['CRTR_ROOT_SESSION'];
|
|
102
|
-
if (session === undefined || session === '')
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
ensureSession(session, opts.cwd);
|
|
106
|
-
}
|
|
114
|
+
if (session === undefined || session === '')
|
|
115
|
+
session = nodeSession();
|
|
116
|
+
ensureSession(session, opts.cwd);
|
|
107
117
|
const inv = buildPiArgv(meta, { prompt: opts.prompt });
|
|
108
118
|
const env = { ...inv.env, CRTR_ROOT_SESSION: session };
|
|
109
119
|
const window = openNodeWindow({
|
|
@@ -1,6 +1,11 @@
|
|
|
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;
|
|
4
9
|
export interface TmuxLocation {
|
|
5
10
|
session: string;
|
|
6
11
|
window: string;
|
|
@@ -46,6 +51,12 @@ export declare function paneOfWindow(session: string, window: string): string |
|
|
|
46
51
|
* are not, so the node→window mapping must be re-derived from the pane. Returns
|
|
47
52
|
* null if the pane is gone or tmux fails. */
|
|
48
53
|
export declare function windowOfPane(pane: string): string | null;
|
|
54
|
+
/** The session + window a pane currently lives in. Used by demote to place the
|
|
55
|
+
* recycled root's meta on the pane it respawns into. Null if tmux fails. */
|
|
56
|
+
export declare function paneLocation(pane: string): {
|
|
57
|
+
session: string;
|
|
58
|
+
window: string;
|
|
59
|
+
} | null;
|
|
49
60
|
/** Swap `targetPane` into `callerPane`'s layout slot, IN PLACE. `-d` keeps the
|
|
50
61
|
* caller's window active, so the target's pane appears where the caller is
|
|
51
62
|
* rather than navigating the client off to the target's window. The caller's
|
|
@@ -86,3 +97,10 @@ export declare function selectWindow(session: string, window: string): boolean;
|
|
|
86
97
|
* `tmux switch-client -t <session>`. Best-effort; never throws. The caller is
|
|
87
98
|
* responsible for following up with selectWindow to land on the right window. */
|
|
88
99
|
export declare function switchClient(session: string): boolean;
|
|
100
|
+
/** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails. */
|
|
101
|
+
export declare function installMenuBinding(): boolean;
|
|
102
|
+
/** Bind Alt+] (forward) and Alt+[ (back) to the DFS canvas walk. Best-effort;
|
|
103
|
+
* false if either bind fails. NOTE: Alt+[ is only delivered cleanly when the
|
|
104
|
+
* terminal/tmux disambiguate it from a raw CSI introducer (`extended-keys on`).
|
|
105
|
+
*/
|
|
106
|
+
export declare function installNavBindings(): boolean;
|
|
@@ -26,6 +26,14 @@ function tmux(args) {
|
|
|
26
26
|
export function inTmux() {
|
|
27
27
|
return process.env['TMUX'] !== undefined && process.env['TMUX'] !== '';
|
|
28
28
|
}
|
|
29
|
+
/** The single, shared tmux session that ALL canvas node windows live in.
|
|
30
|
+
* Overridable with CRTR_NODE_SESSION (default `crtr`). Every root and every
|
|
31
|
+
* child opens a window here rather than cluttering the user's own working
|
|
32
|
+
* session — switch to it to browse the whole live graph, ignore it otherwise. */
|
|
33
|
+
export function nodeSession() {
|
|
34
|
+
const v = process.env['CRTR_NODE_SESSION'];
|
|
35
|
+
return v !== undefined && v !== '' ? v : 'crtr';
|
|
36
|
+
}
|
|
29
37
|
/** Where the caller currently is, or null if not inside tmux. */
|
|
30
38
|
export function currentTmux() {
|
|
31
39
|
if (!inTmux())
|
|
@@ -118,6 +126,17 @@ export function windowOfPane(pane) {
|
|
|
118
126
|
const r = tmux(['display-message', '-p', '-t', pane, '#{window_id}']);
|
|
119
127
|
return r.ok && r.stdout !== '' ? r.stdout : null;
|
|
120
128
|
}
|
|
129
|
+
/** The session + window a pane currently lives in. Used by demote to place the
|
|
130
|
+
* recycled root's meta on the pane it respawns into. Null if tmux fails. */
|
|
131
|
+
export function paneLocation(pane) {
|
|
132
|
+
const r = tmux(['display-message', '-p', '-t', pane, '#{session_name}\t#{window_id}']);
|
|
133
|
+
if (!r.ok)
|
|
134
|
+
return null;
|
|
135
|
+
const [session, window] = r.stdout.split('\t');
|
|
136
|
+
if (session === undefined || session === '' || window === undefined || window === '')
|
|
137
|
+
return null;
|
|
138
|
+
return { session, window };
|
|
139
|
+
}
|
|
121
140
|
/** Swap `targetPane` into `callerPane`'s layout slot, IN PLACE. `-d` keeps the
|
|
122
141
|
* caller's window active, so the target's pane appears where the caller is
|
|
123
142
|
* rather than navigating the client off to the target's window. The caller's
|
|
@@ -196,3 +215,61 @@ export function selectWindow(session, window) {
|
|
|
196
215
|
export function switchClient(session) {
|
|
197
216
|
return tmux(['switch-client', '-t', session]).ok;
|
|
198
217
|
}
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Prefix menu — Alt+C opens a which-key-style tmux display-menu of crouter
|
|
220
|
+
// actions. Installed on the running server at root boot; idempotent (a re-bind
|
|
221
|
+
// overwrites the previous one). Items shell out to `crtr`, passing the active
|
|
222
|
+
// pane so an action targets the agent currently in front of you.
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
/** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails. */
|
|
225
|
+
export function installMenuBinding() {
|
|
226
|
+
const sess = nodeSession();
|
|
227
|
+
const title = ' crtr ';
|
|
228
|
+
const items = [
|
|
229
|
+
// Promote types `/promote` into the agent's pane rather than shelling out:
|
|
230
|
+
// the slash command delivers the orchestration guidance into the node's
|
|
231
|
+
// context, which a bare `run-shell` (output discarded) could not.
|
|
232
|
+
{ name: 'promote to orchestrator', key: 'o', cmd: `send-keys -t '#{pane_id}' '/promote' Enter` },
|
|
233
|
+
{ name: 'finish agent + recycle pane', key: 'd', cmd: `run-shell "crtr node demote --pane '#{pane_id}'"` },
|
|
234
|
+
{ name: 'browse background agents', key: 'g', cmd: `switch-client -t ${sess}` },
|
|
235
|
+
];
|
|
236
|
+
// tmux's -x sets the menu's LEFT edge. To sit the box INSIDE the pane's
|
|
237
|
+
// top-right corner, shift x left by the box width (longest line + tmux chrome:
|
|
238
|
+
// borders + padding + the right-aligned mnemonic-key column) via format math.
|
|
239
|
+
const boxW = Math.max(title.length, ...items.map((i) => i.name.length)) + 6;
|
|
240
|
+
// Fine-tune nudges off the pane's top-right corner: a hair further left and
|
|
241
|
+
// one row down so the box doesn't kiss the pane border.
|
|
242
|
+
const nudgeX = 1; // extra columns left
|
|
243
|
+
const nudgeY = 3; // rows down
|
|
244
|
+
const args = [
|
|
245
|
+
'bind-key', '-n', 'M-c', 'display-menu',
|
|
246
|
+
'-T', `#[align=centre]${title}`,
|
|
247
|
+
'-x', `#{e|-:#{pane_right},${boxW + nudgeX}}`,
|
|
248
|
+
'-y', `#{e|+:#{pane_top},${nudgeY}}`,
|
|
249
|
+
];
|
|
250
|
+
for (const it of items)
|
|
251
|
+
args.push(it.name, it.key, it.cmd);
|
|
252
|
+
return tmux(args).ok;
|
|
253
|
+
}
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Nav bindings — Alt+] / Alt+[ DFS-walk the canvas one window at a time. Each
|
|
256
|
+
// key shells out to `crtr node cycle`, passing the active pane so the walk is
|
|
257
|
+
// relative to the agent in front of you; cycle then swaps the next/prev node
|
|
258
|
+
// into that pane (like `node focus`). Output is discarded so the keypress never
|
|
259
|
+
// pops a results view. Installed at root boot alongside the Alt+C menu.
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
/** Bind Alt+] (forward) and Alt+[ (back) to the DFS canvas walk. Best-effort;
|
|
262
|
+
* false if either bind fails. NOTE: Alt+[ is only delivered cleanly when the
|
|
263
|
+
* terminal/tmux disambiguate it from a raw CSI introducer (`extended-keys on`).
|
|
264
|
+
*/
|
|
265
|
+
export function installNavBindings() {
|
|
266
|
+
const next = tmux([
|
|
267
|
+
'bind-key', '-n', 'M-]', 'run-shell',
|
|
268
|
+
`crtr node cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`,
|
|
269
|
+
]).ok;
|
|
270
|
+
const prev = tmux([
|
|
271
|
+
'bind-key', '-n', 'M-[', 'run-shell',
|
|
272
|
+
`crtr node cycle --dir prev --pane '#{pane_id}' >/dev/null 2>&1`,
|
|
273
|
+
]).ok;
|
|
274
|
+
return next && prev;
|
|
275
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
interface AutocompleteItem {
|
|
2
|
+
value: string;
|
|
3
|
+
label?: string;
|
|
4
|
+
}
|
|
5
|
+
interface CommandUI {
|
|
6
|
+
notify(message: string, type?: 'info' | 'warning' | 'error'): void;
|
|
7
|
+
setStatus(key: string, text: string | undefined): void;
|
|
8
|
+
}
|
|
9
|
+
interface CommandCtx {
|
|
10
|
+
ui: CommandUI;
|
|
11
|
+
}
|
|
12
|
+
interface CustomMessage {
|
|
13
|
+
customType: string;
|
|
14
|
+
content: string;
|
|
15
|
+
display?: boolean;
|
|
16
|
+
}
|
|
17
|
+
interface PiLike {
|
|
18
|
+
registerCommand(name: string, options: {
|
|
19
|
+
description?: string;
|
|
20
|
+
getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null;
|
|
21
|
+
handler: (args: string, ctx: CommandCtx) => Promise<void>;
|
|
22
|
+
}): void;
|
|
23
|
+
sendMessage(message: CustomMessage, options?: {
|
|
24
|
+
triggerTurn?: boolean;
|
|
25
|
+
}): void;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Register the canvas slash-commands on `pi`.
|
|
29
|
+
*
|
|
30
|
+
* Returns immediately when CRTR_NODE_ID is absent — the extension is fully
|
|
31
|
+
* inert in a non-canvas pi session.
|
|
32
|
+
*/
|
|
33
|
+
export declare function registerCanvasCommands(pi: PiLike): void;
|
|
34
|
+
export default registerCanvasCommands;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// canvas-commands.ts — pi extension registering canvas slash-commands on nodes.
|
|
2
|
+
//
|
|
3
|
+
// /promote [kind] — promote THIS node to a resident orchestrator. Runs
|
|
4
|
+
// `crtr node promote --json` for CRTR_NODE_ID (optionally specializing its
|
|
5
|
+
// kind), then injects the orchestration guidance the command returns into
|
|
6
|
+
// context and triggers a turn, so the node authors its roadmap immediately.
|
|
7
|
+
// This is the same mid-turn guidance dump the node would get by running the
|
|
8
|
+
// command itself by hand — surfaced as a one-keystroke affordance.
|
|
9
|
+
//
|
|
10
|
+
// The Alt+C tmux action menu's "promote to orchestrator" item (key `o`) simply
|
|
11
|
+
// send-keys `/promote` into the active pane, so the menu and the slash command
|
|
12
|
+
// share this one implementation.
|
|
13
|
+
//
|
|
14
|
+
// INERT when CRTR_NODE_ID is absent (a plain pi session, not a canvas node).
|
|
15
|
+
//
|
|
16
|
+
// Plain TS-with-types — no imports from @earendil-works/* so this compiles
|
|
17
|
+
// inside crouter's own tsc build without a dep on the pi packages (mirrors
|
|
18
|
+
// canvas-nav.ts). The only crouter import is availableKinds, used to offer
|
|
19
|
+
// `/promote <kind>` completions.
|
|
20
|
+
import { execFile } from 'node:child_process';
|
|
21
|
+
import { promisify } from 'node:util';
|
|
22
|
+
import { availableKinds } from '../core/personas/index.js';
|
|
23
|
+
const pexec = promisify(execFile);
|
|
24
|
+
// Kinds for `/promote <kind>` completions — computed once (persona dirs rarely
|
|
25
|
+
// change within a session), best-effort so a loader hiccup never breaks input.
|
|
26
|
+
let cachedKinds = null;
|
|
27
|
+
function kinds() {
|
|
28
|
+
if (cachedKinds === null) {
|
|
29
|
+
try {
|
|
30
|
+
cachedKinds = availableKinds();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
cachedKinds = [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return cachedKinds;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Register the canvas slash-commands on `pi`.
|
|
40
|
+
*
|
|
41
|
+
* Returns immediately when CRTR_NODE_ID is absent — the extension is fully
|
|
42
|
+
* inert in a non-canvas pi session.
|
|
43
|
+
*/
|
|
44
|
+
export function registerCanvasCommands(pi) {
|
|
45
|
+
const nodeId = process.env['CRTR_NODE_ID'];
|
|
46
|
+
if (nodeId === undefined || nodeId.trim() === '')
|
|
47
|
+
return; // not a canvas node
|
|
48
|
+
pi.registerCommand('promote', {
|
|
49
|
+
description: 'Promote this node to a resident orchestrator — /promote, or /promote <kind> to specialize',
|
|
50
|
+
getArgumentCompletions: (prefix) => {
|
|
51
|
+
const items = kinds()
|
|
52
|
+
.filter((k) => k.startsWith(prefix))
|
|
53
|
+
.map((k) => ({ value: k, label: k }));
|
|
54
|
+
return items.length > 0 ? items : null;
|
|
55
|
+
},
|
|
56
|
+
handler: async (args, ctx) => {
|
|
57
|
+
const kind = args.trim().toLowerCase();
|
|
58
|
+
ctx.ui.setStatus('crtr-promote', kind ? `promoting → ${kind}…` : 'promoting…');
|
|
59
|
+
const argv = ['node', 'promote', '--json'];
|
|
60
|
+
if (kind !== '')
|
|
61
|
+
argv.push('--kind', kind);
|
|
62
|
+
// Run promote out-of-process. On a non-zero exit, crtr still prints the
|
|
63
|
+
// structured error to stdout, so prefer its `message` over the raw throw.
|
|
64
|
+
let result = null;
|
|
65
|
+
let errMsg = null;
|
|
66
|
+
try {
|
|
67
|
+
const { stdout } = await pexec('crtr', argv, { timeout: 15_000, maxBuffer: 4 * 1024 * 1024 });
|
|
68
|
+
result = JSON.parse(stdout);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
const e = err;
|
|
72
|
+
const stdout = typeof e.stdout === 'string' ? e.stdout : '';
|
|
73
|
+
try {
|
|
74
|
+
const payload = JSON.parse(stdout);
|
|
75
|
+
errMsg = typeof payload.message === 'string' ? payload.message : null;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
/* stdout wasn't JSON */
|
|
79
|
+
}
|
|
80
|
+
if (errMsg === null)
|
|
81
|
+
errMsg = typeof e.message === 'string' ? e.message : String(err);
|
|
82
|
+
}
|
|
83
|
+
ctx.ui.setStatus('crtr-promote', '');
|
|
84
|
+
if (result === null) {
|
|
85
|
+
ctx.ui.notify(`promote failed: ${errMsg ?? 'unknown error'}`, 'error');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const rmPath = (result.roadmap_path ?? '').trim();
|
|
89
|
+
ctx.ui.notify(`Promoted to ${result.kind ?? 'orchestrator'} orchestrator — authoring roadmap${rmPath !== '' ? ` (${rmPath})` : ''}.`, 'info');
|
|
90
|
+
// The guidance is operating instructions for the node, not the user.
|
|
91
|
+
// Inject it silently and trigger a turn so the node acts on it now —
|
|
92
|
+
// exactly what happens when the node runs `crtr node promote` by hand.
|
|
93
|
+
const guidance = (result.guidance ?? '').trim();
|
|
94
|
+
if (guidance === '')
|
|
95
|
+
return;
|
|
96
|
+
pi.sendMessage({ customType: 'crtr-promote', content: guidance, display: false }, { triggerTurn: true });
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
export default registerCanvasCommands;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface InputEventLike {
|
|
2
|
+
type: 'input';
|
|
3
|
+
text: string;
|
|
4
|
+
images?: unknown[];
|
|
5
|
+
source: 'interactive' | 'rpc' | 'extension';
|
|
6
|
+
}
|
|
7
|
+
interface PiLike {
|
|
8
|
+
on: (event: 'input', handler: (event: InputEventLike, ctx: any) => void) => void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Register the goal-capture handler on `pi`.
|
|
12
|
+
*
|
|
13
|
+
* Returns immediately (inert) when CRTR_NODE_ID is absent. The `input` handler
|
|
14
|
+
* is the whole extension: on the first interactive message of a goal-less node,
|
|
15
|
+
* persist it as the goal.
|
|
16
|
+
*/
|
|
17
|
+
export declare function registerCanvasGoalCapture(pi: PiLike): void;
|
|
18
|
+
export default registerCanvasGoalCapture;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// canvas-goal-capture.ts — pi extension for pi-native canvas agent nodes.
|
|
2
|
+
//
|
|
3
|
+
// Loaded into every canvas node's pi process via the node's launch.extensions
|
|
4
|
+
// list. INERT when CRTR_NODE_ID is absent (plain pi session or legacy job agent).
|
|
5
|
+
//
|
|
6
|
+
// A node spawned with a prompt has its goal persisted at birth (writeGoal in
|
|
7
|
+
// spawn.ts). A bare root (`crtr` with no prompt) starts goal-less — its mandate
|
|
8
|
+
// only arrives when the human types their first message. This extension closes
|
|
9
|
+
// that gap: on the FIRST interactive user message, if the node has no goal yet,
|
|
10
|
+
// it persists that message as context/initial-prompt.md. Subsequent messages
|
|
11
|
+
// never clobber it (captureGoalIfAbsent is guarded), and a fresh-revive kickoff
|
|
12
|
+
// prompt is skipped via its sentinel so it can never be mistaken for a mandate.
|
|
13
|
+
//
|
|
14
|
+
// Pure observation — it writes the goal file as a side effect and always lets
|
|
15
|
+
// the message through unchanged (returns nothing ⇒ continue). Registered before
|
|
16
|
+
// canvas-passive-context so it reads the raw user text, not a backlog-prepended
|
|
17
|
+
// transform.
|
|
18
|
+
//
|
|
19
|
+
// Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
|
|
20
|
+
// crouter's own tsc build without a dep on the pi packages.
|
|
21
|
+
import { captureGoalIfAbsent, REVIVE_KICKOFF_SENTINEL } from '../core/runtime/kickoff.js';
|
|
22
|
+
/**
|
|
23
|
+
* Register the goal-capture handler on `pi`.
|
|
24
|
+
*
|
|
25
|
+
* Returns immediately (inert) when CRTR_NODE_ID is absent. The `input` handler
|
|
26
|
+
* is the whole extension: on the first interactive message of a goal-less node,
|
|
27
|
+
* persist it as the goal.
|
|
28
|
+
*/
|
|
29
|
+
export function registerCanvasGoalCapture(pi) {
|
|
30
|
+
pi.on('input', (event) => {
|
|
31
|
+
try {
|
|
32
|
+
const nodeId = process.env['CRTR_NODE_ID'];
|
|
33
|
+
if (nodeId === undefined || nodeId.trim() === '')
|
|
34
|
+
return; // not a canvas node
|
|
35
|
+
// Only a genuine human-typed prompt seeds the mandate — never an RPC or an
|
|
36
|
+
// extension-injected message (inbox wakes, steering nudges, kickoffs).
|
|
37
|
+
if (event.source !== 'interactive')
|
|
38
|
+
return;
|
|
39
|
+
const text = (event.text ?? '').trim();
|
|
40
|
+
if (text === '')
|
|
41
|
+
return;
|
|
42
|
+
// A fresh-revive kickoff is delivered as the launch prompt; never let it
|
|
43
|
+
// masquerade as the user's first mandate.
|
|
44
|
+
if (text.startsWith(REVIVE_KICKOFF_SENTINEL))
|
|
45
|
+
return;
|
|
46
|
+
captureGoalIfAbsent(nodeId, text);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Best-effort: a capture failure must never drop or alter the message.
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export default registerCanvasGoalCapture;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { InboxEntry } from '../core/feed/inbox.js';
|
|
2
|
+
interface InputEventLike {
|
|
3
|
+
type: 'input';
|
|
4
|
+
text: string;
|
|
5
|
+
images?: unknown[];
|
|
6
|
+
source: 'interactive' | 'rpc' | 'extension';
|
|
7
|
+
}
|
|
8
|
+
type InputEventResultLike = {
|
|
9
|
+
action: 'continue';
|
|
10
|
+
} | {
|
|
11
|
+
action: 'transform';
|
|
12
|
+
text: string;
|
|
13
|
+
images?: unknown[];
|
|
14
|
+
} | {
|
|
15
|
+
action: 'handled';
|
|
16
|
+
};
|
|
17
|
+
interface PiLike {
|
|
18
|
+
on: (event: 'input', handler: (event: InputEventLike, ctx: any) => InputEventResultLike | void) => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Render drained passive entries (oldest first) into one XML pre-text block.
|
|
22
|
+
* Each accumulated message is its own timestamped `<update>` element.
|
|
23
|
+
*/
|
|
24
|
+
export declare function formatPassive(entries: InboxEntry[]): string;
|
|
25
|
+
/**
|
|
26
|
+
* Register the passive-context drain on `pi`.
|
|
27
|
+
*
|
|
28
|
+
* Returns immediately (inert) when CRTR_NODE_ID is absent. The `input` handler
|
|
29
|
+
* is the whole extension: drain on every message, prepend when non-empty.
|
|
30
|
+
*/
|
|
31
|
+
export declare function registerCanvasPassiveContext(pi: PiLike): void;
|
|
32
|
+
export default registerCanvasPassiveContext;
|