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,263 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import type { CliLauncher } from "./cli-launcher.js";
4
+ import type { WsBridge } from "./ws-bridge.js";
5
+ import type { BackendType, BrowserIncomingMessage, CLIResultMessage, SubAgentUpdate } from "./session-types.js";
6
+ import { generateSessionTitle } from "./auto-namer.js";
7
+ import * as sessionNames from "./session-names.js";
8
+
9
+ export interface SubSessionOptions {
10
+ timeoutMs?: number;
11
+ toolUseId?: string;
12
+ name?: string;
13
+ description?: string;
14
+ model?: string;
15
+ permissionMode?: string;
16
+ }
17
+
18
+ export interface SubSessionResult {
19
+ sessionId: string;
20
+ backendType: BackendType;
21
+ text: string;
22
+ filesChanged: string[];
23
+ costUsd: number;
24
+ durationMs: number;
25
+ error?: string;
26
+ }
27
+
28
+ const DEFAULT_TIMEOUT_MS = 300_000;
29
+ const POLL_MS = 500;
30
+
31
+ function sleep(ms: number): Promise<void> {
32
+ return new Promise((resolve) => setTimeout(resolve, ms));
33
+ }
34
+
35
+ function gitLines(args: string[], cwd: string): string[] {
36
+ try {
37
+ const out = execFileSync("git", args, { cwd, encoding: "utf-8", timeout: 10_000, stdio: ["ignore", "pipe", "ignore"] });
38
+ return out.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ export function getChangedFiles(cwd: string): string[] {
45
+ const names = new Set<string>();
46
+ for (const file of gitLines(["diff", "--name-only"], cwd)) names.add(file);
47
+ for (const file of gitLines(["diff", "--name-only", "--cached"], cwd)) names.add(file);
48
+ for (const line of gitLines(["ls-files", "--others", "--exclude-standard"], cwd)) names.add(line);
49
+ return Array.from(names).sort();
50
+ }
51
+
52
+ function textFromContent(content: unknown): string {
53
+ if (typeof content === "string") return content;
54
+ if (!Array.isArray(content)) return "";
55
+ return content
56
+ .map((block) => {
57
+ if (!block || typeof block !== "object") return "";
58
+ const typed = block as { type?: string; text?: string; content?: unknown };
59
+ if (typed.type === "text" && typeof typed.text === "string") return typed.text;
60
+ if (typed.type === "tool_result") return textFromContent(typed.content);
61
+ return "";
62
+ })
63
+ .filter(Boolean)
64
+ .join("\n");
65
+ }
66
+
67
+ function extractAssistantText(messages: BrowserIncomingMessage[]): string {
68
+ const chunks: string[] = [];
69
+ for (const msg of messages) {
70
+ if (msg.type !== "assistant") continue;
71
+ const text = textFromContent(msg.message.content);
72
+ if (text) chunks.push(text);
73
+ }
74
+ return chunks.join("\n\n").trim();
75
+ }
76
+
77
+ function findResult(messages: BrowserIncomingMessage[], afterIndex: number): CLIResultMessage | null {
78
+ for (const msg of messages.slice(afterIndex)) {
79
+ if (msg.type === "result") return msg.data;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ function inheritedEnv(env: Record<string, string> | undefined): Record<string, string> | undefined {
85
+ if (!env || Object.keys(env).length === 0) return undefined;
86
+ return { ...env };
87
+ }
88
+
89
+ export class SubSessionManager {
90
+ private readonly childrenByParent = new Map<string, Set<string>>();
91
+
92
+ constructor(
93
+ private readonly launcher: CliLauncher,
94
+ private readonly wsBridge: WsBridge,
95
+ ) {}
96
+
97
+ getChildSessions(parentSessionId: string): string[] {
98
+ return Array.from(this.childrenByParent.get(parentSessionId) ?? []);
99
+ }
100
+
101
+ async spawnSubSession(
102
+ parentSessionId: string,
103
+ backendType: BackendType,
104
+ prompt: string,
105
+ cwd: string,
106
+ opts: SubSessionOptions = {},
107
+ ): Promise<SubSessionResult> {
108
+ const startedAt = Date.now();
109
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
110
+ const toolUseId = opts.toolUseId ?? `subagent-${randomUUID()}`;
111
+ const name = opts.name ?? `Ask ${backendType}`;
112
+ const description = opts.description ?? prompt.slice(0, 160);
113
+
114
+ const runningUpdate: SubAgentUpdate = {
115
+ parentSessionId,
116
+ toolUseId,
117
+ backendType,
118
+ name,
119
+ description,
120
+ status: "running",
121
+ startedAt,
122
+ };
123
+ this.wsBridge.broadcastSubAgentUpdate(parentSessionId, runningUpdate);
124
+
125
+ const parentSession = this.launcher.getSession(parentSessionId);
126
+ const info = this.launcher.launch({
127
+ cwd,
128
+ backendType,
129
+ model: opts.model,
130
+ permissionMode: opts.permissionMode ?? "bypassPermissions",
131
+ env: inheritedEnv(parentSession?.sessionEnv),
132
+ parentSessionId,
133
+ orchestrationRole: "subagent",
134
+ });
135
+ this.wsBridge.markSessionOrchestration(info.sessionId, {
136
+ parentSessionId,
137
+ role: "subagent",
138
+ detectedEnvironment: info.detectedEnvironment,
139
+ });
140
+ this.trackChild(parentSessionId, info.sessionId);
141
+
142
+ this.wsBridge.broadcastSubAgentUpdate(parentSessionId, {
143
+ ...runningUpdate,
144
+ sessionId: info.sessionId,
145
+ });
146
+
147
+ let error: string | undefined;
148
+ try {
149
+ await this.waitForConnection(info.sessionId, Math.min(15_000, timeoutMs));
150
+ const session = this.wsBridge.getSession(info.sessionId);
151
+ const startIndex = session?.messageHistory.length ?? 0;
152
+ this.wsBridge.injectUserMessage(info.sessionId, prompt);
153
+
154
+ const result = await this.waitForResult(info.sessionId, startIndex, timeoutMs - (Date.now() - startedAt));
155
+ if (result.is_error) {
156
+ error = result.errors?.join("\n") || result.result || "Sub-agent returned an error result.";
157
+ }
158
+
159
+ const resultSession = this.wsBridge.getSession(info.sessionId);
160
+ const messages = resultSession?.messageHistory ?? [];
161
+ const text = extractAssistantText(messages) || result.result || "";
162
+ const filesChanged = getChangedFiles(cwd);
163
+ const costUsd = result.total_cost_usd || resultSession?.state.total_cost_usd || 0;
164
+ const durationMs = Date.now() - startedAt;
165
+ if (costUsd > 0) this.wsBridge.addSubAgentCost(parentSessionId, costUsd);
166
+
167
+ const finalResult: SubSessionResult = {
168
+ sessionId: info.sessionId,
169
+ backendType,
170
+ text,
171
+ filesChanged,
172
+ costUsd,
173
+ durationMs,
174
+ error,
175
+ };
176
+ const completedUpdate: SubAgentUpdate = {
177
+ ...runningUpdate,
178
+ sessionId: info.sessionId,
179
+ status: error ? "failed" : "completed",
180
+ completedAt: Date.now(),
181
+ costUsd,
182
+ durationMs,
183
+ filesChanged,
184
+ summary: text.slice(0, 500),
185
+ error,
186
+ };
187
+ this.wsBridge.broadcastSubAgentUpdate(parentSessionId, completedUpdate);
188
+ void this.applyGeneratedSubAgentName(parentSessionId, info.sessionId, completedUpdate, prompt, opts.model ?? backendType);
189
+ return finalResult;
190
+ } catch (err) {
191
+ const isTimeout = err instanceof Error && err.message.includes("Timed out");
192
+ error = err instanceof Error ? err.message : String(err);
193
+ this.wsBridge.broadcastSubAgentUpdate(parentSessionId, {
194
+ ...runningUpdate,
195
+ sessionId: info.sessionId,
196
+ status: isTimeout ? "timeout" : "failed",
197
+ completedAt: Date.now(),
198
+ durationMs: Date.now() - startedAt,
199
+ filesChanged: getChangedFiles(cwd),
200
+ error,
201
+ });
202
+ return {
203
+ sessionId: info.sessionId,
204
+ backendType,
205
+ text: "",
206
+ filesChanged: getChangedFiles(cwd),
207
+ costUsd: 0,
208
+ durationMs: Date.now() - startedAt,
209
+ error,
210
+ };
211
+ } finally {
212
+ await this.launcher.kill(info.sessionId).catch(() => false);
213
+ this.launcher.markSessionExited(info.sessionId, error ? 1 : 0);
214
+ }
215
+ }
216
+
217
+ private trackChild(parentSessionId: string, childSessionId: string): void {
218
+ let children = this.childrenByParent.get(parentSessionId);
219
+ if (!children) {
220
+ children = new Set();
221
+ this.childrenByParent.set(parentSessionId, children);
222
+ }
223
+ children.add(childSessionId);
224
+ }
225
+
226
+ private async applyGeneratedSubAgentName(
227
+ parentSessionId: string,
228
+ childSessionId: string,
229
+ completedUpdate: SubAgentUpdate,
230
+ prompt: string,
231
+ model: string,
232
+ ): Promise<void> {
233
+ const existing = sessionNames.getName(childSessionId);
234
+ const title = existing ?? await generateSessionTitle(prompt, model, { timeoutMs: 15_000 });
235
+ if (!title) return;
236
+ if (!existing) sessionNames.setName(childSessionId, title);
237
+ this.wsBridge.broadcastNameUpdate(childSessionId, title);
238
+ this.wsBridge.broadcastSubAgentUpdate(parentSessionId, {
239
+ ...completedUpdate,
240
+ name: title,
241
+ });
242
+ }
243
+
244
+ private async waitForConnection(sessionId: string, timeoutMs: number): Promise<void> {
245
+ const deadline = Date.now() + timeoutMs;
246
+ while (Date.now() < deadline) {
247
+ if (this.wsBridge.isCliConnected(sessionId)) return;
248
+ await sleep(POLL_MS);
249
+ }
250
+ throw new Error(`Timed out waiting for sub-session ${sessionId} to connect.`);
251
+ }
252
+
253
+ private async waitForResult(sessionId: string, startIndex: number, timeoutMs: number): Promise<CLIResultMessage> {
254
+ const deadline = Date.now() + Math.max(timeoutMs, 1);
255
+ while (Date.now() < deadline) {
256
+ const session = this.wsBridge.getSession(sessionId);
257
+ const result = session ? findResult(session.messageHistory as BrowserIncomingMessage[], startIndex) : null;
258
+ if (result) return result;
259
+ await sleep(POLL_MS);
260
+ }
261
+ throw new Error(`Timed out waiting for sub-session ${sessionId} to finish.`);
262
+ }
263
+ }
@@ -0,0 +1,185 @@
1
+ import type { ServerWebSocket } from "bun";
2
+ import { existsSync } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
4
+ import type { SocketData } from "./ws-bridge.js";
5
+
6
+ /** Bun's PTY terminal handle exposed on proc when spawned with `terminal` option */
7
+ interface BunTerminalHandle {
8
+ write(data: string): void;
9
+ resize(cols: number, rows: number): void;
10
+ close(): void;
11
+ }
12
+
13
+ interface TerminalInstance {
14
+ id: string;
15
+ cwd: string;
16
+ proc: ReturnType<typeof Bun.spawn>;
17
+ terminal: BunTerminalHandle;
18
+ browserSockets: Set<ServerWebSocket<SocketData>>;
19
+ cols: number;
20
+ rows: number;
21
+ orphanTimer: ReturnType<typeof setTimeout> | null;
22
+ }
23
+
24
+ function resolveShell(): string {
25
+ if (process.env.SHELL && existsSync(process.env.SHELL)) return process.env.SHELL;
26
+ if (existsSync("/bin/bash")) return "/bin/bash";
27
+ return "/bin/sh";
28
+ }
29
+
30
+ export class TerminalManager {
31
+ private instance: TerminalInstance | null = null;
32
+
33
+ /** Spawn a new global terminal in the given directory */
34
+ spawn(cwd: string, cols = 80, rows = 24): string {
35
+ // Kill existing instance if any
36
+ if (this.instance) {
37
+ this.kill();
38
+ }
39
+
40
+ const id = randomUUID();
41
+ const shell = resolveShell();
42
+ const sockets = new Set<ServerWebSocket<SocketData>>();
43
+
44
+ const proc = Bun.spawn([shell, "-l"], {
45
+ cwd,
46
+ env: { ...process.env, TERM: "xterm-256color", CLAUDECODE: undefined },
47
+ terminal: {
48
+ cols,
49
+ rows,
50
+ data: (_terminal, data) => {
51
+ // Broadcast raw PTY output as binary to all connected browsers
52
+ for (const ws of sockets) {
53
+ try {
54
+ ws.sendBinary(data);
55
+ } catch {
56
+ // socket may have closed
57
+ }
58
+ }
59
+ },
60
+ exit: () => {
61
+ // PTY stream closed — get exit code from proc
62
+ const inst = this.instance;
63
+ if (inst && inst.id === id) {
64
+ const exitMsg = JSON.stringify({ type: "exit", exitCode: proc.exitCode ?? 0 });
65
+ for (const ws of inst.browserSockets) {
66
+ try {
67
+ ws.send(exitMsg);
68
+ } catch {
69
+ // socket may have closed
70
+ }
71
+ }
72
+ }
73
+ },
74
+ },
75
+ });
76
+
77
+ // Extract the terminal handle from the proc — Bun attaches it when spawned with `terminal` option
78
+ const terminal = (proc as any).terminal as BunTerminalHandle;
79
+ this.instance = { id, cwd, proc, terminal, browserSockets: sockets, cols, rows, orphanTimer: null };
80
+ console.log(`[terminal] Spawned terminal ${id} in ${cwd} (${shell}, ${cols}x${rows})`);
81
+
82
+ // Handle process exit
83
+ proc.exited.then((exitCode) => {
84
+ if (this.instance?.id === id) {
85
+ console.log(`[terminal] Terminal ${id} exited with code ${exitCode}`);
86
+ }
87
+ });
88
+
89
+ return id;
90
+ }
91
+
92
+ /** Handle a message from a browser WebSocket */
93
+ handleBrowserMessage(_ws: ServerWebSocket<SocketData>, msg: string | Buffer): void {
94
+ if (!this.instance) return;
95
+ try {
96
+ const str = typeof msg === "string" ? msg : msg.toString();
97
+ const parsed = JSON.parse(str);
98
+ if (parsed.type === "input" && typeof parsed.data === "string") {
99
+ this.instance.terminal.write(parsed.data);
100
+ } else if (parsed.type === "resize" && typeof parsed.cols === "number" && typeof parsed.rows === "number") {
101
+ this.resize(parsed.cols, parsed.rows);
102
+ }
103
+ } catch {
104
+ // Malformed message, ignore
105
+ }
106
+ }
107
+
108
+ /** Resize the PTY */
109
+ resize(cols: number, rows: number): void {
110
+ if (!this.instance) return;
111
+ this.instance.cols = cols;
112
+ this.instance.rows = rows;
113
+ try {
114
+ this.instance.terminal.resize(cols, rows);
115
+ } catch {
116
+ // resize not available or failed
117
+ }
118
+ }
119
+
120
+ /** Kill the terminal process and clean up */
121
+ kill(): void {
122
+ if (!this.instance) return;
123
+ const inst = this.instance;
124
+ this.instance = null;
125
+
126
+ if (inst.orphanTimer) {
127
+ clearTimeout(inst.orphanTimer);
128
+ }
129
+
130
+ try {
131
+ inst.proc.kill();
132
+ } catch {
133
+ // process may have already exited
134
+ }
135
+
136
+ // SIGKILL fallback if SIGTERM doesn't work within 2 seconds
137
+ const pid = inst.proc.pid;
138
+ setTimeout(() => {
139
+ try {
140
+ process.kill(pid, 0); // check if still alive
141
+ inst.proc.kill(9); // SIGKILL
142
+ } catch {
143
+ // already dead, good
144
+ }
145
+ }, 2_000);
146
+
147
+ console.log(`[terminal] Killed terminal ${inst.id}`);
148
+ }
149
+
150
+ /** Get current terminal info */
151
+ getInfo(): { id: string; cwd: string } | null {
152
+ if (!this.instance) return null;
153
+ return { id: this.instance.id, cwd: this.instance.cwd };
154
+ }
155
+
156
+ /** Attach a browser WebSocket to the terminal */
157
+ addBrowserSocket(ws: ServerWebSocket<SocketData>): void {
158
+ if (!this.instance) return;
159
+
160
+ // Cancel orphan kill timer if any
161
+ if (this.instance.orphanTimer) {
162
+ clearTimeout(this.instance.orphanTimer);
163
+ this.instance.orphanTimer = null;
164
+ }
165
+
166
+ this.instance.browserSockets.add(ws);
167
+ }
168
+
169
+ /** Remove a browser WebSocket from the terminal */
170
+ removeBrowserSocket(ws: ServerWebSocket<SocketData>): void {
171
+ if (!this.instance) return;
172
+ this.instance.browserSockets.delete(ws);
173
+
174
+ // If no browsers remain, start a grace timer to kill the orphaned terminal
175
+ if (this.instance.browserSockets.size === 0) {
176
+ const id = this.instance.id;
177
+ this.instance.orphanTimer = setTimeout(() => {
178
+ if (this.instance?.id === id && this.instance.browserSockets.size === 0) {
179
+ console.log(`[terminal] No browsers connected, killing orphaned terminal ${id}`);
180
+ this.kill();
181
+ }
182
+ }, 5_000);
183
+ }
184
+ }
185
+ }
@@ -0,0 +1,114 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+
7
+ // Read current version from package.json
8
+ const packageJsonPath = resolve(__dirname, "..", "package.json");
9
+ const currentVersion: string = JSON.parse(
10
+ readFileSync(packageJsonPath, "utf-8"),
11
+ ).version;
12
+
13
+ const NPM_REGISTRY_URL = "https://registry.npmjs.org/the-campfire/latest";
14
+ const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
15
+ const INITIAL_DELAY_MS = 10_000; // 10 seconds after boot
16
+
17
+ interface UpdateState {
18
+ currentVersion: string;
19
+ latestVersion: string | null;
20
+ lastChecked: number;
21
+ isServiceMode: boolean;
22
+ checking: boolean;
23
+ updateInProgress: boolean;
24
+ }
25
+
26
+ const state: UpdateState = {
27
+ currentVersion,
28
+ latestVersion: null,
29
+ lastChecked: 0,
30
+ isServiceMode: false,
31
+ checking: false,
32
+ updateInProgress: false,
33
+ };
34
+
35
+ export function getUpdateState(): Readonly<UpdateState> {
36
+ return { ...state };
37
+ }
38
+
39
+ export function getCurrentVersion(): string {
40
+ return currentVersion;
41
+ }
42
+
43
+ export async function checkForUpdate(): Promise<void> {
44
+ if (state.checking) return;
45
+ state.checking = true;
46
+ try {
47
+ const res = await fetch(NPM_REGISTRY_URL, {
48
+ headers: { Accept: "application/json" },
49
+ signal: AbortSignal.timeout(10_000),
50
+ });
51
+ if (res.ok) {
52
+ const data = (await res.json()) as { version: string };
53
+ state.latestVersion = data.version;
54
+ state.lastChecked = Date.now();
55
+ if (isUpdateAvailable()) {
56
+ console.log(
57
+ `[update-checker] Update available: ${currentVersion} -> ${state.latestVersion}`,
58
+ );
59
+ }
60
+ }
61
+ } catch (err) {
62
+ console.warn(
63
+ "[update-checker] Failed to check for updates:",
64
+ err instanceof Error ? err.message : String(err),
65
+ );
66
+ } finally {
67
+ state.checking = false;
68
+ }
69
+ }
70
+
71
+ export function setServiceMode(isService: boolean): void {
72
+ state.isServiceMode = isService;
73
+ }
74
+
75
+ export function setUpdateInProgress(inProgress: boolean): void {
76
+ state.updateInProgress = inProgress;
77
+ }
78
+
79
+ export function isUpdateAvailable(): boolean {
80
+ if (!state.latestVersion) return false;
81
+ return isNewerVersion(state.latestVersion, currentVersion);
82
+ }
83
+
84
+ /** Simple semver comparison: returns true if a > b */
85
+ export function isNewerVersion(a: string, b: string): boolean {
86
+ const pa = a.split(".").map(Number);
87
+ const pb = b.split(".").map(Number);
88
+ for (let i = 0; i < 3; i++) {
89
+ if ((pa[i] || 0) > (pb[i] || 0)) return true;
90
+ if ((pa[i] || 0) < (pb[i] || 0)) return false;
91
+ }
92
+ return false;
93
+ }
94
+
95
+ let intervalId: ReturnType<typeof setInterval> | null = null;
96
+
97
+ export function startPeriodicCheck(): void {
98
+ // Initial check after a short delay
99
+ setTimeout(() => {
100
+ checkForUpdate();
101
+ }, INITIAL_DELAY_MS);
102
+
103
+ // Periodic checks
104
+ intervalId = setInterval(() => {
105
+ checkForUpdate();
106
+ }, CHECK_INTERVAL_MS);
107
+ }
108
+
109
+ export function stopPeriodicCheck(): void {
110
+ if (intervalId) {
111
+ clearInterval(intervalId);
112
+ intervalId = null;
113
+ }
114
+ }