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,101 @@
1
+ import type { Hono } from "hono";
2
+ import type { RouteDeps } from "./route-deps.js";
3
+ import { join } from "node:path";
4
+ import { loadRecording, filterEntries } from "../replay.js";
5
+
6
+ export function registerRecordingRoutes(api: Hono, deps: RouteDeps): void {
7
+ const { recorder, sessionStore } = deps;
8
+
9
+ api.post("/sessions/:id/recording/start", (c) => {
10
+ const id = c.req.param("id");
11
+ if (!recorder) return c.json({ error: "Recording not available" }, 501);
12
+ recorder.enableForSession(id);
13
+ return c.json({ ok: true, recording: true });
14
+ });
15
+
16
+ api.post("/sessions/:id/recording/stop", (c) => {
17
+ const id = c.req.param("id");
18
+ if (!recorder) return c.json({ error: "Recording not available" }, 501);
19
+ recorder.disableForSession(id);
20
+ return c.json({ ok: true, recording: false });
21
+ });
22
+
23
+ api.get("/sessions/:id/recording/status", (c) => {
24
+ const id = c.req.param("id");
25
+ if (!recorder) return c.json({ recording: false, available: false });
26
+ return c.json({
27
+ recording: recorder.isRecording(id),
28
+ available: true,
29
+ ...recorder.getRecordingStatus(id),
30
+ });
31
+ });
32
+
33
+ api.get("/recordings", (c) => {
34
+ if (!recorder) return c.json({ recordings: [] });
35
+ return c.json({ recordings: recorder.listRecordings() });
36
+ });
37
+
38
+ api.get("/recordings/:filename", (c) => {
39
+ if (!recorder) return c.json({ error: "Recording not available" }, 501);
40
+ const filename = c.req.param("filename");
41
+ if (filename.includes("/") || filename.includes("..")) {
42
+ return c.json({ error: "Invalid filename" }, 400);
43
+ }
44
+ const filePath = join(recorder.getRecordingsDir(), filename);
45
+ try {
46
+ const recording = loadRecording(filePath);
47
+
48
+ // Playable message types for the replay UI (expanded to cover all backends)
49
+ const PLAYABLE_TYPES = new Set([
50
+ "assistant", "result", "user_message", "stream_event",
51
+ "permission_request", "permission_cancelled", "status_change",
52
+ "session_init", "session_update", "cli_connected", "cli_disconnected",
53
+ "tool_progress", "tool_use_summary", "error",
54
+ "session_name_update", "mcp_status",
55
+ ]);
56
+
57
+ // Collect outgoing browser messages (server→browser)
58
+ const outEntries = filterEntries(recording.entries, "out", "browser");
59
+ // Collect incoming browser messages (browser→server) for user_message
60
+ const inEntries = filterEntries(recording.entries, "in", "browser");
61
+
62
+ // Merge both channels chronologically, filtering to playable types
63
+ const merged: { ts: number; parsed: unknown }[] = [];
64
+ for (const e of outEntries) {
65
+ try {
66
+ const parsed = JSON.parse(e.raw);
67
+ if (PLAYABLE_TYPES.has(parsed.type)) {
68
+ merged.push({ ts: e.ts, parsed });
69
+ }
70
+ } catch { /* skip malformed */ }
71
+ }
72
+ for (const e of inEntries) {
73
+ try {
74
+ const parsed = JSON.parse(e.raw);
75
+ if (parsed.type === "user_message") {
76
+ merged.push({ ts: e.ts, parsed });
77
+ }
78
+ } catch { /* skip malformed */ }
79
+ }
80
+
81
+ // Sort by timestamp
82
+ merged.sort((a, b) => a.ts - b.ts);
83
+
84
+ const messages = merged.map((m) => m.parsed);
85
+ const timestamps = merged.map((m) => m.ts);
86
+ return c.json({ header: recording.header, messages, timestamps });
87
+ } catch (err: any) {
88
+ return c.json({ error: err?.message || "Failed to load recording" }, 404);
89
+ }
90
+ });
91
+
92
+ api.get("/sessions/:id/history", (c) => {
93
+ const id = c.req.param("id");
94
+ const persisted = sessionStore.load(id);
95
+ if (!persisted) return c.json({ error: "Session not found" }, 404);
96
+ return c.json({
97
+ messages: persisted.messageHistory || [],
98
+ state: persisted.state || null,
99
+ });
100
+ });
101
+ }
@@ -0,0 +1,32 @@
1
+ import type { Hono } from "hono";
2
+ import type { CliLauncher } from "../cli-launcher.js";
3
+ import type { WsBridge } from "../ws-bridge.js";
4
+ import type { SessionStore } from "../session-store.js";
5
+ import type { WorktreeTracker } from "../worktree-tracker.js";
6
+ import type { TerminalManager } from "../terminal-manager.js";
7
+ import type { PRPoller } from "../pr-poller.js";
8
+ import type { RecorderManager } from "../recorder.js";
9
+ import type { CronScheduler } from "../cron-scheduler.js";
10
+ import type { WebhookManager } from "../webhook-manager.js";
11
+ import type { AdapterRegistry } from "../adapter-registry.js";
12
+ import type { AgentExecutor } from "../agent-executor.js";
13
+ import type { ProtocolMonitor } from "../protocol-monitor.js";
14
+ import type { AgentMcpBridge } from "../agent-mcp-bridge.js";
15
+
16
+ export interface RouteDeps {
17
+ launcher: CliLauncher;
18
+ wsBridge: WsBridge;
19
+ sessionStore: SessionStore;
20
+ worktreeTracker: WorktreeTracker;
21
+ terminalManager: TerminalManager;
22
+ prPoller?: PRPoller;
23
+ recorder?: RecorderManager;
24
+ cronScheduler?: CronScheduler;
25
+ webhookManager?: WebhookManager;
26
+ adapterRegistry?: AdapterRegistry;
27
+ agentExecutor?: AgentExecutor;
28
+ protocolMonitor?: ProtocolMonitor;
29
+ agentMcpBridge?: AgentMcpBridge;
30
+ }
31
+
32
+ export type RegisterRoutes = (api: Hono, deps: RouteDeps) => void;
@@ -0,0 +1,560 @@
1
+ import type { Hono } from "hono";
2
+ import type { RouteDeps } from "./route-deps.js";
3
+ import * as envManager from "../env-manager.js";
4
+ import { getSettings } from "../settings-manager.js";
5
+ import * as gitUtils from "../git-utils.js";
6
+ import * as sessionNames from "../session-names.js";
7
+ import { containerManager, type ContainerConfig, type ContainerInfo } from "../container-manager.js";
8
+ import { seedClaudeAuth } from "../claude-container-auth.js";
9
+ import { seedCodexAuth } from "../codex-container-auth.js";
10
+ import { pullImage, imageExistsLocally } from "../image-pull-manager.js";
11
+
12
+ /**
13
+ * Resolve environment variables from an envSlug and inject global provider tokens.
14
+ * Returns the merged env vars (or undefined if none).
15
+ */
16
+ function resolveEnvVars(
17
+ envSlug: string | undefined,
18
+ bodyEnv: Record<string, string> | undefined,
19
+ backend: string,
20
+ ): Record<string, string> | undefined {
21
+ let envVars: Record<string, string> | undefined = bodyEnv;
22
+
23
+ if (envSlug) {
24
+ const campfireEnv = envManager.getEnv(envSlug);
25
+ if (campfireEnv) {
26
+ console.log(
27
+ `[routes] Injecting env "${campfireEnv.name}" (${Object.keys(campfireEnv.variables).length} vars):`,
28
+ Object.keys(campfireEnv.variables).join(", "),
29
+ );
30
+ envVars = { ...campfireEnv.variables, ...bodyEnv };
31
+ } else {
32
+ console.warn(
33
+ `[routes] Environment "${envSlug}" not found, ignoring`,
34
+ );
35
+ }
36
+ }
37
+
38
+ const globalSettings = getSettings();
39
+ if (backend === "claude" && globalSettings.claudeOAuthToken && !envVars?.["CLAUDE_CODE_OAUTH_TOKEN"]) {
40
+ envVars = { ...envVars, CLAUDE_CODE_OAUTH_TOKEN: globalSettings.claudeOAuthToken };
41
+ }
42
+ if (backend === "codex" && globalSettings.openaiApiKey && !envVars?.["OPENAI_API_KEY"]) {
43
+ envVars = { ...envVars, OPENAI_API_KEY: globalSettings.openaiApiKey };
44
+ }
45
+ if (globalSettings.anthropicApiKey && !envVars?.["ANTHROPIC_API_KEY"]) {
46
+ envVars = { ...envVars, ANTHROPIC_API_KEY: globalSettings.anthropicApiKey };
47
+ }
48
+
49
+ return envVars;
50
+ }
51
+
52
+ interface WorktreeResult {
53
+ cwd: string;
54
+ worktreeInfo?: {
55
+ isWorktree: boolean;
56
+ repoRoot: string;
57
+ branch: string;
58
+ actualBranch: string;
59
+ worktreePath: string;
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Set up a git worktree (or checkout a branch in-place) for a session.
65
+ * Returns the resolved cwd and optional worktreeInfo.
66
+ */
67
+ function setupWorktree(
68
+ cwd: string,
69
+ branch: string | undefined,
70
+ useWorktree: boolean | undefined,
71
+ createBranch: boolean | undefined,
72
+ ): WorktreeResult {
73
+ if (!branch) return { cwd };
74
+
75
+ if (useWorktree) {
76
+ return createWorktreeForBranch(cwd, branch, createBranch);
77
+ }
78
+
79
+ checkoutBranchInPlace(cwd, branch);
80
+ return { cwd };
81
+ }
82
+
83
+ /** Create a git worktree for the given branch, returning updated cwd and worktreeInfo. */
84
+ function createWorktreeForBranch(
85
+ cwd: string,
86
+ branch: string,
87
+ createBranch: boolean | undefined,
88
+ ): WorktreeResult {
89
+ const repoInfo = gitUtils.getRepoInfo(cwd);
90
+ if (!repoInfo) return { cwd };
91
+
92
+ const result = gitUtils.ensureWorktree(
93
+ repoInfo.repoRoot,
94
+ branch,
95
+ {
96
+ baseBranch: repoInfo.defaultBranch,
97
+ createBranch,
98
+ forceNew: true,
99
+ },
100
+ );
101
+ return {
102
+ cwd: result.worktreePath,
103
+ worktreeInfo: {
104
+ isWorktree: true,
105
+ repoRoot: repoInfo.repoRoot,
106
+ branch,
107
+ actualBranch: result.actualBranch,
108
+ worktreePath: result.worktreePath,
109
+ },
110
+ };
111
+ }
112
+
113
+ /** Checkout a branch in-place (fetch, checkout, pull). */
114
+ function checkoutBranchInPlace(cwd: string, branch: string): void {
115
+ const repoInfo = gitUtils.getRepoInfo(cwd);
116
+ if (!repoInfo) return;
117
+
118
+ const fetchResult = gitUtils.gitFetch(repoInfo.repoRoot);
119
+ if (!fetchResult.success) {
120
+ throw new Error(`git fetch failed before session create: ${fetchResult.output}`);
121
+ }
122
+ if (repoInfo.currentBranch !== branch) {
123
+ gitUtils.checkoutBranch(repoInfo.repoRoot, branch);
124
+ }
125
+ const pullResult = gitUtils.gitPull(repoInfo.repoRoot);
126
+ if (!pullResult.success) {
127
+ console.warn(`[routes] git pull warning (non-fatal): ${pullResult.output}`);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Create a container and seed authentication credentials.
133
+ * Returns the ContainerInfo if a container was requested, undefined otherwise.
134
+ */
135
+ function setupContainer(
136
+ containerOpts: { image?: string; ports?: unknown[]; volumes?: string[]; env?: Record<string, string> } | undefined,
137
+ cwd: string,
138
+ backend: string,
139
+ ): ContainerInfo | undefined {
140
+ if (!containerOpts) return undefined;
141
+
142
+ const cConfig: ContainerConfig = {
143
+ image: containerOpts.image || "campfire-dev:latest",
144
+ ports: Array.isArray(containerOpts.ports)
145
+ ? containerOpts.ports.map(Number).filter((n: number) => n > 0)
146
+ : [],
147
+ volumes: containerOpts.volumes,
148
+ env: containerOpts.env,
149
+ };
150
+ const containerId = crypto.randomUUID().slice(0, 8);
151
+ const containerInfo = containerManager.createContainer(containerId, cwd, cConfig);
152
+
153
+ if (backend === "claude") {
154
+ seedClaudeAuth(containerInfo.containerId);
155
+ } else if (backend === "codex") {
156
+ seedCodexAuth(containerInfo.containerId);
157
+ }
158
+
159
+ return containerInfo;
160
+ }
161
+
162
+ export function registerSessionRoutes(api: Hono, deps: RouteDeps): void {
163
+ const { launcher, wsBridge, sessionStore, worktreeTracker, prPoller } = deps;
164
+
165
+ api.post("/sessions/create", async (c) => {
166
+ const body = await c.req.json().catch(() => ({}));
167
+ try {
168
+ const backend = body.backend ?? "claude";
169
+ const validBackends = ["claude", "codex", "goose", "aider", "openhands", "openclaw", "opencode"];
170
+ if (!validBackends.includes(backend)) {
171
+ return c.json({ error: `Invalid backend: ${String(backend)}` }, 400);
172
+ }
173
+
174
+ const envVars = resolveEnvVars(body.envSlug, body.env, backend);
175
+
176
+ const worktreeResult: WorktreeResult = body.cwd
177
+ ? setupWorktree(body.cwd, body.branch, body.useWorktree, body.createBranch)
178
+ : { cwd: body.cwd };
179
+ const cwd = worktreeResult.cwd;
180
+ const worktreeInfo = worktreeResult.worktreeInfo;
181
+
182
+ const containerInfo = setupContainer(body.container, cwd, backend);
183
+
184
+ const session = launcher.launch({
185
+ model: body.model,
186
+ permissionMode: body.permissionMode,
187
+ cwd,
188
+ claudeBinary: body.claudeBinary,
189
+ codexBinary: body.codexBinary,
190
+ codexInternetAccess: backend === "codex" && body.codexInternetAccess === true,
191
+ codexSandbox: backend === "codex" && body.codexInternetAccess === true
192
+ ? "danger-full-access"
193
+ : "workspace-write",
194
+ codexReasoningEffort: backend === "codex" ? body.codexReasoningEffort : undefined,
195
+ allowedTools: body.allowedTools,
196
+ env: envVars,
197
+ backendType: backend,
198
+ worktreeInfo,
199
+ containerId: containerInfo?.containerId,
200
+ });
201
+
202
+ wsBridge.markSessionOrchestration(session.sessionId, {
203
+ role: session.orchestrationRole,
204
+ parentSessionId: session.parentSessionId,
205
+ detectedEnvironment: session.detectedEnvironment,
206
+ });
207
+
208
+ // Re-track container with real session ID
209
+ if (containerInfo) {
210
+ containerManager.retrack(containerInfo.containerId, session.sessionId);
211
+ }
212
+
213
+ // Track the worktree mapping
214
+ if (worktreeInfo) {
215
+ worktreeTracker.addMapping({
216
+ sessionId: session.sessionId,
217
+ repoRoot: worktreeInfo.repoRoot,
218
+ branch: worktreeInfo.branch,
219
+ actualBranch: worktreeInfo.actualBranch,
220
+ worktreePath: worktreeInfo.worktreePath,
221
+ createdAt: Date.now(),
222
+ });
223
+ }
224
+
225
+ return c.json(session);
226
+ } catch (e: unknown) {
227
+ const msg = e instanceof Error ? e.message : "Unknown error";
228
+ console.error("[routes] Failed to create session:", msg);
229
+ return c.json({ error: msg }, 500);
230
+ }
231
+ });
232
+
233
+ api.get("/sessions", (c) => {
234
+ const sessions = launcher.listSessions();
235
+ const names = sessionNames.getAllNames();
236
+ const bridgeStates = wsBridge.getAllSessions();
237
+ const bridgeMap = new Map(bridgeStates.map((s) => [s.session_id, s]));
238
+ const enriched = sessions.map((s) => {
239
+ const bridge = bridgeMap.get(s.sessionId);
240
+ return {
241
+ ...s,
242
+ name: names[s.sessionId] ?? s.name,
243
+ gitBranch: bridge?.git_branch || "",
244
+ gitAhead: bridge?.git_ahead || 0,
245
+ gitBehind: bridge?.git_behind || 0,
246
+ totalLinesAdded: bridge?.total_lines_added || 0,
247
+ totalLinesRemoved: bridge?.total_lines_removed || 0,
248
+ };
249
+ });
250
+ return c.json(enriched);
251
+ });
252
+
253
+ api.get("/sessions/:id", (c) => {
254
+ const id = c.req.param("id");
255
+ const session = launcher.getSession(id);
256
+ if (!session) return c.json({ error: "Session not found" }, 404);
257
+ return c.json(session);
258
+ });
259
+
260
+ api.patch("/sessions/:id/name", async (c) => {
261
+ const id = c.req.param("id");
262
+ const body = await c.req.json().catch(() => ({}));
263
+ if (typeof body.name !== "string" || !body.name.trim()) {
264
+ return c.json({ error: "name is required" }, 400);
265
+ }
266
+ const session = launcher.getSession(id);
267
+ if (!session) return c.json({ error: "Session not found" }, 404);
268
+ sessionNames.setName(id, body.name.trim());
269
+ return c.json({ ok: true, name: body.name.trim() });
270
+ });
271
+
272
+ api.post("/sessions/:id/kill", async (c) => {
273
+ const id = c.req.param("id");
274
+ const killed = await launcher.kill(id);
275
+ if (!killed)
276
+ return c.json({ error: "Session not found or already exited" }, 404);
277
+ containerManager.removeContainer(id);
278
+ return c.json({ ok: true });
279
+ });
280
+
281
+ api.post("/sessions/:id/relaunch", async (c) => {
282
+ const id = c.req.param("id");
283
+ const ok = await launcher.relaunch(id);
284
+ if (!ok) return c.json({ error: "Session not found" }, 404);
285
+ return c.json({ ok: true });
286
+ });
287
+
288
+ api.delete("/sessions/:id", async (c) => {
289
+ const id = c.req.param("id");
290
+ await launcher.kill(id);
291
+ containerManager.removeContainer(id);
292
+ const worktreeResult = cleanupWorktree(worktreeTracker, id, true);
293
+ prPoller?.unwatch(id);
294
+ launcher.removeSession(id);
295
+ wsBridge.closeSession(id);
296
+ return c.json({ ok: true, worktree: worktreeResult });
297
+ });
298
+
299
+ api.post("/sessions/:id/archive", async (c) => {
300
+ const id = c.req.param("id");
301
+ const body = await c.req.json().catch(() => ({}));
302
+ await launcher.kill(id);
303
+ containerManager.removeContainer(id);
304
+ prPoller?.unwatch(id);
305
+ const worktreeResult = cleanupWorktree(worktreeTracker, id, body.force);
306
+ launcher.setArchived(id, true);
307
+ sessionStore.setArchived(id, true);
308
+ return c.json({ ok: true, worktree: worktreeResult });
309
+ });
310
+
311
+ api.post("/sessions/:id/unarchive", (c) => {
312
+ const id = c.req.param("id");
313
+ launcher.setArchived(id, false);
314
+ sessionStore.setArchived(id, false);
315
+ return c.json({ ok: true });
316
+ });
317
+
318
+ // ─── Fork ──────────────────────────────────────────────────────
319
+ api.post("/sessions/:id/fork", async (c) => {
320
+ const sourceId = c.req.param("id");
321
+ const body = await c.req.json().catch(() => ({}));
322
+ try {
323
+ const liveSession = wsBridge.getSession(sourceId);
324
+ const persisted = sessionStore.load(sourceId);
325
+ const sourceHistory = liveSession?.messageHistory ?? persisted?.messageHistory ?? [];
326
+ const sourceState = liveSession?.state ?? persisted?.state ?? null;
327
+ if (!sourceState) {
328
+ return c.json({ error: "Source session not found" }, 404);
329
+ }
330
+
331
+ const messageIndex = typeof body.messageIndex === "number" ? body.messageIndex : sourceHistory.length;
332
+ const forkedHistory = sourceHistory.slice(0, messageIndex);
333
+
334
+ const backend = sourceState.backend_type || "claude";
335
+ const model = body.model || sourceState.model;
336
+ const permissionMode = body.permissionMode || sourceState.permissionMode;
337
+ const cwd = sourceState.cwd || process.cwd();
338
+
339
+ let worktreeInfo: { isWorktree: boolean; repoRoot: string; branch: string; actualBranch: string; worktreePath: string } | undefined;
340
+ let forkCwd = cwd;
341
+ const repoInfo = gitUtils.getRepoInfo(cwd);
342
+ if (repoInfo) {
343
+ const branchName = body.branch || repoInfo.currentBranch || "main";
344
+ const result = gitUtils.ensureWorktree(repoInfo.repoRoot, branchName, {
345
+ baseBranch: repoInfo.defaultBranch,
346
+ createBranch: true,
347
+ forceNew: true,
348
+ });
349
+ forkCwd = result.worktreePath;
350
+ worktreeInfo = {
351
+ isWorktree: true,
352
+ repoRoot: repoInfo.repoRoot,
353
+ branch: branchName,
354
+ actualBranch: result.actualBranch,
355
+ worktreePath: result.worktreePath,
356
+ };
357
+ }
358
+
359
+ const session = launcher.launch({
360
+ model,
361
+ permissionMode,
362
+ cwd: forkCwd,
363
+ backendType: backend,
364
+ worktreeInfo,
365
+ });
366
+
367
+ if (worktreeInfo) {
368
+ worktreeTracker.addMapping({
369
+ sessionId: session.sessionId,
370
+ repoRoot: worktreeInfo.repoRoot,
371
+ branch: worktreeInfo.branch,
372
+ actualBranch: worktreeInfo.actualBranch,
373
+ worktreePath: worktreeInfo.worktreePath,
374
+ createdAt: Date.now(),
375
+ });
376
+ }
377
+
378
+ wsBridge.seedMessageHistory(session.sessionId, forkedHistory);
379
+ return c.json({ ...session, forkedFrom: sourceId });
380
+ } catch (e: unknown) {
381
+ const msg = e instanceof Error ? e.message : "Unknown error";
382
+ console.error("[routes] Failed to fork session:", msg);
383
+ return c.json({ error: msg }, 500);
384
+ }
385
+ });
386
+
387
+ // ─── Invite links ─────────────────────────────────────────────
388
+ api.post("/sessions/:id/invite", async (c) => {
389
+ const id = c.req.param("id");
390
+ let role: "owner" | "collaborator" | "spectator" = "collaborator";
391
+ try {
392
+ const body = await c.req.json();
393
+ if (body.role === "spectator" || body.role === "collaborator" || body.role === "owner") {
394
+ role = body.role;
395
+ }
396
+ } catch { /* no body, use default */ }
397
+ const token = wsBridge.createInviteToken(id, role);
398
+ if (!token) return c.json({ error: "Session not found" }, 404);
399
+
400
+ const protocol = c.req.header("x-forwarded-proto") || "http";
401
+ const host = c.req.header("host") || "localhost:4567";
402
+ const url = `${protocol}://${host}/#/join/${token}`;
403
+ return c.json({ token, url, role });
404
+ });
405
+
406
+ api.get("/sessions/join/:token", (c) => {
407
+ const token = c.req.param("token");
408
+ const sessionId = wsBridge.resolveInviteToken(token);
409
+ if (!sessionId) return c.json({ error: "Invalid or expired invite token" }, 404);
410
+ const role = wsBridge.resolveInviteTokenRole(token) || "collaborator";
411
+ return c.json({ session_id: sessionId, token, role });
412
+ });
413
+
414
+ // ─── Voting policy ──────────────────────────────────────────
415
+ api.get("/voting-policy", (c) => {
416
+ return c.json({ policy: wsBridge.getVotingPolicy() });
417
+ });
418
+
419
+ api.put("/voting-policy", async (c) => {
420
+ const body = await c.req.json();
421
+ const policy = body.policy;
422
+ if (policy !== "majority-rules" && policy !== "any-deny-blocks" && policy !== "owner-decides") {
423
+ return c.json({ error: "Invalid policy. Must be: majority-rules, any-deny-blocks, or owner-decides" }, 400);
424
+ }
425
+ wsBridge.setVotingPolicy(policy);
426
+ return c.json({ policy });
427
+ });
428
+
429
+ // ─── SSE session creation with progress ─────────────────────────────
430
+ api.post("/sessions/create-with-progress", async (c) => {
431
+ const body = await c.req.json().catch(() => ({}));
432
+ const backend = body.backend ?? "claude";
433
+ const image = body.container?.image || "campfire-dev:latest";
434
+
435
+ // Set up SSE stream
436
+ const encoder = new TextEncoder();
437
+ const stream = new ReadableStream({
438
+ async start(controller) {
439
+ function sendEvent(type: string, data: Record<string, unknown>) {
440
+ const msg = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
441
+ controller.enqueue(encoder.encode(msg));
442
+ }
443
+
444
+ try {
445
+ // Step 1: Check image
446
+ sendEvent("step", { step: "checking_image", message: `Checking image ${image}...` });
447
+ const exists = await imageExistsLocally(image);
448
+
449
+ // Step 2: Pull image if needed
450
+ if (!exists) {
451
+ sendEvent("step", { step: "pulling_image", message: `Pulling ${image}...` });
452
+ const pulled = await pullImage(image, (progress) => {
453
+ sendEvent("step", {
454
+ step: "pulling_image",
455
+ message: progress.message || progress.status,
456
+ percent: progress.percent,
457
+ layer: progress.layer,
458
+ });
459
+ });
460
+ if (!pulled) {
461
+ sendEvent("error", { error: `Failed to pull image ${image}` });
462
+ controller.close();
463
+ return;
464
+ }
465
+ }
466
+
467
+ // Step 3: Create container and seed auth
468
+ sendEvent("step", { step: "creating_container", message: "Creating container..." });
469
+ const cwd = body.cwd || process.cwd();
470
+ const containerInfo = setupContainer(body.container, cwd, backend);
471
+
472
+ if (containerInfo) {
473
+ sendEvent("step", { step: "seeding_auth", message: "Seeding authentication..." });
474
+ }
475
+
476
+ // Step 4: Launch agent
477
+ sendEvent("step", { step: "launching_agent", message: "Launching agent..." });
478
+
479
+ const envVars = resolveEnvVars(body.envSlug, body.env, backend);
480
+
481
+ const session = launcher.launch({
482
+ model: body.model,
483
+ permissionMode: body.permissionMode,
484
+ cwd,
485
+ claudeBinary: body.claudeBinary,
486
+ codexBinary: body.codexBinary,
487
+ codexInternetAccess: backend === "codex" && body.codexInternetAccess === true,
488
+ codexSandbox: backend === "codex" && body.codexInternetAccess === true
489
+ ? "danger-full-access"
490
+ : "workspace-write",
491
+ codexReasoningEffort: backend === "codex" ? body.codexReasoningEffort : undefined,
492
+ allowedTools: body.allowedTools,
493
+ env: envVars,
494
+ backendType: backend,
495
+ containerId: containerInfo?.containerId,
496
+ });
497
+
498
+ if (containerInfo) {
499
+ containerManager.retrack(containerInfo.containerId, session.sessionId);
500
+ }
501
+
502
+ sendEvent("done", { sessionId: session.sessionId, session });
503
+ } catch (err: unknown) {
504
+ const msg = err instanceof Error ? err.message : "Unknown error";
505
+ sendEvent("error", { error: msg });
506
+ } finally {
507
+ controller.close();
508
+ }
509
+ },
510
+ });
511
+
512
+ return new Response(stream, {
513
+ headers: {
514
+ "Content-Type": "text/event-stream",
515
+ "Cache-Control": "no-cache",
516
+ "Connection": "keep-alive",
517
+ },
518
+ });
519
+ });
520
+ }
521
+
522
+ // ─── Helper ─────────────────────────────────────────────────────────
523
+ export function cleanupWorktree(
524
+ worktreeTracker: RouteDeps["worktreeTracker"],
525
+ sessionId: string,
526
+ force?: boolean,
527
+ ): { cleaned?: boolean; dirty?: boolean; path?: string } | undefined {
528
+ const mapping = worktreeTracker.getBySession(sessionId);
529
+ if (!mapping) return undefined;
530
+
531
+ if (worktreeTracker.isWorktreeInUse(mapping.worktreePath, sessionId)) {
532
+ worktreeTracker.removeBySession(sessionId);
533
+ return { cleaned: false, path: mapping.worktreePath };
534
+ }
535
+
536
+ const dirty = gitUtils.isWorktreeDirty(mapping.worktreePath);
537
+ if (dirty && !force) {
538
+ console.log(
539
+ `[routes] Worktree ${mapping.worktreePath} is dirty, not auto-removing`,
540
+ );
541
+ return { cleaned: false, dirty: true, path: mapping.worktreePath };
542
+ }
543
+
544
+ const branchToDelete =
545
+ mapping.actualBranch && mapping.actualBranch !== mapping.branch
546
+ ? mapping.actualBranch
547
+ : undefined;
548
+ const result = gitUtils.removeWorktree(
549
+ mapping.repoRoot,
550
+ mapping.worktreePath,
551
+ { force: dirty, branchToDelete },
552
+ );
553
+ if (result.removed) {
554
+ worktreeTracker.removeBySession(sessionId);
555
+ console.log(
556
+ `[routes] ${dirty ? "Force-removed dirty" : "Auto-removed clean"} worktree ${mapping.worktreePath}`,
557
+ );
558
+ }
559
+ return { cleaned: result.removed, path: mapping.worktreePath };
560
+ }