@crouton-kit/crouter 0.3.13 → 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/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 +195 -24
- 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 -18
- package/dist/core/runtime/presence.js +7 -51
- package/dist/core/runtime/promote.d.ts +4 -0
- package/dist/core/runtime/promote.js +21 -6
- package/dist/core/runtime/roadmap.d.ts +5 -4
- package/dist/core/runtime/roadmap.js +9 -16
- package/dist/core/runtime/spawn.js +7 -2
- package/dist/core/runtime/tmux.d.ts +11 -12
- package/dist/core/runtime/tmux.js +57 -26
- 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/package.json +1 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// demote.ts — the "graduate this agent" action behind `crtr node demote`.
|
|
2
|
+
//
|
|
3
|
+
// Demote finishes the agent occupying a tmux pane and recycles that pane for
|
|
4
|
+
// fresh work, in three steps:
|
|
5
|
+
//
|
|
6
|
+
// 1. Finalize — push the agent's last surfaced message as a `final` report so
|
|
7
|
+
// every subscriber/manager waiting on it is unblocked, and mark it done.
|
|
8
|
+
// 2. Close — kill the agent's pi (respawn-pane -k tears it down in place).
|
|
9
|
+
// 3. Recycle — boot a fresh resident root in that same pane (a new `crtr`).
|
|
10
|
+
//
|
|
11
|
+
// The agent's real conversation lives inside pi (not on disk), so the final
|
|
12
|
+
// body is its newest report (which, on a natural stop, IS its last assistant
|
|
13
|
+
// message) — falling back to a short note when it never reported.
|
|
14
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { getNode, updateNode } from '../canvas/index.js';
|
|
17
|
+
import { reportsDir } from '../canvas/paths.js';
|
|
18
|
+
import { pushFinal } from '../feed/feed.js';
|
|
19
|
+
import { spawnNode } from './nodes.js';
|
|
20
|
+
import { buildLaunchSpec, buildPiArgv } from './launch.js';
|
|
21
|
+
import { respawnPane, piCommand, paneLocation, nodeSession } from './tmux.js';
|
|
22
|
+
import { FRONT_DOOR_ENV } from './front-door.js';
|
|
23
|
+
import { getFocus, setFocus } from './presence.js';
|
|
24
|
+
import { ensureDaemon } from '../../daemon/manage.js';
|
|
25
|
+
/** The agent's most recent surfaced message: the newest reports/*.md body with
|
|
26
|
+
* its YAML frontmatter stripped. Empty string when the node never reported. */
|
|
27
|
+
function lastReportBody(nodeId) {
|
|
28
|
+
try {
|
|
29
|
+
const dir = reportsDir(nodeId);
|
|
30
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.md'));
|
|
31
|
+
if (files.length === 0)
|
|
32
|
+
return '';
|
|
33
|
+
let newest = '';
|
|
34
|
+
let newestMs = -1;
|
|
35
|
+
for (const f of files) {
|
|
36
|
+
const ms = statSync(join(dir, f)).mtimeMs;
|
|
37
|
+
if (ms > newestMs) {
|
|
38
|
+
newestMs = ms;
|
|
39
|
+
newest = f;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const raw = readFileSync(join(dir, newest), 'utf8');
|
|
43
|
+
// Strip leading YAML frontmatter: ---\n …\n---\n<body>
|
|
44
|
+
const m = /^---\n[\s\S]*?\n---\n/.exec(raw);
|
|
45
|
+
return (m !== null ? raw.slice(m[0].length) : raw).trim();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Finish `nodeId` and recycle its pane into a fresh root. `callerPane` is the
|
|
52
|
+
* tmux pane the agent occupies (the Alt+C menu passes it as `#{pane_id}`).
|
|
53
|
+
* Best-effort; `demoted:false` when there is no pane to act on. */
|
|
54
|
+
export async function demoteNode(nodeId, callerPane) {
|
|
55
|
+
const meta = getNode(nodeId);
|
|
56
|
+
if (meta === null)
|
|
57
|
+
return { demoted: false, finalized: false, newRoot: null, delivered: [] };
|
|
58
|
+
const pane = callerPane ?? process.env['TMUX_PANE'];
|
|
59
|
+
if (pane === undefined || pane === '') {
|
|
60
|
+
return { demoted: false, finalized: false, newRoot: null, delivered: [] };
|
|
61
|
+
}
|
|
62
|
+
// 1. Finalize — fan the agent's last message out as a `final`, mark it done.
|
|
63
|
+
const body = lastReportBody(nodeId) ||
|
|
64
|
+
`Closed via demote — no final summary was authored by ${meta.name}.`;
|
|
65
|
+
let delivered = [];
|
|
66
|
+
let finalized = false;
|
|
67
|
+
try {
|
|
68
|
+
const res = await pushFinal(nodeId, body);
|
|
69
|
+
delivered = res.deliveredTo;
|
|
70
|
+
finalized = true;
|
|
71
|
+
}
|
|
72
|
+
catch { /* recycle the pane even if the report failed */ }
|
|
73
|
+
// The demoted node no longer holds a window — the pane is being reclaimed.
|
|
74
|
+
try {
|
|
75
|
+
updateNode(nodeId, { window: null, tmux_session: null });
|
|
76
|
+
}
|
|
77
|
+
catch { /* best-effort */ }
|
|
78
|
+
if (getFocus() === nodeId)
|
|
79
|
+
setFocus('');
|
|
80
|
+
// 2 + 3. Recycle — boot a fresh resident root in the SAME pane.
|
|
81
|
+
try {
|
|
82
|
+
ensureDaemon();
|
|
83
|
+
}
|
|
84
|
+
catch { /* daemon is best-effort */ }
|
|
85
|
+
const loc = paneLocation(pane);
|
|
86
|
+
const { launch } = buildLaunchSpec('general', 'base');
|
|
87
|
+
const root = spawnNode({
|
|
88
|
+
kind: 'general',
|
|
89
|
+
mode: 'base',
|
|
90
|
+
lifecycle: 'resident',
|
|
91
|
+
cwd: meta.cwd,
|
|
92
|
+
name: 'general',
|
|
93
|
+
parent: null,
|
|
94
|
+
launch,
|
|
95
|
+
});
|
|
96
|
+
if (loc !== null)
|
|
97
|
+
updateNode(root.node_id, { tmux_session: loc.session, window: loc.window });
|
|
98
|
+
const fresh = getNode(root.node_id);
|
|
99
|
+
const inv = buildPiArgv(fresh);
|
|
100
|
+
const env = { ...inv.env, CRTR_ROOT_SESSION: nodeSession(), [FRONT_DOOR_ENV]: '1' };
|
|
101
|
+
const ok = respawnPane({ pane, cwd: meta.cwd, env, command: piCommand(inv.argv) });
|
|
102
|
+
return { demoted: ok, finalized, newRoot: root.node_id, delivered };
|
|
103
|
+
}
|
|
@@ -6,6 +6,15 @@ export declare function readGoal(nodeId: string): string | null;
|
|
|
6
6
|
/** Persist the spawning prompt as the node's goal. No-op for an empty prompt
|
|
7
7
|
* (e.g. a bare root). Idempotent enough — call once at spawn/boot. */
|
|
8
8
|
export declare function writeGoal(nodeId: string, text: string): void;
|
|
9
|
+
/** Write the goal ONLY if the node has none yet. This is how a bare root (no
|
|
10
|
+
* spawn prompt) acquires its mandate: the first real user message becomes the
|
|
11
|
+
* goal. Returns true when it wrote one, false when a goal already existed or
|
|
12
|
+
* the text was empty. Guarded so a later message never clobbers the mandate. */
|
|
13
|
+
export declare function captureGoalIfAbsent(nodeId: string, text: string): boolean;
|
|
14
|
+
/** Sentinel opening the fresh-revive kickoff message (see buildReviveKickoff).
|
|
15
|
+
* The goal-capture extension skips any input starting with this so a kickoff
|
|
16
|
+
* prompt is never mistaken for a user's first mandate. */
|
|
17
|
+
export declare const REVIVE_KICKOFF_SENTINEL = "You have been revived fresh after a context refresh";
|
|
9
18
|
/** The yield-message file — a short note `crtr node yield` records for the next
|
|
10
19
|
* revive ("on wake, do X"). Consumed (deleted) when the revive reads it. */
|
|
11
20
|
export declare function yieldMessagePath(nodeId: string): string;
|
|
@@ -41,6 +41,24 @@ export function writeGoal(nodeId, text) {
|
|
|
41
41
|
mkdirSync(contextDir(nodeId), { recursive: true });
|
|
42
42
|
writeFileSync(goalPath(nodeId), body + '\n', 'utf8');
|
|
43
43
|
}
|
|
44
|
+
/** Write the goal ONLY if the node has none yet. This is how a bare root (no
|
|
45
|
+
* spawn prompt) acquires its mandate: the first real user message becomes the
|
|
46
|
+
* goal. Returns true when it wrote one, false when a goal already existed or
|
|
47
|
+
* the text was empty. Guarded so a later message never clobbers the mandate. */
|
|
48
|
+
export function captureGoalIfAbsent(nodeId, text) {
|
|
49
|
+
const existing = readGoal(nodeId);
|
|
50
|
+
if (existing !== null && existing.trim() !== '')
|
|
51
|
+
return false;
|
|
52
|
+
const body = text.trim();
|
|
53
|
+
if (body === '')
|
|
54
|
+
return false;
|
|
55
|
+
writeGoal(nodeId, body);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
/** Sentinel opening the fresh-revive kickoff message (see buildReviveKickoff).
|
|
59
|
+
* The goal-capture extension skips any input starting with this so a kickoff
|
|
60
|
+
* prompt is never mistaken for a user's first mandate. */
|
|
61
|
+
export const REVIVE_KICKOFF_SENTINEL = 'You have been revived fresh after a context refresh';
|
|
44
62
|
/** The yield-message file — a short note `crtr node yield` records for the next
|
|
45
63
|
* revive ("on wake, do X"). Consumed (deleted) when the revive reads it. */
|
|
46
64
|
export function yieldMessagePath(nodeId) {
|
|
@@ -112,7 +130,7 @@ export function buildReviveKickoff(meta) {
|
|
|
112
130
|
// Consume the one-shot yield note first so it never shows in the dir listing.
|
|
113
131
|
const yieldMsg = consumeYieldMessage(nodeId);
|
|
114
132
|
const parts = [
|
|
115
|
-
|
|
133
|
+
`${REVIVE_KICKOFF_SENTINEL} — your previous in-memory ` +
|
|
116
134
|
'context is gone, by design. Everything below was just read from disk; it is your ' +
|
|
117
135
|
'full bearings. Rebuild from it and continue toward your goal.',
|
|
118
136
|
];
|
|
@@ -2,9 +2,15 @@ import type { NodeMeta, LaunchSpec, Mode } from '../canvas/index.js';
|
|
|
2
2
|
export declare const CANVAS_STOPHOOK_PATH: string;
|
|
3
3
|
export declare const CANVAS_INBOX_WATCHER_PATH: string;
|
|
4
4
|
export declare const CANVAS_NAV_PATH: string;
|
|
5
|
+
export declare const CANVAS_GOAL_CAPTURE_PATH: string;
|
|
6
|
+
export declare const CANVAS_PASSIVE_CONTEXT_PATH: string;
|
|
7
|
+
export declare const CANVAS_COMMANDS_PATH: string;
|
|
5
8
|
/** The canvas extensions every node loads, in order: stophook (routing +
|
|
6
9
|
* telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
|
|
7
|
-
* graph chrome)
|
|
10
|
+
* graph chrome), goal-capture (persist the first user message as the goal),
|
|
11
|
+
* passive-context (drain passive backlog as pre-text on the next message),
|
|
12
|
+
* commands (the /promote slash-command). All self-gate on CRTR_NODE_ID.
|
|
13
|
+
* goal-capture precedes passive-context so it reads the raw user text. */
|
|
8
14
|
export declare const CANVAS_EXTENSIONS: string[];
|
|
9
15
|
/** Bare model aliases resolve to the anthropic provider under pi (avoids the
|
|
10
16
|
* bedrock default). Anything with a `/` or an unknown name passes through. */
|
|
@@ -24,6 +30,11 @@ export interface PiInvocation {
|
|
|
24
30
|
/** env to merge into the process. */
|
|
25
31
|
env: Record<string, string>;
|
|
26
32
|
}
|
|
33
|
+
/** The pi session display name — the editor label in the top-left. Shows the
|
|
34
|
+
* node's name plus its current mode so base vs orchestrator reads at a glance
|
|
35
|
+
* (e.g. `developer (orchestrator)`). Recomputed from `meta.mode` on every
|
|
36
|
+
* revive, so a base→orchestrator polymorph updates the label. */
|
|
37
|
+
export declare function editorLabel(meta: NodeMeta): string;
|
|
27
38
|
/** Construct the pi invocation for a node.
|
|
28
39
|
* - fresh start: pass `prompt` (the node's first user message), no resume.
|
|
29
40
|
* - revive idle/done: pass `resumeSessionId` to `--resume` (keeps conversation).
|
|
@@ -27,13 +27,22 @@ function resolveExtension(name) {
|
|
|
27
27
|
export const CANVAS_STOPHOOK_PATH = resolveExtension('canvas-stophook');
|
|
28
28
|
export const CANVAS_INBOX_WATCHER_PATH = resolveExtension('canvas-inbox-watcher');
|
|
29
29
|
export const CANVAS_NAV_PATH = resolveExtension('canvas-nav');
|
|
30
|
+
export const CANVAS_GOAL_CAPTURE_PATH = resolveExtension('canvas-goal-capture');
|
|
31
|
+
export const CANVAS_PASSIVE_CONTEXT_PATH = resolveExtension('canvas-passive-context');
|
|
32
|
+
export const CANVAS_COMMANDS_PATH = resolveExtension('canvas-commands');
|
|
30
33
|
/** The canvas extensions every node loads, in order: stophook (routing +
|
|
31
34
|
* telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
|
|
32
|
-
* graph chrome)
|
|
35
|
+
* graph chrome), goal-capture (persist the first user message as the goal),
|
|
36
|
+
* passive-context (drain passive backlog as pre-text on the next message),
|
|
37
|
+
* commands (the /promote slash-command). All self-gate on CRTR_NODE_ID.
|
|
38
|
+
* goal-capture precedes passive-context so it reads the raw user text. */
|
|
33
39
|
export const CANVAS_EXTENSIONS = [
|
|
34
40
|
CANVAS_STOPHOOK_PATH,
|
|
35
41
|
CANVAS_INBOX_WATCHER_PATH,
|
|
36
42
|
CANVAS_NAV_PATH,
|
|
43
|
+
CANVAS_GOAL_CAPTURE_PATH,
|
|
44
|
+
CANVAS_PASSIVE_CONTEXT_PATH,
|
|
45
|
+
CANVAS_COMMANDS_PATH,
|
|
37
46
|
];
|
|
38
47
|
/** Bare model aliases resolve to the anthropic provider under pi (avoids the
|
|
39
48
|
* bedrock default). Anything with a `/` or an unknown name passes through. */
|
|
@@ -59,6 +68,13 @@ export function buildLaunchSpec(kind, mode, opts = {}) {
|
|
|
59
68
|
};
|
|
60
69
|
return { launch, lifecycle: p.lifecycle, skills: p.skills };
|
|
61
70
|
}
|
|
71
|
+
/** The pi session display name — the editor label in the top-left. Shows the
|
|
72
|
+
* node's name plus its current mode so base vs orchestrator reads at a glance
|
|
73
|
+
* (e.g. `developer (orchestrator)`). Recomputed from `meta.mode` on every
|
|
74
|
+
* revive, so a base→orchestrator polymorph updates the label. */
|
|
75
|
+
export function editorLabel(meta) {
|
|
76
|
+
return `${meta.name} (${meta.mode})`;
|
|
77
|
+
}
|
|
62
78
|
/** Construct the pi invocation for a node.
|
|
63
79
|
* - fresh start: pass `prompt` (the node's first user message), no resume.
|
|
64
80
|
* - revive idle/done: pass `resumeSessionId` to `--resume` (keeps conversation).
|
|
@@ -69,7 +85,7 @@ export function buildPiArgv(meta, opts = {}) {
|
|
|
69
85
|
for (const ext of spec?.extensions ?? CANVAS_EXTENSIONS) {
|
|
70
86
|
argv.push('-e', ext);
|
|
71
87
|
}
|
|
72
|
-
argv.push('-n', meta
|
|
88
|
+
argv.push('-n', editorLabel(meta));
|
|
73
89
|
if (opts.resumeSessionId !== undefined)
|
|
74
90
|
argv.push('--resume', opts.resumeSessionId);
|
|
75
91
|
if (spec?.model !== undefined)
|
|
@@ -31,25 +31,8 @@ export declare function focusNode(nodeId: string): {
|
|
|
31
31
|
*
|
|
32
32
|
* Falls back to window focus when there is no caller pane (not inside tmux) or
|
|
33
33
|
* the target pane can't be resolved. `inPlace` reports which path ran. */
|
|
34
|
-
export declare function focusNodeInPlace(nodeId: string, callerPane?: string): {
|
|
34
|
+
export declare function focusNodeInPlace(nodeId: string, callerPane?: string, callerNodeId?: string): {
|
|
35
35
|
focused: boolean;
|
|
36
36
|
session: string | null;
|
|
37
37
|
inPlace: boolean;
|
|
38
38
|
};
|
|
39
|
-
/** Send a node's running pi OUT of the caller's pane and into a window in the
|
|
40
|
-
* shared global session, leaving a fresh shell where it was — the pane
|
|
41
|
-
* "becomes a terminal" and the agent keeps running, detached, in the
|
|
42
|
-
* background. The inverse of `focusNodeInPlace`; reversible via `node focus`.
|
|
43
|
-
*
|
|
44
|
-
* Mechanism: open a shell window in the global session, then swap that shell
|
|
45
|
-
* pane INTO the caller's pane — tmux exchanges the two panes, so the node's pi
|
|
46
|
-
* pane lands in the shell's window (global session) and the shell lands in the
|
|
47
|
-
* caller's pane. The node's meta is re-pointed to the new window so the daemon
|
|
48
|
-
* keeps supervising it.
|
|
49
|
-
*
|
|
50
|
-
* Best-effort; `demoted:false` when not in tmux or any tmux step fails. */
|
|
51
|
-
export declare function demoteNode(nodeId: string, callerPane?: string): {
|
|
52
|
-
demoted: boolean;
|
|
53
|
-
session: string | null;
|
|
54
|
-
window: string | null;
|
|
55
|
-
};
|
|
@@ -19,7 +19,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
19
19
|
import { dirname } from 'node:path';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { crtrHome, getNode, updateNode } from '../canvas/index.js';
|
|
22
|
-
import { selectWindow, switchClient, windowAlive, currentTmux, paneOfWindow, swapPaneInPlace, windowOfPane
|
|
22
|
+
import { selectWindow, switchClient, windowAlive, currentTmux, paneOfWindow, swapPaneInPlace, windowOfPane } from './tmux.js';
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
24
|
// Focus pointer
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
@@ -99,7 +99,7 @@ export function focusNode(nodeId) {
|
|
|
99
99
|
*
|
|
100
100
|
* Falls back to window focus when there is no caller pane (not inside tmux) or
|
|
101
101
|
* the target pane can't be resolved. `inPlace` reports which path ran. */
|
|
102
|
-
export function focusNodeInPlace(nodeId, callerPane) {
|
|
102
|
+
export function focusNodeInPlace(nodeId, callerPane, callerNodeId) {
|
|
103
103
|
const meta = getNode(nodeId);
|
|
104
104
|
// Always write the pointer so the dashboard reflects intent even on failure.
|
|
105
105
|
setFocus(nodeId);
|
|
@@ -140,59 +140,15 @@ export function focusNodeInPlace(nodeId, callerPane) {
|
|
|
140
140
|
catch { /* best-effort */ }
|
|
141
141
|
// The caller is the node running this focus (its pi process owns callerPane).
|
|
142
142
|
// Its pane moved to the target's old window, so re-point its window there.
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
// Prefer an explicit id (the `node cycle` tmux binding runs outside any pi,
|
|
144
|
+
// so CRTR_NODE_ID is unset there) and fall back to the env for `node focus`.
|
|
145
|
+
const cnid = callerNodeId ?? process.env['CRTR_NODE_ID'];
|
|
146
|
+
if (cnid !== undefined && cnid.trim() !== '' && cnid !== nodeId) {
|
|
145
147
|
try {
|
|
146
|
-
updateNode(
|
|
148
|
+
updateNode(cnid, { window });
|
|
147
149
|
}
|
|
148
150
|
catch { /* best-effort */ }
|
|
149
151
|
}
|
|
150
152
|
}
|
|
151
153
|
return { focused: ok, session, inPlace: true };
|
|
152
154
|
}
|
|
153
|
-
// ---------------------------------------------------------------------------
|
|
154
|
-
// Demote — detach the agent in the caller's pane to the background
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
/** Send a node's running pi OUT of the caller's pane and into a window in the
|
|
157
|
-
* shared global session, leaving a fresh shell where it was — the pane
|
|
158
|
-
* "becomes a terminal" and the agent keeps running, detached, in the
|
|
159
|
-
* background. The inverse of `focusNodeInPlace`; reversible via `node focus`.
|
|
160
|
-
*
|
|
161
|
-
* Mechanism: open a shell window in the global session, then swap that shell
|
|
162
|
-
* pane INTO the caller's pane — tmux exchanges the two panes, so the node's pi
|
|
163
|
-
* pane lands in the shell's window (global session) and the shell lands in the
|
|
164
|
-
* caller's pane. The node's meta is re-pointed to the new window so the daemon
|
|
165
|
-
* keeps supervising it.
|
|
166
|
-
*
|
|
167
|
-
* Best-effort; `demoted:false` when not in tmux or any tmux step fails. */
|
|
168
|
-
export function demoteNode(nodeId, callerPane) {
|
|
169
|
-
const meta = getNode(nodeId);
|
|
170
|
-
if (meta === null)
|
|
171
|
-
return { demoted: false, session: null, window: null };
|
|
172
|
-
const pane = callerPane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
|
|
173
|
-
if (pane === undefined || pane === '') {
|
|
174
|
-
return { demoted: false, session: meta.tmux_session ?? null, window: meta.window ?? null };
|
|
175
|
-
}
|
|
176
|
-
const session = nodeSession();
|
|
177
|
-
ensureSession(session, meta.cwd);
|
|
178
|
-
const shell = openShellWindow({ session, name: meta.name, cwd: meta.cwd });
|
|
179
|
-
if (shell === null)
|
|
180
|
-
return { demoted: false, session, window: meta.window ?? null };
|
|
181
|
-
// Swap the fresh shell into the caller's pane; the node's pi pane is exchanged
|
|
182
|
-
// out into the shell's window (now living in the global session).
|
|
183
|
-
const ok = swapPaneInPlace(shell.pane, pane);
|
|
184
|
-
if (!ok) {
|
|
185
|
-
closeWindow(shell.window);
|
|
186
|
-
return { demoted: false, session, window: meta.window ?? null };
|
|
187
|
-
}
|
|
188
|
-
// The node's pi now occupies the shell window; re-point its meta there so
|
|
189
|
-
// liveness checks resolve the right window.
|
|
190
|
-
try {
|
|
191
|
-
updateNode(nodeId, { tmux_session: session, window: shell.window });
|
|
192
|
-
}
|
|
193
|
-
catch { /* best-effort */ }
|
|
194
|
-
// The caller pane reverted to a terminal — if this node held focus, clear it.
|
|
195
|
-
if (getFocus() === nodeId)
|
|
196
|
-
setFocus('');
|
|
197
|
-
return { demoted: true, session, window: shell.window };
|
|
198
|
-
}
|
|
@@ -4,6 +4,10 @@ export interface PromoteResult {
|
|
|
4
4
|
/** Orchestration guidance to surface into the node's current context now. */
|
|
5
5
|
guidance: string;
|
|
6
6
|
roadmapWritten: boolean;
|
|
7
|
+
/** Absolute path to the node's roadmap doc (context/roadmap.md). */
|
|
8
|
+
roadmapPath: string;
|
|
9
|
+
/** Absolute path to the node's goal doc (context/initial-prompt.md). */
|
|
10
|
+
goalPath: string;
|
|
7
11
|
}
|
|
8
12
|
/** Promote a node to resident orchestrator, optionally specializing its kind
|
|
9
13
|
* (e.g. a `general` worker becoming a `developer.orchestrator`). Idempotent:
|
|
@@ -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
|
|
@@ -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;
|
|
@@ -11,7 +11,7 @@ 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, nodeSession, installMenuBinding, } 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
17
|
/** Create a root node and bring up its pi. Returns the node; for 'inline' this
|
|
@@ -43,12 +43,17 @@ export function bootRoot(opts) {
|
|
|
43
43
|
// Every node window — root or child — lives in the one shared session.
|
|
44
44
|
const session = nodeSession();
|
|
45
45
|
ensureSession(session, opts.cwd);
|
|
46
|
-
// Make the Alt+C action menu live on this server
|
|
46
|
+
// Make the Alt+C action menu + Alt+] / Alt+[ nav keys live on this server
|
|
47
|
+
// (idempotent, in-tmux only).
|
|
47
48
|
if (inTmux()) {
|
|
48
49
|
try {
|
|
49
50
|
installMenuBinding();
|
|
50
51
|
}
|
|
51
52
|
catch { /* best-effort */ }
|
|
53
|
+
try {
|
|
54
|
+
installNavBindings();
|
|
55
|
+
}
|
|
56
|
+
catch { /* best-effort */ }
|
|
52
57
|
}
|
|
53
58
|
if (opts.placement === 'session') {
|
|
54
59
|
updateNode(meta.node_id, { tmux_session: session });
|
|
@@ -39,18 +39,6 @@ export interface OpenWindowOpts {
|
|
|
39
39
|
* `-a` also keeps node windows off index 0, which is reserved for the optional
|
|
40
40
|
* dashboard. */
|
|
41
41
|
export declare function openNodeWindow(opts: OpenWindowOpts): string | null;
|
|
42
|
-
/** Open a background window running a plain login shell (no pi) and return its
|
|
43
|
-
* window + pane ids. Used by demote: the agent's pi is swapped OUT into this
|
|
44
|
-
* window's slot and the shell is swapped INTO the caller's pane. `-a` keeps it
|
|
45
|
-
* off index 0 (reserved for a dashboard), `-d` keeps it from stealing focus. */
|
|
46
|
-
export declare function openShellWindow(opts: {
|
|
47
|
-
session: string;
|
|
48
|
-
name: string;
|
|
49
|
-
cwd: string;
|
|
50
|
-
}): {
|
|
51
|
-
window: string;
|
|
52
|
-
pane: string;
|
|
53
|
-
} | null;
|
|
54
42
|
/** Bring a node's window forefront. Switches client across roots when needed. */
|
|
55
43
|
export declare function focusWindow(session: string, window: string): boolean;
|
|
56
44
|
/** Close a node's window (drop it from the UI). */
|
|
@@ -63,6 +51,12 @@ export declare function paneOfWindow(session: string, window: string): string |
|
|
|
63
51
|
* are not, so the node→window mapping must be re-derived from the pane. Returns
|
|
64
52
|
* null if the pane is gone or tmux fails. */
|
|
65
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;
|
|
66
60
|
/** Swap `targetPane` into `callerPane`'s layout slot, IN PLACE. `-d` keeps the
|
|
67
61
|
* caller's window active, so the target's pane appears where the caller is
|
|
68
62
|
* rather than navigating the client off to the target's window. The caller's
|
|
@@ -105,3 +99,8 @@ export declare function selectWindow(session: string, window: string): boolean;
|
|
|
105
99
|
export declare function switchClient(session: string): boolean;
|
|
106
100
|
/** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails. */
|
|
107
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;
|