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
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Unified tmux-ide daemon — background process that combines:
3
+ * 1. Pane monitor loop (port detection, agent state, title drift)
4
+ * 2. Orchestrator startup (task dispatch, stall detection)
5
+ * 3. Command Center HTTP/WebSocket server
6
+ *
7
+ * Entry: bun src/lib/daemon.ts <session> [port]
8
+ * Spawned as a detached child by launch.ts (via daemon-watchdog.ts).
9
+ */
10
+
11
+ import { execFileSync } from "node:child_process";
12
+ import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { createServer, type Server } from "node:http";
15
+ import { computePortPanes, computeAgentStates } from "./session-monitor.ts";
16
+
17
+ /** Remove dispatch files older than 24 hours */
18
+ function cleanupDispatchFiles(dir: string): void {
19
+ const dispatchDir = join(dir, ".tasks", "dispatch");
20
+ if (!existsSync(dispatchDir)) return;
21
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
22
+ try {
23
+ for (const file of readdirSync(dispatchDir)) {
24
+ const filePath = join(dispatchDir, file);
25
+ try {
26
+ const mtime = statSync(filePath).mtimeMs;
27
+ if (mtime < cutoff) unlinkSync(filePath);
28
+ } catch {
29
+ // skip files we can't stat/delete
30
+ }
31
+ }
32
+ } catch {
33
+ // dispatch dir unreadable — skip
34
+ }
35
+ }
36
+
37
+ const INTERVAL = 1000;
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Args
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const sessionArg = process.argv[2];
44
+ if (!sessionArg) {
45
+ console.error("Usage: daemon.ts <session> [port]");
46
+ process.exit(1);
47
+ }
48
+ const session: string = sessionArg;
49
+ const requestedPort = parseInt(process.argv[3] ?? "6060", 10);
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // tmux helpers (same as session-monitor.ts)
53
+ // ---------------------------------------------------------------------------
54
+
55
+ interface MonitorPane {
56
+ id: string;
57
+ pid: string;
58
+ cmd?: string;
59
+ title?: string;
60
+ role?: string;
61
+ type?: string;
62
+ name?: string;
63
+ }
64
+
65
+ function tmux(...args: string[]): string {
66
+ return execFileSync("tmux", args, { encoding: "utf-8" }).trim();
67
+ }
68
+
69
+ function tmuxSilent(...args: string[]): string {
70
+ try {
71
+ return tmux(...args);
72
+ } catch {
73
+ return "";
74
+ }
75
+ }
76
+
77
+ function sessionExists(): boolean {
78
+ try {
79
+ tmux("has-session", "-t", session);
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function hasClients(): boolean {
87
+ return tmuxSilent("list-clients").length > 0;
88
+ }
89
+
90
+ function listPanes(): MonitorPane[] {
91
+ const raw = tmuxSilent(
92
+ "list-panes",
93
+ "-t",
94
+ session,
95
+ "-F",
96
+ "#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{pane_title}\t#{@ide_role}\t#{@ide_type}\t#{@ide_name}",
97
+ );
98
+ if (!raw) return [];
99
+ return raw.split("\n").map((line) => {
100
+ const [id, pid, cmd, title, role, type, name] = line.split("\t");
101
+ return {
102
+ id: id!,
103
+ pid: pid!,
104
+ cmd,
105
+ title,
106
+ role: role || undefined,
107
+ type: type || undefined,
108
+ name: name || undefined,
109
+ };
110
+ });
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Monitor loop
115
+ // ---------------------------------------------------------------------------
116
+
117
+ let lastState = "";
118
+ let prevAgentStates = new Map<string, "busy" | "idle" | null>();
119
+ let monitorInitialized = false;
120
+
121
+ function tick(): void {
122
+ if (!sessionExists()) {
123
+ shutdown();
124
+ return;
125
+ }
126
+ if (!hasClients()) return; // skip when nobody is watching
127
+
128
+ const panes = listPanes();
129
+ if (panes.length === 0) return;
130
+
131
+ const portPanes = computePortPanes(panes);
132
+ const agentStates = computeAgentStates(panes);
133
+
134
+ // Build state fingerprint for change detection (includes title drift)
135
+ const stateKey = panes
136
+ .map((p) => {
137
+ const port = portPanes.has(p.id) ? "1" : "0";
138
+ const agent = agentStates.get(p.id) ?? "-";
139
+ const titleDrift = p.name && p.title !== p.name ? "d" : "ok";
140
+ return `${p.id}:${port}:${agent}:${titleDrift}`;
141
+ })
142
+ .join("|");
143
+
144
+ if (stateKey === lastState) return;
145
+
146
+ // Apply changes
147
+ for (const pane of panes) {
148
+ const hasPort = portPanes.has(pane.id) ? "1" : "0";
149
+ const agent = agentStates.get(pane.id);
150
+
151
+ tmuxSilent("set-option", "-pqt", pane.id, "@has_port", hasPort);
152
+ tmuxSilent("set-option", "-pqt", pane.id, "@agent_busy", agent === "busy" ? "1" : "0");
153
+ tmuxSilent("set-option", "-pqt", pane.id, "@agent_idle", agent === "idle" ? "1" : "0");
154
+
155
+ // Restore configured pane title if Claude Code changed it
156
+ if (pane.name && pane.title !== pane.name) {
157
+ tmuxSilent("select-pane", "-t", pane.id, "-T", pane.name);
158
+ }
159
+ }
160
+
161
+ // Notify master pane when an agent transitions from busy → idle
162
+ if (monitorInitialized) {
163
+ const masterPane = panes.find((p) => p.role === "lead");
164
+ if (masterPane) {
165
+ for (const pane of panes) {
166
+ const prev = prevAgentStates.get(pane.id);
167
+ const curr = agentStates.get(pane.id);
168
+ if (prev === "busy" && curr === "idle" && pane.id !== masterPane.id) {
169
+ const label = pane.name ?? pane.title ?? pane.id;
170
+ tmuxSilent("send-keys", "-t", masterPane.id, `Agent "${label}" is now idle`, "Enter");
171
+ }
172
+ }
173
+ }
174
+ }
175
+ monitorInitialized = true;
176
+ prevAgentStates = agentStates;
177
+
178
+ tmuxSilent("refresh-client", "-S");
179
+ lastState = stateKey;
180
+ }
181
+
182
+ const monitorInterval = setInterval(tick, INTERVAL);
183
+ tick(); // run immediately
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Orchestrator (if enabled in ide.yml)
187
+ // ---------------------------------------------------------------------------
188
+
189
+ let stopOrchestrator: (() => void) | null = null;
190
+
191
+ async function startOrchestrator(): Promise<void> {
192
+ try {
193
+ const { readConfig } = await import("./yaml-io.ts");
194
+ const { config } = readConfig(process.cwd());
195
+
196
+ if (!config.orchestrator?.enabled) return;
197
+
198
+ try {
199
+ const { createOrchestrator } = await import("./orchestrator.ts");
200
+
201
+ // Configure webhooks for event delivery
202
+ if (config.orchestrator.webhooks?.length) {
203
+ const { setWebhookConfig } = await import("./event-log.ts");
204
+ setWebhookConfig(config.orchestrator.webhooks);
205
+ }
206
+
207
+ const orch = config.orchestrator;
208
+
209
+ // Build pane specialty map from ide.yml config
210
+ const paneSpecialties = new Map<string, string[]>();
211
+ for (const row of config.rows) {
212
+ for (const pane of row.panes) {
213
+ if (pane.specialty && pane.title) {
214
+ paneSpecialties.set(
215
+ pane.title,
216
+ pane.specialty.split(",").map((s: string) => s.trim().toLowerCase()),
217
+ );
218
+ }
219
+ }
220
+ }
221
+
222
+ // Clean up stale dispatch files on startup
223
+ cleanupDispatchFiles(process.cwd());
224
+
225
+ stopOrchestrator = createOrchestrator({
226
+ session,
227
+ dir: process.cwd(),
228
+ autoDispatch: orch.auto_dispatch ?? true,
229
+ stallTimeout: orch.stall_timeout ?? 300000,
230
+ pollInterval: orch.poll_interval ?? 5000,
231
+ masterPane: orch.master_pane ?? null,
232
+ beforeRun: orch.before_run ?? null,
233
+ afterRun: orch.after_run ?? null,
234
+ maxConcurrentAgents: orch.max_concurrent_agents ?? 10,
235
+ dispatchMode: orch.dispatch_mode ?? "tasks",
236
+ paneSpecialties,
237
+ services:
238
+ (orch.services as Record<
239
+ string,
240
+ { command: string; port?: number; healthcheck?: string }
241
+ >) ?? {},
242
+ research: orch.research,
243
+ });
244
+ } catch {
245
+ // Orchestrator module not available yet
246
+ }
247
+ } catch {
248
+ // Config not readable — skip orchestrator
249
+ }
250
+ }
251
+
252
+ void startOrchestrator();
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Command Center HTTP + WebSocket server
256
+ // ---------------------------------------------------------------------------
257
+
258
+ let httpServer: Server | null = null;
259
+
260
+ async function startCommandCenter(): Promise<void> {
261
+ try {
262
+ const { createApp } = await import("../command-center/server.ts");
263
+ const { getRequestListener } = await import("@hono/node-server");
264
+ const { AuthService } = await import("./auth/auth-service.ts");
265
+ const { AuthConfigSchema } = await import("./auth/types.ts");
266
+ const { TunnelManager } = await import("./tunnels/manager.ts");
267
+ const { RemoteRegistry } = await import("./hq/registry.ts");
268
+ const { HQConfigSchema } = await import("./hq/types.ts");
269
+
270
+ // Load auth + tunnel + hq config from ide.yml
271
+ let authConfig = AuthConfigSchema.parse({});
272
+ let authService: InstanceType<typeof AuthService> | undefined;
273
+ let tunnelConfig: Record<string, unknown> | undefined;
274
+ let hqConfig: ReturnType<typeof HQConfigSchema.parse> | undefined;
275
+ try {
276
+ const { readConfig } = await import("./yaml-io.ts");
277
+ const { config } = readConfig(process.cwd());
278
+ if (config.auth) {
279
+ authConfig = AuthConfigSchema.parse(config.auth);
280
+ }
281
+ if (config.tunnel) {
282
+ tunnelConfig = config.tunnel as Record<string, unknown>;
283
+ }
284
+ if (config.hq) {
285
+ hqConfig = HQConfigSchema.parse(config.hq);
286
+ }
287
+ } catch {
288
+ // Config not readable — use defaults
289
+ }
290
+ authService = new AuthService(authConfig.secret);
291
+ const tunnelManager = new TunnelManager({ session, dir: process.cwd() });
292
+
293
+ // Start HQ registry if role is "hq"
294
+ let remoteRegistry: InstanceType<typeof RemoteRegistry> | undefined;
295
+ if (hqConfig?.enabled && hqConfig.role === "hq") {
296
+ let _isShuttingDown = false;
297
+ remoteRegistry = new RemoteRegistry({
298
+ healthInterval: hqConfig.heartbeat_interval,
299
+ isShuttingDown: () => _isShuttingDown,
300
+ });
301
+ // Wire shutdown flag
302
+ const origShutdown = shutdown;
303
+ const shutdownWithRegistry = () => {
304
+ _isShuttingDown = true;
305
+ remoteRegistry?.destroy();
306
+ origShutdown();
307
+ };
308
+ process.removeListener("SIGTERM", shutdown);
309
+ process.removeListener("SIGINT", shutdown);
310
+ process.on("SIGTERM", shutdownWithRegistry);
311
+ process.on("SIGINT", shutdownWithRegistry);
312
+ console.log("[daemon] HQ registry started");
313
+ }
314
+
315
+ const app = createApp({ authService, authConfig, tunnelManager, remoteRegistry });
316
+
317
+ // Health endpoint for watchdog and external probes
318
+ app.get("/health", (c: { json: (body: unknown, status?: number) => Response }) => {
319
+ return c.json({ ok: true, session, uptime: process.uptime() });
320
+ });
321
+
322
+ const listener = getRequestListener(app.fetch);
323
+ const server = createServer(listener);
324
+
325
+ const bindRequestedPort = (port: number): Promise<boolean> =>
326
+ new Promise<boolean>((res, rej) => {
327
+ server.once("error", (err: NodeJS.ErrnoException) => {
328
+ if (err.code === "EADDRINUSE") {
329
+ console.error(`[daemon] Command Center port ${port} is in use; skipping HTTP server`);
330
+ server.removeAllListeners("error");
331
+ res(false);
332
+ } else {
333
+ rej(err);
334
+ }
335
+ });
336
+ server.listen(port, "0.0.0.0", () => {
337
+ tmuxSilent("set-option", "-t", session, "@command_center_port", String(port));
338
+ console.log(`[daemon] Command Center on http://0.0.0.0:${port} (session: ${session})`);
339
+ res(true);
340
+ });
341
+ });
342
+
343
+ const commandCenterStarted = await bindRequestedPort(requestedPort);
344
+ if (commandCenterStarted) {
345
+ httpServer = server;
346
+ }
347
+
348
+ // Auto-start tunnel if configured
349
+ if (commandCenterStarted && tunnelConfig && tunnelConfig.auto_start) {
350
+ try {
351
+ const { tunnelConfigSchema } = await import("./tunnels/types.ts");
352
+ const parsed = tunnelConfigSchema.parse({
353
+ ...tunnelConfig,
354
+ port: tunnelConfig.port ?? requestedPort,
355
+ });
356
+ await tunnelManager.start(parsed);
357
+ console.log("[daemon] Tunnel auto-started");
358
+ } catch (err) {
359
+ console.error("[daemon] Tunnel auto-start failed:", err);
360
+ }
361
+ }
362
+
363
+ // Auto-start HQ client if role is "remote"
364
+ if (
365
+ commandCenterStarted &&
366
+ hqConfig?.enabled &&
367
+ hqConfig.role === "remote" &&
368
+ hqConfig.hq_url
369
+ ) {
370
+ try {
371
+ const { HQClient } = await import("./hq/client.ts");
372
+ const os = await import("node:os");
373
+ const hqClient = new HQClient({
374
+ hqUrl: hqConfig.hq_url,
375
+ secret: hqConfig.secret ?? "",
376
+ machineName: hqConfig.machine_name ?? os.hostname(),
377
+ remoteUrl: `http://localhost:${requestedPort}`,
378
+ heartbeatInterval: hqConfig.heartbeat_interval,
379
+ });
380
+ await hqClient.register();
381
+ console.log(`[daemon] Registered with HQ as ${hqClient.getName()}`);
382
+
383
+ // Deregister on shutdown
384
+ const origShutdownFn = shutdown;
385
+ process.removeListener("SIGTERM", shutdown);
386
+ process.removeListener("SIGINT", shutdown);
387
+ const shutdownWithHQ = async () => {
388
+ await hqClient.destroy();
389
+ origShutdownFn();
390
+ };
391
+ process.on("SIGTERM", () => void shutdownWithHQ());
392
+ process.on("SIGINT", () => void shutdownWithHQ());
393
+ } catch (err) {
394
+ console.error("[daemon] HQ client registration failed:", err);
395
+ }
396
+ }
397
+ } catch (err) {
398
+ // Command center failed but daemon continues (monitor + orchestrator still work)
399
+ console.error("[daemon] Command Center failed to start:", err);
400
+ }
401
+ }
402
+
403
+ void startCommandCenter();
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // Unified shutdown
407
+ // ---------------------------------------------------------------------------
408
+
409
+ function shutdown(): void {
410
+ clearInterval(monitorInterval);
411
+ if (stopOrchestrator) stopOrchestrator();
412
+ if (httpServer) httpServer.close();
413
+ process.exit(0);
414
+ }
415
+
416
+ process.on("SIGTERM", shutdown);
417
+ process.on("SIGINT", shutdown);
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { getByPath, setByPath } from "./dot-path.ts";
3
+
4
+ describe("getByPath", () => {
5
+ it("gets a top-level key", () => {
6
+ expect(getByPath({ name: "test" }, "name")).toBe("test");
7
+ });
8
+
9
+ it("gets a nested value", () => {
10
+ const obj = { rows: [{ panes: [{ title: "Shell" }] }] };
11
+ expect(getByPath(obj, "rows.0.panes.0.title")).toBe("Shell");
12
+ });
13
+
14
+ it("returns undefined for non-existent path", () => {
15
+ expect(getByPath({}, "a.b.c")).toBe(undefined);
16
+ });
17
+
18
+ it("returns undefined for partially valid path", () => {
19
+ expect(getByPath({ a: { b: 1 } }, "a.b.c")).toBe(undefined);
20
+ });
21
+ });
22
+
23
+ describe("setByPath", () => {
24
+ it("sets a top-level key", () => {
25
+ const obj = {};
26
+ setByPath(obj, "name", "test");
27
+ expect(obj).toEqual({ name: "test" });
28
+ });
29
+
30
+ it("sets a nested value", () => {
31
+ const obj = { rows: [{ panes: [{ title: "old" }] }] };
32
+ setByPath(obj, "rows.0.panes.0.title", "new");
33
+ expect(obj.rows[0].panes[0].title).toBe("new");
34
+ });
35
+
36
+ it("creates arrays for numeric keys", () => {
37
+ const obj = {};
38
+ setByPath(obj, "rows.0.title", "test");
39
+ expect(Array.isArray(obj.rows)).toBeTruthy();
40
+ expect(obj.rows[0].title).toBe("test");
41
+ });
42
+
43
+ it("creates objects for string keys", () => {
44
+ const obj = {};
45
+ setByPath(obj, "a.b.c", "val");
46
+ expect(obj).toEqual({ a: { b: { c: "val" } } });
47
+ });
48
+
49
+ it("handles duplicate segment values (regression)", () => {
50
+ // Bug: rows.0.panes.0 — both "0" segments caused indexOf to return
51
+ // the same index, breaking the lookahead for array vs object creation
52
+ const obj = {};
53
+ setByPath(obj, "rows.0.panes.0", "value");
54
+ expect(Array.isArray(obj.rows)).toBeTruthy();
55
+ expect(Array.isArray(obj.rows[0].panes)).toBeTruthy();
56
+ expect(obj.rows[0].panes[0]).toBe("value");
57
+ });
58
+
59
+ it("handles deeper duplicate segments", () => {
60
+ const obj = {};
61
+ setByPath(obj, "a.0.b.0.c", "deep");
62
+ expect(Array.isArray(obj.a)).toBeTruthy();
63
+ expect(Array.isArray(obj.a[0].b)).toBeTruthy();
64
+ expect(obj.a[0].b[0].c).toBe("deep");
65
+ });
66
+ });
@@ -0,0 +1,17 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ export function getByPath(obj: Record<string, any>, path: string): unknown {
3
+ return path.split(".").reduce((o: any, k) => o?.[k], obj);
4
+ }
5
+
6
+ export function setByPath(obj: Record<string, any>, path: string, value: unknown): void {
7
+ const keys = path.split(".");
8
+ const last = keys.pop()!;
9
+ let i = 0;
10
+ const target = keys.reduce((o: any, k) => {
11
+ const nextKey = keys[i + 1] ?? last;
12
+ if (o[k] === undefined) o[k] = /^\d+$/.test(nextKey) ? [] : {};
13
+ i++;
14
+ return o[k];
15
+ }, obj);
16
+ target[last] = value;
17
+ }
@@ -0,0 +1,44 @@
1
+ export class IdeError extends Error {
2
+ code: string | undefined;
3
+ exitCode: number;
4
+
5
+ constructor(
6
+ message: string,
7
+ { code, exitCode = 1, cause }: { code?: string; exitCode?: number; cause?: Error } = {},
8
+ ) {
9
+ super(message, { cause });
10
+ this.name = "IdeError";
11
+ this.code = code;
12
+ this.exitCode = exitCode;
13
+ }
14
+
15
+ toJSON(): { error: string; code: string | undefined; cause?: string } {
16
+ const obj: { error: string; code: string | undefined; cause?: string } = {
17
+ error: this.message,
18
+ code: this.code,
19
+ };
20
+ if (this.cause) obj.cause = (this.cause as Error).message;
21
+ return obj;
22
+ }
23
+ }
24
+
25
+ export class ConfigError extends IdeError {
26
+ constructor(message: string, code: string, { cause }: { cause?: Error } = {}) {
27
+ super(message, { code, exitCode: 1, cause });
28
+ this.name = "ConfigError";
29
+ }
30
+ }
31
+
32
+ export class TmuxError extends IdeError {
33
+ constructor(message: string, code: string, { cause }: { cause?: Error } = {}) {
34
+ super(message, { code, exitCode: 1, cause });
35
+ this.name = "TmuxError";
36
+ }
37
+ }
38
+
39
+ export class SessionError extends IdeError {
40
+ constructor(message: string, code: string, { cause }: { cause?: Error } = {}) {
41
+ super(message, { code, exitCode: 1, cause });
42
+ this.name = "SessionError";
43
+ }
44
+ }