@crouton-kit/crouter 0.3.11 → 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.
Files changed (182) hide show
  1. package/bin/crtrd +2 -0
  2. package/dist/builtin-personas/design/base.md +9 -0
  3. package/dist/builtin-personas/design/orchestrator.md +10 -0
  4. package/dist/builtin-personas/developer/base.md +9 -0
  5. package/dist/builtin-personas/developer/orchestrator.md +12 -0
  6. package/dist/builtin-personas/explore/base.md +9 -0
  7. package/dist/builtin-personas/explore/orchestrator.md +9 -0
  8. package/dist/builtin-personas/general/base.md +5 -0
  9. package/dist/builtin-personas/general/orchestrator.md +7 -0
  10. package/dist/builtin-personas/orchestration-kernel.md +71 -0
  11. package/dist/builtin-personas/plan/base.md +7 -0
  12. package/dist/builtin-personas/plan/orchestrator.md +12 -0
  13. package/dist/builtin-personas/review/base.md +7 -0
  14. package/dist/builtin-personas/review/orchestrator.md +9 -0
  15. package/dist/builtin-personas/runtime-base.md +39 -0
  16. package/dist/builtin-personas/spec/base.md +7 -0
  17. package/dist/builtin-personas/spec/orchestrator.md +10 -0
  18. package/dist/builtin-skills/skills/design/SKILL.md +51 -0
  19. package/dist/builtin-skills/skills/development/SKILL.md +109 -0
  20. package/dist/builtin-skills/skills/planning/SKILL.md +59 -0
  21. package/dist/builtin-skills/skills/spec/SKILL.md +83 -0
  22. package/dist/cli.js +14 -6
  23. package/dist/commands/{mode.d.ts → attention.d.ts} +1 -1
  24. package/dist/commands/attention.js +152 -0
  25. package/dist/commands/canvas.d.ts +2 -0
  26. package/dist/commands/canvas.js +35 -0
  27. package/dist/commands/daemon.d.ts +2 -0
  28. package/dist/commands/daemon.js +111 -0
  29. package/dist/commands/dashboard.d.ts +2 -0
  30. package/dist/commands/dashboard.js +65 -0
  31. package/dist/commands/human/prompts.d.ts +5 -0
  32. package/dist/commands/human/prompts.js +269 -0
  33. package/dist/commands/human/queue.d.ts +3 -0
  34. package/dist/commands/human/queue.js +133 -0
  35. package/dist/commands/human/shared.d.ts +43 -0
  36. package/dist/commands/human/shared.js +107 -0
  37. package/dist/commands/human.js +10 -454
  38. package/dist/commands/node.d.ts +2 -0
  39. package/dist/commands/node.js +407 -0
  40. package/dist/commands/pkg/market-inspect.d.ts +1 -0
  41. package/dist/commands/pkg/market-inspect.js +157 -0
  42. package/dist/commands/pkg/market-manage.d.ts +1 -0
  43. package/dist/commands/pkg/market-manage.js +316 -0
  44. package/dist/commands/pkg/market.d.ts +1 -0
  45. package/dist/commands/pkg/market.js +16 -0
  46. package/dist/commands/pkg/plugin-inspect.d.ts +1 -0
  47. package/dist/commands/pkg/plugin-inspect.js +142 -0
  48. package/dist/commands/pkg/plugin-manage.d.ts +1 -0
  49. package/dist/commands/pkg/plugin-manage.js +294 -0
  50. package/dist/commands/pkg/plugin.d.ts +1 -0
  51. package/dist/commands/pkg/plugin.js +16 -0
  52. package/dist/commands/pkg/shared.d.ts +5 -0
  53. package/dist/commands/pkg/shared.js +61 -0
  54. package/dist/commands/pkg.js +3 -1004
  55. package/dist/commands/push.d.ts +3 -0
  56. package/dist/commands/push.js +159 -0
  57. package/dist/commands/revive.d.ts +2 -0
  58. package/dist/commands/revive.js +64 -0
  59. package/dist/commands/skill/author.d.ts +3 -0
  60. package/dist/commands/skill/author.js +147 -0
  61. package/dist/commands/skill/find.d.ts +4 -0
  62. package/dist/commands/skill/find.js +254 -0
  63. package/dist/commands/skill/read.d.ts +1 -0
  64. package/dist/commands/skill/read.js +89 -0
  65. package/dist/commands/skill/shared.d.ts +19 -0
  66. package/dist/commands/skill/shared.js +207 -0
  67. package/dist/commands/skill/state.d.ts +3 -0
  68. package/dist/commands/skill/state.js +69 -0
  69. package/dist/commands/skill.js +6 -691
  70. package/dist/commands/sys/config.d.ts +1 -0
  71. package/dist/commands/sys/config.js +186 -0
  72. package/dist/commands/sys/doctor.d.ts +1 -0
  73. package/dist/commands/sys/doctor.js +369 -0
  74. package/dist/commands/sys/shared.d.ts +3 -0
  75. package/dist/commands/sys/shared.js +24 -0
  76. package/dist/commands/sys/update.d.ts +2 -0
  77. package/dist/commands/sys/update.js +114 -0
  78. package/dist/commands/sys.js +4 -694
  79. package/dist/core/__tests__/argv-parser.test.js +19 -1
  80. package/dist/core/__tests__/canvas-inbox-watcher.test.js +100 -0
  81. package/dist/core/__tests__/canvas.test.js +154 -0
  82. package/dist/core/__tests__/reset.test.js +105 -0
  83. package/dist/core/canvas/attention.d.ts +24 -0
  84. package/dist/core/canvas/attention.js +94 -0
  85. package/dist/core/canvas/canvas.d.ts +40 -0
  86. package/dist/core/canvas/canvas.js +210 -0
  87. package/dist/core/canvas/db.d.ts +7 -0
  88. package/dist/core/canvas/db.js +61 -0
  89. package/dist/core/canvas/index.d.ts +4 -0
  90. package/dist/core/canvas/index.js +6 -0
  91. package/dist/core/canvas/paths.d.ts +16 -0
  92. package/dist/core/canvas/paths.js +62 -0
  93. package/dist/core/canvas/render.d.ts +30 -0
  94. package/dist/core/canvas/render.js +186 -0
  95. package/dist/core/canvas/types.d.ts +87 -0
  96. package/dist/core/canvas/types.js +8 -0
  97. package/dist/core/command.d.ts +5 -0
  98. package/dist/core/command.js +35 -10
  99. package/dist/core/feed/feed.d.ts +43 -0
  100. package/dist/core/feed/feed.js +116 -0
  101. package/dist/core/feed/inbox.d.ts +50 -0
  102. package/dist/core/feed/inbox.js +124 -0
  103. package/dist/core/help.js +5 -3
  104. package/dist/core/io.d.ts +15 -1
  105. package/dist/core/io.js +56 -6
  106. package/dist/core/personas/index.d.ts +12 -0
  107. package/dist/core/personas/index.js +10 -0
  108. package/dist/core/personas/loader.d.ts +44 -0
  109. package/dist/core/personas/loader.js +157 -0
  110. package/dist/core/personas/resolve.d.ts +36 -0
  111. package/dist/core/personas/resolve.js +110 -0
  112. package/dist/core/render.d.ts +11 -0
  113. package/dist/core/render.js +126 -0
  114. package/dist/core/resolver.d.ts +10 -0
  115. package/dist/core/resolver.js +109 -1
  116. package/dist/core/runtime/front-door.d.ts +10 -0
  117. package/dist/core/runtime/front-door.js +97 -0
  118. package/dist/core/runtime/kickoff.d.ts +23 -0
  119. package/dist/core/runtime/kickoff.js +134 -0
  120. package/dist/core/runtime/launch.d.ts +34 -0
  121. package/dist/core/runtime/launch.js +85 -0
  122. package/dist/core/runtime/nodes.d.ts +38 -0
  123. package/dist/core/runtime/nodes.js +95 -0
  124. package/dist/core/runtime/presence.d.ts +55 -0
  125. package/dist/core/runtime/presence.js +198 -0
  126. package/dist/core/runtime/promote.d.ts +30 -0
  127. package/dist/core/runtime/promote.js +105 -0
  128. package/dist/core/runtime/reset.d.ts +13 -0
  129. package/dist/core/runtime/reset.js +97 -0
  130. package/dist/core/runtime/revive.d.ts +26 -0
  131. package/dist/core/runtime/revive.js +87 -0
  132. package/dist/core/runtime/roadmap.d.ts +12 -0
  133. package/dist/core/runtime/roadmap.js +52 -0
  134. package/dist/core/runtime/spawn.d.ts +31 -0
  135. package/dist/core/runtime/spawn.js +123 -0
  136. package/dist/core/runtime/stop-guard.d.ts +18 -0
  137. package/dist/core/runtime/stop-guard.js +33 -0
  138. package/dist/core/runtime/tmux.d.ts +107 -0
  139. package/dist/core/runtime/tmux.js +244 -0
  140. package/dist/core/spawn.d.ts +17 -197
  141. package/dist/core/spawn.js +16 -539
  142. package/dist/daemon/crtrd-cli.js +4 -0
  143. package/dist/daemon/crtrd.d.ts +20 -0
  144. package/dist/daemon/crtrd.js +200 -0
  145. package/dist/daemon/manage.d.ts +17 -0
  146. package/dist/daemon/manage.js +57 -0
  147. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
  148. package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
  149. package/dist/pi-extensions/canvas-nav.d.ts +32 -0
  150. package/dist/pi-extensions/canvas-nav.js +536 -0
  151. package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
  152. package/dist/pi-extensions/canvas-stophook.js +396 -0
  153. package/package.json +6 -5
  154. package/dist/commands/agent.d.ts +0 -6
  155. package/dist/commands/agent.js +0 -585
  156. package/dist/commands/debug.d.ts +0 -3
  157. package/dist/commands/debug.js +0 -192
  158. package/dist/commands/job.d.ts +0 -11
  159. package/dist/commands/job.js +0 -384
  160. package/dist/commands/mode.js +0 -231
  161. package/dist/commands/plan.d.ts +0 -4
  162. package/dist/commands/plan.js +0 -322
  163. package/dist/commands/spec.d.ts +0 -3
  164. package/dist/commands/spec.js +0 -299
  165. package/dist/core/__tests__/flow-leaves.test.js +0 -248
  166. package/dist/core/__tests__/job.test.js +0 -310
  167. package/dist/core/__tests__/jobs.test.js +0 -98
  168. package/dist/core/__tests__/spawn.test.js +0 -138
  169. package/dist/core/__tests__/subagents.test.d.ts +0 -1
  170. package/dist/core/__tests__/subagents.test.js +0 -75
  171. package/dist/core/jobs.d.ts +0 -107
  172. package/dist/core/jobs.js +0 -565
  173. package/dist/core/subagents.d.ts +0 -18
  174. package/dist/core/subagents.js +0 -163
  175. package/dist/prompts/agent.d.ts +0 -27
  176. package/dist/prompts/agent.js +0 -184
  177. package/dist/prompts/debug.d.ts +0 -8
  178. package/dist/prompts/debug.js +0 -44
  179. /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
  180. /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
  181. /package/dist/core/__tests__/{jobs.test.d.ts → reset.test.d.ts} +0 -0
  182. /package/dist/{core/__tests__/spawn.test.d.ts → daemon/crtrd-cli.d.ts} +0 -0
