@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.
- package/bin/crtrd +2 -0
- package/dist/builtin-personas/design/base.md +9 -0
- package/dist/builtin-personas/design/orchestrator.md +10 -0
- package/dist/builtin-personas/developer/base.md +9 -0
- package/dist/builtin-personas/developer/orchestrator.md +12 -0
- package/dist/builtin-personas/explore/base.md +9 -0
- package/dist/builtin-personas/explore/orchestrator.md +9 -0
- package/dist/builtin-personas/general/base.md +5 -0
- package/dist/builtin-personas/general/orchestrator.md +7 -0
- package/dist/builtin-personas/orchestration-kernel.md +71 -0
- package/dist/builtin-personas/plan/base.md +7 -0
- package/dist/builtin-personas/plan/orchestrator.md +12 -0
- package/dist/builtin-personas/review/base.md +7 -0
- package/dist/builtin-personas/review/orchestrator.md +9 -0
- package/dist/builtin-personas/runtime-base.md +39 -0
- package/dist/builtin-personas/spec/base.md +7 -0
- package/dist/builtin-personas/spec/orchestrator.md +10 -0
- package/dist/builtin-skills/skills/design/SKILL.md +51 -0
- package/dist/builtin-skills/skills/development/SKILL.md +109 -0
- package/dist/builtin-skills/skills/planning/SKILL.md +59 -0
- package/dist/builtin-skills/skills/spec/SKILL.md +83 -0
- package/dist/cli.js +14 -6
- package/dist/commands/{mode.d.ts → attention.d.ts} +1 -1
- package/dist/commands/attention.js +152 -0
- package/dist/commands/canvas.d.ts +2 -0
- package/dist/commands/canvas.js +35 -0
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +111 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.js +65 -0
- package/dist/commands/human/prompts.d.ts +5 -0
- package/dist/commands/human/prompts.js +269 -0
- package/dist/commands/human/queue.d.ts +3 -0
- package/dist/commands/human/queue.js +133 -0
- package/dist/commands/human/shared.d.ts +43 -0
- package/dist/commands/human/shared.js +107 -0
- package/dist/commands/human.js +10 -454
- package/dist/commands/node.d.ts +2 -0
- package/dist/commands/node.js +354 -0
- package/dist/commands/pkg/market-inspect.d.ts +1 -0
- package/dist/commands/pkg/market-inspect.js +157 -0
- package/dist/commands/pkg/market-manage.d.ts +1 -0
- package/dist/commands/pkg/market-manage.js +316 -0
- package/dist/commands/pkg/market.d.ts +1 -0
- package/dist/commands/pkg/market.js +16 -0
- package/dist/commands/pkg/plugin-inspect.d.ts +1 -0
- package/dist/commands/pkg/plugin-inspect.js +142 -0
- package/dist/commands/pkg/plugin-manage.d.ts +1 -0
- package/dist/commands/pkg/plugin-manage.js +294 -0
- package/dist/commands/pkg/plugin.d.ts +1 -0
- package/dist/commands/pkg/plugin.js +16 -0
- package/dist/commands/pkg/shared.d.ts +5 -0
- package/dist/commands/pkg/shared.js +61 -0
- package/dist/commands/pkg.js +3 -1004
- package/dist/commands/push.d.ts +3 -0
- package/dist/commands/push.js +159 -0
- package/dist/commands/revive.d.ts +2 -0
- package/dist/commands/revive.js +64 -0
- package/dist/commands/skill/author.d.ts +3 -0
- package/dist/commands/skill/author.js +147 -0
- package/dist/commands/skill/find.d.ts +4 -0
- package/dist/commands/skill/find.js +254 -0
- package/dist/commands/skill/read.d.ts +1 -0
- package/dist/commands/skill/read.js +89 -0
- package/dist/commands/skill/shared.d.ts +19 -0
- package/dist/commands/skill/shared.js +207 -0
- package/dist/commands/skill/state.d.ts +3 -0
- package/dist/commands/skill/state.js +69 -0
- package/dist/commands/skill.js +6 -691
- package/dist/commands/sys/config.d.ts +1 -0
- package/dist/commands/sys/config.js +186 -0
- package/dist/commands/sys/doctor.d.ts +1 -0
- package/dist/commands/sys/doctor.js +369 -0
- package/dist/commands/sys/shared.d.ts +3 -0
- package/dist/commands/sys/shared.js +24 -0
- package/dist/commands/sys/update.d.ts +2 -0
- package/dist/commands/sys/update.js +114 -0
- package/dist/commands/sys.js +4 -694
- package/dist/core/__tests__/argv-parser.test.js +19 -1
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +100 -0
- package/dist/core/__tests__/canvas.test.js +154 -0
- package/dist/core/__tests__/reset.test.js +105 -0
- package/dist/core/canvas/attention.d.ts +24 -0
- package/dist/core/canvas/attention.js +94 -0
- package/dist/core/canvas/canvas.d.ts +40 -0
- package/dist/core/canvas/canvas.js +210 -0
- package/dist/core/canvas/db.d.ts +7 -0
- package/dist/core/canvas/db.js +61 -0
- package/dist/core/canvas/index.d.ts +4 -0
- package/dist/core/canvas/index.js +6 -0
- package/dist/core/canvas/paths.d.ts +16 -0
- package/dist/core/canvas/paths.js +62 -0
- package/dist/core/canvas/render.d.ts +30 -0
- package/dist/core/canvas/render.js +186 -0
- package/dist/core/canvas/types.d.ts +87 -0
- package/dist/core/canvas/types.js +8 -0
- package/dist/core/command.d.ts +5 -0
- package/dist/core/command.js +35 -10
- package/dist/core/feed/feed.d.ts +43 -0
- package/dist/core/feed/feed.js +116 -0
- package/dist/core/feed/inbox.d.ts +50 -0
- package/dist/core/feed/inbox.js +124 -0
- package/dist/core/help.js +5 -3
- package/dist/core/io.d.ts +15 -1
- package/dist/core/io.js +56 -6
- package/dist/core/personas/index.d.ts +12 -0
- package/dist/core/personas/index.js +10 -0
- package/dist/core/personas/loader.d.ts +44 -0
- package/dist/core/personas/loader.js +157 -0
- package/dist/core/personas/resolve.d.ts +36 -0
- package/dist/core/personas/resolve.js +110 -0
- package/dist/core/render.d.ts +11 -0
- package/dist/core/render.js +126 -0
- package/dist/core/resolver.d.ts +10 -0
- package/dist/core/resolver.js +109 -1
- package/dist/core/runtime/front-door.d.ts +10 -0
- package/dist/core/runtime/front-door.js +97 -0
- package/dist/core/runtime/kickoff.d.ts +23 -0
- package/dist/core/runtime/kickoff.js +134 -0
- package/dist/core/runtime/launch.d.ts +34 -0
- package/dist/core/runtime/launch.js +85 -0
- package/dist/core/runtime/nodes.d.ts +38 -0
- package/dist/core/runtime/nodes.js +95 -0
- package/dist/core/runtime/presence.d.ts +38 -0
- package/dist/core/runtime/presence.js +152 -0
- package/dist/core/runtime/promote.d.ts +30 -0
- package/dist/core/runtime/promote.js +105 -0
- package/dist/core/runtime/reset.d.ts +13 -0
- package/dist/core/runtime/reset.js +97 -0
- package/dist/core/runtime/revive.d.ts +26 -0
- package/dist/core/runtime/revive.js +89 -0
- package/dist/core/runtime/roadmap.d.ts +12 -0
- package/dist/core/runtime/roadmap.js +52 -0
- package/dist/core/runtime/spawn.d.ts +33 -0
- package/dist/core/runtime/spawn.js +118 -0
- package/dist/core/runtime/stop-guard.d.ts +18 -0
- package/dist/core/runtime/stop-guard.js +33 -0
- package/dist/core/runtime/tmux.d.ts +88 -0
- package/dist/core/runtime/tmux.js +198 -0
- package/dist/core/spawn.d.ts +17 -197
- package/dist/core/spawn.js +16 -539
- package/dist/daemon/crtrd-cli.js +4 -0
- package/dist/daemon/crtrd.d.ts +20 -0
- package/dist/daemon/crtrd.js +200 -0
- package/dist/daemon/manage.d.ts +17 -0
- package/dist/daemon/manage.js +57 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
- package/dist/pi-extensions/canvas-nav.d.ts +32 -0
- package/dist/pi-extensions/canvas-nav.js +536 -0
- package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
- package/dist/pi-extensions/canvas-stophook.js +373 -0
- package/package.json +6 -5
- package/dist/commands/agent.d.ts +0 -6
- package/dist/commands/agent.js +0 -585
- package/dist/commands/debug.d.ts +0 -3
- package/dist/commands/debug.js +0 -192
- package/dist/commands/job.d.ts +0 -11
- package/dist/commands/job.js +0 -384
- package/dist/commands/mode.js +0 -231
- package/dist/commands/plan.d.ts +0 -4
- package/dist/commands/plan.js +0 -322
- package/dist/commands/spec.d.ts +0 -3
- package/dist/commands/spec.js +0 -299
- package/dist/core/__tests__/flow-leaves.test.js +0 -248
- package/dist/core/__tests__/job.test.js +0 -310
- package/dist/core/__tests__/jobs.test.js +0 -98
- package/dist/core/__tests__/spawn.test.js +0 -138
- package/dist/core/__tests__/subagents.test.d.ts +0 -1
- package/dist/core/__tests__/subagents.test.js +0 -75
- package/dist/core/jobs.d.ts +0 -107
- package/dist/core/jobs.js +0 -565
- package/dist/core/subagents.d.ts +0 -18
- package/dist/core/subagents.js +0 -163
- package/dist/prompts/agent.d.ts +0 -27
- package/dist/prompts/agent.js +0 -184
- package/dist/prompts/debug.d.ts +0 -8
- package/dist/prompts/debug.js +0 -44
- /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
- /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
- /package/dist/core/__tests__/{jobs.test.d.ts → reset.test.d.ts} +0 -0
- /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
|
+
}
|