tmux-ide 1.3.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 (236) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +27 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/attach.ts +20 -0
  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.ts +248 -0
  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.ts +212 -0
  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.ts +76 -0
  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.ts +100 -0
  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.ts +36 -0
  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.ts +27 -0
  103. package/src/ls.test.ts +76 -0
  104. package/src/ls.ts +40 -0
  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/dist/attach.d.ts +0 -3
  194. package/dist/attach.js +0 -14
  195. package/dist/config.d.ts +0 -5
  196. package/dist/config.js +0 -269
  197. package/dist/detect.d.ts +0 -15
  198. package/dist/detect.js +0 -228
  199. package/dist/doctor.d.ts +0 -3
  200. package/dist/doctor.js +0 -66
  201. package/dist/init.d.ts +0 -4
  202. package/dist/init.js +0 -67
  203. package/dist/inspect.d.ts +0 -54
  204. package/dist/inspect.js +0 -118
  205. package/dist/launch.d.ts +0 -22
  206. package/dist/launch.js +0 -174
  207. package/dist/lib/dot-path.d.ts +0 -2
  208. package/dist/lib/dot-path.js +0 -17
  209. package/dist/lib/errors.d.ts +0 -29
  210. package/dist/lib/errors.js +0 -37
  211. package/dist/lib/launch-plan.d.ts +0 -6
  212. package/dist/lib/launch-plan.js +0 -40
  213. package/dist/lib/output.d.ts +0 -9
  214. package/dist/lib/output.js +0 -65
  215. package/dist/lib/session-monitor.d.ts +0 -12
  216. package/dist/lib/session-monitor.js +0 -160
  217. package/dist/lib/session-options.d.ts +0 -13
  218. package/dist/lib/session-options.js +0 -85
  219. package/dist/lib/sizes.d.ts +0 -16
  220. package/dist/lib/sizes.js +0 -36
  221. package/dist/lib/tmux.d.ts +0 -42
  222. package/dist/lib/tmux.js +0 -225
  223. package/dist/lib/yaml-io.d.ts +0 -10
  224. package/dist/lib/yaml-io.js +0 -24
  225. package/dist/ls.d.ts +0 -3
  226. package/dist/ls.js +0 -35
  227. package/dist/restart.d.ts +0 -4
  228. package/dist/restart.js +0 -13
  229. package/dist/status.d.ts +0 -3
  230. package/dist/status.js +0 -29
  231. package/dist/stop.d.ts +0 -3
  232. package/dist/stop.js +0 -21
  233. package/dist/types.d.ts +0 -41
  234. package/dist/types.js +0 -1
  235. package/dist/validate.d.ts +0 -4
  236. package/dist/validate.js +0 -172
