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.
Files changed (205) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +33 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/{attach.js → attach.ts} +7 -4
  6. package/src/cli.test.ts +314 -0
  7. package/src/command-center/discovery.test.ts +265 -0
  8. package/src/command-center/discovery.ts +296 -0
  9. package/src/command-center/index.ts +31 -0
  10. package/src/command-center/schemas.ts +57 -0
  11. package/src/command-center/server.test.ts +818 -0
  12. package/src/command-center/server.ts +1426 -0
  13. package/src/config-cli.test.ts +86 -0
  14. package/src/config.test.ts +205 -0
  15. package/src/config.ts +313 -0
  16. package/src/detect.test.ts +142 -0
  17. package/src/{detect.js → detect.ts} +31 -15
  18. package/src/dispatch.ts +102 -0
  19. package/src/doctor.test.ts +119 -0
  20. package/src/doctor.ts +167 -0
  21. package/src/init.test.ts +101 -0
  22. package/src/init.ts +144 -0
  23. package/src/inspect.test.ts +67 -0
  24. package/src/{inspect.js → inspect.ts} +95 -10
  25. package/src/integration.test.ts.skip +276 -0
  26. package/src/js-yaml.d.ts +10 -0
  27. package/src/launch.test.ts +222 -0
  28. package/src/launch.ts +494 -0
  29. package/src/lib/auth/auth-service.test.ts +178 -0
  30. package/src/lib/auth/auth-service.ts +227 -0
  31. package/src/lib/auth/middleware.test.ts +119 -0
  32. package/src/lib/auth/middleware.ts +56 -0
  33. package/src/lib/auth/types.ts +13 -0
  34. package/src/lib/authorship.test.ts +242 -0
  35. package/src/lib/authorship.ts +280 -0
  36. package/src/lib/cast/recorder.test.ts +127 -0
  37. package/src/lib/cast/recorder.ts +148 -0
  38. package/src/lib/daemon-watchdog.ts +102 -0
  39. package/src/lib/daemon.ts +417 -0
  40. package/src/lib/dot-path.test.ts +66 -0
  41. package/src/lib/dot-path.ts +17 -0
  42. package/src/lib/errors.ts +44 -0
  43. package/src/lib/event-log.test.ts +189 -0
  44. package/src/lib/event-log.ts +237 -0
  45. package/src/lib/github-pr.ts +182 -0
  46. package/src/lib/hq/client.test.ts +154 -0
  47. package/src/lib/hq/client.ts +142 -0
  48. package/src/lib/hq/mdns.test.ts +54 -0
  49. package/src/lib/hq/mdns.ts +88 -0
  50. package/src/lib/hq/registry.test.ts +237 -0
  51. package/src/lib/hq/registry.ts +145 -0
  52. package/src/lib/hq/types.ts +44 -0
  53. package/src/lib/ipc/socket-protocol.test.ts +126 -0
  54. package/src/lib/ipc/socket-protocol.ts +324 -0
  55. package/src/lib/launch-plan.test.ts +119 -0
  56. package/src/lib/launch-plan.ts +90 -0
  57. package/src/lib/log.test.ts +39 -0
  58. package/src/lib/log.ts +71 -0
  59. package/src/lib/metrics.test.ts +213 -0
  60. package/src/lib/metrics.ts +345 -0
  61. package/src/lib/orchestrator.test.ts +2358 -0
  62. package/src/lib/orchestrator.ts +1792 -0
  63. package/src/lib/{output.js → output.ts} +18 -10
  64. package/src/lib/plan-store.test.ts +148 -0
  65. package/src/lib/plan-store.ts +131 -0
  66. package/src/lib/research.test.ts +208 -0
  67. package/src/lib/research.ts +475 -0
  68. package/src/lib/round-trip.test.ts +341 -0
  69. package/src/lib/session-monitor.test.ts +152 -0
  70. package/src/lib/session-monitor.ts +282 -0
  71. package/src/lib/session-options.test.ts +146 -0
  72. package/src/lib/{session-options.js → session-options.ts} +11 -6
  73. package/src/lib/shell.test.ts +20 -0
  74. package/src/lib/shell.ts +8 -0
  75. package/src/lib/sizes.test.ts +73 -0
  76. package/src/lib/{sizes.js → sizes.ts} +4 -4
  77. package/src/lib/skill-registry.test.ts +134 -0
  78. package/src/lib/skill-registry.ts +86 -0
  79. package/src/lib/slugify.test.ts +35 -0
  80. package/src/lib/slugify.ts +10 -0
  81. package/src/lib/task-store.ts +352 -0
  82. package/src/lib/tmux.test.ts +500 -0
  83. package/src/lib/tmux.ts +372 -0
  84. package/src/lib/token-tracker.test.ts +102 -0
  85. package/src/lib/token-tracker.ts +70 -0
  86. package/src/lib/tunnels/cloudflare.test.ts +73 -0
  87. package/src/lib/tunnels/cloudflare.ts +254 -0
  88. package/src/lib/tunnels/manager.test.ts +63 -0
  89. package/src/lib/tunnels/manager.ts +132 -0
  90. package/src/lib/tunnels/ngrok.test.ts +94 -0
  91. package/src/lib/tunnels/ngrok.ts +261 -0
  92. package/src/lib/tunnels/tailscale.test.ts +103 -0
  93. package/src/lib/tunnels/tailscale.ts +1048 -0
  94. package/src/lib/tunnels/types.ts +44 -0
  95. package/src/lib/validation.test.ts +289 -0
  96. package/src/lib/validation.ts +123 -0
  97. package/src/lib/webhook.ts +51 -0
  98. package/src/lib/workflow-store.ts +209 -0
  99. package/src/lib/ws-v3/protocol.test.ts +127 -0
  100. package/src/lib/ws-v3/protocol.ts +160 -0
  101. package/src/lib/yaml-io.test.ts +125 -0
  102. package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
  103. package/src/ls.test.ts +76 -0
  104. package/src/{ls.js → ls.ts} +4 -4
  105. package/src/metrics-cli.ts +177 -0
  106. package/src/notify.ts +85 -0
  107. package/src/orchestrator-status.ts +228 -0
  108. package/src/plan.ts +117 -0
  109. package/src/postinstall.test.ts +79 -0
  110. package/src/remote.test.ts +115 -0
  111. package/src/remote.ts +148 -0
  112. package/src/restart.ts +24 -0
  113. package/src/schemas/domain.ts +422 -0
  114. package/src/schemas/ide-config.ts +148 -0
  115. package/src/schemas/index.ts +53 -0
  116. package/src/send.test.ts +70 -0
  117. package/src/send.ts +181 -0
  118. package/src/skill.test.ts +114 -0
  119. package/src/skill.ts +138 -0
  120. package/src/status.ts +66 -0
  121. package/src/stop.test.ts +176 -0
  122. package/src/stop.ts +47 -0
  123. package/src/task.test.ts +1466 -0
  124. package/src/task.ts +1143 -0
  125. package/src/tunnel.test.ts +80 -0
  126. package/src/tunnel.ts +133 -0
  127. package/src/types.ts +15 -0
  128. package/src/ui/index.ts +32 -0
  129. package/src/ui/terminal/index.ts +9 -0
  130. package/src/ui/types.ts +91 -0
  131. package/src/ui/web/base.css +80 -0
  132. package/src/ui/web/components/Box.tsx +59 -0
  133. package/src/ui/web/components/Input.tsx +32 -0
  134. package/src/ui/web/components/ScrollBox.tsx +60 -0
  135. package/src/ui/web/components/Text.tsx +28 -0
  136. package/src/ui/web/hooks.ts +106 -0
  137. package/src/ui/web/index.ts +27 -0
  138. package/src/ui/web/render.ts +77 -0
  139. package/src/ui/web/utils/color.test.ts +93 -0
  140. package/src/ui/web/utils/color.ts +27 -0
  141. package/src/validate.test.ts +385 -0
  142. package/src/validate.ts +217 -0
  143. package/src/widgets/changes/index.tsx +576 -0
  144. package/src/widgets/config/index.tsx +440 -0
  145. package/src/widgets/costs/index.tsx +216 -0
  146. package/src/widgets/explorer/breadcrumbs.tsx +77 -0
  147. package/src/widgets/explorer/footer.tsx +20 -0
  148. package/src/widgets/explorer/header.tsx +23 -0
  149. package/src/widgets/explorer/index.tsx +408 -0
  150. package/src/widgets/explorer/tree-model.test.ts +214 -0
  151. package/src/widgets/explorer/tree-model.ts +103 -0
  152. package/src/widgets/explorer/tree.tsx +156 -0
  153. package/src/widgets/lib/config-model.ts +116 -0
  154. package/src/widgets/lib/files.test.ts +103 -0
  155. package/src/widgets/lib/files.ts +88 -0
  156. package/src/widgets/lib/git.test.ts +151 -0
  157. package/src/widgets/lib/git.ts +88 -0
  158. package/src/widgets/lib/pane-comms.test.ts +178 -0
  159. package/src/widgets/lib/pane-comms.ts +192 -0
  160. package/src/widgets/lib/theme.ts +151 -0
  161. package/src/widgets/lib/watcher.ts +50 -0
  162. package/src/widgets/mission-control/activity-feed.tsx +59 -0
  163. package/src/widgets/mission-control/agent-panel.tsx +132 -0
  164. package/src/widgets/mission-control/command-bar.tsx +63 -0
  165. package/src/widgets/mission-control/index.tsx +751 -0
  166. package/src/widgets/mission-control/task-panel.tsx +238 -0
  167. package/src/widgets/preview/index.tsx +415 -0
  168. package/src/widgets/resolve.ts +40 -0
  169. package/src/widgets/setup/agent-naming.tsx +112 -0
  170. package/src/widgets/setup/config-tree.tsx +238 -0
  171. package/src/widgets/setup/detect-panel.tsx +72 -0
  172. package/src/widgets/setup/field-editor.tsx +265 -0
  173. package/src/widgets/setup/footer.tsx +107 -0
  174. package/src/widgets/setup/index.tsx +340 -0
  175. package/src/widgets/setup/layout-picker.tsx +96 -0
  176. package/src/widgets/setup/orchestrator-panel.tsx +200 -0
  177. package/src/widgets/setup/review-panel.tsx +140 -0
  178. package/src/widgets/setup/setup-model.test.ts +303 -0
  179. package/src/widgets/setup/setup-model.ts +188 -0
  180. package/src/widgets/tasks/index.tsx +129 -0
  181. package/src/widgets/tasks/task-detail.tsx +309 -0
  182. package/src/widgets/tasks/task-form.tsx +193 -0
  183. package/src/widgets/tasks/task-list.tsx +205 -0
  184. package/src/widgets/tasks/task-model.test.ts +473 -0
  185. package/src/widgets/tasks/task-model.ts +157 -0
  186. package/templates/AGENTS.md +20 -0
  187. package/templates/missions.yml +41 -0
  188. package/templates/skills/backend.md +30 -0
  189. package/templates/skills/frontend.md +30 -0
  190. package/templates/skills/general-worker.md +30 -0
  191. package/templates/skills/researcher.md +69 -0
  192. package/templates/skills/reviewer.md +30 -0
  193. package/src/config.js +0 -341
  194. package/src/doctor.js +0 -91
  195. package/src/init.js +0 -73
  196. package/src/launch.js +0 -245
  197. package/src/lib/dot-path.js +0 -16
  198. package/src/lib/errors.js +0 -35
  199. package/src/lib/launch-plan.js +0 -49
  200. package/src/lib/session-monitor.js +0 -179
  201. package/src/lib/tmux.js +0 -237
  202. package/src/restart.js +0 -16
  203. package/src/status.js +0 -35
  204. package/src/stop.js +0 -25
  205. 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
+ });