@@ -0,0 +1,123 @@
1
+ // The spawn orchestration — the one place that turns "I want a node here" into
2
+ // a running pi process on the canvas. Composes canvas (birth + spine), persona
3
+ // (resolve), launch (pi argv), and tmux (placement).
4
+ //
5
+ // bootRoot — a user-opened entry point (bare `crtr`).
6
+ // Resident. Runs pi in the foreground (inline) or its own session.
7
+ // spawnChild — a background worker spawned by a live node (`crtr node new`).
8
+ // Terminal. Opens a non-focus-stealing window under the root.
9
+ import { spawnSync } from 'node:child_process';
10
+ import { FRONT_DOOR_ENV } from './front-door.js';
11
+ import { spawnNode, currentNodeContext } from './nodes.js';
12
+ import { buildLaunchSpec, buildPiArgv } from './launch.js';
13
+ import { writeGoal } from './kickoff.js';
14
+ import { ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, nodeSession, installMenuBinding, } from './tmux.js';
15
+ import { updateNode, getNode } from '../canvas/index.js';
16
+ import { ensureDaemon } from '../../daemon/manage.js';
17
+ /** Create a root node and bring up its pi. Returns the node; for 'inline' this
18
+ * only returns after pi exits (it took over the terminal). */
19
+ export function bootRoot(opts) {
20
+ // The thin supervisor must be up before any node exists, so a refresh-yield
21
+ // or crash can be reaped/revived. Idempotent.
22
+ try {
23
+ ensureDaemon();
24
+ }
25
+ catch { /* daemon is best-effort */ }
26
+ const kind = opts.kind ?? 'general';
27
+ // A born-resident root starts in base mode; it earns the orchestrator persona
28
+ // the first time it delegates (or on promotion). Resident lifecycle either way.
29
+ const { launch } = buildLaunchSpec(kind, 'base');
30
+ const meta = spawnNode({
31
+ kind,
32
+ mode: 'base',
33
+ lifecycle: 'resident',
34
+ cwd: opts.cwd,
35
+ name: opts.name ?? kind,
36
+ parent: null,
37
+ launch,
38
+ });
39
+ // Persist the spawning prompt as the goal so a fresh revive can re-read its
40
+ // mandate (bare `crtr` has none — writeGoal no-ops on empty).
41
+ if (opts.prompt !== undefined)
42
+ writeGoal(meta.node_id, opts.prompt);
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
+ }
53
+ if (opts.placement === 'session') {
54
+ updateNode(meta.node_id, { tmux_session: session });
55
+ const withSession = getNode(meta.node_id);
56
+ const inv = buildPiArgv(withSession, { prompt: opts.prompt });
57
+ const env = { ...inv.env, CRTR_ROOT_SESSION: session, [FRONT_DOOR_ENV]: '1' };
58
+ const win = openNodeWindow({
59
+ session,
60
+ name: meta.name,
61
+ cwd: opts.cwd,
62
+ env,
63
+ command: piCommand(inv.argv),
64
+ });
65
+ updateNode(meta.node_id, { window: win });
66
+ return getNode(meta.node_id);
67
+ }
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.
72
+ const here = currentTmux();
73
+ const adopted = here?.session ?? session;
74
+ updateNode(meta.node_id, { tmux_session: adopted, window: here?.window ?? null });
75
+ const withSession = getNode(meta.node_id);
76
+ const inv = buildPiArgv(withSession, { prompt: opts.prompt });
77
+ const env = { ...process.env, ...inv.env, CRTR_ROOT_SESSION: session, [FRONT_DOOR_ENV]: '1' };
78
+ const r = spawnSync('pi', inv.argv, { stdio: 'inherit', env });
79
+ process.exit(r.status ?? 0);
80
+ }
81
+ /** Spawn a terminal worker as a background window under the root session.
82
+ * The parent auto-subscribes (active) to it via spawnNode. */
83
+ export function spawnChild(opts) {
84
+ try {
85
+ ensureDaemon();
86
+ }
87
+ catch { /* daemon is best-effort */ }
88
+ const ctx = currentNodeContext();
89
+ const parent = opts.parent ?? ctx.nodeId;
90
+ if (parent === null || parent === undefined) {
91
+ throw new Error('spawnChild requires a calling node (CRTR_NODE_ID) or an explicit parent');
92
+ }
93
+ const mode = opts.mode ?? 'base';
94
+ const { launch } = buildLaunchSpec(opts.kind, mode);
95
+ const meta = spawnNode({
96
+ kind: opts.kind,
97
+ mode,
98
+ lifecycle: 'terminal',
99
+ cwd: opts.cwd,
100
+ name: opts.name ?? opts.kind,
101
+ parent,
102
+ launch,
103
+ });
104
+ // Persist the task as the child's goal for a fresh revive to re-read.
105
+ writeGoal(meta.node_id, opts.prompt);
106
+ // Children always land in the shared global session: inherited from the
107
+ // parent's CRTR_ROOT_SESSION, else the default node session.
108
+ let session = process.env['CRTR_ROOT_SESSION'];
109
+ if (session === undefined || session === '')
110
+ session = nodeSession();
111
+ ensureSession(session, opts.cwd);
112
+ const inv = buildPiArgv(meta, { prompt: opts.prompt });
113
+ const env = { ...inv.env, CRTR_ROOT_SESSION: session };
114
+ const window = openNodeWindow({
115
+ session,
116
+ name: meta.name,
117
+ cwd: opts.cwd,
118
+ env,
119
+ command: piCommand(inv.argv),
120
+ });
121
+ const saved = updateNode(meta.node_id, { tmux_session: session, window });
122
+ return { node: saved, window, session };
123
+ }
@@ -0,0 +1,18 @@
1
+ export interface StopSignals {
2
+ /** Did the node call `push --final` (finish) this turn? */
3
+ pushedFinal: boolean;
4
+ /** Did the node call `crtr ask` (escalate to the human) this turn? */
5
+ askedHuman: boolean;
6
+ }
7
+ export type StopAction = {
8
+ action: 'allow';
9
+ reason: 'awaiting' | 'finished' | 'escalated' | 'attended';
10
+ } | {
11
+ action: 'reprompt';
12
+ reason: 'stalled';
13
+ message: string;
14
+ };
15
+ export declare const STALL_REPROMPT: string;
16
+ /** Decide what to do when a node stops. Pure given the canvas + this turn's
17
+ * signals — the stophook supplies the signals and enacts the action. */
18
+ export declare function evaluateStop(nodeId: string, signals: StopSignals): StopAction;
@@ -0,0 +1,33 @@
1
+ // The stop-guard — no stalled agents.
2
+ //
3
+ // Every time a node's pi process stops, we ask one question: is this node
4
+ // *legitimately waiting*? A node is legitimately waiting iff it holds an ACTIVE
5
+ // subscription to a node that's still live (active|idle) — something that can
6
+ // actually wake it. (A passive sub won't wake you, so it doesn't count.)
7
+ //
8
+ // • waiting → stopping is correct; it's a dormant orchestrator awaiting
9
+ // its workers. Let it sleep; a child's push wakes it.
10
+ // • finished/asked → it pushed --final (done) or called `crtr ask` this turn.
11
+ // Also fine.
12
+ // • otherwise → it has nothing live to wait for and hasn't resolved.
13
+ // Re-prompt it to finish or escalate. Stalls are impossible.
14
+ import { hasActiveLiveSubscription, getNode } from '../canvas/index.js';
15
+ export const STALL_REPROMPT = "You've stopped but you're not waiting on anyone and haven't finished. " +
16
+ 'Run `crtr push final "<result>"` if the work is done, or `crtr human ask` if you are blocked or need the user.';
17
+ /** Decide what to do when a node stops. Pure given the canvas + this turn's
18
+ * signals — the stophook supplies the signals and enacts the action. */
19
+ export function evaluateStop(nodeId, signals) {
20
+ if (signals.pushedFinal)
21
+ return { action: 'allow', reason: 'finished' };
22
+ if (signals.askedHuman)
23
+ return { action: 'allow', reason: 'escalated' };
24
+ // A user-opened root (no parent) is human-attended: the human is its wake
25
+ // source, so stopping to await input is always legitimate — never nag it.
26
+ const node = getNode(nodeId);
27
+ if (node !== null && (node.parent === null || node.parent === undefined)) {
28
+ return { action: 'allow', reason: 'attended' };
29
+ }
30
+ if (hasActiveLiveSubscription(nodeId))
31
+ return { action: 'allow', reason: 'awaiting' };
32
+ return { action: 'reprompt', reason: 'stalled', message: STALL_REPROMPT };
33
+ }
@@ -0,0 +1,107 @@
1
+ /** POSIX single-quote escaping for one shell word. */
2
+ export declare function shellQuote(s: string): string;
3
+ export declare function inTmux(): boolean;
4
+ /** The single, shared tmux session that ALL canvas node windows live in.
5
+ * Overridable with CRTR_NODE_SESSION (default `crtr`). Every root and every
6
+ * child opens a window here rather than cluttering the user's own working
7
+ * session — switch to it to browse the whole live graph, ignore it otherwise. */
8
+ export declare function nodeSession(): string;
9
+ export interface TmuxLocation {
10
+ session: string;
11
+ window: string;
12
+ pane: string;
13
+ }
14
+ /** Where the caller currently is, or null if not inside tmux. */
15
+ export declare function currentTmux(): TmuxLocation | null;
16
+ export declare function sessionExists(name: string): boolean;
17
+ /** Create a detached session rooted at `cwd` if it doesn't exist. The session
18
+ * name is a root's tmux home; every node under that root is a window in it. */
19
+ export declare function ensureSession(name: string, cwd: string): void;
20
+ export interface OpenWindowOpts {
21
+ session: string;
22
+ /** Window name (the node's display name). */
23
+ name: string;
24
+ cwd: string;
25
+ env: Record<string, string>;
26
+ /** The full command to run in the window (already a shell string). */
27
+ command: string;
28
+ }
29
+ /** Open a background window for a node and run `command` in it. `-d` keeps it
30
+ * detached so it doesn't steal focus or become the current window. Returns the
31
+ * new window id.
32
+ *
33
+ * Target is `${session}:` (trailing colon = the session, no window index) plus
34
+ * `-a` (insert after the current window) so tmux allocates the next free index.
35
+ * Passing a bare session name resolves to the session's *active window*, which
36
+ * makes new-window try to create AT that occupied index and fail with
37
+ * "create window failed: index N in use" whenever the active window is not the
38
+ * last one (common when base-index is 0 but the live window sits at index 1).
39
+ * `-a` also keeps node windows off index 0, which is reserved for the optional
40
+ * dashboard. */
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
+ /** Bring a node's window forefront. Switches client across roots when needed. */
55
+ export declare function focusWindow(session: string, window: string): boolean;
56
+ /** Close a node's window (drop it from the UI). */
57
+ export declare function closeWindow(window: string): boolean;
58
+ /** The active pane id of a window. Node windows are single-pane, so this is the
59
+ * node's pane. Returns null if the window is gone or tmux fails. */
60
+ export declare function paneOfWindow(session: string, window: string): string | null;
61
+ /** The window a pane currently lives in. Used after a swap-pane to learn which
62
+ * slot the caller's pane occupied — pane ids are stable across swaps, windows
63
+ * are not, so the node→window mapping must be re-derived from the pane. Returns
64
+ * null if the pane is gone or tmux fails. */
65
+ export declare function windowOfPane(pane: string): string | null;
66
+ /** Swap `targetPane` into `callerPane`'s layout slot, IN PLACE. `-d` keeps the
67
+ * caller's window active, so the target's pane appears where the caller is
68
+ * rather than navigating the client off to the target's window. The caller's
69
+ * old pane lives on in the target's former window — the move is reversible
70
+ * (focusing back swaps it in again). Best-effort; never throws. */
71
+ export declare function swapPaneInPlace(targetPane: string, callerPane: string): boolean;
72
+ export interface RespawnPaneOpts {
73
+ /** Target pane id (e.g. `%3`) — the pane to re-exec in place. */
74
+ pane: string;
75
+ cwd: string;
76
+ env: Record<string, string>;
77
+ /** The full command to run in the pane (already a shell string). */
78
+ command: string;
79
+ }
80
+ /** Re-exec a command in an EXISTING pane, in place. `-k` kills the pane's
81
+ * current process (e.g. a yielding pi) and starts `command` in the same pane
82
+ * — the window/pane survives, so an interactive session is never dropped to a
83
+ * shell and no window churns. Used by refresh-yield.
84
+ *
85
+ * Spawned DETACHED (own process group, unref'd) so the request reaches the
86
+ * tmux server even though killing the pane tears down the caller's own pi.
87
+ * Returns true once the request was dispatched. */
88
+ export declare function respawnPane(opts: RespawnPaneOpts): boolean;
89
+ /** Turn a pi argv array into a single shell command string. */
90
+ export declare function piCommand(argv: string[], binary?: string): string;
91
+ /** List all window ids present in `session`. Returns [] if the session does
92
+ * not exist or tmux fails for any reason. Each entry is the raw window id
93
+ * string reported by tmux (e.g. `@1`, `@2`, …). */
94
+ export declare function listWindowIds(session: string): string[];
95
+ /** True when both `session` and `window` are present (non-null/undefined) and
96
+ * the window currently exists inside the session. False whenever either arg
97
+ * is absent, the session is gone, or tmux does not know the window. */
98
+ export declare function windowAlive(session: string | null | undefined, window: string | null | undefined): boolean;
99
+ /** Activate a window within its session (same-session navigation). Equivalent
100
+ * to `tmux select-window -t <session>:<window>`. Best-effort; never throws. */
101
+ export declare function selectWindow(session: string, window: string): boolean;
102
+ /** Switch the tmux client to a different session (cross-session focus). Runs
103
+ * `tmux switch-client -t <session>`. Best-effort; never throws. The caller is
104
+ * responsible for following up with selectWindow to land on the right window. */
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;
@@ -0,0 +1,244 @@
1
+ // tmux placement — one window per active node.
2
+ //
3
+ // session = a root window = a node window 0 = optional dashboard
4
+ //
5
+ // Background windows run but don't render — only the current window draws. That
6
+ // is the "detached but switchable" model: nothing tiles, you never see a node's
7
+ // UI unless you switch to it. Bring one forefront with select-window (within a
8
+ // root) or switch-client + select-window (across roots). done/dead nodes close
9
+ // their window; reviving opens a fresh one.
10
+ import { spawn, spawnSync } from 'node:child_process';
11
+ // ---------------------------------------------------------------------------
12
+ // Shell quoting + tmux invocation
13
+ // ---------------------------------------------------------------------------
14
+ /** POSIX single-quote escaping for one shell word. */
15
+ export function shellQuote(s) {
16
+ return `'${s.replace(/'/g, `'\\''`)}'`;
17
+ }
18
+ function tmux(args) {
19
+ const r = spawnSync('tmux', args, { encoding: 'utf8' });
20
+ return {
21
+ ok: r.status === 0,
22
+ stdout: (r.stdout ?? '').trim(),
23
+ stderr: (r.stderr ?? '').trim(),
24
+ };
25
+ }
26
+ export function inTmux() {
27
+ return process.env['TMUX'] !== undefined && process.env['TMUX'] !== '';
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
+ }
37
+ /** Where the caller currently is, or null if not inside tmux. */
38
+ export function currentTmux() {
39
+ if (!inTmux())
40
+ return null;
41
+ const r = tmux([
42
+ 'display-message',
43
+ '-p',
44
+ '#{session_name}\t#{window_id}\t#{pane_id}',
45
+ ]);
46
+ if (!r.ok)
47
+ return null;
48
+ const [session, window, pane] = r.stdout.split('\t');
49
+ return { session, window, pane };
50
+ }
51
+ // ---------------------------------------------------------------------------
52
+ // Sessions + windows
53
+ // ---------------------------------------------------------------------------
54
+ export function sessionExists(name) {
55
+ return tmux(['has-session', '-t', name]).ok;
56
+ }
57
+ /** Create a detached session rooted at `cwd` if it doesn't exist. The session
58
+ * name is a root's tmux home; every node under that root is a window in it. */
59
+ export function ensureSession(name, cwd) {
60
+ if (sessionExists(name))
61
+ return;
62
+ tmux(['new-session', '-d', '-s', name, '-c', cwd]);
63
+ }
64
+ function envFlags(env) {
65
+ const out = [];
66
+ for (const [k, v] of Object.entries(env))
67
+ out.push('-e', `${k}=${v}`);
68
+ return out;
69
+ }
70
+ /** Open a background window for a node and run `command` in it. `-d` keeps it
71
+ * detached so it doesn't steal focus or become the current window. Returns the
72
+ * new window id.
73
+ *
74
+ * Target is `${session}:` (trailing colon = the session, no window index) plus
75
+ * `-a` (insert after the current window) so tmux allocates the next free index.
76
+ * Passing a bare session name resolves to the session's *active window*, which
77
+ * makes new-window try to create AT that occupied index and fail with
78
+ * "create window failed: index N in use" whenever the active window is not the
79
+ * last one (common when base-index is 0 but the live window sits at index 1).
80
+ * `-a` also keeps node windows off index 0, which is reserved for the optional
81
+ * dashboard. */
82
+ export function openNodeWindow(opts) {
83
+ const r = tmux([
84
+ 'new-window',
85
+ '-d',
86
+ '-a',
87
+ '-P',
88
+ '-F',
89
+ '#{window_id}',
90
+ '-t',
91
+ `${opts.session}:`,
92
+ '-n',
93
+ opts.name,
94
+ '-c',
95
+ opts.cwd,
96
+ ...envFlags(opts.env),
97
+ opts.command,
98
+ ]);
99
+ return r.ok ? r.stdout : null;
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
+ }
120
+ /** Bring a node's window forefront. Switches client across roots when needed. */
121
+ export function focusWindow(session, window) {
122
+ const here = currentTmux();
123
+ const sameRoot = here?.session === session;
124
+ if (!sameRoot) {
125
+ if (!tmux(['switch-client', '-t', session]).ok)
126
+ return false;
127
+ }
128
+ return tmux(['select-window', '-t', window]).ok;
129
+ }
130
+ /** Close a node's window (drop it from the UI). */
131
+ export function closeWindow(window) {
132
+ return tmux(['kill-window', '-t', window]).ok;
133
+ }
134
+ /** The active pane id of a window. Node windows are single-pane, so this is the
135
+ * node's pane. Returns null if the window is gone or tmux fails. */
136
+ export function paneOfWindow(session, window) {
137
+ const r = tmux(['display-message', '-p', '-t', `${session}:${window}`, '#{pane_id}']);
138
+ return r.ok && r.stdout !== '' ? r.stdout : null;
139
+ }
140
+ /** The window a pane currently lives in. Used after a swap-pane to learn which
141
+ * slot the caller's pane occupied — pane ids are stable across swaps, windows
142
+ * are not, so the node→window mapping must be re-derived from the pane. Returns
143
+ * null if the pane is gone or tmux fails. */
144
+ export function windowOfPane(pane) {
145
+ const r = tmux(['display-message', '-p', '-t', pane, '#{window_id}']);
146
+ return r.ok && r.stdout !== '' ? r.stdout : null;
147
+ }
148
+ /** Swap `targetPane` into `callerPane`'s layout slot, IN PLACE. `-d` keeps the
149
+ * caller's window active, so the target's pane appears where the caller is
150
+ * rather than navigating the client off to the target's window. The caller's
151
+ * old pane lives on in the target's former window — the move is reversible
152
+ * (focusing back swaps it in again). Best-effort; never throws. */
153
+ export function swapPaneInPlace(targetPane, callerPane) {
154
+ if (targetPane === callerPane)
155
+ return true;
156
+ return tmux(['swap-pane', '-d', '-s', targetPane, '-t', callerPane]).ok;
157
+ }
158
+ /** Re-exec a command in an EXISTING pane, in place. `-k` kills the pane's
159
+ * current process (e.g. a yielding pi) and starts `command` in the same pane
160
+ * — the window/pane survives, so an interactive session is never dropped to a
161
+ * shell and no window churns. Used by refresh-yield.
162
+ *
163
+ * Spawned DETACHED (own process group, unref'd) so the request reaches the
164
+ * tmux server even though killing the pane tears down the caller's own pi.
165
+ * Returns true once the request was dispatched. */
166
+ export function respawnPane(opts) {
167
+ try {
168
+ const child = spawn('tmux', [
169
+ 'respawn-pane',
170
+ '-k',
171
+ '-c',
172
+ opts.cwd,
173
+ ...envFlags(opts.env),
174
+ '-t',
175
+ opts.pane,
176
+ opts.command,
177
+ ], { detached: true, stdio: 'ignore' });
178
+ child.unref();
179
+ return true;
180
+ }
181
+ catch {
182
+ return false;
183
+ }
184
+ }
185
+ // ---------------------------------------------------------------------------
186
+ // pi command assembly
187
+ // ---------------------------------------------------------------------------
188
+ /** Turn a pi argv array into a single shell command string. */
189
+ export function piCommand(argv, binary = 'pi') {
190
+ return [binary, ...argv.map(shellQuote)].join(' ');
191
+ }
192
+ // ---------------------------------------------------------------------------
193
+ // Window liveness helpers (used by the supervisor daemon)
194
+ // ---------------------------------------------------------------------------
195
+ /** List all window ids present in `session`. Returns [] if the session does
196
+ * not exist or tmux fails for any reason. Each entry is the raw window id
197
+ * string reported by tmux (e.g. `@1`, `@2`, …). */
198
+ export function listWindowIds(session) {
199
+ const r = tmux(['list-windows', '-t', session, '-F', '#{window_id}']);
200
+ if (!r.ok || r.stdout === '')
201
+ return [];
202
+ return r.stdout.split('\n').filter((s) => s !== '');
203
+ }
204
+ /** True when both `session` and `window` are present (non-null/undefined) and
205
+ * the window currently exists inside the session. False whenever either arg
206
+ * is absent, the session is gone, or tmux does not know the window. */
207
+ export function windowAlive(session, window) {
208
+ if (session == null || window == null)
209
+ return false;
210
+ return listWindowIds(session).includes(window);
211
+ }
212
+ // ---------------------------------------------------------------------------
213
+ // Focus helpers (used by the presence layer)
214
+ // ---------------------------------------------------------------------------
215
+ /** Activate a window within its session (same-session navigation). Equivalent
216
+ * to `tmux select-window -t <session>:<window>`. Best-effort; never throws. */
217
+ export function selectWindow(session, window) {
218
+ return tmux(['select-window', '-t', `${session}:${window}`]).ok;
219
+ }
220
+ /** Switch the tmux client to a different session (cross-session focus). Runs
221
+ * `tmux switch-client -t <session>`. Best-effort; never throws. The caller is
222
+ * responsible for following up with selectWindow to land on the right window. */
223
+ export function switchClient(session) {
224
+ return tmux(['switch-client', '-t', session]).ok;
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
+ }