@crouton-kit/crouter 0.3.12 → 0.3.13
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/node.js +63 -10
- package/dist/core/runtime/presence.d.ts +17 -0
- package/dist/core/runtime/presence.js +47 -1
- package/dist/core/runtime/revive.js +6 -8
- package/dist/core/runtime/spawn.d.ts +0 -2
- package/dist/core/runtime/spawn.js +21 -16
- package/dist/core/runtime/tmux.d.ts +19 -0
- package/dist/core/runtime/tmux.js +46 -0
- package/dist/pi-extensions/canvas-stophook.js +42 -19
- package/package.json +1 -1
|
@@ -34,6 +34,6 @@ If the work is bigger or different than your task implies, say so in a push to y
|
|
|
34
34
|
## When your task is too big for one context window
|
|
35
35
|
If you discover the job is far larger than one node can hold — many phases, work that won't fit before you run low on context — **promote yourself** instead of grinding:
|
|
36
36
|
|
|
37
|
-
crtr node promote --
|
|
37
|
+
crtr node promote --kind <kind>
|
|
38
38
|
|
|
39
|
-
This makes you a resident orchestrator: you
|
|
39
|
+
This makes you a resident orchestrator: you author a roadmap (`context/roadmap.md`), delegate each phase to children, and when your context fills you `crtr node yield` to refresh against that roadmap. `--kind` specializes the orchestrator you revive into (developer, review, spec, design, plan, explore, general); omit it to keep your current kind. Don't promote for work that fits one window — finish it.
|
package/dist/commands/node.js
CHANGED
|
@@ -10,8 +10,8 @@ import { spawnChild, bootRoot } from '../core/runtime/spawn.js';
|
|
|
10
10
|
import { promote, requestYield } from '../core/runtime/promote.js';
|
|
11
11
|
import { writeYieldMessage } from '../core/runtime/kickoff.js';
|
|
12
12
|
import { reviveNode } from '../core/runtime/revive.js';
|
|
13
|
-
import { focusNodeInPlace } from '../core/runtime/presence.js';
|
|
14
|
-
import { windowAlive } from '../core/runtime/tmux.js';
|
|
13
|
+
import { focusNodeInPlace, demoteNode } from '../core/runtime/presence.js';
|
|
14
|
+
import { windowAlive, windowOfPane, currentTmux } from '../core/runtime/tmux.js';
|
|
15
15
|
import { appendInbox } from '../core/feed/inbox.js';
|
|
16
16
|
import { availableKinds } from '../core/personas/index.js';
|
|
17
17
|
import { getNode, listNodes, subscriptionsOf, subscribersOf, } from '../core/canvas/index.js';
|
|
@@ -42,7 +42,7 @@ const nodeNew = defineLeaf({
|
|
|
42
42
|
{ name: 'node_id', type: 'string', required: true, constraint: 'The new node id.' },
|
|
43
43
|
{ name: 'name', type: 'string', required: true, constraint: 'Display name.' },
|
|
44
44
|
{ name: 'window', type: 'string', required: false, constraint: 'tmux window id of the background window.' },
|
|
45
|
-
{ name: 'session', type: 'string', required: true, constraint: '
|
|
45
|
+
{ name: 'session', type: 'string', required: true, constraint: 'The shared crtr tmux session the node was placed in.' },
|
|
46
46
|
{ name: 'status', type: 'string', required: true, constraint: 'Always "active" on spawn.' },
|
|
47
47
|
{ name: 'follow_up', type: 'string', required: true, constraint: 'A notification to the caller about the spawn: the child runs independently and its finish wakes you automatically, so treat it as fire-and-forget. Read it, then act.' },
|
|
48
48
|
],
|
|
@@ -182,13 +182,65 @@ const nodeFocus = defineLeaf({
|
|
|
182
182
|
},
|
|
183
183
|
});
|
|
184
184
|
// ---------------------------------------------------------------------------
|
|
185
|
+
// node demote — detach the agent in your pane to the background session
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
/** First live node whose window id is `win` (each node owns one window). The
|
|
188
|
+
* queryable row projection omits `window`, so resolve full meta per candidate. */
|
|
189
|
+
function nodeByWindow(win) {
|
|
190
|
+
for (const row of listNodes({ status: ['active', 'idle'] })) {
|
|
191
|
+
if (getNode(row.node_id)?.window === win)
|
|
192
|
+
return row.node_id;
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
const nodeDemote = defineLeaf({
|
|
197
|
+
name: 'demote',
|
|
198
|
+
help: {
|
|
199
|
+
name: 'node demote',
|
|
200
|
+
summary: 'detach the agent in your current pane to the background — swap a fresh terminal into its place and relocate its running pi to a window in the shared crtr session (the inverse of focus; reattach later with `node focus`)',
|
|
201
|
+
params: [
|
|
202
|
+
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to demote. Defaults to the node occupying --pane (or your current pane).' },
|
|
203
|
+
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to demote out of. Defaults to $TMUX_PANE / your current pane. The Alt+C menu passes this for you.' },
|
|
204
|
+
],
|
|
205
|
+
output: [
|
|
206
|
+
{ name: 'demoted', type: 'boolean', required: true, constraint: 'True when the agent was swapped out to the background.' },
|
|
207
|
+
{ name: 'node_id', type: 'string', required: false, constraint: 'The demoted node.' },
|
|
208
|
+
{ name: 'session', type: 'string', required: false, constraint: 'The shared session the agent now lives in.' },
|
|
209
|
+
{ name: 'window', type: 'string', required: false, constraint: 'The agent\'s new background window id.' },
|
|
210
|
+
],
|
|
211
|
+
outputKind: 'object',
|
|
212
|
+
effects: ['Swaps a fresh shell into the caller pane (tmux swap-pane) and relocates the node\'s pi window into the shared crtr session.', 'Clears the focus pointer if the demoted node held it. The pi keeps running — nothing is killed.'],
|
|
213
|
+
},
|
|
214
|
+
run: async (input) => {
|
|
215
|
+
const pane = input['pane'] ?? process.env['TMUX_PANE'];
|
|
216
|
+
let id = input['node'];
|
|
217
|
+
if (id === undefined || id === '') {
|
|
218
|
+
// Derive the node from the pane: which node's window holds it?
|
|
219
|
+
const resolvePane = pane ?? currentTmux()?.pane;
|
|
220
|
+
const win = resolvePane !== undefined ? windowOfPane(resolvePane) : null;
|
|
221
|
+
id = win !== null ? nodeByWindow(win) : undefined;
|
|
222
|
+
}
|
|
223
|
+
if (id === undefined || id === '') {
|
|
224
|
+
throw new InputError({ error: 'no_node', message: 'no node found in this pane to demote', next: 'Pass --node <id>, or run from inside a focused node\'s pane.' });
|
|
225
|
+
}
|
|
226
|
+
if (getNode(id) === null) {
|
|
227
|
+
throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
|
|
228
|
+
}
|
|
229
|
+
const res = demoteNode(id, pane);
|
|
230
|
+
return { demoted: res.demoted, node_id: id, session: res.session ?? undefined, window: res.window ?? undefined };
|
|
231
|
+
},
|
|
232
|
+
render: (r) => r['demoted'] === true
|
|
233
|
+
? `<demoted id="${r['node_id']}" session="${r['session'] ?? ''}"/>`
|
|
234
|
+
: `<demote-failed id="${r['node_id'] ?? ''}">not in tmux, or no agent in this pane</demote-failed>`,
|
|
235
|
+
});
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
185
237
|
// node session — boot a NEW root in its own tmux session (the explicit form)
|
|
186
238
|
// ---------------------------------------------------------------------------
|
|
187
239
|
const nodeSession = defineLeaf({
|
|
188
240
|
name: 'session',
|
|
189
241
|
help: {
|
|
190
242
|
name: 'node session',
|
|
191
|
-
summary: 'start a fresh root node
|
|
243
|
+
summary: 'start a fresh root node as its own window in the shared crtr session (use from inside a node to start a new root without taking your pane)',
|
|
192
244
|
params: [
|
|
193
245
|
{ kind: 'stdin', name: 'prompt', required: false, constraint: 'Optional starter prompt; a root needs none.' },
|
|
194
246
|
{ kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind for the root.' },
|
|
@@ -197,11 +249,11 @@ const nodeSession = defineLeaf({
|
|
|
197
249
|
],
|
|
198
250
|
output: [
|
|
199
251
|
{ name: 'node_id', type: 'string', required: true, constraint: 'The root node id.' },
|
|
200
|
-
{ name: 'session', type: 'string', required: true, constraint: 'The
|
|
252
|
+
{ name: 'session', type: 'string', required: true, constraint: 'The shared crtr tmux session this root\'s window was placed in.' },
|
|
201
253
|
{ name: 'window', type: 'string', required: false, constraint: 'The root node\'s window id.' },
|
|
202
254
|
],
|
|
203
255
|
outputKind: 'object',
|
|
204
|
-
effects: ['
|
|
256
|
+
effects: ['Opens a detached window in the shared crtr session and runs pi in it as a resident root node.'],
|
|
205
257
|
},
|
|
206
258
|
run: async (input) => {
|
|
207
259
|
const prompt = input['prompt'];
|
|
@@ -264,9 +316,9 @@ const nodePromote = defineLeaf({
|
|
|
264
316
|
name: 'promote',
|
|
265
317
|
help: {
|
|
266
318
|
name: 'node promote',
|
|
267
|
-
summary: 'promote yourself to a resident orchestrator
|
|
319
|
+
summary: 'promote yourself to a resident orchestrator — do this when your task outgrows one context window (many phases to delegate and persist across refreshes); not for work that fits one window, and not merely because you spawned a child',
|
|
268
320
|
params: [
|
|
269
|
-
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Specialize as this kind of orchestrator: developer (own feature delivery), review, spec, design, plan, explore, general. Defaults to your current kind. Promoting from a generic kind? CHOOSE a concrete one — it
|
|
321
|
+
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Specialize as this kind of orchestrator: developer (own feature delivery), review, spec, design, plan, explore, general. Defaults to your current kind. Promoting from a generic kind? CHOOSE a concrete one — it sets the orchestrator persona you revive into.' },
|
|
270
322
|
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to promote. Defaults to the caller (CRTR_NODE_ID).' },
|
|
271
323
|
],
|
|
272
324
|
output: [
|
|
@@ -274,7 +326,7 @@ const nodePromote = defineLeaf({
|
|
|
274
326
|
{ name: 'kind', type: 'string', required: true, constraint: 'The kind it now orchestrates as.' },
|
|
275
327
|
{ name: 'mode', type: 'string', required: true, constraint: 'Now "orchestrator".' },
|
|
276
328
|
{ name: 'roadmap_written', type: 'boolean', required: true, constraint: 'True if a roadmap scaffold was seeded by this call.' },
|
|
277
|
-
{ name: 'guidance', type: 'string', required: true, constraint: '
|
|
329
|
+
{ name: 'guidance', type: 'string', required: true, constraint: 'Instructions for your new role — read and act on them this turn.' },
|
|
278
330
|
],
|
|
279
331
|
outputKind: 'object',
|
|
280
332
|
effects: ['Flips lifecycle→resident, mode→orchestrator, kind→chosen; rewrites the launch spec to that kind\'s orchestrator persona; seeds context/roadmap.md scaffold if absent.'],
|
|
@@ -343,12 +395,13 @@ export function registerNode() {
|
|
|
343
395
|
{ name: 'new', desc: 'spawn a terminal worker as a background window', useWhen: 'delegating a self-contained unit of work' },
|
|
344
396
|
{ name: 'inspect', desc: 'read the graph (list nodes / show one)', useWhen: 'surveying the canvas or inspecting a node' },
|
|
345
397
|
{ name: 'focus', desc: 'bring a node window forefront', useWhen: 'jumping to a node to watch or steer it' },
|
|
398
|
+
{ name: 'demote', desc: 'detach the agent in your pane to the background', useWhen: 'parking the agent in front of you and getting your terminal back (Alt+C → d)' },
|
|
346
399
|
{ name: 'session', desc: 'open a fresh root in its own tmux session', useWhen: 'starting a new top-level session from inside a node' },
|
|
347
400
|
{ name: 'msg', desc: 'direct-message any node at a wake tier', useWhen: 'steering or pinging a specific node (wakes it)' },
|
|
348
401
|
{ name: 'promote', desc: 'become a resident orchestrator of a chosen kind', useWhen: 'your task is bigger than one context window and you must delegate + persist' },
|
|
349
402
|
{ name: 'yield', desc: 'refresh your context against your roadmap', useWhen: 'your context window is filling up' },
|
|
350
403
|
],
|
|
351
404
|
},
|
|
352
|
-
children: [nodeNew, nodeInspect, nodeFocus, nodeSession, nodeMsg, nodePromote, nodeYield],
|
|
405
|
+
children: [nodeNew, nodeInspect, nodeFocus, nodeDemote, nodeSession, nodeMsg, nodePromote, nodeYield],
|
|
353
406
|
});
|
|
354
407
|
}
|
|
@@ -36,3 +36,20 @@ export declare function focusNodeInPlace(nodeId: string, callerPane?: string): {
|
|
|
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 } from './tmux.js';
|
|
22
|
+
import { selectWindow, switchClient, windowAlive, currentTmux, paneOfWindow, swapPaneInPlace, windowOfPane, openShellWindow, closeWindow, ensureSession, nodeSession } from './tmux.js';
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
24
|
// Focus pointer
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
@@ -150,3 +150,49 @@ export function focusNodeInPlace(nodeId, callerPane) {
|
|
|
150
150
|
}
|
|
151
151
|
return { focused: ok, session, inPlace: true };
|
|
152
152
|
}
|
|
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
|
+
}
|
|
@@ -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,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, } 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,17 @@ 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 live on this server (idempotent, in-tmux only).
|
|
47
|
+
if (inTmux()) {
|
|
48
|
+
try {
|
|
49
|
+
installMenuBinding();
|
|
50
|
+
}
|
|
51
|
+
catch { /* best-effort */ }
|
|
52
|
+
}
|
|
48
53
|
if (opts.placement === 'session') {
|
|
49
|
-
ensureSession(session, opts.cwd);
|
|
50
54
|
updateNode(meta.node_id, { tmux_session: session });
|
|
51
55
|
const withSession = getNode(meta.node_id);
|
|
52
56
|
const inv = buildPiArgv(withSession, { prompt: opts.prompt });
|
|
@@ -61,14 +65,16 @@ export function bootRoot(opts) {
|
|
|
61
65
|
updateNode(meta.node_id, { window: win });
|
|
62
66
|
return getNode(meta.node_id);
|
|
63
67
|
}
|
|
64
|
-
// inline: the root
|
|
65
|
-
//
|
|
68
|
+
// inline: the root's pi takes over THIS terminal, so its own window stays
|
|
69
|
+
// where the user is (its tmux_session tracks that real pane so supervision
|
|
70
|
+
// sees it alive). But its children spawn into the shared global session via
|
|
71
|
+
// CRTR_ROOT_SESSION — they never clutter the user's working session.
|
|
66
72
|
const here = currentTmux();
|
|
67
73
|
const adopted = here?.session ?? session;
|
|
68
74
|
updateNode(meta.node_id, { tmux_session: adopted, window: here?.window ?? null });
|
|
69
75
|
const withSession = getNode(meta.node_id);
|
|
70
76
|
const inv = buildPiArgv(withSession, { prompt: opts.prompt });
|
|
71
|
-
const env = { ...process.env, ...inv.env, CRTR_ROOT_SESSION:
|
|
77
|
+
const env = { ...process.env, ...inv.env, CRTR_ROOT_SESSION: session, [FRONT_DOOR_ENV]: '1' };
|
|
72
78
|
const r = spawnSync('pi', inv.argv, { stdio: 'inherit', env });
|
|
73
79
|
process.exit(r.status ?? 0);
|
|
74
80
|
}
|
|
@@ -97,13 +103,12 @@ export function spawnChild(opts) {
|
|
|
97
103
|
});
|
|
98
104
|
// Persist the task as the child's goal for a fresh revive to re-read.
|
|
99
105
|
writeGoal(meta.node_id, opts.prompt);
|
|
100
|
-
//
|
|
106
|
+
// Children always land in the shared global session: inherited from the
|
|
107
|
+
// parent's CRTR_ROOT_SESSION, else the default node session.
|
|
101
108
|
let session = process.env['CRTR_ROOT_SESSION'];
|
|
102
|
-
if (session === undefined || session === '')
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
ensureSession(session, opts.cwd);
|
|
106
|
-
}
|
|
109
|
+
if (session === undefined || session === '')
|
|
110
|
+
session = nodeSession();
|
|
111
|
+
ensureSession(session, opts.cwd);
|
|
107
112
|
const inv = buildPiArgv(meta, { prompt: opts.prompt });
|
|
108
113
|
const env = { ...inv.env, CRTR_ROOT_SESSION: session };
|
|
109
114
|
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;
|
|
@@ -34,6 +39,18 @@ export interface OpenWindowOpts {
|
|
|
34
39
|
* `-a` also keeps node windows off index 0, which is reserved for the optional
|
|
35
40
|
* dashboard. */
|
|
36
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;
|
|
37
54
|
/** Bring a node's window forefront. Switches client across roots when needed. */
|
|
38
55
|
export declare function focusWindow(session: string, window: string): boolean;
|
|
39
56
|
/** Close a node's window (drop it from the UI). */
|
|
@@ -86,3 +103,5 @@ export declare function selectWindow(session: string, window: string): boolean;
|
|
|
86
103
|
* `tmux switch-client -t <session>`. Best-effort; never throws. The caller is
|
|
87
104
|
* responsible for following up with selectWindow to land on the right window. */
|
|
88
105
|
export declare function switchClient(session: string): boolean;
|
|
106
|
+
/** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails. */
|
|
107
|
+
export declare function installMenuBinding(): 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())
|
|
@@ -90,6 +98,25 @@ export function openNodeWindow(opts) {
|
|
|
90
98
|
]);
|
|
91
99
|
return r.ok ? r.stdout : null;
|
|
92
100
|
}
|
|
101
|
+
/** Open a background window running a plain login shell (no pi) and return its
|
|
102
|
+
* window + pane ids. Used by demote: the agent's pi is swapped OUT into this
|
|
103
|
+
* window's slot and the shell is swapped INTO the caller's pane. `-a` keeps it
|
|
104
|
+
* off index 0 (reserved for a dashboard), `-d` keeps it from stealing focus. */
|
|
105
|
+
export function openShellWindow(opts) {
|
|
106
|
+
const r = tmux([
|
|
107
|
+
'new-window', '-d', '-a', '-P',
|
|
108
|
+
'-F', '#{window_id}\t#{pane_id}',
|
|
109
|
+
'-t', `${opts.session}:`,
|
|
110
|
+
'-n', opts.name,
|
|
111
|
+
'-c', opts.cwd,
|
|
112
|
+
]);
|
|
113
|
+
if (!r.ok)
|
|
114
|
+
return null;
|
|
115
|
+
const [window, pane] = r.stdout.split('\t');
|
|
116
|
+
if (window === undefined || pane === undefined)
|
|
117
|
+
return null;
|
|
118
|
+
return { window, pane };
|
|
119
|
+
}
|
|
93
120
|
/** Bring a node's window forefront. Switches client across roots when needed. */
|
|
94
121
|
export function focusWindow(session, window) {
|
|
95
122
|
const here = currentTmux();
|
|
@@ -196,3 +223,22 @@ export function selectWindow(session, window) {
|
|
|
196
223
|
export function switchClient(session) {
|
|
197
224
|
return tmux(['switch-client', '-t', session]).ok;
|
|
198
225
|
}
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Prefix menu — Alt+C opens a which-key-style tmux display-menu of crouter
|
|
228
|
+
// actions. Installed on the running server at root boot; idempotent (a re-bind
|
|
229
|
+
// overwrites the previous one). Items shell out to `crtr`, passing the active
|
|
230
|
+
// pane so an action targets the agent currently in front of you.
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
/** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails. */
|
|
233
|
+
export function installMenuBinding() {
|
|
234
|
+
const sess = nodeSession();
|
|
235
|
+
return tmux([
|
|
236
|
+
'bind-key', '-n', 'M-c', 'display-menu',
|
|
237
|
+
'-T', '#[align=centre] crtr ',
|
|
238
|
+
// Anchor to the top-right of the pane it was called from (tmux clamps it
|
|
239
|
+
// back on-screen) rather than centring on the whole terminal.
|
|
240
|
+
'-x', '#{pane_right}', '-y', '#{pane_top}',
|
|
241
|
+
'detach agent \u2192 background', 'd', `run-shell "crtr node demote --pane '#{pane_id}'"`,
|
|
242
|
+
'browse background agents', 'g', `switch-client -t ${sess}`,
|
|
243
|
+
]).ok;
|
|
244
|
+
}
|
|
@@ -132,29 +132,52 @@ function extractText(msg) {
|
|
|
132
132
|
.trim();
|
|
133
133
|
}
|
|
134
134
|
// ---------------------------------------------------------------------------
|
|
135
|
-
// Context-size steering bands —
|
|
136
|
-
//
|
|
137
|
-
//
|
|
135
|
+
// Context-size steering bands — mode-specific schedules that ESCALATE in tone
|
|
136
|
+
// as input context grows. The first band is a gentle "consider it"; a later
|
|
137
|
+
// band turns firm. Past the last explicit band the firmest nudge repeats every
|
|
138
|
+
// 50k, so a long-lived node keeps getting reminded.
|
|
139
|
+
//
|
|
140
|
+
// orchestrator: 130k gentle (consider yielding) → 150k+ firm (do it now)
|
|
141
|
+
// base worker: 130k suggest promote → 160k+ suggest promote (+ "ignore if
|
|
142
|
+
// nearly done")
|
|
138
143
|
// ---------------------------------------------------------------------------
|
|
139
|
-
const STEER_FLOOR = 100_000;
|
|
140
144
|
const STEER_STEP = 50_000;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
+
const ORCH_BANDS = [130_000, 150_000]; // gentle, then firm (firm repeats +50k)
|
|
146
|
+
const WORKER_BANDS = [130_000, 160_000]; // suggest, then suggest+ignore (repeats +50k)
|
|
147
|
+
/** The highest band threshold at or below `tokens` for `mode`. Below the first
|
|
148
|
+
* band → null. At/past the last listed band, bands continue every STEER_STEP
|
|
149
|
+
* (so the firmest nudge keeps recurring). */
|
|
150
|
+
function steerBand(tokens, mode) {
|
|
151
|
+
const bands = mode === 'orchestrator' ? ORCH_BANDS : WORKER_BANDS;
|
|
152
|
+
const first = bands[0];
|
|
153
|
+
const last = bands[bands.length - 1];
|
|
154
|
+
if (tokens < first)
|
|
145
155
|
return null;
|
|
146
|
-
|
|
156
|
+
if (tokens >= last)
|
|
157
|
+
return last + Math.floor((tokens - last) / STEER_STEP) * STEER_STEP;
|
|
158
|
+
let chosen = first;
|
|
159
|
+
for (const b of bands)
|
|
160
|
+
if (tokens >= b)
|
|
161
|
+
chosen = b;
|
|
162
|
+
return chosen;
|
|
147
163
|
}
|
|
148
|
-
/** The nudge text for a crossed band, specialized to the node's mode
|
|
149
|
-
* orchestrator is steered to checkpoint its
|
|
150
|
-
*
|
|
151
|
-
*
|
|
164
|
+
/** The nudge text for a crossed band, specialized to the node's mode + how far
|
|
165
|
+
* along the escalation it is. An orchestrator is steered to checkpoint its
|
|
166
|
+
* roadmap and yield (gently first, then firmly); a non-orchestrator (base
|
|
167
|
+
* worker) is steered to PROMOTE itself — become a resident orchestrator — when
|
|
168
|
+
* work remains, with an "ignore if nearly done" once it's deeper in. */
|
|
152
169
|
function steerNote(at, mode) {
|
|
153
170
|
const k = Math.round(at / 1000);
|
|
154
171
|
if (mode === 'orchestrator') {
|
|
172
|
+
if (at < 150_000) {
|
|
173
|
+
return `Context ~${k}k and growing. When you reach a good stopping point, consider updating context/roadmap.md and running \`crtr node yield\` to refresh against it — no rush yet.`;
|
|
174
|
+
}
|
|
155
175
|
return `Context ~${k}k. Update context/roadmap.md so a fresh revive can continue, delegate any outstanding work, then \`crtr node yield\` to refresh.`;
|
|
156
176
|
}
|
|
157
|
-
|
|
177
|
+
const suggest = `If much more work remains than this context can finish, consider \`crtr node promote\` to become a resident orchestrator (seeds a roadmap, lets you delegate and \`crtr node yield\` to refresh).`;
|
|
178
|
+
if (at < 160_000)
|
|
179
|
+
return `Context ~${k}k. ${suggest}`;
|
|
180
|
+
return `Context ~${k}k. ${suggest} If you're nearly done, ignore this suggestion and finish with \`crtr push final\`.`;
|
|
158
181
|
}
|
|
159
182
|
// ---------------------------------------------------------------------------
|
|
160
183
|
// Extension
|
|
@@ -179,9 +202,9 @@ export function registerCanvasStophook(pi) {
|
|
|
179
202
|
let totalOut = 0;
|
|
180
203
|
let model = '';
|
|
181
204
|
// Context-size steering. As input context grows we nudge the node once per
|
|
182
|
-
// band
|
|
183
|
-
// read at fire time since a base worker can promote mid-session: an
|
|
184
|
-
// orchestrator
|
|
205
|
+
// band on an escalating, mode-specific schedule (see steerBand/steerNote).
|
|
206
|
+
// Mode is read at fire time since a base worker can promote mid-session: an
|
|
207
|
+
// orchestrator is steered to checkpoint + yield; a base worker to promote.
|
|
185
208
|
const firedBands = new Set();
|
|
186
209
|
// ---------------------------------------------------------------------------
|
|
187
210
|
// session_start — capture pi's session id, and detect `/new`.
|
|
@@ -246,10 +269,10 @@ export function registerCanvasStophook(pi) {
|
|
|
246
269
|
// Context-size steering: fire the current band once, with mode-specific
|
|
247
270
|
// guidance (mode is read live — a worker may have promoted since launch).
|
|
248
271
|
try {
|
|
249
|
-
const
|
|
272
|
+
const mode = getNode(nodeId)?.mode ?? 'base';
|
|
273
|
+
const at = steerBand(totalIn, mode);
|
|
250
274
|
if (at !== null && !firedBands.has(at)) {
|
|
251
275
|
firedBands.add(at);
|
|
252
|
-
const mode = getNode(nodeId)?.mode ?? 'base';
|
|
253
276
|
pi.sendUserMessage(`[crtr] ${steerNote(at, mode)}`, { deliverAs: 'followUp' });
|
|
254
277
|
}
|
|
255
278
|
}
|