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
package/src/launch.ts
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { resolve, dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { execSync, spawn, type SpawnOptions } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { readConfig, getSessionName } from "./lib/yaml-io.ts";
|
|
7
|
+
import { computeSizes, toSplitPercents } from "./lib/sizes.ts";
|
|
8
|
+
import { outputError } from "./lib/output.ts";
|
|
9
|
+
import { collectPaneStartupPlan } from "./lib/launch-plan.ts";
|
|
10
|
+
import { buildSessionOptions } from "./lib/session-options.ts";
|
|
11
|
+
import {
|
|
12
|
+
attachSession,
|
|
13
|
+
createDetachedSession,
|
|
14
|
+
getPaneCurrentCommand,
|
|
15
|
+
getSessionVariable,
|
|
16
|
+
hasSession,
|
|
17
|
+
runSessionCommand,
|
|
18
|
+
selectPane,
|
|
19
|
+
sendLiteral,
|
|
20
|
+
setPaneOption,
|
|
21
|
+
setPaneTitle,
|
|
22
|
+
setSessionEnvironment,
|
|
23
|
+
setSessionVariable,
|
|
24
|
+
splitPane,
|
|
25
|
+
startSessionMonitor,
|
|
26
|
+
} from "./lib/tmux.ts";
|
|
27
|
+
import { validateConfig } from "./validate.ts";
|
|
28
|
+
import { resolveWidgetCommand } from "./widgets/resolve.ts";
|
|
29
|
+
import { shellEscape } from "./lib/shell.ts";
|
|
30
|
+
import type { IdeConfig, Row, Pane } from "./types.ts";
|
|
31
|
+
|
|
32
|
+
const DEFAULT_COMMAND_CENTER_PORT = 6060;
|
|
33
|
+
const DEFAULT_DASHBOARD_PORT = 6061;
|
|
34
|
+
|
|
35
|
+
function packageRoot(): string {
|
|
36
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resolveDashboardDir(rootDir = packageRoot()): string | null {
|
|
40
|
+
const dashboardDir = join(rootDir, "dashboard");
|
|
41
|
+
return existsSync(join(dashboardDir, "package.json")) ? dashboardDir : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function startDashboard(
|
|
45
|
+
session: string,
|
|
46
|
+
apiPort: number,
|
|
47
|
+
{
|
|
48
|
+
dashboardPort = DEFAULT_DASHBOARD_PORT,
|
|
49
|
+
dashboardDir = resolveDashboardDir(),
|
|
50
|
+
spawnFn = spawn,
|
|
51
|
+
setVar = setSessionVariable,
|
|
52
|
+
}: {
|
|
53
|
+
dashboardPort?: number;
|
|
54
|
+
dashboardDir?: string | null;
|
|
55
|
+
spawnFn?: (
|
|
56
|
+
command: string,
|
|
57
|
+
args: readonly string[],
|
|
58
|
+
options: SpawnOptions,
|
|
59
|
+
) => { pid?: number; unref: () => void };
|
|
60
|
+
setVar?: (sessionName: string, name: string, value: string) => void;
|
|
61
|
+
} = {},
|
|
62
|
+
): string | null {
|
|
63
|
+
if (!dashboardDir) return null;
|
|
64
|
+
|
|
65
|
+
const url = `http://localhost:${dashboardPort}`;
|
|
66
|
+
const child = spawnFn("pnpm", ["dev", "--port", String(dashboardPort)], {
|
|
67
|
+
cwd: dashboardDir,
|
|
68
|
+
detached: true,
|
|
69
|
+
stdio: "ignore",
|
|
70
|
+
env: {
|
|
71
|
+
...process.env,
|
|
72
|
+
NEXT_PUBLIC_API_URL: `http://localhost:${apiPort}`,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
child.unref();
|
|
76
|
+
|
|
77
|
+
if (child.pid != null) {
|
|
78
|
+
setVar(session, "@dashboard_pid", String(child.pid));
|
|
79
|
+
}
|
|
80
|
+
setVar(session, "@dashboard_url", url);
|
|
81
|
+
return url;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function stripWidgetPanes(rows: Row[]): Row[] {
|
|
85
|
+
return rows
|
|
86
|
+
.map((row) => ({
|
|
87
|
+
...row,
|
|
88
|
+
panes: row.panes.filter((p) => !p.type),
|
|
89
|
+
}))
|
|
90
|
+
.filter((row) => row.panes.length > 0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface SplitPaneArgs {
|
|
94
|
+
targetPane: string;
|
|
95
|
+
direction: "vertical" | "horizontal";
|
|
96
|
+
cwd: string;
|
|
97
|
+
percent: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sleepMs(ms: number): void {
|
|
101
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function configHash(config: IdeConfig): string {
|
|
105
|
+
return createHash("sha256").update(JSON.stringify(config)).digest("hex").slice(0, 12);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function waitForPaneCommand(
|
|
109
|
+
targetPane: string,
|
|
110
|
+
expectedCommands: string[],
|
|
111
|
+
{
|
|
112
|
+
attempts = 20,
|
|
113
|
+
delayMs = 100,
|
|
114
|
+
getCurrentCommand = getPaneCurrentCommand,
|
|
115
|
+
sleep = sleepMs,
|
|
116
|
+
}: {
|
|
117
|
+
attempts?: number;
|
|
118
|
+
delayMs?: number;
|
|
119
|
+
getCurrentCommand?: (pane: string) => string;
|
|
120
|
+
sleep?: (ms: number) => void;
|
|
121
|
+
} = {},
|
|
122
|
+
): boolean {
|
|
123
|
+
const allowed = new Set(expectedCommands.map((command) => command.toLowerCase()));
|
|
124
|
+
|
|
125
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
126
|
+
try {
|
|
127
|
+
const current = getCurrentCommand(targetPane)?.trim().toLowerCase();
|
|
128
|
+
if (current && allowed.has(current)) return true;
|
|
129
|
+
} catch {
|
|
130
|
+
// Fall through to retry; tmux can briefly report transitional state.
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (attempt < attempts - 1) {
|
|
134
|
+
sleep(delayMs);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function buildPaneMap(
|
|
142
|
+
rows: Row[],
|
|
143
|
+
dir: string,
|
|
144
|
+
rootPaneId: string,
|
|
145
|
+
splitPaneFn: (args: SplitPaneArgs) => string,
|
|
146
|
+
): { paneMap: string[][]; firstPanesOfRows: Set<string> } {
|
|
147
|
+
const rowSizes = computeSizes(rows);
|
|
148
|
+
const rowSplitPercents = toSplitPercents(rowSizes);
|
|
149
|
+
|
|
150
|
+
// Create all rows vertically first so each row spans the full width.
|
|
151
|
+
const rowPaneIds = [rootPaneId];
|
|
152
|
+
for (let rowIdx = 1; rowIdx < rows.length; rowIdx++) {
|
|
153
|
+
const splitFrom = rowPaneIds[rowIdx - 1]!;
|
|
154
|
+
const newPaneId = splitPaneFn({
|
|
155
|
+
targetPane: splitFrom,
|
|
156
|
+
direction: "vertical",
|
|
157
|
+
cwd: dir,
|
|
158
|
+
percent: rowSplitPercents[rowIdx - 1]!,
|
|
159
|
+
});
|
|
160
|
+
rowPaneIds.push(newPaneId);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const paneMap: string[][] = [];
|
|
164
|
+
const firstPanesOfRows = new Set(rowPaneIds);
|
|
165
|
+
|
|
166
|
+
for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
|
|
167
|
+
const row = rows[rowIdx]!;
|
|
168
|
+
const panes = row.panes ?? [];
|
|
169
|
+
const rowPaneId = rowPaneIds[rowIdx]!;
|
|
170
|
+
const rowPanes = [rowPaneId];
|
|
171
|
+
|
|
172
|
+
const paneSizes = computeSizes(panes);
|
|
173
|
+
const paneSplitPercents = toSplitPercents(paneSizes);
|
|
174
|
+
|
|
175
|
+
for (let paneIdx = 1; paneIdx < panes.length; paneIdx++) {
|
|
176
|
+
const pane = panes[paneIdx]!;
|
|
177
|
+
const targetPane = rowPanes[paneIdx - 1]!;
|
|
178
|
+
const paneDir = pane.dir ? resolve(dir, pane.dir) : dir;
|
|
179
|
+
const newPaneId = splitPaneFn({
|
|
180
|
+
targetPane,
|
|
181
|
+
direction: "horizontal",
|
|
182
|
+
cwd: paneDir,
|
|
183
|
+
percent: paneSplitPercents[paneIdx - 1]!,
|
|
184
|
+
});
|
|
185
|
+
rowPanes.push(newPaneId);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
paneMap.push(rowPanes);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { paneMap, firstPanesOfRows };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function loadLaunchConfig(dir: string): IdeConfig {
|
|
195
|
+
let config;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
({ config } = readConfig(dir));
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
201
|
+
outputError(
|
|
202
|
+
`No ide.yml found in ${dir}. Run "tmux-ide init" or "tmux-ide detect --write" to create one.`,
|
|
203
|
+
"CONFIG_NOT_FOUND",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
outputError(`Cannot read ide.yml: ${(error as Error).message}`, "READ_ERROR");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const errors = validateConfig(config);
|
|
211
|
+
if (errors.length > 0) {
|
|
212
|
+
outputError(
|
|
213
|
+
`Invalid ide.yml in ${dir}. Run "tmux-ide validate" for details.`,
|
|
214
|
+
"INVALID_CONFIG",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return config;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function runBeforeHook(command: string | undefined, dir: string): void {
|
|
222
|
+
if (!command) return;
|
|
223
|
+
|
|
224
|
+
console.log(`Running: ${command}`);
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
execSync(command, { cwd: dir, stdio: "inherit", timeout: 60_000 });
|
|
228
|
+
} catch {
|
|
229
|
+
outputError(`The before hook failed: ${command}`, "BEFORE_HOOK_FAILED");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function launch(
|
|
234
|
+
targetDir: string | undefined,
|
|
235
|
+
{ json = false, attach = true }: { json?: boolean; attach?: boolean } = {},
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
const dir = resolve(targetDir ?? ".");
|
|
238
|
+
const config = loadLaunchConfig(dir);
|
|
239
|
+
|
|
240
|
+
const { name: fallbackName } = getSessionName(dir);
|
|
241
|
+
const session = config.name ?? fallbackName;
|
|
242
|
+
const headless = config.orchestrator?.widgets === false;
|
|
243
|
+
const rows = headless ? stripWidgetPanes(config.rows) : config.rows;
|
|
244
|
+
const theme = config.theme ?? {};
|
|
245
|
+
const team = config.team ?? null;
|
|
246
|
+
|
|
247
|
+
runBeforeHook(config.before, dir);
|
|
248
|
+
|
|
249
|
+
// If session already exists, check for config drift and attach
|
|
250
|
+
if (hasSession(session)) {
|
|
251
|
+
const currentHash = configHash(config);
|
|
252
|
+
const storedHash = getSessionVariable(session, "@config_hash");
|
|
253
|
+
const configChanged = Boolean(storedHash && currentHash !== storedHash);
|
|
254
|
+
const commandCenterUrl = `http://localhost:${config.orchestrator?.port ?? config.command_center?.port ?? DEFAULT_COMMAND_CENTER_PORT}`;
|
|
255
|
+
const dashboardUrl = getSessionVariable(session, "@dashboard_url");
|
|
256
|
+
|
|
257
|
+
if (json) {
|
|
258
|
+
console.log(
|
|
259
|
+
JSON.stringify({ session, running: true, configChanged, commandCenterUrl, dashboardUrl }),
|
|
260
|
+
);
|
|
261
|
+
} else if (configChanged) {
|
|
262
|
+
console.log(`Session "${session}" is running but ide.yml has changed.`);
|
|
263
|
+
console.log(`Run "tmux-ide restart" to apply changes.`);
|
|
264
|
+
} else {
|
|
265
|
+
console.log(`Session "${session}" is already running. Attaching...`);
|
|
266
|
+
console.log(`Command Center: ${commandCenterUrl}`);
|
|
267
|
+
if (dashboardUrl) {
|
|
268
|
+
console.log(`Dashboard: ${dashboardUrl}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (attach) {
|
|
273
|
+
attachSession(session);
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Get terminal dimensions
|
|
279
|
+
const cols = process.stdout.columns ?? 200;
|
|
280
|
+
const lines = process.stdout.rows ?? 50;
|
|
281
|
+
|
|
282
|
+
// Create session with first pane
|
|
283
|
+
const rootPaneId = createDetachedSession(session, dir, { cols, lines });
|
|
284
|
+
|
|
285
|
+
// Set agent teams env var if team config is present
|
|
286
|
+
if (team) {
|
|
287
|
+
setSessionEnvironment(session, "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", "1");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const { paneMap, firstPanesOfRows } = buildPaneMap(
|
|
291
|
+
rows,
|
|
292
|
+
dir,
|
|
293
|
+
rootPaneId,
|
|
294
|
+
({ targetPane, direction, cwd, percent }) => splitPane(targetPane, direction, cwd, percent),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const { focusPane, paneActions } = collectPaneStartupPlan(rows, paneMap, firstPanesOfRows, dir);
|
|
298
|
+
|
|
299
|
+
for (const action of paneActions) {
|
|
300
|
+
if (action.title) {
|
|
301
|
+
setPaneTitle(action.targetPane, action.title);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Set pane identity options for discovery by orchestrator/widgets
|
|
305
|
+
setPaneOption(action.targetPane, "@ide_role", action.paneRole ?? "shell");
|
|
306
|
+
setPaneOption(action.targetPane, "@ide_name", action.title ?? "");
|
|
307
|
+
setPaneOption(action.targetPane, "@ide_type", action.paneType ?? "shell");
|
|
308
|
+
|
|
309
|
+
// Lock agent pane titles so Claude Code can't overwrite them
|
|
310
|
+
if (action.paneRole === "lead" || action.paneRole === "teammate") {
|
|
311
|
+
setPaneOption(action.targetPane, "allow-rename", "off");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (action.chdir) {
|
|
315
|
+
sendLiteral(action.targetPane, `cd ${shellEscape(action.chdir)}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
for (const exportCommand of action.exports) {
|
|
319
|
+
sendLiteral(action.targetPane, exportCommand);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (action.widgetType) {
|
|
323
|
+
const widgetCmd = resolveWidgetCommand(action.widgetType, {
|
|
324
|
+
session,
|
|
325
|
+
dir,
|
|
326
|
+
target: action.widgetTarget ?? null,
|
|
327
|
+
theme: config.theme ?? null,
|
|
328
|
+
});
|
|
329
|
+
sendLiteral(action.targetPane, widgetCmd);
|
|
330
|
+
} else if (action.command) {
|
|
331
|
+
sendLiteral(action.targetPane, action.command);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
for (const command of buildSessionOptions(session, { theme })) {
|
|
336
|
+
runSessionCommand(command);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Store config hash for drift detection on re-launch
|
|
340
|
+
setSessionVariable(session, "@config_hash", configHash(config));
|
|
341
|
+
|
|
342
|
+
// Start background daemon watchdog (command center + session monitor)
|
|
343
|
+
const monitorScript = resolve(
|
|
344
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
345
|
+
"lib",
|
|
346
|
+
"daemon-watchdog.ts",
|
|
347
|
+
);
|
|
348
|
+
const commandCenterPort =
|
|
349
|
+
config.orchestrator?.port ?? config.command_center?.port ?? DEFAULT_COMMAND_CENTER_PORT;
|
|
350
|
+
startSessionMonitor(session, monitorScript, commandCenterPort);
|
|
351
|
+
const dashboardPort = config.dashboard?.port ?? DEFAULT_DASHBOARD_PORT;
|
|
352
|
+
const dashboardUrl = startDashboard(session, commandCenterPort, {
|
|
353
|
+
dashboardPort,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Inject master agent prompt and task docs if orchestrator is enabled
|
|
357
|
+
if (config.orchestrator?.enabled) {
|
|
358
|
+
ensureTaskDocs(dir);
|
|
359
|
+
|
|
360
|
+
const masterPaneTitle = config.orchestrator.master_pane;
|
|
361
|
+
if (masterPaneTitle) {
|
|
362
|
+
const masterAction = paneActions.find((a) => a.title === masterPaneTitle);
|
|
363
|
+
if (masterAction) {
|
|
364
|
+
const masterPrompt = buildMasterAgentPrompt(config);
|
|
365
|
+
setTimeout(() => {
|
|
366
|
+
sendLiteral(masterAction.targetPane, masterPrompt);
|
|
367
|
+
}, 5000);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Focus the correct pane
|
|
373
|
+
selectPane(focusPane);
|
|
374
|
+
|
|
375
|
+
// Launch summary
|
|
376
|
+
const totalPanes = rows.reduce((sum, r) => sum + (r.panes?.length ?? 0), 0);
|
|
377
|
+
console.log(
|
|
378
|
+
`Starting "${session}" (${rows.length} row${rows.length === 1 ? "" : "s"}, ${totalPanes} pane${totalPanes === 1 ? "" : "s"})...`,
|
|
379
|
+
);
|
|
380
|
+
console.log(`Command Center: http://localhost:${commandCenterPort}`);
|
|
381
|
+
if (dashboardUrl) {
|
|
382
|
+
console.log(`Dashboard: ${dashboardUrl}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Attach
|
|
386
|
+
if (attach) {
|
|
387
|
+
attachSession(session);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function buildMasterAgentPrompt(config: IdeConfig): string {
|
|
392
|
+
const teammatePanes = config.rows
|
|
393
|
+
.flatMap((r) => r.panes ?? [])
|
|
394
|
+
.filter((p: Pane) => p.role === "teammate" && p.command === "claude")
|
|
395
|
+
.map((p: Pane) => p.title)
|
|
396
|
+
.filter(Boolean);
|
|
397
|
+
|
|
398
|
+
return `You are the Master Agent for this tmux-ide session.
|
|
399
|
+
|
|
400
|
+
## Your role
|
|
401
|
+
You coordinate a team of coding agents. The human gives you high-level goals, and you break them into structured tasks that your teammates execute.
|
|
402
|
+
|
|
403
|
+
## Your teammates
|
|
404
|
+
${teammatePanes.map((t) => `- ${t}`).join("\n")}
|
|
405
|
+
|
|
406
|
+
## Task management commands
|
|
407
|
+
- tmux-ide mission set "title" --description "..."
|
|
408
|
+
- tmux-ide goal create "title" --priority N --acceptance "criteria"
|
|
409
|
+
- tmux-ide goal list --json
|
|
410
|
+
- tmux-ide task create "title" --goal NN --priority N
|
|
411
|
+
- tmux-ide task list --json
|
|
412
|
+
- tmux-ide task show NNN --json (shows full mission→goal→task context)
|
|
413
|
+
- tmux-ide task done NNN --proof "what was accomplished"
|
|
414
|
+
- tmux-ide goal done NN
|
|
415
|
+
|
|
416
|
+
## How it works
|
|
417
|
+
1. You create tasks with tmux-ide task create
|
|
418
|
+
2. The orchestrator automatically assigns unassigned tasks to idle teammates
|
|
419
|
+
3. Teammates work in the project directory
|
|
420
|
+
4. When teammates finish, they run tmux-ide task done
|
|
421
|
+
5. You get notified and review their work
|
|
422
|
+
6. You report progress to the human
|
|
423
|
+
|
|
424
|
+
## Important
|
|
425
|
+
- Focus on PLANNING and REVIEWING, not implementing
|
|
426
|
+
- Break work into small, clear tasks (one per teammate)
|
|
427
|
+
- Each task should be completable independently
|
|
428
|
+
- Set clear acceptance criteria in goals
|
|
429
|
+
- Check progress: tmux-ide task list --json`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const TASK_DOCS_MARKER = "## Task Management";
|
|
433
|
+
|
|
434
|
+
const TASK_DOCS_SECTION = `
|
|
435
|
+
## Task Management
|
|
436
|
+
|
|
437
|
+
tmux-ide provides structured task management for coordinated multi-agent work.
|
|
438
|
+
|
|
439
|
+
### Mission & Goals
|
|
440
|
+
|
|
441
|
+
\`\`\`bash
|
|
442
|
+
tmux-ide mission set "title" --description "..." # Set the project mission
|
|
443
|
+
tmux-ide mission show # Show current mission
|
|
444
|
+
tmux-ide mission clear # Clear the mission
|
|
445
|
+
|
|
446
|
+
tmux-ide goal create "title" --priority N --acceptance "criteria"
|
|
447
|
+
tmux-ide goal list [--json] # List all goals
|
|
448
|
+
tmux-ide goal show <id> [--json] # Show goal with tasks
|
|
449
|
+
tmux-ide goal update <id> --status done
|
|
450
|
+
tmux-ide goal done <id> # Mark goal complete
|
|
451
|
+
tmux-ide goal delete <id>
|
|
452
|
+
\`\`\`
|
|
453
|
+
|
|
454
|
+
### Tasks
|
|
455
|
+
|
|
456
|
+
\`\`\`bash
|
|
457
|
+
tmux-ide task create "title" --goal NN --priority N --assign "Agent" --tags "a,b" --depends "001,002"
|
|
458
|
+
tmux-ide task list [--status todo --goal NN] [--json]
|
|
459
|
+
tmux-ide task show <id> [--json] # Full mission→goal→task context
|
|
460
|
+
tmux-ide task update <id> --status review --proof '{"tests":{"passed":10,"total":10}}'
|
|
461
|
+
tmux-ide task claim <id> --assign "Agent Name" # Claim and start a task
|
|
462
|
+
tmux-ide task done <id> --proof "description" # Mark task complete with proof
|
|
463
|
+
tmux-ide task delete <id>
|
|
464
|
+
\`\`\`
|
|
465
|
+
|
|
466
|
+
### Proof Format
|
|
467
|
+
|
|
468
|
+
The \`--proof\` flag accepts either a plain string (stored as \`notes\`) or a JSON object:
|
|
469
|
+
|
|
470
|
+
\`\`\`json
|
|
471
|
+
{
|
|
472
|
+
"tests": { "passed": 10, "total": 10 },
|
|
473
|
+
"pr": { "number": 42, "url": "https://...", "status": "merged" },
|
|
474
|
+
"ci": { "status": "passing", "url": "https://..." },
|
|
475
|
+
"notes": "Additional context"
|
|
476
|
+
}
|
|
477
|
+
\`\`\`
|
|
478
|
+
|
|
479
|
+
### Task Dependencies
|
|
480
|
+
|
|
481
|
+
Use \`--depends "001,002"\` to declare that a task depends on other tasks. The orchestrator will not dispatch a task until all its dependencies are complete.
|
|
482
|
+
`;
|
|
483
|
+
|
|
484
|
+
export function ensureTaskDocs(dir: string): void {
|
|
485
|
+
const claudeMdPath = join(dir, "CLAUDE.md");
|
|
486
|
+
|
|
487
|
+
if (existsSync(claudeMdPath)) {
|
|
488
|
+
const content = readFileSync(claudeMdPath, "utf-8");
|
|
489
|
+
if (content.includes(TASK_DOCS_MARKER)) return;
|
|
490
|
+
writeFileSync(claudeMdPath, content + TASK_DOCS_SECTION);
|
|
491
|
+
} else {
|
|
492
|
+
writeFileSync(claudeMdPath, `# Project\n${TASK_DOCS_SECTION}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import * as crypto from "node:crypto";
|
|
3
|
+
import { AuthService } from "./auth-service.ts";
|
|
4
|
+
|
|
5
|
+
describe("AuthService", () => {
|
|
6
|
+
let auth: AuthService;
|
|
7
|
+
const SECRET = "test-secret-key-for-jwt";
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
auth = new AuthService(SECRET);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
auth.dispose();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// ---- JWT ----------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
describe("JWT sign/verify round-trip", () => {
|
|
20
|
+
it("generates and verifies a valid token", () => {
|
|
21
|
+
const token = auth.generateToken("alice");
|
|
22
|
+
const result = auth.verifyToken(token);
|
|
23
|
+
expect(result.valid).toBe(true);
|
|
24
|
+
expect(result.userId).toBe("alice");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("works with different user IDs", () => {
|
|
28
|
+
const token = auth.generateToken("bob@example.com");
|
|
29
|
+
const result = auth.verifyToken(token);
|
|
30
|
+
expect(result.valid).toBe(true);
|
|
31
|
+
expect(result.userId).toBe("bob@example.com");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("expired token rejected", () => {
|
|
36
|
+
it("rejects a token with exp in the past", () => {
|
|
37
|
+
// Create a service with extremely short expiry by manipulating the token
|
|
38
|
+
// We'll manually craft a token with an expired timestamp
|
|
39
|
+
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString(
|
|
40
|
+
"base64url",
|
|
41
|
+
);
|
|
42
|
+
const payload = Buffer.from(
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
userId: "alice",
|
|
45
|
+
iat: Math.floor(Date.now() / 1000) - 7200,
|
|
46
|
+
exp: Math.floor(Date.now() / 1000) - 3600, // expired 1 hour ago
|
|
47
|
+
}),
|
|
48
|
+
).toString("base64url");
|
|
49
|
+
const sigInput = header + "." + payload;
|
|
50
|
+
const sig = crypto.createHmac("sha256", SECRET).update(sigInput).digest("base64url");
|
|
51
|
+
const expiredToken = `${header}.${payload}.${sig}`;
|
|
52
|
+
|
|
53
|
+
const result = auth.verifyToken(expiredToken);
|
|
54
|
+
expect(result.valid).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("tampered token rejected", () => {
|
|
59
|
+
it("rejects a token with modified payload", () => {
|
|
60
|
+
const token = auth.generateToken("alice");
|
|
61
|
+
const parts = token.split(".");
|
|
62
|
+
|
|
63
|
+
// Tamper with payload — change userId
|
|
64
|
+
const payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString());
|
|
65
|
+
payload.userId = "eve";
|
|
66
|
+
parts[1] = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
67
|
+
const tampered = parts.join(".");
|
|
68
|
+
|
|
69
|
+
const result = auth.verifyToken(tampered);
|
|
70
|
+
expect(result.valid).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("rejects a token with modified signature", () => {
|
|
74
|
+
const token = auth.generateToken("alice");
|
|
75
|
+
const parts = token.split(".");
|
|
76
|
+
// Flip a character in the signature
|
|
77
|
+
const sig = parts[2]!;
|
|
78
|
+
parts[2] = sig[0] === "A" ? "B" + sig.slice(1) : "A" + sig.slice(1);
|
|
79
|
+
const tampered = parts.join(".");
|
|
80
|
+
|
|
81
|
+
const result = auth.verifyToken(tampered);
|
|
82
|
+
expect(result.valid).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("rejects a token signed with a different secret", () => {
|
|
86
|
+
const other = new AuthService("different-secret");
|
|
87
|
+
const token = other.generateToken("alice");
|
|
88
|
+
other.dispose();
|
|
89
|
+
|
|
90
|
+
const result = auth.verifyToken(token);
|
|
91
|
+
expect(result.valid).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("rejects garbage strings", () => {
|
|
95
|
+
expect(auth.verifyToken("not.a.jwt").valid).toBe(false);
|
|
96
|
+
expect(auth.verifyToken("").valid).toBe(false);
|
|
97
|
+
expect(auth.verifyToken("only-one-part").valid).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---- SSH challenge-response -------------------------------------------
|
|
102
|
+
|
|
103
|
+
describe("SSH challenge with test keypair", () => {
|
|
104
|
+
// Generate an Ed25519 keypair for testing
|
|
105
|
+
function makeTestKey() {
|
|
106
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
107
|
+
|
|
108
|
+
// Export raw 32-byte public key
|
|
109
|
+
const rawPub = publicKey.export({ type: "spki", format: "der" });
|
|
110
|
+
// Last 32 bytes of the DER encoding are the raw key
|
|
111
|
+
const raw32 = rawPub.subarray(rawPub.length - 32);
|
|
112
|
+
|
|
113
|
+
// Build SSH wire format: uint32(len) + "ssh-ed25519" + uint32(32) + raw32
|
|
114
|
+
const alg = Buffer.from("ssh-ed25519");
|
|
115
|
+
const algLenBuf = Buffer.alloc(4);
|
|
116
|
+
algLenBuf.writeUInt32BE(alg.length);
|
|
117
|
+
const keyLenBuf = Buffer.alloc(4);
|
|
118
|
+
keyLenBuf.writeUInt32BE(32);
|
|
119
|
+
const sshPubBuf = Buffer.concat([algLenBuf, alg, keyLenBuf, raw32]);
|
|
120
|
+
const sshPubStr = `ssh-ed25519 ${sshPubBuf.toString("base64")} test@test`;
|
|
121
|
+
|
|
122
|
+
return { publicKey, privateKey, sshPubStr };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
it("creates a challenge and verifies a valid signature", async () => {
|
|
126
|
+
const { privateKey, sshPubStr } = makeTestKey();
|
|
127
|
+
|
|
128
|
+
// Create challenge
|
|
129
|
+
const { challengeId, challenge } = auth.createChallenge("testuser");
|
|
130
|
+
expect(challengeId).toBeTruthy();
|
|
131
|
+
expect(challenge).toBeTruthy();
|
|
132
|
+
|
|
133
|
+
// Sign the challenge
|
|
134
|
+
const challengeBuf = Buffer.from(challenge, "base64");
|
|
135
|
+
const signature = crypto.sign(null, challengeBuf, privateKey);
|
|
136
|
+
|
|
137
|
+
// authenticateWithSSHKey checks authorized_keys which won't exist in test,
|
|
138
|
+
// so we just verify the signature verification path works by checking
|
|
139
|
+
// that it gets past signature verification (fails at authorization)
|
|
140
|
+
const result = await auth.authenticateWithSSHKey({
|
|
141
|
+
publicKey: sshPubStr,
|
|
142
|
+
challengeId,
|
|
143
|
+
signature: signature.toString("base64"),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Expect "not authorized" — which means signature verification passed
|
|
147
|
+
expect(result.success).toBe(false);
|
|
148
|
+
expect(result.error).toBe("SSH key not authorized for this user");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("rejects an invalid signature", async () => {
|
|
152
|
+
const { sshPubStr } = makeTestKey();
|
|
153
|
+
|
|
154
|
+
const { challengeId } = auth.createChallenge("testuser");
|
|
155
|
+
|
|
156
|
+
// Send garbage signature (wrong length for ed25519 = 64 bytes)
|
|
157
|
+
const result = await auth.authenticateWithSSHKey({
|
|
158
|
+
publicKey: sshPubStr,
|
|
159
|
+
challengeId,
|
|
160
|
+
signature: Buffer.alloc(64).toString("base64"),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(result.success).toBe(false);
|
|
164
|
+
expect(result.error).toBe("Invalid SSH key signature");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("rejects an expired/unknown challenge ID", async () => {
|
|
168
|
+
const result = await auth.authenticateWithSSHKey({
|
|
169
|
+
publicKey: "ssh-ed25519 AAAA test",
|
|
170
|
+
challengeId: "nonexistent-id",
|
|
171
|
+
signature: Buffer.alloc(64).toString("base64"),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(result.success).toBe(false);
|
|
175
|
+
expect(result.error).toBe("Invalid or expired challenge");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
});
|