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,144 @@
1
+ /**
2
+ * Docker image pull manager with progress tracking.
3
+ * Spawns `docker pull`, parses JSON output, and emits progress events.
4
+ */
5
+
6
+ export interface PullProgress {
7
+ status: string;
8
+ layer?: string;
9
+ percent?: number;
10
+ message?: string;
11
+ }
12
+
13
+ interface CachedImage {
14
+ image: string;
15
+ pulledAt: number;
16
+ }
17
+
18
+ const IMAGE_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
19
+ const cachedImages = new Map<string, CachedImage>();
20
+
21
+ /** Check if an image was recently pulled and is likely available locally. */
22
+ export function isImageCached(image: string): boolean {
23
+ const cached = cachedImages.get(image);
24
+ if (!cached) return false;
25
+ if (Date.now() - cached.pulledAt > IMAGE_CACHE_TTL_MS) {
26
+ cachedImages.delete(image);
27
+ return false;
28
+ }
29
+ return true;
30
+ }
31
+
32
+ /** Check if an image exists locally via `docker image inspect`. */
33
+ export async function imageExistsLocally(image: string): Promise<boolean> {
34
+ try {
35
+ const proc = Bun.spawn(["docker", "image", "inspect", image], {
36
+ stdout: "pipe",
37
+ stderr: "pipe",
38
+ });
39
+ const exitCode = await proc.exited;
40
+ if (exitCode === 0) {
41
+ cachedImages.set(image, { image, pulledAt: Date.now() });
42
+ return true;
43
+ }
44
+ return false;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Pull a Docker image with progress reporting.
52
+ * @param image Docker image to pull (e.g. "campfire-dev:latest")
53
+ * @param onProgress Callback for progress updates
54
+ * @param timeout Maximum time to wait in ms (default 5 minutes)
55
+ * @returns true if pull succeeded
56
+ */
57
+ export async function pullImage(
58
+ image: string,
59
+ onProgress?: (progress: PullProgress) => void,
60
+ timeout: number = 5 * 60 * 1000,
61
+ ): Promise<boolean> {
62
+ // Skip if recently pulled
63
+ if (isImageCached(image)) {
64
+ onProgress?.({ status: "cached", message: "Image already available" });
65
+ return true;
66
+ }
67
+
68
+ // Check locally first
69
+ if (await imageExistsLocally(image)) {
70
+ onProgress?.({ status: "exists", message: "Image found locally" });
71
+ return true;
72
+ }
73
+
74
+ onProgress?.({ status: "pulling", message: `Pulling ${image}...` });
75
+
76
+ return new Promise<boolean>((resolve) => {
77
+ const proc = Bun.spawn(["docker", "pull", image], {
78
+ stdout: "pipe",
79
+ stderr: "pipe",
80
+ });
81
+
82
+ const timer = setTimeout(() => {
83
+ try { proc.kill(); } catch {}
84
+ onProgress?.({ status: "timeout", message: "Image pull timed out" });
85
+ resolve(false);
86
+ }, timeout);
87
+
88
+ // Read stdout for progress
89
+ const reader = proc.stdout?.getReader();
90
+ if (reader) {
91
+ const decoder = new TextDecoder();
92
+ let buffer = "";
93
+
94
+ function pump() {
95
+ reader!.read().then(({ done, value }) => {
96
+ if (done) return;
97
+ buffer += decoder.decode(value, { stream: true });
98
+ // Parse line-by-line
99
+ const lines = buffer.split("\n");
100
+ buffer = lines.pop() || "";
101
+ for (const line of lines) {
102
+ if (!line.trim()) continue;
103
+ try {
104
+ const data = JSON.parse(line);
105
+ const progress: PullProgress = {
106
+ status: data.status || "pulling",
107
+ layer: data.id,
108
+ };
109
+ if (data.progressDetail?.current && data.progressDetail?.total) {
110
+ progress.percent = Math.round(
111
+ (data.progressDetail.current / data.progressDetail.total) * 100,
112
+ );
113
+ }
114
+ if (data.progress) {
115
+ progress.message = data.progress;
116
+ }
117
+ onProgress?.(progress);
118
+ } catch {
119
+ // Not JSON, just report as status
120
+ onProgress?.({ status: "pulling", message: line.trim() });
121
+ }
122
+ }
123
+ pump();
124
+ }).catch(() => {});
125
+ }
126
+ pump();
127
+ }
128
+
129
+ proc.exited.then((exitCode) => {
130
+ clearTimeout(timer);
131
+ if (exitCode === 0) {
132
+ cachedImages.set(image, { image, pulledAt: Date.now() });
133
+ onProgress?.({ status: "complete", message: "Image pulled successfully" });
134
+ resolve(true);
135
+ } else {
136
+ onProgress?.({ status: "error", message: `Pull failed with exit code ${exitCode}` });
137
+ resolve(false);
138
+ }
139
+ }).catch(() => {
140
+ clearTimeout(timer);
141
+ resolve(false);
142
+ });
143
+ });
144
+ }
@@ -0,0 +1,363 @@
1
+ process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1";
2
+
3
+ // Enrich process PATH at startup so binary resolution and `which` calls can find
4
+ // binaries installed via version managers (nvm, volta, fnm, etc.).
5
+ // Critical when running as a launchd/systemd service with a restricted PATH.
6
+ import { getEnrichedPath } from "./path-resolver.js";
7
+ process.env.PATH = getEnrichedPath();
8
+
9
+ import { dirname, resolve } from "node:path";
10
+ import { existsSync, readFileSync, statSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+ import { Hono } from "hono";
13
+ import { cors } from "hono/cors";
14
+ import { serveStatic } from "hono/bun";
15
+ import { createRoutes } from "./routes.js";
16
+ import { CliLauncher } from "./cli-launcher.js";
17
+ import { WsBridge } from "./ws-bridge.js";
18
+ import { SessionStore } from "./session-store.js";
19
+ import { WorktreeTracker } from "./worktree-tracker.js";
20
+ import { TerminalManager } from "./terminal-manager.js";
21
+ import { generateSessionTitle } from "./auto-namer.js";
22
+ import * as sessionNames from "./session-names.js";
23
+ import { getSettings } from "./settings-manager.js";
24
+ import { PRPoller } from "./pr-poller.js";
25
+ import { RecorderManager } from "./recorder.js";
26
+ import { CronScheduler } from "./cron-scheduler.js";
27
+ import { AgentExecutor } from "./agent-executor.js";
28
+ import { AgentMcpBridge } from "./agent-mcp-bridge.js";
29
+ import { SubSessionManager } from "./sub-session-manager.js";
30
+ import { ProtocolMonitor } from "./protocol-monitor.js";
31
+ import { ProactiveKeepalive } from "./proactive-keepalive.js";
32
+ import { securityHeaders, rateLimiter, tagRequestIp } from "./security-middleware.js";
33
+ import { isAuthEnabled, verifyToken } from "./auth.js";
34
+ import { WebhookManager } from "./webhook-manager.js";
35
+ import { AdapterRegistry } from "./adapter-registry.js";
36
+ import { collectiveIntelligenceLayer } from "./collective-intelligence.js";
37
+ import { startPeriodicCheck, setServiceMode } from "./update-checker.js";
38
+ import { isRunningAsService } from "./service.js";
39
+ import type { SocketData } from "./ws-bridge.js";
40
+ import type { ServerWebSocket } from "bun";
41
+
42
+ const __dirname = dirname(fileURLToPath(import.meta.url));
43
+ const packageRoot = process.env.__CAMPFIRE_PACKAGE_ROOT || resolve(__dirname, "..");
44
+
45
+ import { DEFAULT_PORT, INTERNAL_DEV_BACKEND_PORT } from "./constants.js";
46
+
47
+ // In dev mode, the backend listens on an internal port (Vite proxies to it).
48
+ // In production, the backend IS the user-facing server on DEFAULT_PORT.
49
+ const port = Number(process.env.PORT)
50
+ || (process.env.NODE_ENV === "production"
51
+ ? DEFAULT_PORT
52
+ : (Number(process.env.__CAMPFIRE_INTERNAL_PORT) || INTERNAL_DEV_BACKEND_PORT));
53
+ const sessionStore = new SessionStore();
54
+ const wsBridge = new WsBridge();
55
+ const launcher = new CliLauncher(port);
56
+ const worktreeTracker = new WorktreeTracker();
57
+ const terminalManager = new TerminalManager();
58
+ const prPoller = new PRPoller(wsBridge);
59
+ const recorder = new RecorderManager();
60
+ const cronScheduler = new CronScheduler(launcher, wsBridge);
61
+ const webhookManager = new WebhookManager();
62
+ const adapterRegistry = new AdapterRegistry();
63
+ const agentExecutor = new AgentExecutor(launcher, wsBridge);
64
+ const subSessionManager = new SubSessionManager(launcher, wsBridge);
65
+ const agentMcpBridge = new AgentMcpBridge(wsBridge, subSessionManager, { port, packageRoot });
66
+ const protocolMonitor = new ProtocolMonitor();
67
+ // Proactive keepalive — auto-relaunches crashed CLI sessions with exponential backoff.
68
+ const _keepalive = new ProactiveKeepalive(launcher);
69
+ process.once("SIGTERM", () => {
70
+ _keepalive.destroy();
71
+ process.exit(0);
72
+ });
73
+
74
+ // ── Restore persisted sessions from disk ────────────────────────────────────
75
+ wsBridge.setStore(sessionStore);
76
+ wsBridge.setRecorder(recorder);
77
+ wsBridge.setWebhookManager(webhookManager);
78
+ wsBridge.setProtocolMonitor(protocolMonitor);
79
+ wsBridge.setCollectiveIntelligence(collectiveIntelligenceLayer);
80
+ wsBridge.setAgentMcpBridge(agentMcpBridge);
81
+ launcher.setStore(sessionStore);
82
+ launcher.setRecorder(recorder);
83
+ launcher.restoreFromDisk();
84
+ wsBridge.restoreFromDisk();
85
+
86
+ // When the CLI reports its internal session_id, store it for --resume on relaunch
87
+ wsBridge.onCLISessionIdReceived((sessionId, cliSessionId) => {
88
+ launcher.setCLISessionId(sessionId, cliSessionId);
89
+ });
90
+
91
+ // When an adapter is created (Codex, Goose, etc.), attach it to the WsBridge
92
+ launcher.onAdapterCreated((sessionId, adapter, backendType) => {
93
+ wsBridge.attachAdapter(sessionId, adapter, backendType);
94
+ });
95
+
96
+ // Start watching PRs when git info is resolved for a session
97
+ wsBridge.onSessionGitInfoReadyCallback((sessionId, cwd, branch) => {
98
+ prPoller.watch(sessionId, cwd, branch);
99
+ });
100
+
101
+ // Auto-relaunch CLI when a browser connects to a session with no CLI
102
+ const relaunchingSet = new Set<string>();
103
+ function cwdIsUsable(cwd: string | undefined): boolean {
104
+ if (!cwd || !existsSync(cwd)) return false;
105
+ try {
106
+ return statSync(cwd).isDirectory();
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ wsBridge.onCLIRelaunchNeededCallback((sessionId) => {
113
+ if (relaunchingSet.has(sessionId)) return true;
114
+ const info = launcher.getSession(sessionId);
115
+ if (!info || info.archived || info.state === "exited") {
116
+ return false;
117
+ }
118
+ if (info.parentSessionId || info.orchestrationRole === "subagent" || info.orchestrationRole === "race_entry") {
119
+ launcher.markSessionExited(sessionId, info.exitCode ?? 0);
120
+ return false;
121
+ }
122
+ if (info.state === "starting") return true;
123
+ if (!cwdIsUsable(info.cwd)) {
124
+ console.warn(`[server] Not auto-relaunching session ${sessionId}: working directory is unavailable (${info.cwd || "empty"})`);
125
+ launcher.markSessionExited(sessionId, 127);
126
+ return false;
127
+ }
128
+
129
+ relaunchingSet.add(sessionId);
130
+ console.log(`[server] Auto-relaunching CLI for session ${sessionId}`);
131
+ wsBridge.notifyLaunching(sessionId);
132
+ void (async () => {
133
+ try {
134
+ await launcher.relaunch(sessionId);
135
+ } finally {
136
+ setTimeout(() => relaunchingSet.delete(sessionId), 5000);
137
+ }
138
+ })();
139
+ return true;
140
+ });
141
+
142
+ // Auto-generate session title after first turn completes
143
+ wsBridge.onFirstTurnCompletedCallback(async (sessionId, firstUserMessage) => {
144
+ // Don't overwrite a name that was already set (manual rename or prior auto-name)
145
+ if (sessionNames.getName(sessionId)) return true;
146
+ if (!getSettings().openrouterApiKey.trim()) return true;
147
+ const info = launcher.getSession(sessionId);
148
+ const model = info?.model || "claude-sonnet-4-5-20250929";
149
+ console.log(`[server] Auto-naming session ${sessionId} via OpenRouter with model ${model}...`);
150
+ const title = await generateSessionTitle(firstUserMessage, model);
151
+ // Re-check: a manual rename may have occurred while we were generating
152
+ if (sessionNames.getName(sessionId)) return true;
153
+ if (title && !sessionNames.getName(sessionId)) {
154
+ console.log(`[server] Auto-named session ${sessionId}: "${title}"`);
155
+ sessionNames.setName(sessionId, title);
156
+ wsBridge.broadcastNameUpdate(sessionId, title);
157
+ return true;
158
+ }
159
+ return false;
160
+ });
161
+
162
+ console.log(`[server] Session persistence: ${sessionStore.directory}`);
163
+ if (recorder.isGloballyEnabled()) {
164
+ console.log(`[server] Recording enabled (dir: ${recorder.getRecordingsDir()}, max: ${recorder.getMaxLines()} lines)`);
165
+ }
166
+
167
+ const app = new Hono();
168
+
169
+ app.onError((err, c) => {
170
+ console.error(`[server] ${c.req.method} ${c.req.path} error:`, err);
171
+ return c.json({ error: err.message }, 500);
172
+ });
173
+
174
+ app.use("/*", securityHeaders);
175
+ app.use("/api/*", rateLimiter);
176
+ app.use("/api/*", cors());
177
+ app.route("/api", createRoutes(launcher, wsBridge, sessionStore, worktreeTracker, terminalManager, prPoller, recorder, cronScheduler, webhookManager, adapterRegistry, agentExecutor, protocolMonitor, agentMcpBridge));
178
+
179
+ function publicOriginForRequest(req: Request): string {
180
+ const configured = process.env.CAMPFIRE_PUBLIC_URL?.trim();
181
+ if (configured) return configured.replace(/\/+$/, "");
182
+
183
+ const url = new URL(req.url);
184
+ const forwardedHost = req.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
185
+ const forwardedProto = req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
186
+ const host = forwardedHost || req.headers.get("host") || url.host;
187
+ const proto = forwardedProto || url.protocol.replace(/:$/, "");
188
+ return `${proto}://${host}`;
189
+ }
190
+
191
+ function isHtmlNavigation(req: Request, path: string): boolean {
192
+ if (req.method !== "GET") return false;
193
+ if (path.startsWith("/api/") || path.startsWith("/ws/")) return false;
194
+ if (/\.[a-zA-Z0-9]+$/.test(path)) return false;
195
+ const accept = req.headers.get("accept") || "";
196
+ return accept.includes("text/html") || accept === "*/*" || accept === "";
197
+ }
198
+
199
+ // In production, serve built frontend using absolute path (works when installed as npm package)
200
+ if (process.env.NODE_ENV === "production") {
201
+ const distDir = resolve(packageRoot, "dist");
202
+ const indexPath = resolve(distDir, "index.html");
203
+ app.get("/*", (c, next) => {
204
+ if (!isHtmlNavigation(c.req.raw, c.req.path)) return next();
205
+ const html = readFileSync(indexPath, "utf-8")
206
+ .replaceAll("__CAMPFIRE_ORIGIN__", publicOriginForRequest(c.req.raw));
207
+ return c.html(html);
208
+ });
209
+ app.use("/*", serveStatic({ root: distDir }));
210
+ app.get("/*", serveStatic({ path: indexPath }));
211
+ }
212
+
213
+ // ── WebSocket auth helper ──────────────────────────────────────────────────
214
+ // When auth is enabled, all WebSocket upgrades (except CLI from localhost)
215
+ // require a valid session token. Returns null if authorized, or a 401 Response.
216
+ function checkWsAuth(request: Request, wsUrl: URL, allowLocalCli = false): Response | null {
217
+ if (!isAuthEnabled()) return null;
218
+ if (allowLocalCli) {
219
+ const host = request.headers.get("host") || "";
220
+ if (host.startsWith("localhost") || host.startsWith("127.0.0.1")) return null;
221
+ }
222
+ const token = wsUrl.searchParams.get("auth_token")
223
+ || request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
224
+ if (token && verifyToken(token)) return null;
225
+ return new Response("Unauthorized", { status: 401 });
226
+ }
227
+
228
+ // ── WebSocket upgrade routing (extracted for cognitive complexity) ──────────
229
+
230
+ const CLI_RE = /^\/ws\/cli\/([a-f0-9-]+)$/;
231
+ const BROWSER_RE = /^\/ws\/browser\/([a-f0-9-]+)$/;
232
+ const TERMINAL_RE = /^\/ws\/terminal\/([a-f0-9-]+)$/;
233
+
234
+ type WsServer = { upgrade: (req: Request, opts: { data: SocketData; headers?: HeadersInit }) => boolean };
235
+
236
+ function upgradeOrFail(server: WsServer, req: Request, data: SocketData): Response | undefined {
237
+ return server.upgrade(req, { data }) ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
238
+ }
239
+
240
+ function handleCliWs(req: Request, url: URL, server: WsServer, sessionId: string): Response | undefined {
241
+ const authReject = checkWsAuth(req, url, true);
242
+ if (authReject) return authReject;
243
+ return upgradeOrFail(server, req, { kind: "cli", sessionId });
244
+ }
245
+
246
+ function handleBrowserWs(req: Request, url: URL, server: WsServer, sessionId: string): Response | undefined {
247
+ const inviteToken = url.searchParams.get("token");
248
+ const role = inviteToken ? wsBridge.resolveInviteTokenRole(inviteToken) : undefined;
249
+ if (!role) {
250
+ const authReject = checkWsAuth(req, url);
251
+ if (authReject) return authReject;
252
+ }
253
+ return upgradeOrFail(server, req, { kind: "browser", sessionId, _joinRole: role } as SocketData);
254
+ }
255
+
256
+ function handleTerminalWs(req: Request, url: URL, server: WsServer, terminalId: string): Response | undefined {
257
+ const authReject = checkWsAuth(req, url);
258
+ if (authReject) return authReject;
259
+ return upgradeOrFail(server, req, { kind: "terminal", terminalId });
260
+ }
261
+
262
+ function handleWsUpgrade(req: Request, url: URL, server: WsServer): Response | undefined {
263
+ const cliMatch = CLI_RE.exec(url.pathname);
264
+ if (cliMatch) return handleCliWs(req, url, server, cliMatch[1]);
265
+
266
+ const browserMatch = BROWSER_RE.exec(url.pathname);
267
+ if (browserMatch) return handleBrowserWs(req, url, server, browserMatch[1]);
268
+
269
+ const termMatch = TERMINAL_RE.exec(url.pathname);
270
+ if (termMatch) return handleTerminalWs(req, url, server, termMatch[1]);
271
+
272
+ return undefined;
273
+ }
274
+
275
+ const server = Bun.serve<SocketData>({
276
+ port,
277
+ async fetch(req, server) {
278
+ const url = new URL(req.url);
279
+ const wsResult = handleWsUpgrade(req, url, server);
280
+ if (wsResult) return wsResult;
281
+
282
+ // Tag the socket remote address so the rate limiter can bucket direct
283
+ // clients correctly (client-supplied headers alone are spoofable).
284
+ tagRequestIp(req, server.requestIP(req)?.address);
285
+
286
+ // Hono handles the rest
287
+ return app.fetch(req, server);
288
+ },
289
+ websocket: {
290
+ // Disable Bun's built-in ping timeout to prevent idle CLI connections from being killed (code 1006)
291
+ idleTimeout: 0,
292
+ sendPings: false,
293
+ open(ws: ServerWebSocket<SocketData>) {
294
+ const data = ws.data;
295
+ if (data.kind === "cli") {
296
+ wsBridge.handleCLIOpen(ws, data.sessionId);
297
+ launcher.markConnected(data.sessionId);
298
+ } else if (data.kind === "browser") {
299
+ wsBridge.handleBrowserOpen(ws, data.sessionId);
300
+ } else if (data.kind === "terminal") {
301
+ terminalManager.addBrowserSocket(ws);
302
+ }
303
+ },
304
+ message(ws: ServerWebSocket<SocketData>, msg: string | Buffer) {
305
+ const data = ws.data;
306
+ if (data.kind === "cli") {
307
+ wsBridge.handleCLIMessage(ws, msg);
308
+ } else if (data.kind === "browser") {
309
+ wsBridge.handleBrowserMessage(ws, msg);
310
+ } else if (data.kind === "terminal") {
311
+ terminalManager.handleBrowserMessage(ws, msg);
312
+ }
313
+ },
314
+ close(ws: ServerWebSocket<SocketData>) {
315
+ const data = ws.data;
316
+ if (data.kind === "cli") {
317
+ wsBridge.handleCLIClose(ws);
318
+ } else if (data.kind === "browser") {
319
+ wsBridge.handleBrowserClose(ws);
320
+ } else if (data.kind === "terminal") {
321
+ terminalManager.removeBrowserSocket(ws);
322
+ }
323
+ },
324
+ },
325
+ });
326
+
327
+ console.log(`Server running on http://localhost:${server.port}`);
328
+ console.log(` CLI WebSocket: ws://localhost:${server.port}/ws/cli/:sessionId`);
329
+ console.log(` Browser WebSocket: ws://localhost:${server.port}/ws/browser/:sessionId`);
330
+
331
+ if (process.env.NODE_ENV !== "production") {
332
+ console.log("Dev mode: open http://localhost:4567 (Vite proxies to this backend)");
333
+ }
334
+
335
+ // ── Cron scheduler ──────────────────────────────────────────────────────────
336
+ cronScheduler.startAll();
337
+ agentExecutor.startAll();
338
+
339
+ // ── Update checker ──────────────────────────────────────────────────────────
340
+ startPeriodicCheck();
341
+ if (isRunningAsService()) {
342
+ setServiceMode(true);
343
+ console.log("[server] Running as background service (auto-update available)");
344
+ }
345
+
346
+ // ── Reconnection watchdog ────────────────────────────────────────────────────
347
+ // After a server restart, restored CLI processes may not reconnect their
348
+ // WebSocket. Give them a grace period, then kill + relaunch any that are
349
+ // still in "starting" state (alive but no WS connection).
350
+ const RECONNECT_GRACE_MS = 10_000;
351
+ const starting = launcher.getStartingSessions();
352
+ if (starting.length > 0) {
353
+ console.log(`[server] Waiting ${RECONNECT_GRACE_MS / 1000}s for ${starting.length} CLI process(es) to reconnect...`);
354
+ setTimeout(async () => {
355
+ const stale = launcher.getStartingSessions();
356
+ for (const info of stale) {
357
+ if (info.archived) continue;
358
+ console.log(`[server] CLI for session ${info.sessionId} did not reconnect, relaunching...`);
359
+ wsBridge.notifyLaunching(info.sessionId);
360
+ await launcher.relaunch(info.sessionId);
361
+ }
362
+ }, RECONNECT_GRACE_MS);
363
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Maps git repositories to Linear teams/projects.
3
+ * Persisted to ~/.campfire/linear-projects.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 ProjectMapping {
11
+ repoRoot: string;
12
+ teamId: string;
13
+ teamKey: string;
14
+ teamName: string;
15
+ projectId?: string;
16
+ projectName?: string;
17
+ updatedAt: number;
18
+ }
19
+
20
+ interface MappingsFile {
21
+ mappings: ProjectMapping[];
22
+ }
23
+
24
+ const STORE_DIR = join(homedir(), ".campfire");
25
+ const STORE_PATH = join(STORE_DIR, "linear-projects.json");
26
+
27
+ function loadMappings(): MappingsFile {
28
+ if (!existsSync(STORE_PATH)) return { mappings: [] };
29
+ try {
30
+ return JSON.parse(readFileSync(STORE_PATH, "utf-8"));
31
+ } catch {
32
+ return { mappings: [] };
33
+ }
34
+ }
35
+
36
+ function saveMappings(data: MappingsFile): void {
37
+ mkdirSync(STORE_DIR, { recursive: true });
38
+ writeFileSync(STORE_PATH, JSON.stringify(data, null, 2), "utf-8");
39
+ }
40
+
41
+ /** Get the Linear project mapping for a git repo root. */
42
+ export function getProjectForRepo(repoRoot: string): ProjectMapping | null {
43
+ const { mappings } = loadMappings();
44
+ return mappings.find((m) => m.repoRoot === repoRoot) ?? null;
45
+ }
46
+
47
+ /** Set or update the Linear project mapping for a git repo root. */
48
+ export function setProjectForRepo(repoRoot: string, mapping: Omit<ProjectMapping, "repoRoot" | "updatedAt">): ProjectMapping {
49
+ const data = loadMappings();
50
+ const existing = data.mappings.findIndex((m) => m.repoRoot === repoRoot);
51
+ const entry: ProjectMapping = {
52
+ ...mapping,
53
+ repoRoot,
54
+ updatedAt: Date.now(),
55
+ };
56
+ if (existing >= 0) {
57
+ data.mappings[existing] = entry;
58
+ } else {
59
+ data.mappings.push(entry);
60
+ }
61
+ saveMappings(data);
62
+ return entry;
63
+ }
64
+
65
+ /** Remove the mapping for a git repo root. */
66
+ export function removeProjectMapping(repoRoot: string): boolean {
67
+ const data = loadMappings();
68
+ const idx = data.mappings.findIndex((m) => m.repoRoot === repoRoot);
69
+ if (idx < 0) return false;
70
+ data.mappings.splice(idx, 1);
71
+ saveMappings(data);
72
+ return true;
73
+ }
74
+
75
+ /** List all repo-to-project mappings. */
76
+ export function listMappings(): ProjectMapping[] {
77
+ return loadMappings().mappings;
78
+ }