@@ -0,0 +1,372 @@
1
+ import { execFileSync, spawn, type ExecFileSyncOptions } from "node:child_process";
2
+ import { TmuxError } from "./errors.ts";
3
+ import type { SessionState } from "../types.ts";
4
+
5
+ const DEBUG = process.env.TMUX_IDE_DEBUG === "1";
6
+
7
+ const SESSION_NOT_FOUND_PATTERNS = ["can't find session", "can't find window", "unknown target"];
8
+
9
+ const TMUX_UNAVAILABLE_PATTERNS = [
10
+ "failed to connect to server",
11
+ "no server running",
12
+ "error connecting to",
13
+ "connection refused",
14
+ ];
15
+
16
+ export { TmuxError };
17
+
18
+ export function getSessionState(session: string): SessionState {
19
+ try {
20
+ runTmux(["has-session", "-t", session]);
21
+ return { running: true, reason: null };
22
+ } catch (error) {
23
+ if (error instanceof TmuxError) {
24
+ if (error.code === "SESSION_NOT_FOUND") {
25
+ return { running: false, reason: "SESSION_NOT_FOUND" };
26
+ }
27
+ if (error.code === "TMUX_UNAVAILABLE") {
28
+ return { running: false, reason: "TMUX_UNAVAILABLE" };
29
+ }
30
+ }
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ export function attachSession(session: string): void {
36
+ runTmux(["attach", "-t", session], { stdio: "inherit" });
37
+ }
38
+
39
+ export function hasSession(session: string): boolean {
40
+ try {
41
+ runTmux(["has-session", "-t", session]);
42
+ return true;
43
+ } catch (error) {
44
+ if (
45
+ error instanceof TmuxError &&
46
+ (error.code === "SESSION_NOT_FOUND" || error.code === "TMUX_UNAVAILABLE")
47
+ ) {
48
+ return false;
49
+ }
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ export function killSession(session: string): { stopped: boolean; reason: string | null } {
55
+ try {
56
+ runTmux(["kill-session", "-t", session]);
57
+ return { stopped: true, reason: null };
58
+ } catch (error) {
59
+ if (error instanceof TmuxError) {
60
+ if (error.code === "SESSION_NOT_FOUND") {
61
+ return { stopped: false, reason: "SESSION_NOT_FOUND" };
62
+ }
63
+ if (error.code === "TMUX_UNAVAILABLE") {
64
+ return { stopped: false, reason: "TMUX_UNAVAILABLE" };
65
+ }
66
+ }
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ export function listPanes(session: string) {
72
+ const raw = (
73
+ runTmux(
74
+ [
75
+ "list-panes",
76
+ "-t",
77
+ session,
78
+ "-F",
79
+ "#{pane_index}|#{pane_title}|#{pane_width}|#{pane_height}|#{pane_active}",
80
+ ],
81
+ { encoding: "utf-8" },
82
+ ) as string
83
+ ).trim();
84
+
85
+ if (!raw) return [];
86
+
87
+ return raw.split("\n").map((line) => {
88
+ const [index, title, width, height, active] = line.split("|");
89
+ return {
90
+ index: Number.parseInt(index!, 10),
91
+ title,
92
+ width: Number.parseInt(width!, 10),
93
+ height: Number.parseInt(height!, 10),
94
+ active: active === "1",
95
+ };
96
+ });
97
+ }
98
+
99
+ export function createDetachedSession(
100
+ session: string,
101
+ cwd: string,
102
+ { cols, lines }: { cols?: number; lines?: number } = {},
103
+ ): string {
104
+ return (
105
+ runTmux(
106
+ [
107
+ "new-session",
108
+ "-d",
109
+ "-P",
110
+ "-F",
111
+ "#{pane_id}",
112
+ "-s",
113
+ session,
114
+ "-c",
115
+ cwd,
116
+ "-x",
117
+ String(cols ?? 200),
118
+ "-y",
119
+ String(lines ?? 50),
120
+ ],
121
+ { encoding: "utf-8" },
122
+ ) as string
123
+ ).trim();
124
+ }
125
+
126
+ export function setSessionEnvironment(session: string, key: string, value: string | number): void {
127
+ runTmux(["set-environment", "-t", session, key, String(value)]);
128
+ }
129
+
130
+ export function splitPane(
131
+ targetPane: string,
132
+ direction: string,
133
+ cwd: string,
134
+ percent: number,
135
+ ): string {
136
+ return (
137
+ runTmux(
138
+ [
139
+ "split-window",
140
+ "-P",
141
+ "-F",
142
+ "#{pane_id}",
143
+ "-t",
144
+ targetPane,
145
+ direction === "vertical" ? "-v" : "-h",
146
+ "-c",
147
+ cwd,
148
+ "-p",
149
+ String(percent),
150
+ ],
151
+ { encoding: "utf-8" },
152
+ ) as string
153
+ ).trim();
154
+ }
155
+
156
+ export function sendLiteral(targetPane: string, text: string): void {
157
+ runTmux(["send-keys", "-t", targetPane, "-l", "--", text], { stdio: "inherit" });
158
+ runTmux(["send-keys", "-t", targetPane, "Enter"], { stdio: "inherit" });
159
+ }
160
+
161
+ export function getPaneCurrentCommand(targetPane: string): string {
162
+ return (
163
+ runTmux(["display-message", "-p", "-t", targetPane, "#{pane_current_command}"], {
164
+ encoding: "utf-8",
165
+ }) as string
166
+ ).trim();
167
+ }
168
+
169
+ export function selectPane(targetPane: string): void {
170
+ runTmux(["select-pane", "-t", targetPane], { stdio: "inherit" });
171
+ }
172
+
173
+ export function setPaneTitle(targetPane: string, title: string): void {
174
+ runTmux(["select-pane", "-t", targetPane, "-T", title], { stdio: "inherit" });
175
+ }
176
+
177
+ export function setPaneOption(targetPane: string, option: string, value: string): void {
178
+ runTmux(["set-option", "-pqt", targetPane, option, value]);
179
+ }
180
+
181
+ export function runSessionCommand(args: string[]): void {
182
+ runTmux(args, { stdio: "inherit" });
183
+ }
184
+
185
+ /**
186
+ * Check if a process is still alive.
187
+ */
188
+ export function isProcessAlive(pid: number): boolean {
189
+ try {
190
+ process.kill(pid, 0); // signal 0 = check existence
191
+ return true;
192
+ } catch {
193
+ return false;
194
+ }
195
+ }
196
+
197
+ export function startSessionMonitor(session: string, monitorScript: string, port?: number): void {
198
+ // Check if an existing monitor is already alive for this session.
199
+ // This prevents duplicate monitors on rapid restart cycles.
200
+ try {
201
+ const existingPid = (
202
+ runTmux(["show-option", "-qvt", session, "@monitor_pid"], {
203
+ encoding: "utf-8",
204
+ }) as string
205
+ ).trim();
206
+ if (existingPid) {
207
+ const pid = parseInt(existingPid, 10);
208
+ if (isProcessAlive(pid)) {
209
+ // Monitor is still running — kill it first for a clean handoff
210
+ stopSessionMonitor(session);
211
+ // Brief wait for graceful shutdown
212
+ let attempts = 0;
213
+ while (isProcessAlive(pid) && attempts < 10) {
214
+ const { Atomics, SharedArrayBuffer } = globalThis;
215
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
216
+ attempts++;
217
+ }
218
+ }
219
+ }
220
+ } catch {
221
+ // Session variable not readable — continue with fresh start
222
+ }
223
+
224
+ // Spawn the daemon via bun (runs TypeScript source directly).
225
+ // Use a process group so we can kill the entire tree on stop.
226
+ const child = _spawner("bun", [monitorScript, session, String(port ?? 0)], {
227
+ detached: true,
228
+ stdio: "ignore",
229
+ cwd: process.cwd(),
230
+ });
231
+ child.unref();
232
+ // Store PID as tmux session variable for later cleanup.
233
+ // This is the actual node process PID (not a shell wrapper).
234
+ runTmux(["set-option", "-t", session, "@monitor_pid", String(child.pid)]);
235
+ }
236
+
237
+ export function stopSessionMonitor(session: string): void {
238
+ try {
239
+ const pid = (
240
+ runTmux(["show-option", "-qvt", session, "@monitor_pid"], {
241
+ encoding: "utf-8",
242
+ }) as string
243
+ ).trim();
244
+ if (pid) {
245
+ const numPid = parseInt(pid, 10);
246
+ // Kill the process group (negative PID) to catch any children
247
+ try {
248
+ process.kill(-numPid, "SIGTERM");
249
+ } catch {
250
+ // Process group kill failed — try direct kill
251
+ try {
252
+ process.kill(numPid, "SIGTERM");
253
+ } catch {
254
+ /* already gone */
255
+ }
256
+ }
257
+ }
258
+ } catch {
259
+ /* session or process already gone */
260
+ }
261
+ }
262
+
263
+ export function getDaemonPort(session: string): number | null {
264
+ try {
265
+ const raw = runTmux(["show-option", "-qvt", session, "@command_center_port"], {
266
+ encoding: "utf-8",
267
+ }) as string;
268
+ const port = parseInt(raw.trim(), 10);
269
+ return isNaN(port) ? null : port;
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+
275
+ export async function isDaemonHealthy(session: string): Promise<boolean> {
276
+ const port = getDaemonPort(session);
277
+ if (!port) return false;
278
+ try {
279
+ const controller = new AbortController();
280
+ const timeout = setTimeout(() => controller.abort(), 2000);
281
+ const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
282
+ clearTimeout(timeout);
283
+ return res.ok;
284
+ } catch {
285
+ return false;
286
+ }
287
+ }
288
+
289
+ export function getSessionVariable(session: string, name: string): string | null {
290
+ try {
291
+ const raw = runTmux(["show-option", "-qvt", session, name], {
292
+ encoding: "utf-8",
293
+ }) as string;
294
+ return raw.trim() || null;
295
+ } catch {
296
+ return null;
297
+ }
298
+ }
299
+
300
+ export function setSessionVariable(session: string, name: string, value: string): void {
301
+ runTmux(["set-option", "-t", session, name, value]);
302
+ }
303
+
304
+ type Executor = typeof execFileSync;
305
+ type Spawner = typeof spawn;
306
+
307
+ let _executor: Executor = execFileSync;
308
+ let _spawner: Spawner = spawn;
309
+
310
+ /** @internal Replace the executor for testing. Returns a restore function. */
311
+ export function _setExecutor(fn: Executor): () => void {
312
+ const prev = _executor;
313
+ _executor = fn;
314
+ return () => {
315
+ _executor = prev;
316
+ };
317
+ }
318
+
319
+ /** @internal Replace the spawner for testing. Returns a restore function. */
320
+ export function _setSpawner(fn: Spawner): () => void {
321
+ const prev = _spawner;
322
+ _spawner = fn;
323
+ return () => {
324
+ _spawner = prev;
325
+ };
326
+ }
327
+
328
+ declare global {
329
+ var __tmuxIdeVerbose: boolean | undefined;
330
+ }
331
+
332
+ function runTmux(args: string[], options: ExecFileSyncOptions = {}) {
333
+ if (DEBUG || globalThis.__tmuxIdeVerbose) {
334
+ console.error(` [tmux] ${args.join(" ")}`);
335
+ }
336
+
337
+ const execOptions: ExecFileSyncOptions = {
338
+ stdio: ["ignore", "pipe", "pipe"],
339
+ ...options,
340
+ };
341
+
342
+ try {
343
+ return _executor("tmux", args, execOptions);
344
+ } catch (error) {
345
+ throw classifyTmuxError(error);
346
+ }
347
+ }
348
+
349
+ function classifyTmuxError(error: unknown): TmuxError {
350
+ const detail = getErrorDetail(error).toLowerCase();
351
+
352
+ if (SESSION_NOT_FOUND_PATTERNS.some((pattern) => detail.includes(pattern))) {
353
+ return new TmuxError("tmux session was not found", "SESSION_NOT_FOUND", {
354
+ cause: error as Error,
355
+ });
356
+ }
357
+
358
+ if (TMUX_UNAVAILABLE_PATTERNS.some((pattern) => detail.includes(pattern))) {
359
+ return new TmuxError("tmux is unavailable or its socket is inaccessible", "TMUX_UNAVAILABLE", {
360
+ cause: error as Error,
361
+ });
362
+ }
363
+
364
+ return new TmuxError("tmux command failed", "TMUX_ERROR", { cause: error as Error });
365
+ }
366
+
367
+ function getErrorDetail(error: unknown): string {
368
+ const stderr = (error as { stderr?: string | Buffer })?.stderr;
369
+ if (typeof stderr === "string" && stderr.length > 0) return stderr;
370
+ if (Buffer.isBuffer(stderr) && stderr.length > 0) return stderr.toString("utf-8");
371
+ return (error as Error)?.message ?? "";
372
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, it, beforeEach, afterEach, expect } from "bun:test";
2
+ import { mkdtempSync, rmSync, existsSync, mkdirSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ loadAccounting,
7
+ saveAccounting,
8
+ recordTaskTime,
9
+ formatDuration,
10
+ type AccountingData,
11
+ } from "./token-tracker.ts";
12
+
13
+ let tmpDir: string;
14
+
15
+ beforeEach(() => {
16
+ tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-tracker-test-"));
17
+ });
18
+
19
+ afterEach(() => {
20
+ rmSync(tmpDir, { recursive: true, force: true });
21
+ });
22
+
23
+ describe("loadAccounting", () => {
24
+ it("returns empty data when no file exists", () => {
25
+ const data = loadAccounting(tmpDir);
26
+ expect(data.agents).toEqual({});
27
+ expect(data.sessionStart).toBeTruthy();
28
+ expect(data.updated).toBeTruthy();
29
+ });
30
+
31
+ it("loads existing accounting data", () => {
32
+ mkdirSync(join(tmpDir, ".tasks"), { recursive: true });
33
+ const saved: AccountingData = {
34
+ agents: { "Agent 1": { totalTimeMs: 60000, taskCount: 2, lastTaskId: "002" } },
35
+ sessionStart: "2026-01-01T00:00:00Z",
36
+ updated: "2026-01-01T00:01:00Z",
37
+ };
38
+ saveAccounting(tmpDir, saved);
39
+ const loaded = loadAccounting(tmpDir);
40
+ expect(loaded.agents["Agent 1"]!.totalTimeMs).toBe(60000);
41
+ expect(loaded.agents["Agent 1"]!.taskCount).toBe(2);
42
+ });
43
+ });
44
+
45
+ describe("saveAccounting", () => {
46
+ it("creates .tasks/ directory if needed", () => {
47
+ const data: AccountingData = {
48
+ agents: {},
49
+ sessionStart: new Date().toISOString(),
50
+ updated: new Date().toISOString(),
51
+ };
52
+ saveAccounting(tmpDir, data);
53
+ expect(existsSync(join(tmpDir, ".tasks", "accounting.json"))).toBeTruthy();
54
+ });
55
+ });
56
+
57
+ describe("recordTaskTime", () => {
58
+ it("creates agent entry on first recording", () => {
59
+ mkdirSync(join(tmpDir, ".tasks"), { recursive: true });
60
+ recordTaskTime(tmpDir, "Agent 1", "001", 30000);
61
+ const data = loadAccounting(tmpDir);
62
+ expect(data.agents["Agent 1"]!.totalTimeMs).toBe(30000);
63
+ expect(data.agents["Agent 1"]!.taskCount).toBe(1);
64
+ expect(data.agents["Agent 1"]!.lastTaskId).toBe("001");
65
+ });
66
+
67
+ it("accumulates time across multiple tasks", () => {
68
+ mkdirSync(join(tmpDir, ".tasks"), { recursive: true });
69
+ recordTaskTime(tmpDir, "Agent 1", "001", 30000);
70
+ recordTaskTime(tmpDir, "Agent 1", "002", 45000);
71
+ const data = loadAccounting(tmpDir);
72
+ expect(data.agents["Agent 1"]!.totalTimeMs).toBe(75000);
73
+ expect(data.agents["Agent 1"]!.taskCount).toBe(2);
74
+ expect(data.agents["Agent 1"]!.lastTaskId).toBe("002");
75
+ });
76
+
77
+ it("tracks multiple agents independently", () => {
78
+ mkdirSync(join(tmpDir, ".tasks"), { recursive: true });
79
+ recordTaskTime(tmpDir, "Agent 1", "001", 30000);
80
+ recordTaskTime(tmpDir, "Agent 2", "002", 60000);
81
+ const data = loadAccounting(tmpDir);
82
+ expect(data.agents["Agent 1"]!.totalTimeMs).toBe(30000);
83
+ expect(data.agents["Agent 2"]!.totalTimeMs).toBe(60000);
84
+ });
85
+ });
86
+
87
+ describe("formatDuration", () => {
88
+ it("formats seconds", () => {
89
+ expect(formatDuration(5000)).toBe("5s");
90
+ expect(formatDuration(45000)).toBe("45s");
91
+ });
92
+
93
+ it("formats minutes and seconds", () => {
94
+ expect(formatDuration(90000)).toBe("1m 30s");
95
+ expect(formatDuration(300000)).toBe("5m 0s");
96
+ });
97
+
98
+ it("formats hours and minutes", () => {
99
+ expect(formatDuration(3600000)).toBe("1h 0m");
100
+ expect(formatDuration(5400000)).toBe("1h 30m");
101
+ });
102
+ });
@@ -0,0 +1,70 @@
1
+ import { join } from "node:path";
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
3
+
4
+ export interface AgentAccounting {
5
+ totalTimeMs: number;
6
+ taskCount: number;
7
+ lastTaskId: string | null;
8
+ }
9
+
10
+ export interface AccountingData {
11
+ agents: Record<string, AgentAccounting>;
12
+ sessionStart: string;
13
+ updated: string;
14
+ }
15
+
16
+ function accountingPath(dir: string): string {
17
+ return join(dir, ".tasks", "accounting.json");
18
+ }
19
+
20
+ export function loadAccounting(dir: string): AccountingData {
21
+ const path = accountingPath(dir);
22
+ if (existsSync(path)) {
23
+ try {
24
+ return JSON.parse(readFileSync(path, "utf-8")) as AccountingData;
25
+ } catch {
26
+ // Corrupted file, start fresh
27
+ }
28
+ }
29
+ return {
30
+ agents: {},
31
+ sessionStart: new Date().toISOString(),
32
+ updated: new Date().toISOString(),
33
+ };
34
+ }
35
+
36
+ export function saveAccounting(dir: string, data: AccountingData): void {
37
+ const tasksDir = join(dir, ".tasks");
38
+ if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
39
+ data.updated = new Date().toISOString();
40
+ const dest = accountingPath(dir);
41
+ const tmpPath = dest + ".tmp";
42
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
43
+ renameSync(tmpPath, dest);
44
+ }
45
+
46
+ export function recordTaskTime(
47
+ dir: string,
48
+ agentName: string,
49
+ taskId: string,
50
+ elapsedMs: number,
51
+ ): void {
52
+ const data = loadAccounting(dir);
53
+ if (!data.agents[agentName]) {
54
+ data.agents[agentName] = { totalTimeMs: 0, taskCount: 0, lastTaskId: null };
55
+ }
56
+ const agent = data.agents[agentName]!;
57
+ agent.totalTimeMs += elapsedMs;
58
+ agent.taskCount += 1;
59
+ agent.lastTaskId = taskId;
60
+ saveAccounting(dir, data);
61
+ }
62
+
63
+ export function formatDuration(ms: number): string {
64
+ const secs = Math.floor(ms / 1000);
65
+ if (secs < 60) return `${secs}s`;
66
+ const mins = Math.floor(secs / 60);
67
+ if (mins < 60) return `${mins}m ${secs % 60}s`;
68
+ const hrs = Math.floor(mins / 60);
69
+ return `${hrs}h ${mins % 60}m`;
70
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect, afterEach } from "bun:test";
2
+ import { EventEmitter } from "node:events";
3
+ import type { ChildProcess } from "node:child_process";
4
+ import { CloudflareService, _setSpawnForTesting } from "./cloudflare.ts";
5
+
6
+ function makeChild(hooks: {
7
+ onReady?: (stdout: EventEmitter, stderr: EventEmitter, proc: EventEmitter) => void;
8
+ }): ChildProcess {
9
+ const stdout = new EventEmitter();
10
+ const stderr = new EventEmitter();
11
+ const proc = new EventEmitter() as EventEmitter & Partial<ChildProcess>;
12
+ proc.stdout = stdout as NodeJS.ReadableStream;
13
+ proc.stderr = stderr as NodeJS.ReadableStream;
14
+ proc.pid = 9002;
15
+ proc.killed = false;
16
+ proc.kill = () => {
17
+ proc.killed = true;
18
+ queueMicrotask(() => proc.emit("close", 0));
19
+ return true;
20
+ };
21
+ queueMicrotask(() => hooks.onReady?.(stdout, stderr, proc));
22
+ return proc as ChildProcess;
23
+ }
24
+
25
+ describe("CloudflareService", () => {
26
+ let restore: () => void = () => {};
27
+
28
+ afterEach(() => {
29
+ restore();
30
+ restore = () => {};
31
+ });
32
+
33
+ it("parses trycloudflare.com URL from stdout", async () => {
34
+ const mockSpawn = (_cmd: string, args: string[]) => {
35
+ if (args[0] === "--version") {
36
+ return makeChild({
37
+ onReady: (_o, _e, proc) => proc.emit("close", 0),
38
+ });
39
+ }
40
+ if (args[0] === "tunnel") {
41
+ return makeChild({
42
+ onReady: (stdout) => {
43
+ const msg =
44
+ "Your quick Tunnel has been created! Visit https://random-words-abc123.trycloudflare.com\n";
45
+ stdout.emit("data", Buffer.from(msg, "utf8"));
46
+ },
47
+ });
48
+ }
49
+ return makeChild({ onReady: (_o, _e, proc) => proc.emit("close", 0) });
50
+ };
51
+ restore = _setSpawnForTesting(mockSpawn as typeof import("node:child_process").spawn);
52
+
53
+ const svc = new CloudflareService(7777, { startupTimeoutMs: 5000 });
54
+ const tunnel = await svc.start();
55
+ expect(tunnel.publicUrl).toBe("https://random-words-abc123.trycloudflare.com");
56
+ await svc.stop();
57
+ });
58
+
59
+ it("rejects when cloudflared is not installed", async () => {
60
+ const mockSpawn = (_cmd: string, args: string[]) => {
61
+ if (args[0] === "--version") {
62
+ return makeChild({
63
+ onReady: (_o, _e, proc) => proc.emit("close", 1),
64
+ });
65
+ }
66
+ return makeChild({ onReady: (_o, _e, proc) => proc.emit("close", 0) });
67
+ };
68
+ restore = _setSpawnForTesting(mockSpawn as typeof import("node:child_process").spawn);
69
+
70
+ const svc = new CloudflareService(7777);
71
+ await expect(svc.start()).rejects.toThrow(/cloudflared binary not found/);
72
+ });
73
+ });