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,525 @@
1
+ /**
2
+ * Aider Adapter
3
+ *
4
+ * Translates between Aider's unstructured CLI output (stdout/stdin)
5
+ * and Campfire's BrowserIncomingMessage/BrowserOutgoingMessage types.
6
+ *
7
+ * Aider has no structured protocol — it's a terminal tool. We spawn it with
8
+ * `--no-pretty --yes --no-auto-commits` and parse stdout for:
9
+ * - SEARCH/REPLACE edit blocks → tool_use/tool_result
10
+ * - "Applied edit to {file}" → success tool_result
11
+ * - "Tokens: ..." → usage/result message
12
+ * - Other non-empty lines → streaming assistant text
13
+ * - Aider prompt ">" → turn complete
14
+ */
15
+
16
+ import { randomUUID } from "node:crypto";
17
+ import type { Subprocess } from "bun";
18
+ import type {
19
+ BrowserIncomingMessage,
20
+ BrowserOutgoingMessage,
21
+ SessionState,
22
+ CLIResultMessage,
23
+ } from "./session-types.js";
24
+ import type { RecorderManager } from "./recorder.js";
25
+ import type { AgentAdapter, AdapterSessionMeta } from "./adapter-types.js";
26
+
27
+ // ─── Adapter Options ─────────────────────────────────────────────────────────
28
+
29
+ export interface AiderAdapterOptions {
30
+ model?: string;
31
+ cwd?: string;
32
+ permissionMode?: string;
33
+ recorder?: RecorderManager;
34
+ }
35
+
36
+ // ─── Edit Block Parser State ─────────────────────────────────────────────────
37
+
38
+ interface EditBlock {
39
+ file: string;
40
+ search: string;
41
+ replace: string;
42
+ }
43
+
44
+ type ParseState = "idle" | "search" | "divider" | "replace";
45
+
46
+ // ─── Aider Adapter ──────────────────────────────────────────────────────────
47
+
48
+ export class AiderAdapter implements AgentAdapter {
49
+ private proc: Subprocess;
50
+ private sessionId: string;
51
+ private options: AiderAdapterOptions;
52
+
53
+ private browserMessageCb: ((msg: BrowserIncomingMessage) => void) | null = null;
54
+ private sessionMetaCb: ((meta: AdapterSessionMeta) => void) | null = null;
55
+ private disconnectCb: (() => void) | null = null;
56
+ private initErrorCb: ((error: string) => void) | null = null;
57
+
58
+ // State
59
+ private _connected = false;
60
+ private streamingText = "";
61
+ private streamingActive = false;
62
+
63
+ // Edit block parsing
64
+ private parseState: ParseState = "idle";
65
+ private currentFile = "";
66
+ private searchLines: string[] = [];
67
+ private replaceLines: string[] = [];
68
+ private editCounter = 0;
69
+
70
+ // Prompt detection
71
+ private lastLineWasPrompt = false;
72
+
73
+ // Buffer for incomplete lines from stdout
74
+ private stdoutBuffer = "";
75
+
76
+ constructor(proc: Subprocess, sessionId: string, options: AiderAdapterOptions = {}) {
77
+ this.proc = proc;
78
+ this.sessionId = sessionId;
79
+ this.options = options;
80
+
81
+ const stdout = proc.stdout;
82
+ if (!stdout || typeof stdout === "number") {
83
+ throw new Error("Aider process must have stdout pipe");
84
+ }
85
+
86
+ // Read stdout
87
+ this.readStdout(stdout as ReadableStream<Uint8Array>);
88
+
89
+ // Wire stderr for debugging
90
+ const stderr = proc.stderr;
91
+ if (stderr && typeof stderr !== "number") {
92
+ this.readStderr(stderr as ReadableStream<Uint8Array>);
93
+ }
94
+
95
+ // Monitor process exit
96
+ proc.exited.then(() => {
97
+ this._connected = false;
98
+ this.flushStreaming();
99
+ this.disconnectCb?.();
100
+ });
101
+
102
+ // Emit session_init immediately
103
+ this._connected = true;
104
+ this.emitInit();
105
+ }
106
+
107
+ // ── Public API ──────────────────────────────────────────────────────────
108
+
109
+ sendBrowserMessage(msg: BrowserOutgoingMessage): boolean {
110
+ switch (msg.type) {
111
+ case "user_message":
112
+ this.sendUserMessage(msg.content);
113
+ return true;
114
+ case "interrupt":
115
+ this.proc.kill("SIGINT");
116
+ return true;
117
+ default:
118
+ return false;
119
+ }
120
+ }
121
+
122
+ onBrowserMessage(cb: (msg: BrowserIncomingMessage) => void): void {
123
+ this.browserMessageCb = cb;
124
+ }
125
+
126
+ onSessionMeta(cb: (meta: AdapterSessionMeta) => void): void {
127
+ this.sessionMetaCb = cb;
128
+ }
129
+
130
+ onDisconnect(cb: () => void): void {
131
+ this.disconnectCb = cb;
132
+ }
133
+
134
+ onInitError(cb: (error: string) => void): void {
135
+ this.initErrorCb = cb;
136
+ }
137
+
138
+ isConnected(): boolean {
139
+ return this._connected;
140
+ }
141
+
142
+ async disconnect(): Promise<void> {
143
+ this._connected = false;
144
+ try {
145
+ this.proc.kill("SIGTERM");
146
+ await Promise.race([
147
+ this.proc.exited,
148
+ new Promise((r) => setTimeout(r, 5000)),
149
+ ]);
150
+ } catch {}
151
+ }
152
+
153
+ getBackendSessionId(): string | null {
154
+ return null; // Aider has no session concept
155
+ }
156
+
157
+ // ── Initialization ─────────────────────────────────────────────────────
158
+
159
+ private emitInit(): void {
160
+ this.sessionMetaCb?.({
161
+ model: this.options.model,
162
+ cwd: this.options.cwd,
163
+ });
164
+
165
+ const state: SessionState = {
166
+ session_id: this.sessionId,
167
+ backend_type: "aider",
168
+ model: this.options.model || "default",
169
+ cwd: this.options.cwd || "",
170
+ tools: [],
171
+ permissionMode: "default",
172
+ claude_code_version: "",
173
+ mcp_servers: [],
174
+ agents: [],
175
+ slash_commands: [],
176
+ skills: [],
177
+ total_cost_usd: 0,
178
+ num_turns: 0,
179
+ context_used_percent: 0,
180
+ is_compacting: false,
181
+ git_branch: "",
182
+ is_worktree: false,
183
+ repo_root: "",
184
+ git_ahead: 0,
185
+ git_behind: 0,
186
+ total_lines_added: 0,
187
+ total_lines_removed: 0,
188
+ total_duration_api_ms: 0,
189
+ };
190
+
191
+ this.emit({ type: "session_init", session: state });
192
+ }
193
+
194
+ // ── Outgoing ───────────────────────────────────────────────────────────
195
+
196
+ private sendUserMessage(content: string): void {
197
+ const stdin = this.proc.stdin;
198
+ if (!stdin) return;
199
+
200
+ // Record outgoing message
201
+ if (this.options.recorder) {
202
+ this.options.recorder.record(
203
+ this.sessionId,
204
+ "out",
205
+ content,
206
+ "cli",
207
+ "aider",
208
+ this.options.cwd || "",
209
+ );
210
+ }
211
+
212
+ // Write message to stdin followed by newline
213
+ const writer = (stdin as unknown as WritableStream<Uint8Array>).getWriter();
214
+ writer.write(new TextEncoder().encode(content + "\n")).then(() => writer.releaseLock());
215
+ }
216
+
217
+ // ── Stdout parsing ─────────────────────────────────────────────────────
218
+
219
+ private async readStdout(stdout: ReadableStream<Uint8Array>): Promise<void> {
220
+ const reader = stdout.getReader();
221
+ const decoder = new TextDecoder();
222
+ try {
223
+ while (true) {
224
+ const { done, value } = await reader.read();
225
+ if (done) break;
226
+ const text = decoder.decode(value, { stream: true });
227
+
228
+ // Record raw incoming
229
+ if (this.options.recorder) {
230
+ this.options.recorder.record(
231
+ this.sessionId,
232
+ "in",
233
+ text.replace(/\n$/, ""),
234
+ "cli",
235
+ "aider",
236
+ this.options.cwd || "",
237
+ );
238
+ }
239
+
240
+ this.stdoutBuffer += text;
241
+ this.processStdoutBuffer();
242
+ }
243
+ } catch (err) {
244
+ console.error("[aider-adapter] stdout reader error:", err);
245
+ }
246
+ }
247
+
248
+ private processStdoutBuffer(): void {
249
+ const lines = this.stdoutBuffer.split("\n");
250
+ this.stdoutBuffer = lines.pop() || ""; // Keep incomplete last line
251
+
252
+ for (const line of lines) {
253
+ this.processLine(line);
254
+ }
255
+ }
256
+
257
+ /** Process a single line of aider output. */
258
+ processLine(line: string): void {
259
+ const trimmed = line.trim();
260
+
261
+ // Detect aider prompt (turn complete)
262
+ if (trimmed === ">" || trimmed.endsWith("> ") || trimmed === "aider>") {
263
+ this.lastLineWasPrompt = true;
264
+ this.flushStreaming();
265
+ this.emitResult("success", false);
266
+ return;
267
+ }
268
+
269
+ // Edit block detection: <<<<<<< SEARCH
270
+ if (trimmed.startsWith("<<<<<<< SEARCH")) {
271
+ this.flushStreaming();
272
+ this.parseState = "search";
273
+ this.searchLines = [];
274
+ this.replaceLines = [];
275
+ return;
276
+ }
277
+
278
+ // Edit block: separator
279
+ if (this.parseState === "search" && trimmed === "=======") {
280
+ this.parseState = "replace";
281
+ return;
282
+ }
283
+
284
+ // Edit block: end
285
+ if (this.parseState === "replace" && trimmed.startsWith(">>>>>>> REPLACE")) {
286
+ this.parseState = "idle";
287
+ const editBlock: EditBlock = {
288
+ file: this.currentFile,
289
+ search: this.searchLines.join("\n"),
290
+ replace: this.replaceLines.join("\n"),
291
+ };
292
+ this.emitEditBlock(editBlock);
293
+ return;
294
+ }
295
+
296
+ // Accumulate lines within edit blocks
297
+ if (this.parseState === "search") {
298
+ this.searchLines.push(line);
299
+ return;
300
+ }
301
+ if (this.parseState === "replace") {
302
+ this.replaceLines.push(line);
303
+ return;
304
+ }
305
+
306
+ // File reference: "> filename" (appears before edit blocks)
307
+ if (trimmed.startsWith("> ") && !trimmed.includes(" ") === false) {
308
+ const possibleFile = trimmed.slice(2);
309
+ // Only treat as file reference if it looks like a path
310
+ if (possibleFile && !possibleFile.includes(" ") && (possibleFile.includes(".") || possibleFile.includes("/"))) {
311
+ this.currentFile = possibleFile;
312
+ return;
313
+ }
314
+ }
315
+
316
+ // "Applied edit to {file}" — success indication
317
+ if (trimmed.startsWith("Applied edit to ")) {
318
+ const file = trimmed.slice("Applied edit to ".length);
319
+ this.emitAppliedEdit(file);
320
+ return;
321
+ }
322
+
323
+ // Token usage lines (e.g. "Tokens: 1.2k sent, 0.5k received.")
324
+ if (trimmed.startsWith("Tokens:") || trimmed.startsWith("Cost:")) {
325
+ // Don't stream these to chat — they're metadata
326
+ return;
327
+ }
328
+
329
+ // Empty lines — skip during idle
330
+ if (!trimmed) return;
331
+
332
+ // Default: accumulate as streaming text
333
+ this.lastLineWasPrompt = false;
334
+ this.appendStreaming(trimmed);
335
+ }
336
+
337
+ // ── Streaming ──────────────────────────────────────────────────────────
338
+
339
+ private appendStreaming(text: string): void {
340
+ if (!this.streamingActive) {
341
+ this.streamingActive = true;
342
+ this.streamingText = "";
343
+
344
+ // Emit message_start
345
+ this.emit({
346
+ type: "stream_event",
347
+ event: {
348
+ type: "message_start",
349
+ message: {
350
+ id: `aider-msg-${randomUUID()}`,
351
+ type: "message",
352
+ role: "assistant",
353
+ model: this.options.model || "",
354
+ content: [],
355
+ stop_reason: null,
356
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
357
+ },
358
+ },
359
+ parent_tool_use_id: null,
360
+ });
361
+
362
+ this.emit({
363
+ type: "stream_event",
364
+ event: {
365
+ type: "content_block_start",
366
+ index: 0,
367
+ content_block: { type: "text", text: "" },
368
+ },
369
+ parent_tool_use_id: null,
370
+ });
371
+ }
372
+
373
+ const chunk = (this.streamingText ? "\n" : "") + text;
374
+ this.streamingText += chunk;
375
+
376
+ this.emit({
377
+ type: "stream_event",
378
+ event: {
379
+ type: "content_block_delta",
380
+ index: 0,
381
+ delta: { type: "text_delta", text: chunk },
382
+ },
383
+ parent_tool_use_id: null,
384
+ });
385
+ }
386
+
387
+ private flushStreaming(): void {
388
+ if (!this.streamingActive) return;
389
+
390
+ this.emit({
391
+ type: "stream_event",
392
+ event: { type: "content_block_stop", index: 0 },
393
+ parent_tool_use_id: null,
394
+ });
395
+ this.emit({
396
+ type: "stream_event",
397
+ event: {
398
+ type: "message_delta",
399
+ delta: { stop_reason: "end_turn" },
400
+ usage: { output_tokens: 0 },
401
+ },
402
+ parent_tool_use_id: null,
403
+ });
404
+
405
+ if (this.streamingText) {
406
+ this.emit({
407
+ type: "assistant",
408
+ message: {
409
+ id: `aider-msg-${randomUUID()}`,
410
+ type: "message",
411
+ role: "assistant",
412
+ model: this.options.model || "",
413
+ content: [{ type: "text", text: this.streamingText }],
414
+ stop_reason: "end_turn",
415
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
416
+ },
417
+ parent_tool_use_id: null,
418
+ timestamp: Date.now(),
419
+ });
420
+ }
421
+
422
+ this.streamingText = "";
423
+ this.streamingActive = false;
424
+ }
425
+
426
+ // ── Edit block emission ────────────────────────────────────────────────
427
+
428
+ private emitEditBlock(edit: EditBlock): void {
429
+ const toolUseId = `aider-edit-${++this.editCounter}`;
430
+
431
+ // Emit tool_use (Edit tool)
432
+ this.emit({
433
+ type: "assistant",
434
+ message: {
435
+ id: `aider-tu-${toolUseId}`,
436
+ type: "message",
437
+ role: "assistant",
438
+ model: this.options.model || "",
439
+ content: [
440
+ {
441
+ type: "tool_use",
442
+ id: toolUseId,
443
+ name: "Edit",
444
+ input: {
445
+ file_path: edit.file,
446
+ old_string: edit.search,
447
+ new_string: edit.replace,
448
+ },
449
+ },
450
+ ],
451
+ stop_reason: null,
452
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
453
+ },
454
+ parent_tool_use_id: null,
455
+ timestamp: Date.now(),
456
+ });
457
+ }
458
+
459
+ private emitAppliedEdit(file: string): void {
460
+ // Find most recent edit tool use ID for this file
461
+ const toolUseId = `aider-edit-${this.editCounter}`;
462
+ this.emit({
463
+ type: "assistant",
464
+ message: {
465
+ id: `aider-tr-${toolUseId}`,
466
+ type: "message",
467
+ role: "assistant",
468
+ model: this.options.model || "",
469
+ content: [
470
+ {
471
+ type: "tool_result",
472
+ tool_use_id: toolUseId,
473
+ content: `Applied edit to ${file}`,
474
+ is_error: false,
475
+ },
476
+ ],
477
+ stop_reason: null,
478
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
479
+ },
480
+ parent_tool_use_id: null,
481
+ timestamp: Date.now(),
482
+ });
483
+ }
484
+
485
+ // ── Helpers ────────────────────────────────────────────────────────────
486
+
487
+ private emit(msg: BrowserIncomingMessage): void {
488
+ this.browserMessageCb?.(msg);
489
+ }
490
+
491
+ private emitResult(subtype: CLIResultMessage["subtype"], isError: boolean): void {
492
+ const result: CLIResultMessage = {
493
+ type: "result",
494
+ subtype,
495
+ is_error: isError,
496
+ duration_ms: 0,
497
+ duration_api_ms: 0,
498
+ num_turns: 1,
499
+ total_cost_usd: 0,
500
+ stop_reason: subtype === "success" ? "end_turn" : "error",
501
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
502
+ uuid: randomUUID(),
503
+ session_id: this.sessionId,
504
+ };
505
+
506
+ this.emit({ type: "result", data: result });
507
+ }
508
+
509
+ private async readStderr(stderr: ReadableStream<Uint8Array>): Promise<void> {
510
+ const reader = stderr.getReader();
511
+ const decoder = new TextDecoder();
512
+ try {
513
+ while (true) {
514
+ const { done, value } = await reader.read();
515
+ if (done) break;
516
+ const text = decoder.decode(value, { stream: true });
517
+ for (const line of text.split("\n")) {
518
+ if (line.trim()) {
519
+ console.log(`[aider-adapter:stderr] ${line}`);
520
+ }
521
+ }
522
+ }
523
+ } catch {}
524
+ }
525
+ }
package/server/auth.ts ADDED
@@ -0,0 +1,153 @@
1
+ import { randomBytes, createHash, scryptSync, timingSafeEqual } from "node:crypto";
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { homedir } from "node:os";
5
+
6
+ const AUTH_FILE = join(homedir(), ".campfire", "auth.json");
7
+
8
+ interface AuthConfig {
9
+ /** Salted scrypt hash (`scrypt:<saltHex>:<hashHex>`), or a legacy unsalted SHA-256 hex digest. */
10
+ passwordHash: string;
11
+ /** Active session tokens (browser login sessions) */
12
+ sessions: { token: string; createdAt: number; userAgent?: string }[];
13
+ /** Whether auth is enabled */
14
+ enabled: boolean;
15
+ }
16
+
17
+ const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
18
+ const SCRYPT_PREFIX = "scrypt";
19
+ const SCRYPT_KEYLEN = 32;
20
+
21
+ function sha256(input: string): string {
22
+ return createHash("sha256").update(input).digest("hex");
23
+ }
24
+
25
+ function hashPassword(password: string): string {
26
+ const salt = randomBytes(16);
27
+ const hash = scryptSync(password, salt, SCRYPT_KEYLEN);
28
+ return `${SCRYPT_PREFIX}:${salt.toString("hex")}:${hash.toString("hex")}`;
29
+ }
30
+
31
+ function constantTimeEqualHex(aHex: string, bHex: string): boolean {
32
+ const a = Buffer.from(aHex, "hex");
33
+ const b = Buffer.from(bHex, "hex");
34
+ if (a.length === 0 || a.length !== b.length) return false;
35
+ return timingSafeEqual(a, b);
36
+ }
37
+
38
+ /** Verify a password against a stored hash. Supports the current salted
39
+ * scrypt format and the legacy unsalted SHA-256 format (pre-migration). */
40
+ function verifyPassword(password: string, stored: string): { valid: boolean; legacy: boolean } {
41
+ if (stored.startsWith(`${SCRYPT_PREFIX}:`)) {
42
+ const [, saltHex, hashHex] = stored.split(":");
43
+ if (!saltHex || !hashHex) return { valid: false, legacy: false };
44
+ const candidate = scryptSync(password, Buffer.from(saltHex, "hex"), SCRYPT_KEYLEN);
45
+ return { valid: constantTimeEqualHex(candidate.toString("hex"), hashHex), legacy: false };
46
+ }
47
+ return { valid: constantTimeEqualHex(sha256(password), stored), legacy: true };
48
+ }
49
+
50
+ function load(): AuthConfig {
51
+ try {
52
+ if (existsSync(AUTH_FILE)) {
53
+ return JSON.parse(readFileSync(AUTH_FILE, "utf-8"));
54
+ }
55
+ } catch { /* ignore */ }
56
+ return { passwordHash: "", sessions: [], enabled: false };
57
+ }
58
+
59
+ function save(config: AuthConfig): void {
60
+ mkdirSync(dirname(AUTH_FILE), { recursive: true });
61
+ writeFileSync(AUTH_FILE, JSON.stringify(config, null, 2), "utf-8");
62
+ }
63
+
64
+ /** Check if auth is enabled (either via env var or saved config) */
65
+ export function isAuthEnabled(): boolean {
66
+ // CAMPFIRE_PASSWORD env var takes priority
67
+ if (process.env.CAMPFIRE_PASSWORD) return true;
68
+ return load().enabled;
69
+ }
70
+
71
+ /** Set a password (for UI-based setup) */
72
+ export function setPassword(password: string): void {
73
+ const config = load();
74
+ config.passwordHash = hashPassword(password);
75
+ config.enabled = true;
76
+ save(config);
77
+ }
78
+
79
+ /** Disable auth entirely */
80
+ export function disableAuth(): void {
81
+ const config = load();
82
+ config.enabled = false;
83
+ config.passwordHash = "";
84
+ config.sessions = [];
85
+ save(config);
86
+ }
87
+
88
+ /** Check the supplied password against the env var or saved config.
89
+ * Returns whether it matched and whether a legacy hash should be upgraded. */
90
+ function checkPassword(password: string, config: AuthConfig): { valid: boolean; upgradeLegacy: boolean } {
91
+ const envPassword = process.env.CAMPFIRE_PASSWORD;
92
+ if (envPassword) {
93
+ // Hash both sides so the comparison is constant-time for any input length.
94
+ return { valid: constantTimeEqualHex(sha256(password), sha256(envPassword)), upgradeLegacy: false };
95
+ }
96
+ if (!config.enabled || !config.passwordHash) return { valid: false, upgradeLegacy: false };
97
+ const { valid, legacy } = verifyPassword(password, config.passwordHash);
98
+ return { valid, upgradeLegacy: valid && legacy };
99
+ }
100
+
101
+ /** Verify a password and return a session token if valid */
102
+ export function login(password: string, userAgent?: string): string | null {
103
+ const config = load();
104
+ const { valid, upgradeLegacy } = checkPassword(password, config);
105
+ if (!valid) return null;
106
+
107
+ // Transparently migrate legacy unsalted SHA-256 hashes to salted scrypt.
108
+ if (upgradeLegacy) {
109
+ config.passwordHash = hashPassword(password);
110
+ }
111
+
112
+ const token = randomBytes(32).toString("hex");
113
+ // Prune expired sessions
114
+ const now = Date.now();
115
+ config.sessions = config.sessions.filter((s) => now - s.createdAt < SESSION_MAX_AGE_MS);
116
+ config.sessions.push({ token: sha256(token), createdAt: now, userAgent });
117
+ save(config);
118
+ return token;
119
+ }
120
+
121
+ /** Verify a session token */
122
+ export function verifyToken(token: string): boolean {
123
+ if (!isAuthEnabled()) return true; // no auth = always valid
124
+ const config = load();
125
+ const hashed = sha256(token);
126
+ const now = Date.now();
127
+ return config.sessions.some(
128
+ (s) => s.token === hashed && now - s.createdAt < SESSION_MAX_AGE_MS,
129
+ );
130
+ }
131
+
132
+ /** Logout — invalidate a session token */
133
+ export function logout(token: string): void {
134
+ const config = load();
135
+ const hashed = sha256(token);
136
+ config.sessions = config.sessions.filter((s) => s.token !== hashed);
137
+ save(config);
138
+ }
139
+
140
+ /** Get auth status (for the frontend) */
141
+ export function getAuthStatus(): { enabled: boolean; hasPassword: boolean; activeSessions: number } {
142
+ const envPassword = !!process.env.CAMPFIRE_PASSWORD;
143
+ const config = load();
144
+ const now = Date.now();
145
+ const activeSessions = config.sessions.filter(
146
+ (s) => now - s.createdAt < SESSION_MAX_AGE_MS,
147
+ ).length;
148
+ return {
149
+ enabled: envPassword || config.enabled,
150
+ hasPassword: envPassword || !!config.passwordHash,
151
+ activeSessions,
152
+ };
153
+ }