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,119 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { buildPaneCommand, collectPaneStartupPlan } from "./launch-plan.ts";
3
+ import type { Row } from "../types.ts";
4
+
5
+ describe("buildPaneCommand", () => {
6
+ it("passes through normal pane commands", () => {
7
+ expect(buildPaneCommand({ command: "pnpm dev" })).toBe("pnpm dev");
8
+ });
9
+
10
+ it("returns the command unchanged for Claude panes", () => {
11
+ expect(buildPaneCommand({ command: "claude", role: "lead" })).toBe("claude");
12
+ expect(buildPaneCommand({ command: "claude", role: "teammate", task: 'Fix "lint"' })).toBe(
13
+ "claude",
14
+ );
15
+ });
16
+ });
17
+
18
+ describe("collectPaneStartupPlan", () => {
19
+ it("launches team panes as normal pane commands", () => {
20
+ const rows = [
21
+ {
22
+ panes: [
23
+ { title: "Lead", command: "claude", role: "lead", focus: true, env: { PORT: 3000 } },
24
+ { title: "Worker", command: "claude", role: "teammate", task: "Review" },
25
+ ],
26
+ },
27
+ {
28
+ panes: [{ title: "Shell", dir: "apps/web" }],
29
+ },
30
+ ];
31
+
32
+ const result = collectPaneStartupPlan(
33
+ rows,
34
+ [["%1", "%2"], ["%3"]],
35
+ new Set(["%1", "%3"]),
36
+ "/workspace",
37
+ );
38
+
39
+ expect(result.focusPane).toBe("%1");
40
+ expect(result.paneActions).toEqual([
41
+ {
42
+ targetPane: "%1",
43
+ title: "Lead",
44
+ chdir: null,
45
+ exports: [`export 'PORT'='3000'`],
46
+ command: `claude --name 'Lead'`,
47
+ widgetType: null,
48
+ widgetTarget: null,
49
+ paneRole: "lead",
50
+ paneType: "agent",
51
+ },
52
+ {
53
+ targetPane: "%2",
54
+ title: "Worker",
55
+ chdir: null,
56
+ exports: [],
57
+ command: `claude --name 'Worker'`,
58
+ widgetType: null,
59
+ widgetTarget: null,
60
+ paneRole: "teammate",
61
+ paneType: "agent",
62
+ },
63
+ {
64
+ targetPane: "%3",
65
+ title: "Shell",
66
+ chdir: "/workspace/apps/web",
67
+ exports: [],
68
+ command: null,
69
+ widgetType: null,
70
+ widgetTarget: null,
71
+ paneRole: "shell",
72
+ paneType: "shell",
73
+ },
74
+ ]);
75
+ });
76
+
77
+ it("widgets:false config produces fewer pane actions when widget panes are stripped", () => {
78
+ const fullRows: Row[] = [
79
+ {
80
+ panes: [
81
+ { title: "Claude", command: "claude", role: "lead" },
82
+ { title: "Tasks", type: "tasks" },
83
+ { title: "Explorer", type: "explorer" },
84
+ ],
85
+ },
86
+ {
87
+ panes: [{ title: "Shell" }, { title: "War Room", type: "warroom" }],
88
+ },
89
+ ];
90
+
91
+ // Simulate headless mode: strip widget panes (panes with type set)
92
+ const headlessRows = fullRows
93
+ .map((row) => ({ ...row, panes: row.panes.filter((p) => !p.type) }))
94
+ .filter((row) => row.panes.length > 0);
95
+
96
+ const fullResult = collectPaneStartupPlan(
97
+ fullRows,
98
+ [
99
+ ["%1", "%2", "%3"],
100
+ ["%4", "%5"],
101
+ ],
102
+ new Set(["%1", "%4"]),
103
+ "/workspace",
104
+ );
105
+
106
+ const headlessResult = collectPaneStartupPlan(
107
+ headlessRows,
108
+ [["%1"], ["%4"]],
109
+ new Set(["%1", "%4"]),
110
+ "/workspace",
111
+ );
112
+
113
+ // Full has 5 pane actions (3 widgets + agent + shell)
114
+ expect(fullResult.paneActions.length).toBe(5);
115
+ // Headless has only 2 (agent + shell), 3 widget panes stripped
116
+ expect(headlessResult.paneActions.length).toBe(2);
117
+ expect(headlessResult.paneActions.every((a) => a.widgetType === null)).toBeTruthy();
118
+ });
119
+ });
@@ -0,0 +1,90 @@
1
+ import { resolve } from "node:path";
2
+ import type { Pane, Row, PaneAction } from "../types.ts";
3
+ import { shellEscape } from "./shell.ts";
4
+
5
+ export function buildPaneCommand(pane: Pane): string | null {
6
+ if (!pane.command) return null;
7
+ return pane.command;
8
+ }
9
+
10
+ export function collectPaneStartupPlan(
11
+ rows: Row[],
12
+ paneMap: string[][],
13
+ firstPanesOfRows: Set<string>,
14
+ dir: string,
15
+ ): { focusPane: string; paneActions: PaneAction[] } {
16
+ let focusPane = paneMap[0]![0]!;
17
+ const paneActions: PaneAction[] = [];
18
+
19
+ for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
20
+ const row = rows[rowIdx]!;
21
+ const panes = row.panes ?? [];
22
+
23
+ for (let paneIdx = 0; paneIdx < panes.length; paneIdx++) {
24
+ const pane = panes[paneIdx]!;
25
+ const tmuxPane = paneMap[rowIdx]![paneIdx]!;
26
+ // Derive @ide_role from pane config
27
+ let paneRole: string;
28
+ if (pane.role === "lead") {
29
+ paneRole = "lead";
30
+ } else if (pane.role === "teammate" || pane.role === "planner") {
31
+ paneRole = "teammate";
32
+ } else if (pane.type) {
33
+ paneRole = "widget";
34
+ } else {
35
+ paneRole = "shell";
36
+ }
37
+
38
+ // Derive @ide_type from pane config
39
+ let paneType: string;
40
+ if (pane.type) {
41
+ paneType = pane.type;
42
+ } else if (pane.command && /claude|codex/i.test(pane.command)) {
43
+ paneType = "agent";
44
+ } else {
45
+ paneType = "shell";
46
+ }
47
+
48
+ const action: PaneAction = {
49
+ targetPane: tmuxPane,
50
+ title: pane.title ?? null,
51
+ chdir: null,
52
+ exports: [],
53
+ command: null,
54
+ widgetType: pane.type ?? null,
55
+ widgetTarget: pane.target ?? null,
56
+ paneRole,
57
+ paneType,
58
+ };
59
+
60
+ if (pane.dir && firstPanesOfRows.has(tmuxPane)) {
61
+ action.chdir = resolve(dir, pane.dir);
62
+ }
63
+
64
+ if (pane.env && typeof pane.env === "object") {
65
+ action.exports = Object.entries(pane.env).map(
66
+ ([key, value]) => `export ${shellEscape(key)}=${shellEscape(String(value))}`,
67
+ );
68
+ }
69
+
70
+ let command = buildPaneCommand(pane);
71
+ // Inject --name flag into Claude/Codex commands so the agent
72
+ // sets its own pane title to match the configured name.
73
+ // This is agent-agnostic: any CLI that respects --name will work.
74
+ if (command && pane.title && /claude|codex/i.test(command) && !command.includes("--name")) {
75
+ command = `${command} --name ${shellEscape(pane.title)}`;
76
+ }
77
+ if (command) {
78
+ action.command = command;
79
+ }
80
+
81
+ if (pane.focus) {
82
+ focusPane = tmuxPane;
83
+ }
84
+
85
+ paneActions.push(action);
86
+ }
87
+ }
88
+
89
+ return { focusPane, paneActions };
90
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { log } from "./log.ts";
3
+
4
+ function captureConsole(method: "log" | "warn" | "error" | "debug", fn: () => void): unknown[][] {
5
+ const original = console[method];
6
+ const calls: unknown[][] = [];
7
+ (console as Record<string, typeof console.log>)[method] = (...args: unknown[]) => {
8
+ calls.push(args);
9
+ };
10
+ try {
11
+ fn();
12
+ } finally {
13
+ (console as Record<string, typeof console.log>)[method] = original;
14
+ }
15
+ return calls;
16
+ }
17
+
18
+ describe("log", () => {
19
+ it("log.info forwards to console.log", () => {
20
+ const calls = captureConsole("log", () => log.info("a", 1, { b: 2 }));
21
+ expect(calls).toEqual([["a", 1, { b: 2 }]]);
22
+ });
23
+
24
+ it("log.warn forwards to console.warn", () => {
25
+ const calls = captureConsole("warn", () => log.warn("warn"));
26
+ expect(calls).toEqual([["warn"]]);
27
+ });
28
+
29
+ it("log.error forwards to console.error", () => {
30
+ const calls = captureConsole("error", () => log.error(new Error("x")));
31
+ expect(calls.length).toBe(1);
32
+ expect(calls[0]![0]).toBeInstanceOf(Error);
33
+ });
34
+
35
+ it("log.debug forwards to console.debug", () => {
36
+ const calls = captureConsole("debug", () => log.debug("dbg"));
37
+ expect(calls).toEqual([["dbg"]]);
38
+ });
39
+ });
package/src/lib/log.ts ADDED
@@ -0,0 +1,71 @@
1
+ /** Minimal structured logger for tmux-ide daemon processes. */
2
+
3
+ type Level = "debug" | "info" | "warn" | "error";
4
+
5
+ const LEVEL_RANK: Record<Level, number> = { debug: 0, info: 1, warn: 2, error: 3 };
6
+
7
+ let minLevel: Level = (process.env.LOG_LEVEL as Level) ?? "info";
8
+
9
+ export function setLogLevel(level: Level): void {
10
+ minLevel = level;
11
+ }
12
+
13
+ function writeStructuredLog(
14
+ level: Level,
15
+ component: string,
16
+ message: string,
17
+ data?: Record<string, unknown>,
18
+ ): void {
19
+ if (LEVEL_RANK[level] < LEVEL_RANK[minLevel]) return;
20
+ const entry: Record<string, unknown> = {
21
+ ts: new Date().toISOString(),
22
+ level,
23
+ component,
24
+ msg: message,
25
+ };
26
+ if (data) Object.assign(entry, data);
27
+ const out = level === "error" ? process.stderr : process.stdout;
28
+ out.write(JSON.stringify(entry) + "\n");
29
+ }
30
+
31
+ export const logger = {
32
+ debug: (component: string, msg: string, data?: Record<string, unknown>) =>
33
+ writeStructuredLog("debug", component, msg, data),
34
+ info: (component: string, msg: string, data?: Record<string, unknown>) =>
35
+ writeStructuredLog("info", component, msg, data),
36
+ warn: (component: string, msg: string, data?: Record<string, unknown>) =>
37
+ writeStructuredLog("warn", component, msg, data),
38
+ error: (component: string, msg: string, data?: Record<string, unknown>) =>
39
+ writeStructuredLog("error", component, msg, data),
40
+ };
41
+
42
+ /**
43
+ * Thin logger shim compatible with VibeTunnel's module logger API (`createLogger`-style methods).
44
+ * Wraps `console.*` (distinct from structured JSON `logger` above).
45
+ */
46
+ export type LogMethod = (...args: unknown[]) => void;
47
+
48
+ export const log = {
49
+ /** Alias for `info` / console.log (VibeTunnel `createLogger` compatibility). */
50
+ log: (...args: unknown[]) => {
51
+ console.log(...args);
52
+ },
53
+ info: (...args: unknown[]) => {
54
+ console.log(...args);
55
+ },
56
+ warn: (...args: unknown[]) => {
57
+ console.warn(...args);
58
+ },
59
+ error: (...args: unknown[]) => {
60
+ console.error(...args);
61
+ },
62
+ debug: (...args: unknown[]) => {
63
+ console.debug(...args);
64
+ },
65
+ } satisfies {
66
+ log: LogMethod;
67
+ info: LogMethod;
68
+ warn: LogMethod;
69
+ error: LogMethod;
70
+ debug: LogMethod;
71
+ };
@@ -0,0 +1,213 @@
1
+ import { describe, it, beforeEach, afterEach, expect } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ computeMetrics,
7
+ loadMetrics,
8
+ saveMetrics,
9
+ appendMissionHistory,
10
+ loadMissionHistory,
11
+ type MetricsSnapshot,
12
+ } from "./metrics.ts";
13
+ import { ensureTasksDir, saveTask, saveMission } from "./task-store.ts";
14
+ import { appendEvent } from "./event-log.ts";
15
+ import { makeTask, makeMission } from "../__tests__/support.ts";
16
+
17
+ let tmpDir: string;
18
+
19
+ beforeEach(() => {
20
+ tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-metrics-test-"));
21
+ ensureTasksDir(tmpDir);
22
+ });
23
+
24
+ afterEach(() => {
25
+ rmSync(tmpDir, { recursive: true, force: true });
26
+ });
27
+
28
+ describe("computeMetrics", () => {
29
+ it("calculates task completion rate", () => {
30
+ saveTask(tmpDir, makeTask({ id: "001", status: "done", assignee: "Agent 1" }));
31
+ saveTask(tmpDir, makeTask({ id: "002", status: "done", assignee: "Agent 1" }));
32
+ saveTask(tmpDir, makeTask({ id: "003", status: "todo" }));
33
+
34
+ const metrics = computeMetrics(tmpDir);
35
+ expect(metrics.tasks.total).toBe(3);
36
+ expect(metrics.tasks.completed).toBe(2);
37
+ expect(metrics.tasks.completionRate).toBeCloseTo(0.67, 1);
38
+ });
39
+
40
+ it("calculates retry rate", () => {
41
+ saveTask(tmpDir, makeTask({ id: "001", status: "done", retryCount: 2 }));
42
+ saveTask(tmpDir, makeTask({ id: "002", status: "done", retryCount: 0 }));
43
+
44
+ const metrics = computeMetrics(tmpDir);
45
+ expect(metrics.tasks.retried).toBe(1);
46
+ expect(metrics.tasks.retryRate).toBe(0.5);
47
+ });
48
+
49
+ it("computes agent utilization from heartbeat events", () => {
50
+ appendEvent(tmpDir, {
51
+ timestamp: "2026-01-01T00:00:00Z",
52
+ type: "agent_heartbeat",
53
+ message: "agents: Alice=busy, Bob=idle",
54
+ });
55
+ appendEvent(tmpDir, {
56
+ timestamp: "2026-01-01T00:05:00Z",
57
+ type: "agent_heartbeat",
58
+ message: "agents: Alice=busy, Bob=busy",
59
+ });
60
+ appendEvent(tmpDir, {
61
+ timestamp: "2026-01-01T00:10:00Z",
62
+ type: "agent_heartbeat",
63
+ message: "agents: Alice=idle, Bob=idle",
64
+ });
65
+
66
+ const metrics = computeMetrics(tmpDir);
67
+ const alice = metrics.agents.find((a) => a.name === "Alice");
68
+ const bob = metrics.agents.find((a) => a.name === "Bob");
69
+
70
+ expect(alice).toBeTruthy();
71
+ expect(alice!.utilization).toBeCloseTo(0.67, 1); // 2/3 busy
72
+ expect(bob).toBeTruthy();
73
+ expect(bob!.utilization).toBeCloseTo(0.33, 1); // 1/3 busy
74
+ });
75
+
76
+ it("includes milestone metrics", () => {
77
+ saveMission(
78
+ tmpDir,
79
+ makeMission({
80
+ milestones: [
81
+ {
82
+ id: "M1",
83
+ title: "Phase 1",
84
+ description: "",
85
+ status: "done",
86
+ order: 1,
87
+ created: "2026-01-01T00:00:00Z",
88
+ updated: "2026-01-01T01:00:00Z",
89
+ },
90
+ {
91
+ id: "M2",
92
+ title: "Phase 2",
93
+ description: "",
94
+ status: "active",
95
+ order: 2,
96
+ created: "2026-01-01T00:00:00Z",
97
+ updated: "2026-01-01T00:00:00Z",
98
+ },
99
+ ],
100
+ }),
101
+ );
102
+ saveTask(tmpDir, makeTask({ id: "001", milestone: "M1", status: "done" }));
103
+ saveTask(tmpDir, makeTask({ id: "002", milestone: "M2", status: "todo" }));
104
+
105
+ const metrics = computeMetrics(tmpDir);
106
+ expect(metrics.tasks.byMilestone.length).toBe(2);
107
+ expect(metrics.tasks.byMilestone[0]!.id).toBe("M1");
108
+ expect(metrics.tasks.byMilestone[0]!.completedCount).toBe(1);
109
+ expect(metrics.mission.milestonesCompleted).toBe(1);
110
+ });
111
+
112
+ it("handles empty project", () => {
113
+ const metrics = computeMetrics(tmpDir);
114
+ expect(metrics.tasks.total).toBe(0);
115
+ expect(metrics.tasks.completionRate).toBe(0);
116
+ expect(metrics.agents.length).toBe(0);
117
+ expect(metrics.timeline.length).toBe(0);
118
+ });
119
+
120
+ it("generates timeline from events", () => {
121
+ const baseTime = new Date("2026-01-01T00:00:00Z").getTime();
122
+ appendEvent(tmpDir, {
123
+ timestamp: new Date(baseTime).toISOString(),
124
+ type: "dispatch",
125
+ taskId: "001",
126
+ agent: "Agent 1",
127
+ message: "Dispatched",
128
+ });
129
+ appendEvent(tmpDir, {
130
+ timestamp: new Date(baseTime + 10 * 60 * 1000).toISOString(),
131
+ type: "completion",
132
+ taskId: "001",
133
+ agent: "Agent 1",
134
+ message: "Completed",
135
+ });
136
+
137
+ const metrics = computeMetrics(tmpDir);
138
+ expect(metrics.timeline.length).toBeGreaterThan(0);
139
+ });
140
+ });
141
+
142
+ describe("saveMetrics / loadMetrics", () => {
143
+ it("round-trips metrics snapshot", () => {
144
+ const snapshot: MetricsSnapshot = {
145
+ session: {
146
+ startedAt: "2026-01-01T00:00:00Z",
147
+ durationMs: 60000,
148
+ status: "active",
149
+ agentCount: 2,
150
+ },
151
+ tasks: {
152
+ total: 5,
153
+ completed: 3,
154
+ failed: 0,
155
+ retried: 1,
156
+ completionRate: 0.6,
157
+ retryRate: 0.2,
158
+ avgDurationMs: 5000,
159
+ medianDurationMs: 4000,
160
+ p90DurationMs: 8000,
161
+ byMilestone: [],
162
+ },
163
+ agents: [],
164
+ mission: {
165
+ title: "Test",
166
+ status: "active",
167
+ milestonesCompleted: 1,
168
+ validationPassRate: 1,
169
+ wallClockMs: 60000,
170
+ },
171
+ timeline: [],
172
+ };
173
+ saveMetrics(tmpDir, snapshot);
174
+ const loaded = loadMetrics(tmpDir);
175
+ expect(loaded).toEqual(snapshot);
176
+ });
177
+
178
+ it("returns null when no file", () => {
179
+ expect(loadMetrics(tmpDir)).toBeNull();
180
+ });
181
+ });
182
+
183
+ describe("appendMissionHistory / loadMissionHistory", () => {
184
+ it("appends and loads mission summaries", () => {
185
+ const mission = makeMission({ title: "Mission A", status: "complete" });
186
+ const tasks = [
187
+ makeTask({ id: "001", status: "done", assignee: "Agent 1" }),
188
+ makeTask({ id: "002", status: "done", assignee: "Agent 2" }),
189
+ ];
190
+
191
+ appendMissionHistory(tmpDir, mission, tasks);
192
+
193
+ const history = loadMissionHistory(tmpDir);
194
+ expect(history.length).toBe(1);
195
+ expect(history[0]!.title).toBe("Mission A");
196
+ expect(history[0]!.taskCount).toBe(2);
197
+ expect(history[0]!.agentCount).toBe(2);
198
+ });
199
+
200
+ it("appends multiple missions", () => {
201
+ appendMissionHistory(tmpDir, makeMission({ title: "M1" }), []);
202
+ appendMissionHistory(tmpDir, makeMission({ title: "M2" }), [makeTask()]);
203
+
204
+ const history = loadMissionHistory(tmpDir);
205
+ expect(history.length).toBe(2);
206
+ expect(history[0]!.title).toBe("M1");
207
+ expect(history[1]!.title).toBe("M2");
208
+ });
209
+
210
+ it("returns empty array when no history", () => {
211
+ expect(loadMissionHistory(tmpDir)).toEqual([]);
212
+ });
213
+ });