@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.
@@ -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 --goal "<the high-level goal you now own>"
37
+ crtr node promote --kind <kind>
38
38
 
39
- This makes you a resident orchestrator: you get a roadmap (`context/roadmap.md`), you delegate each phase to children, and when your context fills you `crtr node yield` to refresh against that roadmap. Don't promote for work that fits one window — finish it.
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.
@@ -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: 'Root tmux session the node was placed in.' },
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 in its own tmux session and switch to it (use from inside a node to start a new root without taking your pane)',
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 dedicated tmux session created for this root.' },
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: ['Creates a detached tmux session and runs pi in it as a resident root node.'],
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 of a chosen kind flips to that kind\'s orchestrator persona on next revive, dumps its orchestration + roadmap-shaping guidance now, and seeds a roadmap scaffold for you to author',
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 picks the orchestrator persona you revive into and the roadmap-shaping skill dumped now.' },
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: 'Kind-specific orchestration + roadmap-shaping guidance and your roadmap scaffold — read it, then AUTHOR your roadmap (goal, exit criteria, phases) this turn before delegating.' },
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 its root's tmux session. Prefer the stored session name;
30
- // fall back to deriving it from the parent (or the node itself for roots).
31
- const session = meta.tmux_session ??
32
- rootSessionName((meta.parent ?? meta.node_id));
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
- const session = rootSessionName(meta.node_id);
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 adopts the current tmux session (if any) as its home, so
65
- // children spawn as windows alongside it. Then exec pi in this terminal.
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: adopted, [FRONT_DOOR_ENV]: '1' };
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
- // Resolve the root session: inherited from env, else derive + create one.
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
- const here = inTmux() ? currentTmux() : null;
104
- session = here?.session ?? rootSessionName(parent);
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 — first nudge at 100k input tokens, then one
136
- // every 50k thereafter (150k, 200k, 250k, …). Unbounded: a long-lived node
137
- // keeps getting reminded as it grows.
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
- /** The highest band boundary at or below `tokens` (100k, 150k, 200k, …), or
142
- * null below the floor. */
143
- function steerBand(tokens) {
144
- if (tokens < STEER_FLOOR)
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
- return STEER_FLOOR + Math.floor((tokens - STEER_FLOOR) / STEER_STEP) * STEER_STEP;
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. An
149
- * orchestrator is steered to checkpoint its roadmap and yield; a non-
150
- * orchestrator (base worker) is steered to PROMOTE itself become a resident
151
- * orchestrator when work remains, or finish if it's nearly done. */
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
- return `Context ~${k}k and climbing. If more work remains than this context can finish, \`crtr node promote\` to become a resident orchestrator (seeds a roadmap, lets you delegate and \`crtr node yield\` to refresh). If you're nearly done, finish with \`crtr push final\`.`;
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 (100k, then every 50k). The nudge depends on the node's CURRENT mode,
183
- // read at fire time since a base worker can promote mid-session: an
184
- // orchestrator checkpoints + yields; a base worker is steered to promote.
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 at = steerBand(totalIn);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
4
4
  "description": "crtr — fast access to skills, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",