@crouton-kit/crouter 0.3.11 → 0.3.12

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 +354 -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 +38 -0
  125. package/dist/core/runtime/presence.js +152 -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 +89 -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 +33 -0
  135. package/dist/core/runtime/spawn.js +118 -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 +88 -0
  139. package/dist/core/runtime/tmux.js +198 -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 +373 -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,118 @@
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, } from './tmux.js';
15
+ import { updateNode, getNode } from '../canvas/index.js';
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
+ /** Create a root node and bring up its pi. Returns the node; for 'inline' this
22
+ * only returns after pi exits (it took over the terminal). */
23
+ export function bootRoot(opts) {
24
+ // The thin supervisor must be up before any node exists, so a refresh-yield
25
+ // or crash can be reaped/revived. Idempotent.
26
+ try {
27
+ ensureDaemon();
28
+ }
29
+ catch { /* daemon is best-effort */ }
30
+ const kind = opts.kind ?? 'general';
31
+ // A born-resident root starts in base mode; it earns the orchestrator persona
32
+ // the first time it delegates (or on promotion). Resident lifecycle either way.
33
+ const { launch } = buildLaunchSpec(kind, 'base');
34
+ const meta = spawnNode({
35
+ kind,
36
+ mode: 'base',
37
+ lifecycle: 'resident',
38
+ cwd: opts.cwd,
39
+ name: opts.name ?? kind,
40
+ parent: null,
41
+ launch,
42
+ });
43
+ // Persist the spawning prompt as the goal so a fresh revive can re-read its
44
+ // mandate (bare `crtr` has none — writeGoal no-ops on empty).
45
+ if (opts.prompt !== undefined)
46
+ writeGoal(meta.node_id, opts.prompt);
47
+ const session = rootSessionName(meta.node_id);
48
+ if (opts.placement === 'session') {
49
+ ensureSession(session, opts.cwd);
50
+ updateNode(meta.node_id, { tmux_session: session });
51
+ const withSession = getNode(meta.node_id);
52
+ const inv = buildPiArgv(withSession, { prompt: opts.prompt });
53
+ const env = { ...inv.env, CRTR_ROOT_SESSION: session, [FRONT_DOOR_ENV]: '1' };
54
+ const win = openNodeWindow({
55
+ session,
56
+ name: meta.name,
57
+ cwd: opts.cwd,
58
+ env,
59
+ command: piCommand(inv.argv),
60
+ });
61
+ updateNode(meta.node_id, { window: win });
62
+ return getNode(meta.node_id);
63
+ }
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.
66
+ const here = currentTmux();
67
+ const adopted = here?.session ?? session;
68
+ updateNode(meta.node_id, { tmux_session: adopted, window: here?.window ?? null });
69
+ const withSession = getNode(meta.node_id);
70
+ const inv = buildPiArgv(withSession, { prompt: opts.prompt });
71
+ const env = { ...process.env, ...inv.env, CRTR_ROOT_SESSION: adopted, [FRONT_DOOR_ENV]: '1' };
72
+ const r = spawnSync('pi', inv.argv, { stdio: 'inherit', env });
73
+ process.exit(r.status ?? 0);
74
+ }
75
+ /** Spawn a terminal worker as a background window under the root session.
76
+ * The parent auto-subscribes (active) to it via spawnNode. */
77
+ export function spawnChild(opts) {
78
+ try {
79
+ ensureDaemon();
80
+ }
81
+ catch { /* daemon is best-effort */ }
82
+ const ctx = currentNodeContext();
83
+ const parent = opts.parent ?? ctx.nodeId;
84
+ if (parent === null || parent === undefined) {
85
+ throw new Error('spawnChild requires a calling node (CRTR_NODE_ID) or an explicit parent');
86
+ }
87
+ const mode = opts.mode ?? 'base';
88
+ const { launch } = buildLaunchSpec(opts.kind, mode);
89
+ const meta = spawnNode({
90
+ kind: opts.kind,
91
+ mode,
92
+ lifecycle: 'terminal',
93
+ cwd: opts.cwd,
94
+ name: opts.name ?? opts.kind,
95
+ parent,
96
+ launch,
97
+ });
98
+ // Persist the task as the child's goal for a fresh revive to re-read.
99
+ writeGoal(meta.node_id, opts.prompt);
100
+ // Resolve the root session: inherited from env, else derive + create one.
101
+ 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
+ }
107
+ const inv = buildPiArgv(meta, { prompt: opts.prompt });
108
+ const env = { ...inv.env, CRTR_ROOT_SESSION: session };
109
+ const window = openNodeWindow({
110
+ session,
111
+ name: meta.name,
112
+ cwd: opts.cwd,
113
+ env,
114
+ command: piCommand(inv.argv),
115
+ });
116
+ const saved = updateNode(meta.node_id, { tmux_session: session, window });
117
+ return { node: saved, window, session };
118
+ }
@@ -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,88 @@
1
+ /** POSIX single-quote escaping for one shell word. */
2
+ export declare function shellQuote(s: string): string;
3
+ export declare function inTmux(): boolean;
4
+ export interface TmuxLocation {
5
+ session: string;
6
+ window: string;
7
+ pane: string;
8
+ }
9
+ /** Where the caller currently is, or null if not inside tmux. */
10
+ export declare function currentTmux(): TmuxLocation | null;
11
+ export declare function sessionExists(name: string): boolean;
12
+ /** Create a detached session rooted at `cwd` if it doesn't exist. The session
13
+ * name is a root's tmux home; every node under that root is a window in it. */
14
+ export declare function ensureSession(name: string, cwd: string): void;
15
+ export interface OpenWindowOpts {
16
+ session: string;
17
+ /** Window name (the node's display name). */
18
+ name: string;
19
+ cwd: string;
20
+ env: Record<string, string>;
21
+ /** The full command to run in the window (already a shell string). */
22
+ command: string;
23
+ }
24
+ /** Open a background window for a node and run `command` in it. `-d` keeps it
25
+ * detached so it doesn't steal focus or become the current window. Returns the
26
+ * new window id.
27
+ *
28
+ * Target is `${session}:` (trailing colon = the session, no window index) plus
29
+ * `-a` (insert after the current window) so tmux allocates the next free index.
30
+ * Passing a bare session name resolves to the session's *active window*, which
31
+ * makes new-window try to create AT that occupied index and fail with
32
+ * "create window failed: index N in use" whenever the active window is not the
33
+ * last one (common when base-index is 0 but the live window sits at index 1).
34
+ * `-a` also keeps node windows off index 0, which is reserved for the optional
35
+ * dashboard. */
36
+ export declare function openNodeWindow(opts: OpenWindowOpts): string | null;
37
+ /** Bring a node's window forefront. Switches client across roots when needed. */
38
+ export declare function focusWindow(session: string, window: string): boolean;
39
+ /** Close a node's window (drop it from the UI). */
40
+ export declare function closeWindow(window: string): boolean;
41
+ /** The active pane id of a window. Node windows are single-pane, so this is the
42
+ * node's pane. Returns null if the window is gone or tmux fails. */
43
+ export declare function paneOfWindow(session: string, window: string): string | null;
44
+ /** The window a pane currently lives in. Used after a swap-pane to learn which
45
+ * slot the caller's pane occupied — pane ids are stable across swaps, windows
46
+ * are not, so the node→window mapping must be re-derived from the pane. Returns
47
+ * null if the pane is gone or tmux fails. */
48
+ export declare function windowOfPane(pane: string): string | null;
49
+ /** Swap `targetPane` into `callerPane`'s layout slot, IN PLACE. `-d` keeps the
50
+ * caller's window active, so the target's pane appears where the caller is
51
+ * rather than navigating the client off to the target's window. The caller's
52
+ * old pane lives on in the target's former window — the move is reversible
53
+ * (focusing back swaps it in again). Best-effort; never throws. */
54
+ export declare function swapPaneInPlace(targetPane: string, callerPane: string): boolean;
55
+ export interface RespawnPaneOpts {
56
+ /** Target pane id (e.g. `%3`) — the pane to re-exec in place. */
57
+ pane: string;
58
+ cwd: string;
59
+ env: Record<string, string>;
60
+ /** The full command to run in the pane (already a shell string). */
61
+ command: string;
62
+ }
63
+ /** Re-exec a command in an EXISTING pane, in place. `-k` kills the pane's
64
+ * current process (e.g. a yielding pi) and starts `command` in the same pane
65
+ * — the window/pane survives, so an interactive session is never dropped to a
66
+ * shell and no window churns. Used by refresh-yield.
67
+ *
68
+ * Spawned DETACHED (own process group, unref'd) so the request reaches the
69
+ * tmux server even though killing the pane tears down the caller's own pi.
70
+ * Returns true once the request was dispatched. */
71
+ export declare function respawnPane(opts: RespawnPaneOpts): boolean;
72
+ /** Turn a pi argv array into a single shell command string. */
73
+ export declare function piCommand(argv: string[], binary?: string): string;
74
+ /** List all window ids present in `session`. Returns [] if the session does
75
+ * not exist or tmux fails for any reason. Each entry is the raw window id
76
+ * string reported by tmux (e.g. `@1`, `@2`, …). */
77
+ export declare function listWindowIds(session: string): string[];
78
+ /** True when both `session` and `window` are present (non-null/undefined) and
79
+ * the window currently exists inside the session. False whenever either arg
80
+ * is absent, the session is gone, or tmux does not know the window. */
81
+ export declare function windowAlive(session: string | null | undefined, window: string | null | undefined): boolean;
82
+ /** Activate a window within its session (same-session navigation). Equivalent
83
+ * to `tmux select-window -t <session>:<window>`. Best-effort; never throws. */
84
+ export declare function selectWindow(session: string, window: string): boolean;
85
+ /** Switch the tmux client to a different session (cross-session focus). Runs
86
+ * `tmux switch-client -t <session>`. Best-effort; never throws. The caller is
87
+ * responsible for following up with selectWindow to land on the right window. */
88
+ export declare function switchClient(session: string): boolean;
@@ -0,0 +1,198 @@
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
+ /** Where the caller currently is, or null if not inside tmux. */
30
+ export function currentTmux() {
31
+ if (!inTmux())
32
+ return null;
33
+ const r = tmux([
34
+ 'display-message',
35
+ '-p',
36
+ '#{session_name}\t#{window_id}\t#{pane_id}',
37
+ ]);
38
+ if (!r.ok)
39
+ return null;
40
+ const [session, window, pane] = r.stdout.split('\t');
41
+ return { session, window, pane };
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // Sessions + windows
45
+ // ---------------------------------------------------------------------------
46
+ export function sessionExists(name) {
47
+ return tmux(['has-session', '-t', name]).ok;
48
+ }
49
+ /** Create a detached session rooted at `cwd` if it doesn't exist. The session
50
+ * name is a root's tmux home; every node under that root is a window in it. */
51
+ export function ensureSession(name, cwd) {
52
+ if (sessionExists(name))
53
+ return;
54
+ tmux(['new-session', '-d', '-s', name, '-c', cwd]);
55
+ }
56
+ function envFlags(env) {
57
+ const out = [];
58
+ for (const [k, v] of Object.entries(env))
59
+ out.push('-e', `${k}=${v}`);
60
+ return out;
61
+ }
62
+ /** Open a background window for a node and run `command` in it. `-d` keeps it
63
+ * detached so it doesn't steal focus or become the current window. Returns the
64
+ * new window id.
65
+ *
66
+ * Target is `${session}:` (trailing colon = the session, no window index) plus
67
+ * `-a` (insert after the current window) so tmux allocates the next free index.
68
+ * Passing a bare session name resolves to the session's *active window*, which
69
+ * makes new-window try to create AT that occupied index and fail with
70
+ * "create window failed: index N in use" whenever the active window is not the
71
+ * last one (common when base-index is 0 but the live window sits at index 1).
72
+ * `-a` also keeps node windows off index 0, which is reserved for the optional
73
+ * dashboard. */
74
+ export function openNodeWindow(opts) {
75
+ const r = tmux([
76
+ 'new-window',
77
+ '-d',
78
+ '-a',
79
+ '-P',
80
+ '-F',
81
+ '#{window_id}',
82
+ '-t',
83
+ `${opts.session}:`,
84
+ '-n',
85
+ opts.name,
86
+ '-c',
87
+ opts.cwd,
88
+ ...envFlags(opts.env),
89
+ opts.command,
90
+ ]);
91
+ return r.ok ? r.stdout : null;
92
+ }
93
+ /** Bring a node's window forefront. Switches client across roots when needed. */
94
+ export function focusWindow(session, window) {
95
+ const here = currentTmux();
96
+ const sameRoot = here?.session === session;
97
+ if (!sameRoot) {
98
+ if (!tmux(['switch-client', '-t', session]).ok)
99
+ return false;
100
+ }
101
+ return tmux(['select-window', '-t', window]).ok;
102
+ }
103
+ /** Close a node's window (drop it from the UI). */
104
+ export function closeWindow(window) {
105
+ return tmux(['kill-window', '-t', window]).ok;
106
+ }
107
+ /** The active pane id of a window. Node windows are single-pane, so this is the
108
+ * node's pane. Returns null if the window is gone or tmux fails. */
109
+ export function paneOfWindow(session, window) {
110
+ const r = tmux(['display-message', '-p', '-t', `${session}:${window}`, '#{pane_id}']);
111
+ return r.ok && r.stdout !== '' ? r.stdout : null;
112
+ }
113
+ /** The window a pane currently lives in. Used after a swap-pane to learn which
114
+ * slot the caller's pane occupied — pane ids are stable across swaps, windows
115
+ * are not, so the node→window mapping must be re-derived from the pane. Returns
116
+ * null if the pane is gone or tmux fails. */
117
+ export function windowOfPane(pane) {
118
+ const r = tmux(['display-message', '-p', '-t', pane, '#{window_id}']);
119
+ return r.ok && r.stdout !== '' ? r.stdout : null;
120
+ }
121
+ /** Swap `targetPane` into `callerPane`'s layout slot, IN PLACE. `-d` keeps the
122
+ * caller's window active, so the target's pane appears where the caller is
123
+ * rather than navigating the client off to the target's window. The caller's
124
+ * old pane lives on in the target's former window — the move is reversible
125
+ * (focusing back swaps it in again). Best-effort; never throws. */
126
+ export function swapPaneInPlace(targetPane, callerPane) {
127
+ if (targetPane === callerPane)
128
+ return true;
129
+ return tmux(['swap-pane', '-d', '-s', targetPane, '-t', callerPane]).ok;
130
+ }
131
+ /** Re-exec a command in an EXISTING pane, in place. `-k` kills the pane's
132
+ * current process (e.g. a yielding pi) and starts `command` in the same pane
133
+ * — the window/pane survives, so an interactive session is never dropped to a
134
+ * shell and no window churns. Used by refresh-yield.
135
+ *
136
+ * Spawned DETACHED (own process group, unref'd) so the request reaches the
137
+ * tmux server even though killing the pane tears down the caller's own pi.
138
+ * Returns true once the request was dispatched. */
139
+ export function respawnPane(opts) {
140
+ try {
141
+ const child = spawn('tmux', [
142
+ 'respawn-pane',
143
+ '-k',
144
+ '-c',
145
+ opts.cwd,
146
+ ...envFlags(opts.env),
147
+ '-t',
148
+ opts.pane,
149
+ opts.command,
150
+ ], { detached: true, stdio: 'ignore' });
151
+ child.unref();
152
+ return true;
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ }
158
+ // ---------------------------------------------------------------------------
159
+ // pi command assembly
160
+ // ---------------------------------------------------------------------------
161
+ /** Turn a pi argv array into a single shell command string. */
162
+ export function piCommand(argv, binary = 'pi') {
163
+ return [binary, ...argv.map(shellQuote)].join(' ');
164
+ }
165
+ // ---------------------------------------------------------------------------
166
+ // Window liveness helpers (used by the supervisor daemon)
167
+ // ---------------------------------------------------------------------------
168
+ /** List all window ids present in `session`. Returns [] if the session does
169
+ * not exist or tmux fails for any reason. Each entry is the raw window id
170
+ * string reported by tmux (e.g. `@1`, `@2`, …). */
171
+ export function listWindowIds(session) {
172
+ const r = tmux(['list-windows', '-t', session, '-F', '#{window_id}']);
173
+ if (!r.ok || r.stdout === '')
174
+ return [];
175
+ return r.stdout.split('\n').filter((s) => s !== '');
176
+ }
177
+ /** True when both `session` and `window` are present (non-null/undefined) and
178
+ * the window currently exists inside the session. False whenever either arg
179
+ * is absent, the session is gone, or tmux does not know the window. */
180
+ export function windowAlive(session, window) {
181
+ if (session == null || window == null)
182
+ return false;
183
+ return listWindowIds(session).includes(window);
184
+ }
185
+ // ---------------------------------------------------------------------------
186
+ // Focus helpers (used by the presence layer)
187
+ // ---------------------------------------------------------------------------
188
+ /** Activate a window within its session (same-session navigation). Equivalent
189
+ * to `tmux select-window -t <session>:<window>`. Best-effort; never throws. */
190
+ export function selectWindow(session, window) {
191
+ return tmux(['select-window', '-t', `${session}:${window}`]).ok;
192
+ }
193
+ /** Switch the tmux client to a different session (cross-session focus). Runs
194
+ * `tmux switch-client -t <session>`. Best-effort; never throws. The caller is
195
+ * responsible for following up with selectWindow to land on the right window. */
196
+ export function switchClient(session) {
197
+ return tmux(['switch-client', '-t', session]).ok;
198
+ }