the-campfire 0.1.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 (180) hide show
  1. package/bin/cli.ts +182 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
  4. package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
  5. package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
  6. package/dist/assets/arc-D9SEA8dX.js +1 -0
  7. package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
  8. package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
  9. package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
  10. package/dist/assets/channel-CxhcHn7S.js +1 -0
  11. package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
  12. package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
  13. package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
  14. package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
  15. package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
  16. package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
  17. package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
  18. package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
  19. package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
  20. package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
  21. package/dist/assets/clone-C6kt1rE7.js +1 -0
  22. package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
  23. package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  24. package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
  25. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  26. package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
  27. package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
  28. package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
  29. package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
  30. package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
  31. package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
  32. package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
  33. package/dist/assets/graph-DKRbrxcs.js +1 -0
  34. package/dist/assets/index-Byjg9zgI.css +32 -0
  35. package/dist/assets/index-Cwj3m0hT.js +284 -0
  36. package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
  37. package/dist/assets/init-Gi6I4Gst.js +1 -0
  38. package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
  39. package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
  40. package/dist/assets/katex-BlHpptmG.js +261 -0
  41. package/dist/assets/layout-DnxSwLDJ.js +1 -0
  42. package/dist/assets/linear-CWbbIT8l.js +1 -0
  43. package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
  44. package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
  45. package/dist/assets/ordinal-BENe2yWM.js +1 -0
  46. package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
  47. package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
  48. package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
  49. package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
  50. package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
  51. package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
  52. package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
  53. package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
  54. package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
  55. package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
  56. package/dist/favicon.svg +3 -0
  57. package/dist/fonts/Geist-Variable.woff2 +0 -0
  58. package/dist/fonts/GeistMono-Variable.woff2 +0 -0
  59. package/dist/icon-192.png +0 -0
  60. package/dist/icon-512.png +0 -0
  61. package/dist/index.html +33 -0
  62. package/dist/logo-codex.svg +14 -0
  63. package/dist/logo.svg +3 -0
  64. package/dist/manifest.json +26 -0
  65. package/dist/og-image.png +0 -0
  66. package/dist/sw.js +130 -0
  67. package/package.json +92 -0
  68. package/server/ADAPTERS.md +121 -0
  69. package/server/adapter-registry-types.ts +28 -0
  70. package/server/adapter-registry.ts +293 -0
  71. package/server/adapter-types.ts +51 -0
  72. package/server/agent-executor.ts +249 -0
  73. package/server/agent-mcp-bridge.ts +148 -0
  74. package/server/agent-mcp-stdio.ts +171 -0
  75. package/server/agent-mcp-tools.ts +76 -0
  76. package/server/agent-store.ts +183 -0
  77. package/server/agent-types.ts +79 -0
  78. package/server/aider-adapter.ts +525 -0
  79. package/server/auth.ts +153 -0
  80. package/server/auto-namer.ts +132 -0
  81. package/server/capability-discovery.ts +431 -0
  82. package/server/claude-container-auth.ts +40 -0
  83. package/server/claude-stdio-adapter.ts +452 -0
  84. package/server/clawhub-export.ts +218 -0
  85. package/server/cli-launcher.ts +1613 -0
  86. package/server/codex-adapter.ts +1987 -0
  87. package/server/codex-container-auth.ts +40 -0
  88. package/server/codex-home.ts +26 -0
  89. package/server/collective-intelligence.ts +401 -0
  90. package/server/commands-discovery.ts +160 -0
  91. package/server/constants.ts +5 -0
  92. package/server/container-manager.ts +328 -0
  93. package/server/cron-scheduler.ts +243 -0
  94. package/server/cron-store.ts +149 -0
  95. package/server/cron-types.ts +63 -0
  96. package/server/deliberation-engine.ts +323 -0
  97. package/server/embedding.ts +89 -0
  98. package/server/env-manager.ts +146 -0
  99. package/server/environment-detector.ts +106 -0
  100. package/server/environment-rules.ts +106 -0
  101. package/server/gallery-store.ts +216 -0
  102. package/server/gallery-types.ts +52 -0
  103. package/server/gallery-votes.ts +101 -0
  104. package/server/git-utils.ts +384 -0
  105. package/server/github-pr.ts +379 -0
  106. package/server/goose-adapter.ts +929 -0
  107. package/server/image-pull-manager.ts +144 -0
  108. package/server/index.ts +363 -0
  109. package/server/linear-project-manager.ts +78 -0
  110. package/server/moltbook-client.ts +160 -0
  111. package/server/openclaw-adapter.ts +916 -0
  112. package/server/openclaw-channel/channel.ts +188 -0
  113. package/server/openclaw-channel/index.ts +68 -0
  114. package/server/openclaw-channel/package.json +19 -0
  115. package/server/openclaw-channel/types.ts +93 -0
  116. package/server/opencode-adapter.ts +951 -0
  117. package/server/openhands-adapter.ts +775 -0
  118. package/server/orchestrator-executor.ts +184 -0
  119. package/server/orchestrator-store.ts +106 -0
  120. package/server/orchestrator-types.ts +86 -0
  121. package/server/path-resolver.ts +168 -0
  122. package/server/pr-poller.ts +162 -0
  123. package/server/proactive-keepalive.ts +121 -0
  124. package/server/prompt-manager.ts +103 -0
  125. package/server/protocol-monitor.ts +197 -0
  126. package/server/race-controller.ts +295 -0
  127. package/server/race-store.ts +82 -0
  128. package/server/recorder.ts +359 -0
  129. package/server/recording-hub/compat-validator.ts +122 -0
  130. package/server/recording-hub/diagnostics.ts +289 -0
  131. package/server/recording-hub/hub-routes.ts +146 -0
  132. package/server/recording-hub/hub-store.ts +224 -0
  133. package/server/replay.ts +78 -0
  134. package/server/routes/adapter-routes.ts +31 -0
  135. package/server/routes/agent-mcp-routes.ts +35 -0
  136. package/server/routes/agent-routes.ts +185 -0
  137. package/server/routes/auth-routes.ts +101 -0
  138. package/server/routes/ci-routes.ts +165 -0
  139. package/server/routes/commands-routes.ts +94 -0
  140. package/server/routes/cron-routes.ts +100 -0
  141. package/server/routes/env-routes.ts +50 -0
  142. package/server/routes/folder-routes.ts +56 -0
  143. package/server/routes/fs-routes.ts +403 -0
  144. package/server/routes/gallery-routes.ts +410 -0
  145. package/server/routes/git-routes.ts +108 -0
  146. package/server/routes/index.ts +62 -0
  147. package/server/routes/linear-routes.ts +254 -0
  148. package/server/routes/monitor-routes.ts +11 -0
  149. package/server/routes/orchestrator-routes.ts +210 -0
  150. package/server/routes/prompt-routes.ts +47 -0
  151. package/server/routes/race-routes.ts +103 -0
  152. package/server/routes/recording-routes.ts +101 -0
  153. package/server/routes/route-deps.ts +32 -0
  154. package/server/routes/session-routes.ts +560 -0
  155. package/server/routes/settings-routes.ts +108 -0
  156. package/server/routes/skills-routes.ts +52 -0
  157. package/server/routes/system-routes.ts +243 -0
  158. package/server/routes/webhook-routes.ts +135 -0
  159. package/server/routes.ts +44 -0
  160. package/server/security-middleware.ts +100 -0
  161. package/server/semantic-memory.ts +389 -0
  162. package/server/service.ts +733 -0
  163. package/server/session-folders.ts +107 -0
  164. package/server/session-git-info.ts +89 -0
  165. package/server/session-linear-issues.ts +94 -0
  166. package/server/session-names.ts +67 -0
  167. package/server/session-store.ts +169 -0
  168. package/server/session-types.ts +415 -0
  169. package/server/settings-manager.ts +120 -0
  170. package/server/shared-context.ts +401 -0
  171. package/server/skills-manager.ts +250 -0
  172. package/server/sub-session-manager.ts +263 -0
  173. package/server/terminal-manager.ts +185 -0
  174. package/server/update-checker.ts +114 -0
  175. package/server/usage-limits.ts +192 -0
  176. package/server/webhook-manager.ts +228 -0
  177. package/server/webhook-store.ts +168 -0
  178. package/server/webhook-types.ts +66 -0
  179. package/server/worktree-tracker.ts +84 -0
  180. package/server/ws-bridge.ts +2322 -0
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Session Folders — user-defined groups for organizing sessions.
3
+ * Stored in ~/.campfire/session-folders.json
4
+ */
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { homedir } from "node:os";
8
+
9
+ const FOLDERS_FILE = join(homedir(), ".campfire", "session-folders.json");
10
+
11
+ export interface SessionFolder {
12
+ id: string;
13
+ name: string;
14
+ color?: string;
15
+ sessionIds: string[];
16
+ createdAt: number;
17
+ }
18
+
19
+ interface FoldersData {
20
+ folders: SessionFolder[];
21
+ }
22
+
23
+ function ensureDir(): void {
24
+ const dir = join(homedir(), ".campfire");
25
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
26
+ }
27
+
28
+ function load(): FoldersData {
29
+ try {
30
+ if (!existsSync(FOLDERS_FILE)) return { folders: [] };
31
+ return JSON.parse(readFileSync(FOLDERS_FILE, "utf-8"));
32
+ } catch {
33
+ return { folders: [] };
34
+ }
35
+ }
36
+
37
+ function save(data: FoldersData): void {
38
+ ensureDir();
39
+ writeFileSync(FOLDERS_FILE, JSON.stringify(data, null, 2), "utf-8");
40
+ }
41
+
42
+ export function listFolders(): SessionFolder[] {
43
+ return load().folders;
44
+ }
45
+
46
+ export function createFolder(name: string, color?: string): SessionFolder {
47
+ const data = load();
48
+ const folder: SessionFolder = {
49
+ id: `folder-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
50
+ name,
51
+ color,
52
+ sessionIds: [],
53
+ createdAt: Date.now(),
54
+ };
55
+ data.folders.push(folder);
56
+ save(data);
57
+ return folder;
58
+ }
59
+
60
+ export function updateFolder(id: string, updates: { name?: string; color?: string }): SessionFolder | null {
61
+ const data = load();
62
+ const folder = data.folders.find((f) => f.id === id);
63
+ if (!folder) return null;
64
+ if (updates.name !== undefined) folder.name = updates.name;
65
+ if (updates.color !== undefined) folder.color = updates.color;
66
+ save(data);
67
+ return folder;
68
+ }
69
+
70
+ export function deleteFolder(id: string): boolean {
71
+ const data = load();
72
+ const index = data.folders.findIndex((f) => f.id === id);
73
+ if (index === -1) return false;
74
+ data.folders.splice(index, 1);
75
+ save(data);
76
+ return true;
77
+ }
78
+
79
+ export function addSessionToFolder(folderId: string, sessionId: string): boolean {
80
+ const data = load();
81
+ // Remove from any other folder first
82
+ for (const folder of data.folders) {
83
+ folder.sessionIds = folder.sessionIds.filter((s) => s !== sessionId);
84
+ }
85
+ const folder = data.folders.find((f) => f.id === folderId);
86
+ if (!folder) return false;
87
+ folder.sessionIds.push(sessionId);
88
+ save(data);
89
+ return true;
90
+ }
91
+
92
+ export function removeSessionFromFolder(sessionId: string): boolean {
93
+ const data = load();
94
+ let removed = false;
95
+ for (const folder of data.folders) {
96
+ const len = folder.sessionIds.length;
97
+ folder.sessionIds = folder.sessionIds.filter((s) => s !== sessionId);
98
+ if (folder.sessionIds.length < len) removed = true;
99
+ }
100
+ if (removed) save(data);
101
+ return removed;
102
+ }
103
+
104
+ export function getSessionFolder(sessionId: string): SessionFolder | null {
105
+ const data = load();
106
+ return data.folders.find((f) => f.sessionIds.includes(sessionId)) ?? null;
107
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Git info resolution for Docker containers.
3
+ * Runs git commands inside containers via `docker exec`.
4
+ */
5
+
6
+ import { execSync } from "node:child_process";
7
+
8
+ export interface ContainerGitInfo {
9
+ branch: string;
10
+ isWorktree: boolean;
11
+ repoRoot: string;
12
+ ahead: number;
13
+ behind: number;
14
+ }
15
+
16
+ /**
17
+ * Resolve git information from inside a Docker container.
18
+ * @param containerId Docker container ID or name
19
+ * @param containerCwd Working directory inside the container
20
+ * @returns Git info or null if not a git repo
21
+ */
22
+ export function containerGitInfo(
23
+ containerId: string,
24
+ containerCwd: string,
25
+ ): ContainerGitInfo | null {
26
+ try {
27
+ const branch = dockerExec(containerId, containerCwd,
28
+ "git rev-parse --abbrev-ref HEAD").trim();
29
+
30
+ if (!branch) return null;
31
+
32
+ let isWorktree = false;
33
+ try {
34
+ const gitDir = dockerExec(containerId, containerCwd,
35
+ "git rev-parse --git-dir").trim();
36
+ isWorktree = gitDir.includes("/worktrees/");
37
+ } catch { /* ignore */ }
38
+
39
+ let repoRoot = "";
40
+ try {
41
+ repoRoot = dockerExec(containerId, containerCwd,
42
+ "git rev-parse --show-toplevel").trim();
43
+ } catch { /* ignore */ }
44
+
45
+ let ahead = 0;
46
+ let behind = 0;
47
+ try {
48
+ const counts = dockerExec(containerId, containerCwd,
49
+ "git rev-list --left-right --count @{upstream}...HEAD").trim();
50
+ const [b, a] = counts.split(/\s+/).map(Number);
51
+ ahead = a || 0;
52
+ behind = b || 0;
53
+ } catch { /* no upstream */ }
54
+
55
+ return { branch, isWorktree, repoRoot, ahead, behind };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Map a path inside a container to the corresponding host path.
63
+ * @param containerPath Path inside the container
64
+ * @param containerInfo Container metadata (mount points)
65
+ * @returns Host path or the original container path if no mapping found
66
+ */
67
+ export function mapContainerPath(
68
+ containerPath: string,
69
+ mountMappings: Array<{ hostPath: string; containerPath: string }>,
70
+ ): string {
71
+ for (const mount of mountMappings) {
72
+ if (containerPath.startsWith(mount.containerPath)) {
73
+ return containerPath.replace(mount.containerPath, mount.hostPath);
74
+ }
75
+ }
76
+ return containerPath;
77
+ }
78
+
79
+ /** Run a command inside a Docker container and return stdout. */
80
+ function dockerExec(
81
+ containerId: string,
82
+ cwd: string,
83
+ command: string,
84
+ ): string {
85
+ return execSync(`docker exec -w "${cwd}" ${containerId} ${command}`, {
86
+ encoding: "utf-8",
87
+ timeout: 5000,
88
+ });
89
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Associates Linear issues with Campfire sessions.
3
+ * Persisted to ~/.campfire/linear-session-issues.json
4
+ */
5
+
6
+ import { join } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
9
+
10
+ export interface LinkedIssue {
11
+ issueId: string;
12
+ identifier: string;
13
+ title: string;
14
+ url: string;
15
+ state: string;
16
+ teamKey: string;
17
+ linkedAt: number;
18
+ }
19
+
20
+ interface IssueMap {
21
+ [sessionId: string]: LinkedIssue;
22
+ }
23
+
24
+ const STORE_DIR = join(homedir(), ".campfire");
25
+ const STORE_PATH = join(STORE_DIR, "linear-session-issues.json");
26
+
27
+ function loadIssues(): IssueMap {
28
+ if (!existsSync(STORE_PATH)) return {};
29
+ try {
30
+ return JSON.parse(readFileSync(STORE_PATH, "utf-8"));
31
+ } catch {
32
+ return {};
33
+ }
34
+ }
35
+
36
+ function saveIssues(data: IssueMap): void {
37
+ mkdirSync(STORE_DIR, { recursive: true });
38
+ writeFileSync(STORE_PATH, JSON.stringify(data, null, 2), "utf-8");
39
+ }
40
+
41
+ /** Link a Linear issue to a session. */
42
+ export function linkIssueToSession(sessionId: string, issue: Omit<LinkedIssue, "linkedAt">): LinkedIssue {
43
+ const data = loadIssues();
44
+ const entry: LinkedIssue = { ...issue, linkedAt: Date.now() };
45
+ data[sessionId] = entry;
46
+ saveIssues(data);
47
+ return entry;
48
+ }
49
+
50
+ /** Get the linked Linear issue for a session. */
51
+ export function getLinkedIssue(sessionId: string): LinkedIssue | null {
52
+ const data = loadIssues();
53
+ return data[sessionId] ?? null;
54
+ }
55
+
56
+ /** Remove the linked issue for a session. */
57
+ export function unlinkIssue(sessionId: string): boolean {
58
+ const data = loadIssues();
59
+ if (!(sessionId in data)) return false;
60
+ delete data[sessionId];
61
+ saveIssues(data);
62
+ return true;
63
+ }
64
+
65
+ /** Transition a Linear issue to a new state via the API. */
66
+ export async function transitionIssue(
67
+ issueId: string,
68
+ stateId: string,
69
+ apiKey: string,
70
+ ): Promise<boolean> {
71
+ try {
72
+ const res = await fetch("https://api.linear.app/graphql", {
73
+ method: "POST",
74
+ headers: {
75
+ "Content-Type": "application/json",
76
+ "Authorization": apiKey,
77
+ },
78
+ body: JSON.stringify({
79
+ query: `
80
+ mutation TransitionIssue($issueId: String!, $stateId: String!) {
81
+ issueUpdate(id: $issueId, input: { stateId: $stateId }) {
82
+ success
83
+ }
84
+ }
85
+ `,
86
+ variables: { issueId, stateId },
87
+ }),
88
+ });
89
+ const data = await res.json() as { data?: { issueUpdate?: { success: boolean } } };
90
+ return data.data?.issueUpdate?.success ?? false;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
@@ -0,0 +1,67 @@
1
+ import {
2
+ mkdirSync,
3
+ readFileSync,
4
+ writeFileSync,
5
+ existsSync,
6
+ } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import { homedir } from "node:os";
9
+
10
+ // ─── Paths ──────────────────────────────────────────────────────────────────
11
+
12
+ const DEFAULT_PATH = join(homedir(), ".campfire", "session-names.json");
13
+
14
+ // ─── Store ──────────────────────────────────────────────────────────────────
15
+
16
+ let names: Record<string, string> = {};
17
+ let loaded = false;
18
+ let filePath = DEFAULT_PATH;
19
+
20
+ function ensureLoaded(): void {
21
+ if (loaded) return;
22
+ try {
23
+ if (existsSync(filePath)) {
24
+ const raw = readFileSync(filePath, "utf-8");
25
+ names = JSON.parse(raw) as Record<string, string>;
26
+ }
27
+ } catch {
28
+ names = {};
29
+ }
30
+ loaded = true;
31
+ }
32
+
33
+ function persist(): void {
34
+ mkdirSync(dirname(filePath), { recursive: true });
35
+ writeFileSync(filePath, JSON.stringify(names, null, 2), "utf-8");
36
+ }
37
+
38
+ // ─── Public API ─────────────────────────────────────────────────────────────
39
+
40
+ export function getName(sessionId: string): string | undefined {
41
+ ensureLoaded();
42
+ return names[sessionId];
43
+ }
44
+
45
+ export function setName(sessionId: string, name: string): void {
46
+ ensureLoaded();
47
+ names[sessionId] = name;
48
+ persist();
49
+ }
50
+
51
+ export function getAllNames(): Record<string, string> {
52
+ ensureLoaded();
53
+ return { ...names };
54
+ }
55
+
56
+ export function removeName(sessionId: string): void {
57
+ ensureLoaded();
58
+ delete names[sessionId];
59
+ persist();
60
+ }
61
+
62
+ /** Reset internal state and optionally set a custom file path (for testing). */
63
+ export function _resetForTest(customPath?: string): void {
64
+ names = {};
65
+ loaded = false;
66
+ filePath = customPath || DEFAULT_PATH;
67
+ }
@@ -0,0 +1,169 @@
1
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, cpSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir, homedir } 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
+ // Persistent storage: ~/.campfire/sessions/ (survives reboots, unlike /tmp/)
29
+ const PERSISTENT_DIR = join(homedir(), ".campfire", "sessions");
30
+ // Legacy location (may contain data from older installs or pre-migration)
31
+ const LEGACY_DIR = join(tmpdir(), "vibe-sessions");
32
+ const DEFAULT_DIR = process.env.CAMPFIRE_SESSION_DIR || PERSISTENT_DIR;
33
+
34
+ export class SessionStore {
35
+ private readonly dir: string;
36
+ private readonly debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
37
+
38
+ constructor(dir?: string) {
39
+ this.dir = dir || DEFAULT_DIR;
40
+ mkdirSync(this.dir, { recursive: true });
41
+ // Migrate data from legacy /tmp/ location if it exists and persistent dir is empty
42
+ this.migrateFromLegacy();
43
+ }
44
+
45
+ /** One-time migration: copy session files from /tmp/vibe-sessions/ to ~/.campfire/sessions/ */
46
+ private migrateFromLegacy(): void {
47
+ // Skip if using custom dir or legacy dir doesn't exist
48
+ if (this.dir !== PERSISTENT_DIR) return;
49
+ if (!existsSync(LEGACY_DIR)) return;
50
+
51
+ try {
52
+ const legacyFiles = readdirSync(LEGACY_DIR).filter((f) => f.endsWith(".json"));
53
+ const currentFiles = new Set(readdirSync(this.dir).filter((f) => f.endsWith(".json")));
54
+ let migrated = 0;
55
+
56
+ for (const file of legacyFiles) {
57
+ if (currentFiles.has(file)) continue; // don't overwrite existing
58
+ try {
59
+ cpSync(join(LEGACY_DIR, file), join(this.dir, file));
60
+ migrated++;
61
+ } catch { /* skip individual file errors */ }
62
+ }
63
+
64
+ if (migrated > 0) {
65
+ console.log(`[session-store] Migrated ${migrated} session(s) from ${LEGACY_DIR} to ${this.dir}`);
66
+ }
67
+ } catch { /* legacy dir not readable, skip */ }
68
+ }
69
+
70
+ private filePath(sessionId: string): string {
71
+ return join(this.dir, `${sessionId}.json`);
72
+ }
73
+
74
+ /** Debounced write — batches rapid changes (e.g. multiple stream events). */
75
+ save(session: PersistedSession): void {
76
+ const existing = this.debounceTimers.get(session.id);
77
+ if (existing) clearTimeout(existing);
78
+
79
+ const timer = setTimeout(() => {
80
+ this.debounceTimers.delete(session.id);
81
+ this.saveSync(session);
82
+ }, 150);
83
+ this.debounceTimers.set(session.id, timer);
84
+ }
85
+
86
+ /** Immediate write — use for critical state changes. */
87
+ saveSync(session: PersistedSession): void {
88
+ try {
89
+ writeFileSync(this.filePath(session.id), JSON.stringify(session), "utf-8");
90
+ } catch (err) {
91
+ console.error(`[session-store] Failed to save session ${session.id}:`, err);
92
+ }
93
+ }
94
+
95
+ /** Load a single session from disk. */
96
+ load(sessionId: string): PersistedSession | null {
97
+ try {
98
+ const raw = readFileSync(this.filePath(sessionId), "utf-8");
99
+ return JSON.parse(raw) as PersistedSession;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /** Load all sessions from disk. */
106
+ loadAll(): PersistedSession[] {
107
+ const sessions: PersistedSession[] = [];
108
+ try {
109
+ const files = readdirSync(this.dir).filter((f) => f.endsWith(".json") && f !== "launcher.json");
110
+ for (const file of files) {
111
+ try {
112
+ const raw = readFileSync(join(this.dir, file), "utf-8");
113
+ sessions.push(JSON.parse(raw));
114
+ } catch {
115
+ // Skip corrupt files
116
+ }
117
+ }
118
+ } catch {
119
+ // Dir doesn't exist yet
120
+ }
121
+ return sessions;
122
+ }
123
+
124
+ /** Set the archived flag on a persisted session. */
125
+ setArchived(sessionId: string, archived: boolean): boolean {
126
+ const session = this.load(sessionId);
127
+ if (!session) return false;
128
+ session.archived = archived;
129
+ this.saveSync(session);
130
+ return true;
131
+ }
132
+
133
+ /** Remove a session file from disk. */
134
+ remove(sessionId: string): void {
135
+ const timer = this.debounceTimers.get(sessionId);
136
+ if (timer) {
137
+ clearTimeout(timer);
138
+ this.debounceTimers.delete(sessionId);
139
+ }
140
+ try {
141
+ unlinkSync(this.filePath(sessionId));
142
+ } catch {
143
+ // File may not exist
144
+ }
145
+ }
146
+
147
+ /** Persist launcher state (separate file). */
148
+ saveLauncher(data: unknown): void {
149
+ try {
150
+ writeFileSync(join(this.dir, "launcher.json"), JSON.stringify(data), "utf-8");
151
+ } catch (err) {
152
+ console.error("[session-store] Failed to save launcher state:", err);
153
+ }
154
+ }
155
+
156
+ /** Load launcher state. */
157
+ loadLauncher<T>(): T | null {
158
+ try {
159
+ const raw = readFileSync(join(this.dir, "launcher.json"), "utf-8");
160
+ return JSON.parse(raw) as T;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ get directory(): string {
167
+ return this.dir;
168
+ }
169
+ }