@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,207 @@
1
+ // Formal session state machine for the Companion server.
2
+ // Centralizes session phase definitions and validates transitions.
3
+
4
+ import { metricsCollector } from "./metrics-collector.js";
5
+ import { log } from "./logger.js";
6
+
7
+ /**
8
+ * The formal phases a session can be in.
9
+ *
10
+ * - starting: CLI process spawned, WS not yet connected
11
+ * - initializing: CLI WS connected, awaiting system.init
12
+ * - ready: Idle, awaiting user input
13
+ * - streaming: Claude generating output (stream_event / assistant)
14
+ * - awaiting_permission: Tool call pending user approval
15
+ * - compacting: Context window compaction in progress
16
+ * - reconnecting: CLI socket dropped, within grace period
17
+ * - terminated: Process exited or killed
18
+ */
19
+ export type SessionPhase =
20
+ | "starting"
21
+ | "initializing"
22
+ | "ready"
23
+ | "streaming"
24
+ | "awaiting_permission"
25
+ | "compacting"
26
+ | "reconnecting"
27
+ | "terminated";
28
+
29
+ /** Payload emitted on every successful state transition. */
30
+ export interface SessionTransitionEvent {
31
+ sessionId: string;
32
+ from: SessionPhase;
33
+ to: SessionPhase;
34
+ trigger: string;
35
+ timestamp: number;
36
+ }
37
+
38
+ /**
39
+ * Defines which (from -> to) transitions are valid.
40
+ * Any transition not listed here will be blocked with a warning.
41
+ */
42
+ export const VALID_TRANSITIONS: ReadonlyMap<
43
+ SessionPhase,
44
+ ReadonlySet<SessionPhase>
45
+ > = new Map([
46
+ [
47
+ "starting",
48
+ new Set<SessionPhase>(["initializing", "streaming", "reconnecting", "terminated"]),
49
+ ],
50
+ [
51
+ "initializing",
52
+ new Set<SessionPhase>(["ready", "streaming", "reconnecting", "terminated"]),
53
+ ],
54
+ [
55
+ "ready",
56
+ new Set<SessionPhase>([
57
+ "streaming",
58
+ "compacting",
59
+ "reconnecting",
60
+ "terminated",
61
+ ]),
62
+ ],
63
+ [
64
+ "streaming",
65
+ new Set<SessionPhase>([
66
+ "ready",
67
+ "initializing",
68
+ "awaiting_permission",
69
+ "compacting",
70
+ "reconnecting",
71
+ "terminated",
72
+ ]),
73
+ ],
74
+ [
75
+ "awaiting_permission",
76
+ new Set<SessionPhase>(["streaming", "ready", "reconnecting", "terminated"]),
77
+ ],
78
+ [
79
+ "compacting",
80
+ new Set<SessionPhase>([
81
+ "ready",
82
+ "streaming",
83
+ "reconnecting",
84
+ "terminated",
85
+ ]),
86
+ ],
87
+ [
88
+ "reconnecting",
89
+ new Set<SessionPhase>(["initializing", "starting", "ready", "streaming", "terminated"]),
90
+ ],
91
+ ["terminated", new Set<SessionPhase>(["starting"])],
92
+ ]);
93
+
94
+ type TransitionListener = (event: SessionTransitionEvent) => void;
95
+
96
+ export class SessionStateMachine {
97
+ private _phase: SessionPhase;
98
+ private readonly _sessionId: string;
99
+ private _listeners: TransitionListener[] = [];
100
+
101
+ constructor(sessionId: string, initialPhase: SessionPhase = "starting") {
102
+ this._sessionId = sessionId;
103
+ this._phase = initialPhase;
104
+ }
105
+
106
+ get phase(): SessionPhase {
107
+ return this._phase;
108
+ }
109
+
110
+ get sessionId(): string {
111
+ return this._sessionId;
112
+ }
113
+
114
+ /**
115
+ * Attempt a state transition.
116
+ * Returns true if successful (or same-state no-op), false if blocked.
117
+ * Invalid transitions are logged but never throw.
118
+ */
119
+ transition(to: SessionPhase, trigger: string): boolean {
120
+ if (this._phase === to) return true;
121
+
122
+ const allowed = VALID_TRANSITIONS.get(this._phase);
123
+ if (!allowed || !allowed.has(to)) {
124
+ metricsCollector.recordError("invalid_state_transition");
125
+ log.warn("state-machine", "Blocked invalid transition", {
126
+ sessionId: this._sessionId,
127
+ from: this._phase,
128
+ to,
129
+ trigger,
130
+ });
131
+ return false;
132
+ }
133
+
134
+ const event: SessionTransitionEvent = {
135
+ sessionId: this._sessionId,
136
+ from: this._phase,
137
+ to,
138
+ trigger,
139
+ timestamp: Date.now(),
140
+ };
141
+
142
+ this._phase = to;
143
+
144
+ // Snapshot listeners so additions/removals during iteration are safe
145
+ const snapshot = this._listeners.slice();
146
+ for (const listener of snapshot) {
147
+ try {
148
+ listener(event);
149
+ } catch (err) {
150
+ console.error(
151
+ `[state-machine] Listener error for ${this._sessionId}:`,
152
+ err,
153
+ );
154
+ }
155
+ }
156
+
157
+ return true;
158
+ }
159
+
160
+ /** Subscribe to state transitions. Returns an unsubscribe function. */
161
+ onTransition(listener: TransitionListener): () => void {
162
+ this._listeners.push(listener);
163
+ return () => {
164
+ const idx = this._listeners.indexOf(listener);
165
+ if (idx !== -1) this._listeners.splice(idx, 1);
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Force-set state without validation or listener notification.
171
+ * Used for restoring state from disk.
172
+ */
173
+ forceState(phase: SessionPhase): void {
174
+ this._phase = phase;
175
+ }
176
+
177
+ // -- Guard methods --
178
+
179
+ /** True only when session is idle and ready for a new user message. */
180
+ canAcceptUserMessage(): boolean {
181
+ return this._phase === "ready";
182
+ }
183
+
184
+ /** True only when a permission request is pending. */
185
+ canRespondToPermission(): boolean {
186
+ return this._phase === "awaiting_permission";
187
+ }
188
+
189
+ /** True when the CLI socket is expected to be reachable. */
190
+ canSendToCLI(): boolean {
191
+ return (
192
+ this._phase !== "terminated" &&
193
+ this._phase !== "reconnecting" &&
194
+ this._phase !== "starting"
195
+ );
196
+ }
197
+
198
+ /** True when the session has not terminated. */
199
+ isActive(): boolean {
200
+ return this._phase !== "terminated";
201
+ }
202
+
203
+ /** True only when the session is idle (ready). */
204
+ isIdle(): boolean {
205
+ return this._phase === "ready";
206
+ }
207
+ }
@@ -0,0 +1,290 @@
1
+ import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { SessionStore, type PersistedSession } from "./session-store.js";
5
+
6
+ let tempDir: string;
7
+ let store: SessionStore;
8
+
9
+ function makeSession(id: string, overrides: Partial<PersistedSession> = {}): PersistedSession {
10
+ return {
11
+ id,
12
+ state: {
13
+ session_id: id,
14
+ model: "claude-sonnet-4-6",
15
+ cwd: "/test",
16
+ tools: [],
17
+ permissionMode: "default",
18
+ claude_code_version: "1.0.0",
19
+ mcp_servers: [],
20
+ agents: [],
21
+ slash_commands: [],
22
+ skills: [],
23
+ total_cost_usd: 0,
24
+ num_turns: 0,
25
+ context_used_percent: 0,
26
+ is_compacting: false,
27
+ git_branch: "",
28
+ is_worktree: false,
29
+ is_containerized: false,
30
+ repo_root: "",
31
+ git_ahead: 0,
32
+ git_behind: 0,
33
+ total_lines_added: 0,
34
+ total_lines_removed: 0,
35
+ },
36
+ messageHistory: [],
37
+ pendingMessages: [],
38
+ pendingPermissions: [],
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ beforeEach(() => {
44
+ tempDir = mkdtempSync(join(tmpdir(), "ss-test-"));
45
+ store = new SessionStore(tempDir);
46
+ });
47
+
48
+ afterEach(() => {
49
+ rmSync(tempDir, { recursive: true, force: true });
50
+ });
51
+
52
+ // ─── saveSync / load ──────────────────────────────────────────────────────────
53
+
54
+ describe("saveSync / load", () => {
55
+ it("writes a session to disk and reads it back", () => {
56
+ const session = makeSession("s1");
57
+ store.saveSync(session);
58
+
59
+ const filePath = join(tempDir, "s1.json");
60
+ expect(existsSync(filePath)).toBe(true);
61
+
62
+ const loaded = store.load("s1");
63
+ expect(loaded).toEqual(session);
64
+ });
65
+
66
+ it("returns null for a non-existent session", () => {
67
+ const loaded = store.load("does-not-exist");
68
+ expect(loaded).toBeNull();
69
+ });
70
+
71
+ it("returns null for a corrupt JSON file", () => {
72
+ writeFileSync(join(tempDir, "corrupt.json"), "{{not valid json!!", "utf-8");
73
+ const loaded = store.load("corrupt");
74
+ expect(loaded).toBeNull();
75
+ });
76
+
77
+ it("preserves all session fields through round-trip", () => {
78
+ const session = makeSession("s2", {
79
+ messageHistory: [{ type: "error", message: "test error" }],
80
+ pendingMessages: ["msg1", "msg2"],
81
+ pendingPermissions: [
82
+ [
83
+ "req-1",
84
+ {
85
+ request_id: "req-1",
86
+ tool_name: "Write",
87
+ input: { path: "/tmp/test.txt" },
88
+ tool_use_id: "tu-1",
89
+ timestamp: Date.now(),
90
+ },
91
+ ],
92
+ ],
93
+ eventBuffer: [
94
+ { seq: 1, message: { type: "cli_connected" } },
95
+ ],
96
+ nextEventSeq: 2,
97
+ lastAckSeq: 1,
98
+ processedClientMessageIds: ["client-msg-1", "client-msg-2"],
99
+ archived: true,
100
+ });
101
+
102
+ store.saveSync(session);
103
+ const loaded = store.load("s2");
104
+ expect(loaded).toEqual(session);
105
+ expect(loaded!.archived).toBe(true);
106
+ expect(loaded!.pendingPermissions).toHaveLength(1);
107
+ expect(loaded!.pendingMessages).toEqual(["msg1", "msg2"]);
108
+ expect(loaded!.eventBuffer).toEqual([{ seq: 1, message: { type: "cli_connected" } }]);
109
+ expect(loaded!.nextEventSeq).toBe(2);
110
+ expect(loaded!.lastAckSeq).toBe(1);
111
+ expect(loaded!.processedClientMessageIds).toEqual(["client-msg-1", "client-msg-2"]);
112
+ });
113
+ });
114
+
115
+ // ─── save (debounced) ─────────────────────────────────────────────────────────
116
+
117
+ describe("save (debounced)", () => {
118
+ beforeEach(() => {
119
+ vi.useFakeTimers();
120
+ });
121
+
122
+ afterEach(() => {
123
+ vi.useRealTimers();
124
+ });
125
+
126
+ it("does not write immediately", () => {
127
+ const session = makeSession("debounce-1");
128
+ store.save(session);
129
+
130
+ const filePath = join(tempDir, "debounce-1.json");
131
+ expect(existsSync(filePath)).toBe(false);
132
+ });
133
+
134
+ it("writes after the 150ms debounce period", () => {
135
+ const session = makeSession("debounce-2");
136
+ store.save(session);
137
+
138
+ vi.advanceTimersByTime(150);
139
+
140
+ const filePath = join(tempDir, "debounce-2.json");
141
+ expect(existsSync(filePath)).toBe(true);
142
+
143
+ const loaded = store.load("debounce-2");
144
+ expect(loaded).toEqual(session);
145
+ });
146
+
147
+ it("coalesces rapid calls and only writes the last version", () => {
148
+ const session1 = makeSession("debounce-3", {
149
+ pendingMessages: ["first"],
150
+ });
151
+ const session2 = makeSession("debounce-3", {
152
+ pendingMessages: ["second"],
153
+ });
154
+ const session3 = makeSession("debounce-3", {
155
+ pendingMessages: ["third"],
156
+ });
157
+
158
+ store.save(session1);
159
+ vi.advanceTimersByTime(50);
160
+ store.save(session2);
161
+ vi.advanceTimersByTime(50);
162
+ store.save(session3);
163
+
164
+ // Not yet written (timer restarted with session3)
165
+ expect(existsSync(join(tempDir, "debounce-3.json"))).toBe(false);
166
+
167
+ vi.advanceTimersByTime(150);
168
+
169
+ const loaded = store.load("debounce-3");
170
+ expect(loaded!.pendingMessages).toEqual(["third"]);
171
+ });
172
+ });
173
+
174
+ // ─── loadAll ──────────────────────────────────────────────────────────────────
175
+
176
+ describe("loadAll", () => {
177
+ it("returns all saved sessions", () => {
178
+ store.saveSync(makeSession("a"));
179
+ store.saveSync(makeSession("b"));
180
+ store.saveSync(makeSession("c"));
181
+
182
+ const all = store.loadAll();
183
+ const ids = all.map((s) => s.id).sort();
184
+ expect(ids).toEqual(["a", "b", "c"]);
185
+ });
186
+
187
+ it("skips corrupt JSON files", () => {
188
+ store.saveSync(makeSession("good"));
189
+ writeFileSync(join(tempDir, "bad.json"), "not-json!", "utf-8");
190
+
191
+ const all = store.loadAll();
192
+ expect(all).toHaveLength(1);
193
+ expect(all[0].id).toBe("good");
194
+ });
195
+
196
+ it("excludes launcher.json from results", () => {
197
+ store.saveSync(makeSession("session-1"));
198
+ store.saveLauncher({ some: "launcher data" });
199
+
200
+ const all = store.loadAll();
201
+ expect(all).toHaveLength(1);
202
+ expect(all[0].id).toBe("session-1");
203
+ });
204
+
205
+ it("returns an empty array for an empty directory", () => {
206
+ const all = store.loadAll();
207
+ expect(all).toEqual([]);
208
+ });
209
+ });
210
+
211
+ // ─── setArchived ──────────────────────────────────────────────────────────────
212
+
213
+ describe("setArchived", () => {
214
+ it("sets archived flag to true and persists it", () => {
215
+ store.saveSync(makeSession("arch-1"));
216
+ const result = store.setArchived("arch-1", true);
217
+
218
+ expect(result).toBe(true);
219
+
220
+ const loaded = store.load("arch-1");
221
+ expect(loaded!.archived).toBe(true);
222
+ });
223
+
224
+ it("sets archived flag to false and persists it", () => {
225
+ store.saveSync(makeSession("arch-2", { archived: true }));
226
+ const result = store.setArchived("arch-2", false);
227
+
228
+ expect(result).toBe(true);
229
+
230
+ const loaded = store.load("arch-2");
231
+ expect(loaded!.archived).toBe(false);
232
+ });
233
+
234
+ it("returns false for a non-existent session", () => {
235
+ const result = store.setArchived("no-such-session", true);
236
+ expect(result).toBe(false);
237
+ });
238
+ });
239
+
240
+ // ─── remove ───────────────────────────────────────────────────────────────────
241
+
242
+ describe("remove", () => {
243
+ it("deletes the session file from disk", () => {
244
+ store.saveSync(makeSession("rm-1"));
245
+ expect(existsSync(join(tempDir, "rm-1.json"))).toBe(true);
246
+
247
+ store.remove("rm-1");
248
+ expect(existsSync(join(tempDir, "rm-1.json"))).toBe(false);
249
+ expect(store.load("rm-1")).toBeNull();
250
+ });
251
+
252
+ it("cancels a pending debounced save so it never writes", () => {
253
+ vi.useFakeTimers();
254
+ try {
255
+ const session = makeSession("rm-2");
256
+ store.save(session);
257
+
258
+ // Remove before the debounce fires
259
+ store.remove("rm-2");
260
+
261
+ // Advance past the debounce window
262
+ vi.advanceTimersByTime(300);
263
+
264
+ expect(existsSync(join(tempDir, "rm-2.json"))).toBe(false);
265
+ } finally {
266
+ vi.useRealTimers();
267
+ }
268
+ });
269
+
270
+ it("does not throw when removing a non-existent session", () => {
271
+ expect(() => store.remove("ghost-session")).not.toThrow();
272
+ });
273
+ });
274
+
275
+ // ─── saveLauncher / loadLauncher ──────────────────────────────────────────────
276
+
277
+ describe("saveLauncher / loadLauncher", () => {
278
+ it("writes and reads launcher data", () => {
279
+ const data = { pids: [123, 456], lastBoot: "2025-01-01T00:00:00Z" };
280
+ store.saveLauncher(data);
281
+
282
+ const loaded = store.loadLauncher<{ pids: number[]; lastBoot: string }>();
283
+ expect(loaded).toEqual(data);
284
+ });
285
+
286
+ it("returns null when no launcher file exists", () => {
287
+ const loaded = store.loadLauncher();
288
+ expect(loaded).toBeNull();
289
+ });
290
+ });
@@ -0,0 +1,146 @@
1
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import type {
5
+ SessionState,
6
+ BrowserIncomingMessage,
7
+ PermissionRequest,
8
+ BufferedBrowserEvent,
9
+ } from "./session-types.js";
10
+
11
+ // ─── Serializable session shape ─────────────────────────────────────────────
12
+
13
+ export interface PersistedSession {
14
+ id: string;
15
+ state: SessionState;
16
+ messageHistory: BrowserIncomingMessage[];
17
+ pendingMessages: string[];
18
+ pendingPermissions: [string, PermissionRequest][];
19
+ eventBuffer?: BufferedBrowserEvent[];
20
+ nextEventSeq?: number;
21
+ lastAckSeq?: number;
22
+ processedClientMessageIds?: string[];
23
+ archived?: boolean;
24
+ }
25
+
26
+ // ─── Store ──────────────────────────────────────────────────────────────────
27
+
28
+ const DEFAULT_DIR = join(tmpdir(), "vibe-sessions");
29
+
30
+ export class SessionStore {
31
+ private dir: string;
32
+ private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
33
+
34
+ constructor(dir?: string) {
35
+ this.dir = dir || DEFAULT_DIR;
36
+ mkdirSync(this.dir, { recursive: true });
37
+ }
38
+
39
+ private filePath(sessionId: string): string {
40
+ return join(this.dir, `${sessionId}.json`);
41
+ }
42
+
43
+ /** Debounced write — batches rapid changes (e.g. multiple stream events). */
44
+ save(session: PersistedSession): void {
45
+ const existing = this.debounceTimers.get(session.id);
46
+ if (existing) clearTimeout(existing);
47
+
48
+ const timer = setTimeout(() => {
49
+ this.debounceTimers.delete(session.id);
50
+ this.saveSync(session);
51
+ }, 150);
52
+ this.debounceTimers.set(session.id, timer);
53
+ }
54
+
55
+ /** Immediate write — use for critical state changes. */
56
+ saveSync(session: PersistedSession): void {
57
+ try {
58
+ writeFileSync(this.filePath(session.id), JSON.stringify(session), "utf-8");
59
+ } catch (err) {
60
+ console.error(`[session-store] Failed to save session ${session.id}:`, err);
61
+ }
62
+ }
63
+
64
+ /** Load a single session from disk. */
65
+ load(sessionId: string): PersistedSession | null {
66
+ try {
67
+ const raw = readFileSync(this.filePath(sessionId), "utf-8");
68
+ return JSON.parse(raw) as PersistedSession;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /** Load all sessions from disk. */
75
+ loadAll(): PersistedSession[] {
76
+ const sessions: PersistedSession[] = [];
77
+ try {
78
+ const files = readdirSync(this.dir).filter((f) => f.endsWith(".json") && f !== "launcher.json");
79
+ for (const file of files) {
80
+ try {
81
+ const raw = readFileSync(join(this.dir, file), "utf-8");
82
+ sessions.push(JSON.parse(raw));
83
+ } catch {
84
+ // Skip corrupt files
85
+ }
86
+ }
87
+ } catch {
88
+ // Dir doesn't exist yet
89
+ }
90
+ return sessions;
91
+ }
92
+
93
+ /** Set the archived flag on a persisted session. */
94
+ setArchived(sessionId: string, archived: boolean): boolean {
95
+ const session = this.load(sessionId);
96
+ if (!session) return false;
97
+ session.archived = archived;
98
+ this.saveSync(session);
99
+ return true;
100
+ }
101
+
102
+ /** Remove a session file from disk. */
103
+ remove(sessionId: string): void {
104
+ const timer = this.debounceTimers.get(sessionId);
105
+ if (timer) {
106
+ clearTimeout(timer);
107
+ this.debounceTimers.delete(sessionId);
108
+ }
109
+ try {
110
+ unlinkSync(this.filePath(sessionId));
111
+ } catch {
112
+ // File may not exist
113
+ }
114
+ }
115
+
116
+ /** Persist launcher state (separate file). */
117
+ saveLauncher(data: unknown): void {
118
+ try {
119
+ writeFileSync(join(this.dir, "launcher.json"), JSON.stringify(data), "utf-8");
120
+ } catch (err) {
121
+ console.error("[session-store] Failed to save launcher state:", err);
122
+ }
123
+ }
124
+
125
+ /** Load launcher state. */
126
+ loadLauncher<T>(): T | null {
127
+ try {
128
+ const raw = readFileSync(join(this.dir, "launcher.json"), "utf-8");
129
+ return JSON.parse(raw) as T;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ /** Cancel all pending debounce timers (for clean test teardown). */
136
+ dispose(): void {
137
+ for (const timer of this.debounceTimers.values()) {
138
+ clearTimeout(timer);
139
+ }
140
+ this.debounceTimers.clear();
141
+ }
142
+
143
+ get directory(): string {
144
+ return this.dir;
145
+ }
146
+ }