tmux-ide 1.2.1 → 2.0.0
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/cli.js +4 -216
- package/bin/cli.ts +536 -0
- package/package.json +33 -14
- package/src/__tests__/support.ts +186 -0
- package/src/{attach.js → attach.ts} +7 -4
- package/src/cli.test.ts +314 -0
- package/src/command-center/discovery.test.ts +265 -0
- package/src/command-center/discovery.ts +296 -0
- package/src/command-center/index.ts +31 -0
- package/src/command-center/schemas.ts +57 -0
- package/src/command-center/server.test.ts +818 -0
- package/src/command-center/server.ts +1426 -0
- package/src/config-cli.test.ts +86 -0
- package/src/config.test.ts +205 -0
- package/src/config.ts +313 -0
- package/src/detect.test.ts +142 -0
- package/src/{detect.js → detect.ts} +31 -15
- package/src/dispatch.ts +102 -0
- package/src/doctor.test.ts +119 -0
- package/src/doctor.ts +167 -0
- package/src/init.test.ts +101 -0
- package/src/init.ts +144 -0
- package/src/inspect.test.ts +67 -0
- package/src/{inspect.js → inspect.ts} +95 -10
- package/src/integration.test.ts.skip +276 -0
- package/src/js-yaml.d.ts +10 -0
- package/src/launch.test.ts +222 -0
- package/src/launch.ts +494 -0
- package/src/lib/auth/auth-service.test.ts +178 -0
- package/src/lib/auth/auth-service.ts +227 -0
- package/src/lib/auth/middleware.test.ts +119 -0
- package/src/lib/auth/middleware.ts +56 -0
- package/src/lib/auth/types.ts +13 -0
- package/src/lib/authorship.test.ts +242 -0
- package/src/lib/authorship.ts +280 -0
- package/src/lib/cast/recorder.test.ts +127 -0
- package/src/lib/cast/recorder.ts +148 -0
- package/src/lib/daemon-watchdog.ts +102 -0
- package/src/lib/daemon.ts +417 -0
- package/src/lib/dot-path.test.ts +66 -0
- package/src/lib/dot-path.ts +17 -0
- package/src/lib/errors.ts +44 -0
- package/src/lib/event-log.test.ts +189 -0
- package/src/lib/event-log.ts +237 -0
- package/src/lib/github-pr.ts +182 -0
- package/src/lib/hq/client.test.ts +154 -0
- package/src/lib/hq/client.ts +142 -0
- package/src/lib/hq/mdns.test.ts +54 -0
- package/src/lib/hq/mdns.ts +88 -0
- package/src/lib/hq/registry.test.ts +237 -0
- package/src/lib/hq/registry.ts +145 -0
- package/src/lib/hq/types.ts +44 -0
- package/src/lib/ipc/socket-protocol.test.ts +126 -0
- package/src/lib/ipc/socket-protocol.ts +324 -0
- package/src/lib/launch-plan.test.ts +119 -0
- package/src/lib/launch-plan.ts +90 -0
- package/src/lib/log.test.ts +39 -0
- package/src/lib/log.ts +71 -0
- package/src/lib/metrics.test.ts +213 -0
- package/src/lib/metrics.ts +345 -0
- package/src/lib/orchestrator.test.ts +2358 -0
- package/src/lib/orchestrator.ts +1792 -0
- package/src/lib/{output.js → output.ts} +18 -10
- package/src/lib/plan-store.test.ts +148 -0
- package/src/lib/plan-store.ts +131 -0
- package/src/lib/research.test.ts +208 -0
- package/src/lib/research.ts +475 -0
- package/src/lib/round-trip.test.ts +341 -0
- package/src/lib/session-monitor.test.ts +152 -0
- package/src/lib/session-monitor.ts +282 -0
- package/src/lib/session-options.test.ts +146 -0
- package/src/lib/{session-options.js → session-options.ts} +11 -6
- package/src/lib/shell.test.ts +20 -0
- package/src/lib/shell.ts +8 -0
- package/src/lib/sizes.test.ts +73 -0
- package/src/lib/{sizes.js → sizes.ts} +4 -4
- package/src/lib/skill-registry.test.ts +134 -0
- package/src/lib/skill-registry.ts +86 -0
- package/src/lib/slugify.test.ts +35 -0
- package/src/lib/slugify.ts +10 -0
- package/src/lib/task-store.ts +352 -0
- package/src/lib/tmux.test.ts +500 -0
- package/src/lib/tmux.ts +372 -0
- package/src/lib/token-tracker.test.ts +102 -0
- package/src/lib/token-tracker.ts +70 -0
- package/src/lib/tunnels/cloudflare.test.ts +73 -0
- package/src/lib/tunnels/cloudflare.ts +254 -0
- package/src/lib/tunnels/manager.test.ts +63 -0
- package/src/lib/tunnels/manager.ts +132 -0
- package/src/lib/tunnels/ngrok.test.ts +94 -0
- package/src/lib/tunnels/ngrok.ts +261 -0
- package/src/lib/tunnels/tailscale.test.ts +103 -0
- package/src/lib/tunnels/tailscale.ts +1048 -0
- package/src/lib/tunnels/types.ts +44 -0
- package/src/lib/validation.test.ts +289 -0
- package/src/lib/validation.ts +123 -0
- package/src/lib/webhook.ts +51 -0
- package/src/lib/workflow-store.ts +209 -0
- package/src/lib/ws-v3/protocol.test.ts +127 -0
- package/src/lib/ws-v3/protocol.ts +160 -0
- package/src/lib/yaml-io.test.ts +125 -0
- package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
- package/src/ls.test.ts +76 -0
- package/src/{ls.js → ls.ts} +4 -4
- package/src/metrics-cli.ts +177 -0
- package/src/notify.ts +85 -0
- package/src/orchestrator-status.ts +228 -0
- package/src/plan.ts +117 -0
- package/src/postinstall.test.ts +79 -0
- package/src/remote.test.ts +115 -0
- package/src/remote.ts +148 -0
- package/src/restart.ts +24 -0
- package/src/schemas/domain.ts +422 -0
- package/src/schemas/ide-config.ts +148 -0
- package/src/schemas/index.ts +53 -0
- package/src/send.test.ts +70 -0
- package/src/send.ts +181 -0
- package/src/skill.test.ts +114 -0
- package/src/skill.ts +138 -0
- package/src/status.ts +66 -0
- package/src/stop.test.ts +176 -0
- package/src/stop.ts +47 -0
- package/src/task.test.ts +1466 -0
- package/src/task.ts +1143 -0
- package/src/tunnel.test.ts +80 -0
- package/src/tunnel.ts +133 -0
- package/src/types.ts +15 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/terminal/index.ts +9 -0
- package/src/ui/types.ts +91 -0
- package/src/ui/web/base.css +80 -0
- package/src/ui/web/components/Box.tsx +59 -0
- package/src/ui/web/components/Input.tsx +32 -0
- package/src/ui/web/components/ScrollBox.tsx +60 -0
- package/src/ui/web/components/Text.tsx +28 -0
- package/src/ui/web/hooks.ts +106 -0
- package/src/ui/web/index.ts +27 -0
- package/src/ui/web/render.ts +77 -0
- package/src/ui/web/utils/color.test.ts +93 -0
- package/src/ui/web/utils/color.ts +27 -0
- package/src/validate.test.ts +385 -0
- package/src/validate.ts +217 -0
- package/src/widgets/changes/index.tsx +576 -0
- package/src/widgets/config/index.tsx +440 -0
- package/src/widgets/costs/index.tsx +216 -0
- package/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/src/widgets/explorer/footer.tsx +20 -0
- package/src/widgets/explorer/header.tsx +23 -0
- package/src/widgets/explorer/index.tsx +408 -0
- package/src/widgets/explorer/tree-model.test.ts +214 -0
- package/src/widgets/explorer/tree-model.ts +103 -0
- package/src/widgets/explorer/tree.tsx +156 -0
- package/src/widgets/lib/config-model.ts +116 -0
- package/src/widgets/lib/files.test.ts +103 -0
- package/src/widgets/lib/files.ts +88 -0
- package/src/widgets/lib/git.test.ts +151 -0
- package/src/widgets/lib/git.ts +88 -0
- package/src/widgets/lib/pane-comms.test.ts +178 -0
- package/src/widgets/lib/pane-comms.ts +192 -0
- package/src/widgets/lib/theme.ts +151 -0
- package/src/widgets/lib/watcher.ts +50 -0
- package/src/widgets/mission-control/activity-feed.tsx +59 -0
- package/src/widgets/mission-control/agent-panel.tsx +132 -0
- package/src/widgets/mission-control/command-bar.tsx +63 -0
- package/src/widgets/mission-control/index.tsx +751 -0
- package/src/widgets/mission-control/task-panel.tsx +238 -0
- package/src/widgets/preview/index.tsx +415 -0
- package/src/widgets/resolve.ts +40 -0
- package/src/widgets/setup/agent-naming.tsx +112 -0
- package/src/widgets/setup/config-tree.tsx +238 -0
- package/src/widgets/setup/detect-panel.tsx +72 -0
- package/src/widgets/setup/field-editor.tsx +265 -0
- package/src/widgets/setup/footer.tsx +107 -0
- package/src/widgets/setup/index.tsx +340 -0
- package/src/widgets/setup/layout-picker.tsx +96 -0
- package/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/src/widgets/setup/review-panel.tsx +140 -0
- package/src/widgets/setup/setup-model.test.ts +303 -0
- package/src/widgets/setup/setup-model.ts +188 -0
- package/src/widgets/tasks/index.tsx +129 -0
- package/src/widgets/tasks/task-detail.tsx +309 -0
- package/src/widgets/tasks/task-form.tsx +193 -0
- package/src/widgets/tasks/task-list.tsx +205 -0
- package/src/widgets/tasks/task-model.test.ts +473 -0
- package/src/widgets/tasks/task-model.ts +157 -0
- package/templates/AGENTS.md +20 -0
- package/templates/missions.yml +41 -0
- package/templates/skills/backend.md +30 -0
- package/templates/skills/frontend.md +30 -0
- package/templates/skills/general-worker.md +30 -0
- package/templates/skills/researcher.md +69 -0
- package/templates/skills/reviewer.md +30 -0
- package/src/config.js +0 -341
- package/src/doctor.js +0 -91
- package/src/init.js +0 -73
- package/src/launch.js +0 -245
- package/src/lib/dot-path.js +0 -16
- package/src/lib/errors.js +0 -35
- package/src/lib/launch-plan.js +0 -49
- package/src/lib/session-monitor.js +0 -179
- package/src/lib/tmux.js +0 -237
- package/src/restart.js +0 -16
- package/src/status.js +0 -35
- package/src/stop.js +0 -25
- package/src/validate.js +0 -154
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
const INTERVAL = 1000;
|
|
6
|
+
const SPINNERS = /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⠂⠒⠢⠆⠐⠠⠄◐◓◑◒|/\\-] /;
|
|
7
|
+
|
|
8
|
+
interface MonitorPane {
|
|
9
|
+
id: string;
|
|
10
|
+
pid: string;
|
|
11
|
+
cmd?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
role?: string;
|
|
14
|
+
type?: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// --- Port detection (pure helpers) ---
|
|
19
|
+
|
|
20
|
+
function getListeningPids(): Set<string> {
|
|
21
|
+
// Returns Set of PIDs that have a listening TCP port in range 1024-20000
|
|
22
|
+
try {
|
|
23
|
+
const raw = execFileSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-FpPn"], {
|
|
24
|
+
encoding: "utf-8",
|
|
25
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
26
|
+
});
|
|
27
|
+
const pids = new Set<string>();
|
|
28
|
+
let currentPid: string | null = null;
|
|
29
|
+
for (const line of raw.split("\n")) {
|
|
30
|
+
if (line.startsWith("p")) {
|
|
31
|
+
currentPid = line.slice(1);
|
|
32
|
+
} else if (line.startsWith("n") && currentPid) {
|
|
33
|
+
const match = line.match(/:(\d+)$/);
|
|
34
|
+
if (match) {
|
|
35
|
+
const port = parseInt(match[1]!, 10);
|
|
36
|
+
if (port >= 1024 && port <= 20000) pids.add(currentPid);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return pids;
|
|
41
|
+
} catch {
|
|
42
|
+
return new Set<string>();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getProcessTree(): Map<string, string> {
|
|
47
|
+
// Returns Map<pid, ppid>
|
|
48
|
+
try {
|
|
49
|
+
const raw = execFileSync("ps", ["-axo", "pid=,ppid="], {
|
|
50
|
+
encoding: "utf-8",
|
|
51
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
52
|
+
});
|
|
53
|
+
const tree = new Map<string, string>();
|
|
54
|
+
for (const line of raw.trim().split("\n")) {
|
|
55
|
+
const parts = line.trim().split(/\s+/);
|
|
56
|
+
if (parts.length === 2) tree.set(parts[0]!, parts[1]!);
|
|
57
|
+
}
|
|
58
|
+
return tree;
|
|
59
|
+
} catch {
|
|
60
|
+
return new Map<string, string>();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function computePortPanes(
|
|
65
|
+
panes: MonitorPane[],
|
|
66
|
+
{ listeners, tree }: { listeners?: Set<string>; tree?: Map<string, string> } = {},
|
|
67
|
+
): Set<string> {
|
|
68
|
+
// Walk up from each listening PID to find which pane owns it
|
|
69
|
+
const resolvedListeners = listeners ?? getListeningPids();
|
|
70
|
+
const resolvedTree = tree ?? getProcessTree();
|
|
71
|
+
if (resolvedListeners.size === 0) return new Set<string>();
|
|
72
|
+
|
|
73
|
+
const panePids = new Map(panes.map((p) => [p.pid, p.id]));
|
|
74
|
+
const result = new Set<string>();
|
|
75
|
+
|
|
76
|
+
for (const listenerPid of resolvedListeners) {
|
|
77
|
+
let pid: string | undefined = listenerPid;
|
|
78
|
+
while (pid && pid !== "0") {
|
|
79
|
+
if (panePids.has(pid)) {
|
|
80
|
+
result.add(panePids.get(pid)!);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
pid = resolvedTree.get(pid);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- Agent detection ---
|
|
90
|
+
|
|
91
|
+
export function computeAgentStates(panes: MonitorPane[]): Map<string, "busy" | "idle" | null> {
|
|
92
|
+
// Returns Map<paneId, "busy" | "idle" | null>
|
|
93
|
+
const states = new Map<string, "busy" | "idle" | null>();
|
|
94
|
+
for (const pane of panes) {
|
|
95
|
+
const role = pane.role ?? "";
|
|
96
|
+
|
|
97
|
+
// Primary: use @ide_role pane option if available
|
|
98
|
+
if (role === "lead" || role === "teammate") {
|
|
99
|
+
states.set(pane.id, SPINNERS.test(pane.title ?? "") ? "busy" : "idle");
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Fallback: command-based detection for pre-upgrade sessions
|
|
104
|
+
const cmd = (pane.cmd ?? "").toLowerCase();
|
|
105
|
+
if (!cmd.includes("claude") && !cmd.includes("codex")) {
|
|
106
|
+
states.set(pane.id, null);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
states.set(pane.id, SPINNERS.test(pane.title ?? "") ? "busy" : "idle");
|
|
110
|
+
}
|
|
111
|
+
return states;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- Main loop (only runs when executed directly) ---
|
|
115
|
+
|
|
116
|
+
const isMainModule = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
117
|
+
|
|
118
|
+
if (isMainModule) {
|
|
119
|
+
const session: string = process.argv[2]!;
|
|
120
|
+
if (!session) process.exit(1);
|
|
121
|
+
|
|
122
|
+
function tmux(...args: string[]): string {
|
|
123
|
+
return execFileSync("tmux", args, { encoding: "utf-8" }).trim();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function tmuxSilent(...args: string[]): string {
|
|
127
|
+
try {
|
|
128
|
+
return tmux(...args);
|
|
129
|
+
} catch {
|
|
130
|
+
return "";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sessionExists(): boolean {
|
|
135
|
+
try {
|
|
136
|
+
tmux("has-session", "-t", session);
|
|
137
|
+
return true;
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hasClients(): boolean {
|
|
144
|
+
return tmuxSilent("list-clients").length > 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function listPanes(): MonitorPane[] {
|
|
148
|
+
const raw = tmuxSilent(
|
|
149
|
+
"list-panes",
|
|
150
|
+
"-t",
|
|
151
|
+
session,
|
|
152
|
+
"-F",
|
|
153
|
+
"#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{pane_title}\t#{@ide_role}\t#{@ide_type}\t#{@ide_name}",
|
|
154
|
+
);
|
|
155
|
+
if (!raw) return [];
|
|
156
|
+
return raw.split("\n").map((line) => {
|
|
157
|
+
const [id, pid, cmd, title, role, type, name] = line.split("\t");
|
|
158
|
+
return {
|
|
159
|
+
id: id!,
|
|
160
|
+
pid: pid!,
|
|
161
|
+
cmd,
|
|
162
|
+
title,
|
|
163
|
+
role: role || undefined,
|
|
164
|
+
type: type || undefined,
|
|
165
|
+
name: name || undefined,
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let lastState = "";
|
|
171
|
+
|
|
172
|
+
function tick(): void {
|
|
173
|
+
if (!sessionExists()) process.exit(0);
|
|
174
|
+
if (!hasClients()) return; // skip when nobody is watching
|
|
175
|
+
|
|
176
|
+
const panes = listPanes();
|
|
177
|
+
if (panes.length === 0) return;
|
|
178
|
+
|
|
179
|
+
const portPanes = computePortPanes(panes);
|
|
180
|
+
const agentStates = computeAgentStates(panes);
|
|
181
|
+
|
|
182
|
+
// Build state fingerprint for change detection (includes title drift)
|
|
183
|
+
const stateKey = panes
|
|
184
|
+
.map((p) => {
|
|
185
|
+
const port = portPanes.has(p.id) ? "1" : "0";
|
|
186
|
+
const agent = agentStates.get(p.id) ?? "-";
|
|
187
|
+
const titleDrift = p.name && p.title !== p.name ? "d" : "ok";
|
|
188
|
+
return `${p.id}:${port}:${agent}:${titleDrift}`;
|
|
189
|
+
})
|
|
190
|
+
.join("|");
|
|
191
|
+
|
|
192
|
+
if (stateKey === lastState) return;
|
|
193
|
+
|
|
194
|
+
// Apply changes
|
|
195
|
+
for (const pane of panes) {
|
|
196
|
+
const hasPort = portPanes.has(pane.id) ? "1" : "0";
|
|
197
|
+
const agent = agentStates.get(pane.id);
|
|
198
|
+
|
|
199
|
+
tmuxSilent("set-option", "-pqt", pane.id, "@has_port", hasPort);
|
|
200
|
+
tmuxSilent("set-option", "-pqt", pane.id, "@agent_busy", agent === "busy" ? "1" : "0");
|
|
201
|
+
tmuxSilent("set-option", "-pqt", pane.id, "@agent_idle", agent === "idle" ? "1" : "0");
|
|
202
|
+
|
|
203
|
+
// Restore configured pane title if Claude Code changed it
|
|
204
|
+
if (pane.name && pane.title !== pane.name) {
|
|
205
|
+
tmuxSilent("select-pane", "-t", pane.id, "-T", pane.name);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
tmuxSilent("refresh-client", "-S");
|
|
210
|
+
lastState = stateKey;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const monitorInterval = setInterval(tick, INTERVAL);
|
|
214
|
+
tick(); // run immediately
|
|
215
|
+
|
|
216
|
+
// Unified shutdown handler — cleans up everything on SIGTERM/SIGINT
|
|
217
|
+
let stopOrchestrator: (() => void) | null = null;
|
|
218
|
+
|
|
219
|
+
function shutdown() {
|
|
220
|
+
clearInterval(monitorInterval);
|
|
221
|
+
if (stopOrchestrator) stopOrchestrator();
|
|
222
|
+
process.exit(0);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
process.on("SIGTERM", shutdown);
|
|
226
|
+
process.on("SIGINT", shutdown);
|
|
227
|
+
|
|
228
|
+
// Start orchestrator if enabled in config
|
|
229
|
+
(async () => {
|
|
230
|
+
try {
|
|
231
|
+
const { readConfig } = await import("./yaml-io.ts");
|
|
232
|
+
const { config } = readConfig(process.cwd());
|
|
233
|
+
|
|
234
|
+
if (config.orchestrator?.enabled) {
|
|
235
|
+
try {
|
|
236
|
+
const { createOrchestrator } = await import("./orchestrator.ts");
|
|
237
|
+
|
|
238
|
+
// Configure webhooks for event delivery
|
|
239
|
+
if (config.orchestrator.webhooks?.length) {
|
|
240
|
+
const { setWebhookConfig } = await import("./event-log.ts");
|
|
241
|
+
setWebhookConfig(config.orchestrator.webhooks);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const orch = config.orchestrator;
|
|
245
|
+
|
|
246
|
+
// Build pane specialty map from ide.yml config
|
|
247
|
+
const paneSpecialties = new Map<string, string[]>();
|
|
248
|
+
for (const row of config.rows) {
|
|
249
|
+
for (const pane of row.panes) {
|
|
250
|
+
if (pane.specialty && pane.title) {
|
|
251
|
+
paneSpecialties.set(
|
|
252
|
+
pane.title,
|
|
253
|
+
pane.specialty.split(",").map((s: string) => s.trim().toLowerCase()),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
stopOrchestrator = createOrchestrator({
|
|
260
|
+
session,
|
|
261
|
+
dir: process.cwd(),
|
|
262
|
+
autoDispatch: orch.auto_dispatch ?? true,
|
|
263
|
+
stallTimeout: orch.stall_timeout ?? 300000,
|
|
264
|
+
pollInterval: orch.poll_interval ?? 5000,
|
|
265
|
+
masterPane: orch.master_pane ?? null,
|
|
266
|
+
beforeRun: orch.before_run ?? null,
|
|
267
|
+
afterRun: orch.after_run ?? null,
|
|
268
|
+
maxConcurrentAgents: orch.max_concurrent_agents ?? 10,
|
|
269
|
+
dispatchMode: orch.dispatch_mode ?? "tasks",
|
|
270
|
+
paneSpecialties,
|
|
271
|
+
services: orch.services ?? {},
|
|
272
|
+
research: orch.research,
|
|
273
|
+
});
|
|
274
|
+
} catch {
|
|
275
|
+
// Orchestrator module not available yet
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
// Config not readable — skip orchestrator
|
|
280
|
+
}
|
|
281
|
+
})();
|
|
282
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildSessionOptions,
|
|
4
|
+
themeOptions,
|
|
5
|
+
borderOptions,
|
|
6
|
+
behaviorOptions,
|
|
7
|
+
statusBarOptions,
|
|
8
|
+
keyBindings,
|
|
9
|
+
} from "./session-options.ts";
|
|
10
|
+
|
|
11
|
+
describe("buildSessionOptions", () => {
|
|
12
|
+
it("returns an array of command arrays", () => {
|
|
13
|
+
const options = buildSessionOptions("my-session");
|
|
14
|
+
|
|
15
|
+
expect(Array.isArray(options)).toBeTruthy();
|
|
16
|
+
expect(options.length > 0).toBeTruthy();
|
|
17
|
+
for (const cmd of options) {
|
|
18
|
+
expect(Array.isArray(cmd)).toBeTruthy();
|
|
19
|
+
expect(cmd.length >= 2).toBeTruthy();
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("composes all sub-builders", () => {
|
|
24
|
+
const session = "test";
|
|
25
|
+
const theme = {};
|
|
26
|
+
const all = buildSessionOptions(session, { theme });
|
|
27
|
+
const expected = [
|
|
28
|
+
...themeOptions(session, theme),
|
|
29
|
+
...borderOptions(session, theme),
|
|
30
|
+
...behaviorOptions(session),
|
|
31
|
+
...statusBarOptions(session, theme),
|
|
32
|
+
...keyBindings(),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
expect(all).toEqual(expected);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("themeOptions", () => {
|
|
40
|
+
it("includes expected color settings", () => {
|
|
41
|
+
const opts = themeOptions("my-session", {});
|
|
42
|
+
|
|
43
|
+
const optionNames = opts.map((o) => o[3]);
|
|
44
|
+
expect(optionNames.includes("status-style")).toBeTruthy();
|
|
45
|
+
expect(optionNames.includes("pane-border-style")).toBeTruthy();
|
|
46
|
+
expect(optionNames.includes("pane-active-border-style")).toBeTruthy();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("uses default colors when no theme provided", () => {
|
|
50
|
+
const opts = themeOptions("s", {});
|
|
51
|
+
|
|
52
|
+
expect(opts[0]).toEqual(["set-option", "-t", "s", "status-style", "bg=colour235,fg=colour248"]);
|
|
53
|
+
expect(opts[1]).toEqual(["set-option", "-t", "s", "pane-border-style", "fg=colour238"]);
|
|
54
|
+
expect(opts[2]).toEqual(["set-option", "-t", "s", "pane-active-border-style", "fg=colour75"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("respects custom theme overrides", () => {
|
|
58
|
+
const opts = themeOptions("my-session", { accent: "red", border: "blue" });
|
|
59
|
+
|
|
60
|
+
expect(opts[1]).toEqual(["set-option", "-t", "my-session", "pane-border-style", "fg=blue"]);
|
|
61
|
+
expect(opts[2]).toEqual([
|
|
62
|
+
"set-option",
|
|
63
|
+
"-t",
|
|
64
|
+
"my-session",
|
|
65
|
+
"pane-active-border-style",
|
|
66
|
+
"fg=red",
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("borderOptions", () => {
|
|
72
|
+
it("includes pane-border-status and pane-border-format", () => {
|
|
73
|
+
const opts = borderOptions("my-session", {});
|
|
74
|
+
|
|
75
|
+
expect(opts[0]).toEqual(["set-option", "-t", "my-session", "pane-border-status", "top"]);
|
|
76
|
+
expect(opts[1][3]).toBe("pane-border-format");
|
|
77
|
+
expect(opts[1][4].includes("pane_current_path")).toBeTruthy();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("behaviorOptions", () => {
|
|
82
|
+
it("includes mouse, escape-time, and status-interval", () => {
|
|
83
|
+
const opts = behaviorOptions("test-session");
|
|
84
|
+
|
|
85
|
+
const mouseOpt = opts.find((o) => o[3] === "mouse");
|
|
86
|
+
expect(mouseOpt).toEqual(["set-option", "-t", "test-session", "mouse", "on"]);
|
|
87
|
+
|
|
88
|
+
const escapeOpt = opts.find((o) => o[3] === "escape-time");
|
|
89
|
+
expect(escapeOpt).toEqual(["set-option", "-t", "test-session", "escape-time", "0"]);
|
|
90
|
+
|
|
91
|
+
const intervalOpt = opts.find((o) => o[3] === "status-interval");
|
|
92
|
+
expect(intervalOpt).toEqual(["set-option", "-t", "test-session", "status-interval", "1"]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("statusBarOptions", () => {
|
|
97
|
+
it("includes two-line status mode", () => {
|
|
98
|
+
const opts = statusBarOptions("my-session", {});
|
|
99
|
+
const statusOpt = opts.find((o) => o[3] === "status");
|
|
100
|
+
expect(statusOpt).toEqual(["set-option", "-t", "my-session", "status", "2"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("includes status-format[1] with pane tabs", () => {
|
|
104
|
+
const opts = statusBarOptions("my-session", {});
|
|
105
|
+
const formatOpt = opts.find((o) => o[3] === "status-format[1]");
|
|
106
|
+
expect(formatOpt).toBeTruthy();
|
|
107
|
+
expect(formatOpt[4].includes("#{P:")).toBeTruthy();
|
|
108
|
+
expect(formatOpt[4].includes("pane_id")).toBeTruthy();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("includes status-left with session name", () => {
|
|
112
|
+
const opts = statusBarOptions("my-session", {});
|
|
113
|
+
const leftOpt = opts.find((o) => o[3] === "status-left");
|
|
114
|
+
expect(leftOpt[4].includes("MY-SESSION IDE")).toBeTruthy();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("includes all expected status bar settings", () => {
|
|
118
|
+
const opts = statusBarOptions("s", {});
|
|
119
|
+
const names = opts.map((o) => o[3]);
|
|
120
|
+
|
|
121
|
+
expect(names.includes("status-left")).toBeTruthy();
|
|
122
|
+
expect(names.includes("status-left-length")).toBeTruthy();
|
|
123
|
+
expect(names.includes("status-right")).toBeTruthy();
|
|
124
|
+
expect(names.includes("status-justify")).toBeTruthy();
|
|
125
|
+
expect(names.includes("window-status-current-format")).toBeTruthy();
|
|
126
|
+
expect(names.includes("window-status-format")).toBeTruthy();
|
|
127
|
+
expect(names.includes("status")).toBeTruthy();
|
|
128
|
+
expect(names.includes("status-format[1]")).toBeTruthy();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("keyBindings", () => {
|
|
133
|
+
it("includes the mouse click binding", () => {
|
|
134
|
+
const bindings = keyBindings();
|
|
135
|
+
|
|
136
|
+
expect(bindings.length).toBe(1);
|
|
137
|
+
expect(bindings[0]).toEqual([
|
|
138
|
+
"bind-key",
|
|
139
|
+
"-n",
|
|
140
|
+
"MouseDown1StatusDefault",
|
|
141
|
+
"select-pane",
|
|
142
|
+
"-t",
|
|
143
|
+
"=",
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
* Each returns an array of tmux command arrays.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
import type { ThemeConfig, TmuxCommand } from "../types.ts";
|
|
7
|
+
|
|
8
|
+
export function buildSessionOptions(
|
|
9
|
+
session: string,
|
|
10
|
+
{ theme = {} }: { theme?: ThemeConfig } = {},
|
|
11
|
+
): TmuxCommand[] {
|
|
7
12
|
return [
|
|
8
13
|
...themeOptions(session, theme),
|
|
9
14
|
...borderOptions(session, theme),
|
|
@@ -13,7 +18,7 @@ export function buildSessionOptions(session, { theme = {} } = {}) {
|
|
|
13
18
|
];
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
export function themeOptions(session, theme) {
|
|
21
|
+
export function themeOptions(session: string, theme: ThemeConfig): TmuxCommand[] {
|
|
17
22
|
const accent = theme.accent ?? "colour75";
|
|
18
23
|
const border = theme.border ?? "colour238";
|
|
19
24
|
const bg = theme.bg ?? "colour235";
|
|
@@ -26,7 +31,7 @@ export function themeOptions(session, theme) {
|
|
|
26
31
|
];
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
export function borderOptions(session, theme) {
|
|
34
|
+
export function borderOptions(session: string, theme: ThemeConfig): TmuxCommand[] {
|
|
30
35
|
const accent = theme.accent ?? "colour75";
|
|
31
36
|
const border = theme.border ?? "colour238";
|
|
32
37
|
const fg = theme.fg ?? "colour248";
|
|
@@ -43,7 +48,7 @@ export function borderOptions(session, theme) {
|
|
|
43
48
|
];
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
export function behaviorOptions(session) {
|
|
51
|
+
export function behaviorOptions(session: string): TmuxCommand[] {
|
|
47
52
|
return [
|
|
48
53
|
["set-option", "-t", session, "mouse", "on"],
|
|
49
54
|
["set-option", "-t", session, "escape-time", "0"],
|
|
@@ -51,7 +56,7 @@ export function behaviorOptions(session) {
|
|
|
51
56
|
];
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
export function statusBarOptions(session, theme) {
|
|
59
|
+
export function statusBarOptions(session: string, theme: ThemeConfig): TmuxCommand[] {
|
|
55
60
|
const accent = theme.accent ?? "colour75";
|
|
56
61
|
const border = theme.border ?? "colour238";
|
|
57
62
|
const fg = theme.fg ?? "colour248";
|
|
@@ -90,6 +95,6 @@ export function statusBarOptions(session, theme) {
|
|
|
90
95
|
];
|
|
91
96
|
}
|
|
92
97
|
|
|
93
|
-
export function keyBindings() {
|
|
98
|
+
export function keyBindings(): TmuxCommand[] {
|
|
94
99
|
return [["bind-key", "-n", "MouseDown1StatusDefault", "select-pane", "-t", "="]];
|
|
95
100
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { shellEscape } from "./shell.ts";
|
|
3
|
+
|
|
4
|
+
describe("shellEscape", () => {
|
|
5
|
+
it("wraps plain strings in single quotes", () => {
|
|
6
|
+
expect(shellEscape("hello")).toBe("'hello'");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("escapes embedded single quotes", () => {
|
|
10
|
+
expect(shellEscape("don't")).toBe("'don'\\''t'");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("handles empty string", () => {
|
|
14
|
+
expect(shellEscape("")).toBe("''");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("handles newlines and shell metacharacters literally inside quotes", () => {
|
|
18
|
+
expect(shellEscape("a; rm -rf /")).toBe("'a; rm -rf /'");
|
|
19
|
+
});
|
|
20
|
+
});
|
package/src/lib/shell.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote a string for safe embedding in POSIX sh/bash commands as a single word.
|
|
3
|
+
* Wraps in single quotes; embedded `'` becomes `'\''` (end quote, literal quote, resume).
|
|
4
|
+
*/
|
|
5
|
+
export function shellEscape(s: string): string {
|
|
6
|
+
// Each "'" in the input becomes '\'' outside a single-quoted string.
|
|
7
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
8
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { computeSizes, toSplitPercents } from "./sizes.ts";
|
|
3
|
+
|
|
4
|
+
describe("computeSizes", () => {
|
|
5
|
+
it("distributes remaining space to items without size", () => {
|
|
6
|
+
const result = computeSizes([{ size: "70%" }, {}]);
|
|
7
|
+
expect(result).toEqual([70, 30]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("passes through all explicit sizes", () => {
|
|
11
|
+
const result = computeSizes([{ size: "60%" }, { size: "40%" }]);
|
|
12
|
+
expect(result).toEqual([60, 40]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("splits equally when no sizes specified", () => {
|
|
16
|
+
const result = computeSizes([{}, {}, {}]);
|
|
17
|
+
const expected = [100 / 3, 100 / 3, 100 / 3];
|
|
18
|
+
expect(result).toEqual(expected);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("handles 3 items with mixed sizes", () => {
|
|
22
|
+
const result = computeSizes([{ size: "60%" }, { size: "25%" }, {}]);
|
|
23
|
+
expect(result).toEqual([60, 25, 15]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("handles single item without size", () => {
|
|
27
|
+
const result = computeSizes([{}]);
|
|
28
|
+
expect(result).toEqual([100]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("handles single item with size", () => {
|
|
32
|
+
const result = computeSizes([{ size: "50%" }]);
|
|
33
|
+
expect(result).toEqual([50]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("handles empty array", () => {
|
|
37
|
+
const result = computeSizes([]);
|
|
38
|
+
expect(result).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("clamps remaining to zero when sizes exceed 100", () => {
|
|
42
|
+
const result = computeSizes([{ size: "80%" }, { size: "30%" }, {}]);
|
|
43
|
+
expect(result).toEqual([80, 30, 0]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("toSplitPercents", () => {
|
|
48
|
+
it("converts 70/30 to [30]", () => {
|
|
49
|
+
expect(toSplitPercents([70, 30])).toEqual([30]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("converts 60/25/15 correctly", () => {
|
|
53
|
+
const result = toSplitPercents([60, 25, 15]);
|
|
54
|
+
expect(result).toEqual([40, 38]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("converts equal 50/50 to [50]", () => {
|
|
58
|
+
expect(toSplitPercents([50, 50])).toEqual([50]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("converts equal thirds", () => {
|
|
62
|
+
const result = toSplitPercents([100 / 3, 100 / 3, 100 / 3]);
|
|
63
|
+
expect(result).toEqual([67, 50]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns empty array for single item", () => {
|
|
67
|
+
expect(toSplitPercents([100])).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns empty array for empty input", () => {
|
|
71
|
+
expect(toSplitPercents([])).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Items with `size` (e.g. "70%") keep their value; remaining space is split equally
|
|
4
4
|
* among items without a size.
|
|
5
5
|
*/
|
|
6
|
-
export function computeSizes(items) {
|
|
6
|
+
export function computeSizes(items: { size?: string }[]): number[] {
|
|
7
7
|
let claimed = 0;
|
|
8
8
|
let unclaimed = 0;
|
|
9
9
|
for (const item of items) {
|
|
@@ -25,11 +25,11 @@ export function computeSizes(items) {
|
|
|
25
25
|
* Each tmux split divides the current pane. The percentage given to -p is
|
|
26
26
|
* the portion allocated to the NEW (bottom/right) pane.
|
|
27
27
|
*/
|
|
28
|
-
export function toSplitPercents(sizes) {
|
|
29
|
-
const percents = [];
|
|
28
|
+
export function toSplitPercents(sizes: number[]): number[] {
|
|
29
|
+
const percents: number[] = [];
|
|
30
30
|
for (let i = 1; i < sizes.length; i++) {
|
|
31
31
|
const remaining = sizes.slice(i - 1).reduce((a, b) => a + b, 0);
|
|
32
|
-
const topShare = sizes[i - 1]
|
|
32
|
+
const topShare = sizes[i - 1]!;
|
|
33
33
|
percents.push(Math.round(((remaining - topShare) / remaining) * 100));
|
|
34
34
|
}
|
|
35
35
|
return percents;
|