@hellcoder/companion 0.96.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 (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,637 @@
1
+ import { vi, describe, it, expect, beforeEach } from "vitest";
2
+
3
+ // ─── Mock usage-limits ─────────────────────────────────────────────────────
4
+ vi.mock("../usage-limits.js", () => ({
5
+ getUsageLimits: vi.fn(async () => ({
6
+ five_hour: null,
7
+ seven_day: null,
8
+ extra_usage: null,
9
+ })),
10
+ }));
11
+
12
+ // ─── Mock update-checker ───────────────────────────────────────────────────
13
+ vi.mock("../update-checker.js", () => ({
14
+ getUpdateState: vi.fn(() => ({
15
+ currentVersion: "1.0.0",
16
+ latestVersion: null,
17
+ lastChecked: 0,
18
+ isServiceMode: false,
19
+ checking: false,
20
+ updateInProgress: false,
21
+ channel: "stable",
22
+ })),
23
+ checkForUpdate: vi.fn(async () => {}),
24
+ isUpdateAvailable: vi.fn(() => false),
25
+ setUpdateInProgress: vi.fn(),
26
+ }));
27
+
28
+ // ─── Mock service ──────────────────────────────────────────────────────────
29
+ vi.mock("../service.js", () => ({
30
+ refreshServiceDefinition: vi.fn(),
31
+ }));
32
+
33
+ import { Hono } from "hono";
34
+ import { getUsageLimits } from "../usage-limits.js";
35
+ import {
36
+ getUpdateState,
37
+ checkForUpdate,
38
+ isUpdateAvailable,
39
+ setUpdateInProgress,
40
+ } from "../update-checker.js";
41
+ import { registerSystemRoutes } from "./system-routes.js";
42
+
43
+ // ─── Helpers ───────────────────────────────────────────────────────────────
44
+
45
+ /** Build a mock CliLauncher with vi.fn() stubs for the methods used by system routes. */
46
+ function createMockLauncher() {
47
+ return {
48
+ getSession: vi.fn(() => undefined as any),
49
+ isAlive: vi.fn(() => false),
50
+ };
51
+ }
52
+
53
+ /** Build a mock WsBridge with vi.fn() stubs for the methods used by system routes. */
54
+ function createMockWsBridge() {
55
+ return {
56
+ getSession: vi.fn(() => undefined as any),
57
+ getCodexRateLimits: vi.fn(() => null),
58
+ injectUserMessage: vi.fn(),
59
+ };
60
+ }
61
+
62
+ /** Build a mock TerminalManager with vi.fn() stubs for the methods used by system routes. */
63
+ function createMockTerminalManager() {
64
+ return {
65
+ getInfo: vi.fn(() => null as { id: string; cwd: string } | null),
66
+ spawn: vi.fn(() => "terminal-123"),
67
+ kill: vi.fn(),
68
+ };
69
+ }
70
+
71
+ // ─── Test setup ────────────────────────────────────────────────────────────
72
+
73
+ let app: Hono;
74
+ let launcher: ReturnType<typeof createMockLauncher>;
75
+ let wsBridge: ReturnType<typeof createMockWsBridge>;
76
+ let terminalManager: ReturnType<typeof createMockTerminalManager>;
77
+
78
+ beforeEach(() => {
79
+ vi.clearAllMocks();
80
+
81
+ launcher = createMockLauncher();
82
+ wsBridge = createMockWsBridge();
83
+ terminalManager = createMockTerminalManager();
84
+
85
+ app = new Hono();
86
+ const api = new Hono();
87
+ registerSystemRoutes(api, {
88
+ launcher: launcher as any,
89
+ wsBridge: wsBridge as any,
90
+ terminalManager: terminalManager as any,
91
+ updateCheckStaleMs: 60_000,
92
+ });
93
+ app.route("/api", api);
94
+ });
95
+
96
+ // ═══════════════════════════════════════════════════════════════════════════
97
+ // GET /api/usage-limits
98
+ // ═══════════════════════════════════════════════════════════════════════════
99
+
100
+ describe("GET /api/usage-limits", () => {
101
+ it("returns usage limits from the global getter", async () => {
102
+ const limits = {
103
+ five_hour: { utilization: 0.5, resets_at: "2026-01-01T00:00:00Z" },
104
+ seven_day: null,
105
+ extra_usage: null,
106
+ };
107
+ vi.mocked(getUsageLimits).mockResolvedValue(limits as any);
108
+
109
+ const res = await app.request("/api/usage-limits");
110
+
111
+ expect(res.status).toBe(200);
112
+ const json = await res.json();
113
+ expect(json.five_hour.utilization).toBe(0.5);
114
+ expect(json.seven_day).toBeNull();
115
+ expect(getUsageLimits).toHaveBeenCalled();
116
+ });
117
+ });
118
+
119
+ // ═══════════════════════════════════════════════════════════════════════════
120
+ // GET /api/sessions/:id/usage-limits
121
+ // ═══════════════════════════════════════════════════════════════════════════
122
+
123
+ describe("GET /api/sessions/:id/usage-limits", () => {
124
+ it("returns codex rate limits when the session is a codex backend", async () => {
125
+ // When the session's backendType is "codex", we should return mapped codex limits
126
+ wsBridge.getSession.mockReturnValue({ backendType: "codex" } as any);
127
+ wsBridge.getCodexRateLimits.mockReturnValue({
128
+ primary: { usedPercent: 0.42, windowDurationMins: 300, resetsAt: 1700000000 },
129
+ secondary: null,
130
+ } as any);
131
+
132
+ const res = await app.request("/api/sessions/codex-sess-1/usage-limits");
133
+
134
+ expect(res.status).toBe(200);
135
+ const json = await res.json();
136
+ // Primary limit should be mapped to five_hour
137
+ expect(json.five_hour).not.toBeNull();
138
+ expect(json.five_hour.utilization).toBe(0.42);
139
+ // Secondary was null, so seven_day should be null
140
+ expect(json.seven_day).toBeNull();
141
+ expect(json.extra_usage).toBeNull();
142
+ // Should NOT have called getUsageLimits (we used codex-specific path)
143
+ expect(getUsageLimits).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it("returns empty limits when codex session has no rate limit data", async () => {
147
+ wsBridge.getSession.mockReturnValue({ backendType: "codex" } as any);
148
+ wsBridge.getCodexRateLimits.mockReturnValue(null);
149
+
150
+ const res = await app.request("/api/sessions/codex-sess-2/usage-limits");
151
+
152
+ expect(res.status).toBe(200);
153
+ const json = await res.json();
154
+ expect(json).toEqual({ five_hour: null, seven_day: null, extra_usage: null });
155
+ });
156
+
157
+ // When codex rate limits have timestamps in epoch milliseconds (>1e12),
158
+ // they should pass through without conversion.
159
+ it("passes through millisecond timestamps from codex rate limits", async () => {
160
+ wsBridge.getSession.mockReturnValue({ backendType: "codex" } as any);
161
+ const msTimestamp = 1700000000000; // already in ms
162
+ wsBridge.getCodexRateLimits.mockReturnValue({
163
+ primary: { usedPercent: 0.8, windowDurationMins: 300, resetsAt: msTimestamp },
164
+ secondary: { usedPercent: 0.3, windowDurationMins: 10080, resetsAt: msTimestamp },
165
+ } as any);
166
+
167
+ const res = await app.request("/api/sessions/codex-ms/usage-limits");
168
+
169
+ expect(res.status).toBe(200);
170
+ const json = await res.json();
171
+ expect(json.five_hour.utilization).toBe(0.8);
172
+ expect(json.five_hour.resets_at).toBe(new Date(msTimestamp).toISOString());
173
+ expect(json.seven_day.utilization).toBe(0.3);
174
+ expect(json.seven_day.resets_at).toBe(new Date(msTimestamp).toISOString());
175
+ });
176
+
177
+ it("falls back to global usage limits for non-codex sessions", async () => {
178
+ // A claude-type session should use the global getUsageLimits
179
+ wsBridge.getSession.mockReturnValue({ backendType: "claude" } as any);
180
+ vi.mocked(getUsageLimits).mockResolvedValue({
181
+ five_hour: { utilization: 0.1, resets_at: null },
182
+ seven_day: null,
183
+ extra_usage: null,
184
+ } as any);
185
+
186
+ const res = await app.request("/api/sessions/claude-sess-1/usage-limits");
187
+
188
+ expect(res.status).toBe(200);
189
+ const json = await res.json();
190
+ expect(json.five_hour.utilization).toBe(0.1);
191
+ expect(getUsageLimits).toHaveBeenCalled();
192
+ });
193
+
194
+ it("falls back to global usage limits when session is not found", async () => {
195
+ // When wsBridge.getSession returns undefined, should still return global limits
196
+ wsBridge.getSession.mockReturnValue(undefined);
197
+ vi.mocked(getUsageLimits).mockResolvedValue({
198
+ five_hour: null,
199
+ seven_day: null,
200
+ extra_usage: null,
201
+ } as any);
202
+
203
+ const res = await app.request("/api/sessions/unknown/usage-limits");
204
+
205
+ expect(res.status).toBe(200);
206
+ expect(getUsageLimits).toHaveBeenCalled();
207
+ });
208
+ });
209
+
210
+ // ═══════════════════════════════════════════════════════════════════════════
211
+ // GET /api/update-check
212
+ // ═══════════════════════════════════════════════════════════════════════════
213
+
214
+ describe("GET /api/update-check", () => {
215
+ it("calls checkForUpdate when lastChecked is 0 (stale)", async () => {
216
+ // lastChecked=0 means never checked, so it should trigger a refresh
217
+ vi.mocked(getUpdateState).mockReturnValue({
218
+ currentVersion: "1.0.0",
219
+ latestVersion: null,
220
+ lastChecked: 0,
221
+ isServiceMode: false,
222
+ checking: false,
223
+ updateInProgress: false,
224
+ channel: "stable",
225
+ });
226
+ vi.mocked(isUpdateAvailable).mockReturnValue(false);
227
+
228
+ const res = await app.request("/api/update-check");
229
+
230
+ expect(res.status).toBe(200);
231
+ expect(checkForUpdate).toHaveBeenCalled();
232
+ const json = await res.json();
233
+ expect(json.currentVersion).toBe("1.0.0");
234
+ expect(json.updateAvailable).toBe(false);
235
+ expect(json.channel).toBe("stable");
236
+ });
237
+
238
+ it("does NOT call checkForUpdate when lastChecked is recent (not stale)", async () => {
239
+ // Set lastChecked to "now" so it is within the 60s stale window
240
+ vi.mocked(getUpdateState).mockReturnValue({
241
+ currentVersion: "1.0.0",
242
+ latestVersion: "1.0.0",
243
+ lastChecked: Date.now(),
244
+ isServiceMode: false,
245
+ checking: false,
246
+ updateInProgress: false,
247
+ channel: "stable",
248
+ });
249
+ vi.mocked(isUpdateAvailable).mockReturnValue(false);
250
+
251
+ const res = await app.request("/api/update-check");
252
+
253
+ expect(res.status).toBe(200);
254
+ expect(checkForUpdate).not.toHaveBeenCalled();
255
+ });
256
+ });
257
+
258
+ // ═══════════════════════════════════════════════════════════════════════════
259
+ // POST /api/update-check
260
+ // ═══════════════════════════════════════════════════════════════════════════
261
+
262
+ describe("POST /api/update-check", () => {
263
+ it("always calls checkForUpdate regardless of staleness", async () => {
264
+ vi.mocked(getUpdateState).mockReturnValue({
265
+ currentVersion: "1.0.0",
266
+ latestVersion: "2.0.0",
267
+ lastChecked: Date.now(),
268
+ isServiceMode: true,
269
+ checking: false,
270
+ updateInProgress: false,
271
+ channel: "stable",
272
+ });
273
+ vi.mocked(isUpdateAvailable).mockReturnValue(true);
274
+
275
+ const res = await app.request("/api/update-check", { method: "POST" });
276
+
277
+ expect(res.status).toBe(200);
278
+ expect(checkForUpdate).toHaveBeenCalled();
279
+ const json = await res.json();
280
+ expect(json.updateAvailable).toBe(true);
281
+ expect(json.isServiceMode).toBe(true);
282
+ });
283
+ });
284
+
285
+ // ═══════════════════════════════════════════════════════════════════════════
286
+ // POST /api/update
287
+ // ═══════════════════════════════════════════════════════════════════════════
288
+
289
+ describe("POST /api/update", () => {
290
+ it("returns 400 when not running in service mode", async () => {
291
+ vi.mocked(getUpdateState).mockReturnValue({
292
+ currentVersion: "1.0.0",
293
+ latestVersion: "2.0.0",
294
+ lastChecked: Date.now(),
295
+ isServiceMode: false,
296
+ checking: false,
297
+ updateInProgress: false,
298
+ channel: "stable",
299
+ });
300
+
301
+ const res = await app.request("/api/update", { method: "POST" });
302
+
303
+ expect(res.status).toBe(400);
304
+ const json = await res.json();
305
+ expect(json.error).toMatch(/service mode/i);
306
+ });
307
+
308
+ it("returns 400 when no update is available", async () => {
309
+ vi.mocked(getUpdateState).mockReturnValue({
310
+ currentVersion: "1.0.0",
311
+ latestVersion: "1.0.0",
312
+ lastChecked: Date.now(),
313
+ isServiceMode: true,
314
+ checking: false,
315
+ updateInProgress: false,
316
+ channel: "stable",
317
+ });
318
+ vi.mocked(isUpdateAvailable).mockReturnValue(false);
319
+
320
+ const res = await app.request("/api/update", { method: "POST" });
321
+
322
+ expect(res.status).toBe(400);
323
+ const json = await res.json();
324
+ expect(json.error).toMatch(/no update/i);
325
+ });
326
+
327
+ it("returns 409 when an update is already in progress", async () => {
328
+ vi.mocked(getUpdateState).mockReturnValue({
329
+ currentVersion: "1.0.0",
330
+ latestVersion: "2.0.0",
331
+ lastChecked: Date.now(),
332
+ isServiceMode: true,
333
+ checking: false,
334
+ updateInProgress: true,
335
+ channel: "stable",
336
+ });
337
+ vi.mocked(isUpdateAvailable).mockReturnValue(true);
338
+
339
+ const res = await app.request("/api/update", { method: "POST" });
340
+
341
+ expect(res.status).toBe(409);
342
+ const json = await res.json();
343
+ expect(json.error).toMatch(/already in progress/i);
344
+ });
345
+
346
+ it("starts the update when all preconditions are met", async () => {
347
+ vi.mocked(getUpdateState).mockReturnValue({
348
+ currentVersion: "1.0.0",
349
+ latestVersion: "2.0.0",
350
+ lastChecked: Date.now(),
351
+ isServiceMode: true,
352
+ checking: false,
353
+ updateInProgress: false,
354
+ channel: "stable",
355
+ });
356
+ vi.mocked(isUpdateAvailable).mockReturnValue(true);
357
+
358
+ const res = await app.request("/api/update", { method: "POST" });
359
+
360
+ expect(res.status).toBe(200);
361
+ const json = await res.json();
362
+ expect(json.ok).toBe(true);
363
+ expect(json.message).toMatch(/restart/i);
364
+ expect(setUpdateInProgress).toHaveBeenCalledWith(true);
365
+ });
366
+
367
+ // Exercises the async setTimeout callback inside the update handler.
368
+ // Mocks Bun.spawn to simulate a successful install + restart.
369
+ it("runs the install and restart flow inside the deferred callback", async () => {
370
+ vi.useFakeTimers();
371
+
372
+ vi.mocked(getUpdateState).mockReturnValue({
373
+ currentVersion: "1.0.0",
374
+ latestVersion: "2.0.0",
375
+ lastChecked: Date.now(),
376
+ isServiceMode: true,
377
+ checking: false,
378
+ updateInProgress: false,
379
+ channel: "stable",
380
+ });
381
+ vi.mocked(isUpdateAvailable).mockReturnValue(true);
382
+
383
+ // Mock Bun.spawn for the install command
384
+ const mockSpawn = vi.fn()
385
+ .mockReturnValueOnce({
386
+ exited: Promise.resolve(0),
387
+ stdout: new ReadableStream(),
388
+ stderr: new ReadableStream(),
389
+ })
390
+ // Second call is the restart command
391
+ .mockReturnValueOnce({
392
+ exited: Promise.resolve(0),
393
+ stdout: new ReadableStream(),
394
+ stderr: new ReadableStream(),
395
+ });
396
+ // @ts-expect-error -- Bun global mock
397
+ globalThis.Bun = { spawn: mockSpawn };
398
+
399
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
400
+
401
+ const res = await app.request("/api/update", { method: "POST" });
402
+ expect(res.status).toBe(200);
403
+
404
+ // Advance past the 100ms setTimeout that starts the install
405
+ await vi.advanceTimersByTimeAsync(150);
406
+
407
+ // The install spawn should have been called
408
+ expect(mockSpawn).toHaveBeenCalledWith(
409
+ ["bun", "install", "-g", "@hellcoder/companion@2.0.0"],
410
+ expect.anything(),
411
+ );
412
+
413
+ // Advance past the 500ms exit timeout
414
+ await vi.advanceTimersByTimeAsync(600);
415
+
416
+ vi.useRealTimers();
417
+ exitSpy.mockRestore();
418
+ // @ts-expect-error -- cleanup Bun global mock
419
+ delete globalThis.Bun;
420
+ });
421
+
422
+ // When the install command fails, setUpdateInProgress should be reset.
423
+ it("resets updateInProgress when install fails", async () => {
424
+ vi.useFakeTimers();
425
+
426
+ vi.mocked(getUpdateState).mockReturnValue({
427
+ currentVersion: "1.0.0",
428
+ latestVersion: "2.0.0",
429
+ lastChecked: Date.now(),
430
+ isServiceMode: true,
431
+ checking: false,
432
+ updateInProgress: false,
433
+ channel: "stable",
434
+ });
435
+ vi.mocked(isUpdateAvailable).mockReturnValue(true);
436
+
437
+ const stderrStream = new ReadableStream({
438
+ start(controller) {
439
+ controller.enqueue(new TextEncoder().encode("install error"));
440
+ controller.close();
441
+ },
442
+ });
443
+ const mockSpawn = vi.fn().mockReturnValueOnce({
444
+ exited: Promise.resolve(1),
445
+ stdout: new ReadableStream(),
446
+ stderr: stderrStream,
447
+ });
448
+ // @ts-expect-error -- Bun global mock
449
+ globalThis.Bun = { spawn: mockSpawn };
450
+
451
+ const res = await app.request("/api/update", { method: "POST" });
452
+ expect(res.status).toBe(200);
453
+
454
+ await vi.advanceTimersByTimeAsync(150);
455
+
456
+ // After failed install, setUpdateInProgress should be called with false
457
+ expect(setUpdateInProgress).toHaveBeenCalledWith(false);
458
+
459
+ vi.useRealTimers();
460
+ // @ts-expect-error -- cleanup Bun global mock
461
+ delete globalThis.Bun;
462
+ });
463
+ });
464
+
465
+ // ═══════════════════════════════════════════════════════════════════════════
466
+ // GET /api/terminal
467
+ // ═══════════════════════════════════════════════════════════════════════════
468
+
469
+ describe("GET /api/terminal", () => {
470
+ it("returns active: false when no terminal is running", async () => {
471
+ terminalManager.getInfo.mockReturnValue(null);
472
+
473
+ const res = await app.request("/api/terminal");
474
+
475
+ expect(res.status).toBe(200);
476
+ const json = await res.json();
477
+ expect(json.active).toBe(false);
478
+ });
479
+
480
+ it("returns terminal info when a terminal is running", async () => {
481
+ terminalManager.getInfo.mockReturnValue({ id: "t-42", cwd: "/home/user" });
482
+
483
+ const res = await app.request("/api/terminal");
484
+
485
+ expect(res.status).toBe(200);
486
+ const json = await res.json();
487
+ expect(json.active).toBe(true);
488
+ expect(json.terminalId).toBe("t-42");
489
+ expect(json.cwd).toBe("/home/user");
490
+ });
491
+ });
492
+
493
+ // ═══════════════════════════════════════════════════════════════════════════
494
+ // POST /api/terminal/spawn
495
+ // ═══════════════════════════════════════════════════════════════════════════
496
+
497
+ describe("POST /api/terminal/spawn", () => {
498
+ it("spawns a terminal and returns its id", async () => {
499
+ terminalManager.spawn.mockReturnValue("new-terminal-id");
500
+
501
+ const res = await app.request("/api/terminal/spawn", {
502
+ method: "POST",
503
+ headers: { "Content-Type": "application/json" },
504
+ body: JSON.stringify({ cwd: "/workspace" }),
505
+ });
506
+
507
+ expect(res.status).toBe(200);
508
+ const json = await res.json();
509
+ expect(json.terminalId).toBe("new-terminal-id");
510
+ expect(terminalManager.spawn).toHaveBeenCalledWith(
511
+ "/workspace",
512
+ undefined,
513
+ undefined,
514
+ expect.objectContaining({}),
515
+ );
516
+ });
517
+
518
+ it("returns 400 when cwd is missing", async () => {
519
+ const res = await app.request("/api/terminal/spawn", {
520
+ method: "POST",
521
+ headers: { "Content-Type": "application/json" },
522
+ body: JSON.stringify({}),
523
+ });
524
+
525
+ expect(res.status).toBe(400);
526
+ const json = await res.json();
527
+ expect(json.error).toMatch(/cwd/i);
528
+ });
529
+ });
530
+
531
+ // ═══════════════════════════════════════════════════════════════════════════
532
+ // POST /api/terminal/kill
533
+ // ═══════════════════════════════════════════════════════════════════════════
534
+
535
+ describe("POST /api/terminal/kill", () => {
536
+ it("kills the specified terminal", async () => {
537
+ const res = await app.request("/api/terminal/kill", {
538
+ method: "POST",
539
+ headers: { "Content-Type": "application/json" },
540
+ body: JSON.stringify({ terminalId: "t-42" }),
541
+ });
542
+
543
+ expect(res.status).toBe(200);
544
+ const json = await res.json();
545
+ expect(json.ok).toBe(true);
546
+ expect(terminalManager.kill).toHaveBeenCalledWith("t-42");
547
+ });
548
+
549
+ it("returns 400 when terminalId is missing", async () => {
550
+ const res = await app.request("/api/terminal/kill", {
551
+ method: "POST",
552
+ headers: { "Content-Type": "application/json" },
553
+ body: JSON.stringify({}),
554
+ });
555
+
556
+ expect(res.status).toBe(400);
557
+ const json = await res.json();
558
+ expect(json.error).toMatch(/terminalId/i);
559
+ });
560
+ });
561
+
562
+ // ═══════════════════════════════════════════════════════════════════════════
563
+ // POST /api/sessions/:id/message
564
+ // ═══════════════════════════════════════════════════════════════════════════
565
+
566
+ describe("POST /api/sessions/:id/message", () => {
567
+ it("injects a user message into a running session", async () => {
568
+ launcher.getSession.mockReturnValue({ id: "sess-1" } as any);
569
+ launcher.isAlive.mockReturnValue(true);
570
+
571
+ const res = await app.request("/api/sessions/sess-1/message", {
572
+ method: "POST",
573
+ headers: { "Content-Type": "application/json" },
574
+ body: JSON.stringify({ content: "hello world" }),
575
+ });
576
+
577
+ expect(res.status).toBe(200);
578
+ const json = await res.json();
579
+ expect(json.ok).toBe(true);
580
+ expect(json.sessionId).toBe("sess-1");
581
+ expect(wsBridge.injectUserMessage).toHaveBeenCalledWith("sess-1", "hello world");
582
+ });
583
+
584
+ it("returns 404 when the session does not exist", async () => {
585
+ launcher.getSession.mockReturnValue(undefined);
586
+
587
+ const res = await app.request("/api/sessions/missing/message", {
588
+ method: "POST",
589
+ headers: { "Content-Type": "application/json" },
590
+ body: JSON.stringify({ content: "hello" }),
591
+ });
592
+
593
+ expect(res.status).toBe(404);
594
+ const json = await res.json();
595
+ expect(json.error).toMatch(/not found/i);
596
+ });
597
+
598
+ it("returns 400 when the session is not running", async () => {
599
+ launcher.getSession.mockReturnValue({ id: "sess-1" } as any);
600
+ launcher.isAlive.mockReturnValue(false);
601
+
602
+ const res = await app.request("/api/sessions/sess-1/message", {
603
+ method: "POST",
604
+ headers: { "Content-Type": "application/json" },
605
+ body: JSON.stringify({ content: "hello" }),
606
+ });
607
+
608
+ expect(res.status).toBe(400);
609
+ const json = await res.json();
610
+ expect(json.error).toMatch(/not running/i);
611
+ });
612
+
613
+ it("returns 400 when content is missing or empty", async () => {
614
+ launcher.getSession.mockReturnValue({ id: "sess-1" } as any);
615
+ launcher.isAlive.mockReturnValue(true);
616
+
617
+ // Empty content field
618
+ const res1 = await app.request("/api/sessions/sess-1/message", {
619
+ method: "POST",
620
+ headers: { "Content-Type": "application/json" },
621
+ body: JSON.stringify({ content: " " }),
622
+ });
623
+ expect(res1.status).toBe(400);
624
+ const json1 = await res1.json();
625
+ expect(json1.error).toMatch(/content/i);
626
+
627
+ // Missing content field entirely
628
+ const res2 = await app.request("/api/sessions/sess-1/message", {
629
+ method: "POST",
630
+ headers: { "Content-Type": "application/json" },
631
+ body: JSON.stringify({}),
632
+ });
633
+ expect(res2.status).toBe(400);
634
+ const json2 = await res2.json();
635
+ expect(json2.error).toMatch(/content/i);
636
+ });
637
+ });