@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.
- 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 +407 -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 +55 -0
- package/dist/core/runtime/presence.js +198 -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 +87 -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 +31 -0
- package/dist/core/runtime/spawn.js +123 -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 +107 -0
- package/dist/core/runtime/tmux.js +244 -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 +396 -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,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
|
+
}
|