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.
- package/bin/cli.ts +182 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
- package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
- package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
- package/dist/assets/arc-D9SEA8dX.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
- package/dist/assets/channel-CxhcHn7S.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
- package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
- package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
- package/dist/assets/clone-C6kt1rE7.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
- package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
- package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
- package/dist/assets/graph-DKRbrxcs.js +1 -0
- package/dist/assets/index-Byjg9zgI.css +32 -0
- package/dist/assets/index-Cwj3m0hT.js +284 -0
- package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
- package/dist/assets/katex-BlHpptmG.js +261 -0
- package/dist/assets/layout-DnxSwLDJ.js +1 -0
- package/dist/assets/linear-CWbbIT8l.js +1 -0
- package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
- package/dist/assets/ordinal-BENe2yWM.js +1 -0
- package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
- package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
- package/dist/favicon.svg +3 -0
- package/dist/fonts/Geist-Variable.woff2 +0 -0
- package/dist/fonts/GeistMono-Variable.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +33 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo.svg +3 -0
- package/dist/manifest.json +26 -0
- package/dist/og-image.png +0 -0
- package/dist/sw.js +130 -0
- package/package.json +92 -0
- package/server/ADAPTERS.md +121 -0
- package/server/adapter-registry-types.ts +28 -0
- package/server/adapter-registry.ts +293 -0
- package/server/adapter-types.ts +51 -0
- package/server/agent-executor.ts +249 -0
- package/server/agent-mcp-bridge.ts +148 -0
- package/server/agent-mcp-stdio.ts +171 -0
- package/server/agent-mcp-tools.ts +76 -0
- package/server/agent-store.ts +183 -0
- package/server/agent-types.ts +79 -0
- package/server/aider-adapter.ts +525 -0
- package/server/auth.ts +153 -0
- package/server/auto-namer.ts +132 -0
- package/server/capability-discovery.ts +431 -0
- package/server/claude-container-auth.ts +40 -0
- package/server/claude-stdio-adapter.ts +452 -0
- package/server/clawhub-export.ts +218 -0
- package/server/cli-launcher.ts +1613 -0
- package/server/codex-adapter.ts +1987 -0
- package/server/codex-container-auth.ts +40 -0
- package/server/codex-home.ts +26 -0
- package/server/collective-intelligence.ts +401 -0
- package/server/commands-discovery.ts +160 -0
- package/server/constants.ts +5 -0
- package/server/container-manager.ts +328 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +149 -0
- package/server/cron-types.ts +63 -0
- package/server/deliberation-engine.ts +323 -0
- package/server/embedding.ts +89 -0
- package/server/env-manager.ts +146 -0
- package/server/environment-detector.ts +106 -0
- package/server/environment-rules.ts +106 -0
- package/server/gallery-store.ts +216 -0
- package/server/gallery-types.ts +52 -0
- package/server/gallery-votes.ts +101 -0
- package/server/git-utils.ts +384 -0
- package/server/github-pr.ts +379 -0
- package/server/goose-adapter.ts +929 -0
- package/server/image-pull-manager.ts +144 -0
- package/server/index.ts +363 -0
- package/server/linear-project-manager.ts +78 -0
- package/server/moltbook-client.ts +160 -0
- package/server/openclaw-adapter.ts +916 -0
- package/server/openclaw-channel/channel.ts +188 -0
- package/server/openclaw-channel/index.ts +68 -0
- package/server/openclaw-channel/package.json +19 -0
- package/server/openclaw-channel/types.ts +93 -0
- package/server/opencode-adapter.ts +951 -0
- package/server/openhands-adapter.ts +775 -0
- package/server/orchestrator-executor.ts +184 -0
- package/server/orchestrator-store.ts +106 -0
- package/server/orchestrator-types.ts +86 -0
- package/server/path-resolver.ts +168 -0
- package/server/pr-poller.ts +162 -0
- package/server/proactive-keepalive.ts +121 -0
- package/server/prompt-manager.ts +103 -0
- package/server/protocol-monitor.ts +197 -0
- package/server/race-controller.ts +295 -0
- package/server/race-store.ts +82 -0
- package/server/recorder.ts +359 -0
- package/server/recording-hub/compat-validator.ts +122 -0
- package/server/recording-hub/diagnostics.ts +289 -0
- package/server/recording-hub/hub-routes.ts +146 -0
- package/server/recording-hub/hub-store.ts +224 -0
- package/server/replay.ts +78 -0
- package/server/routes/adapter-routes.ts +31 -0
- package/server/routes/agent-mcp-routes.ts +35 -0
- package/server/routes/agent-routes.ts +185 -0
- package/server/routes/auth-routes.ts +101 -0
- package/server/routes/ci-routes.ts +165 -0
- package/server/routes/commands-routes.ts +94 -0
- package/server/routes/cron-routes.ts +100 -0
- package/server/routes/env-routes.ts +50 -0
- package/server/routes/folder-routes.ts +56 -0
- package/server/routes/fs-routes.ts +403 -0
- package/server/routes/gallery-routes.ts +410 -0
- package/server/routes/git-routes.ts +108 -0
- package/server/routes/index.ts +62 -0
- package/server/routes/linear-routes.ts +254 -0
- package/server/routes/monitor-routes.ts +11 -0
- package/server/routes/orchestrator-routes.ts +210 -0
- package/server/routes/prompt-routes.ts +47 -0
- package/server/routes/race-routes.ts +103 -0
- package/server/routes/recording-routes.ts +101 -0
- package/server/routes/route-deps.ts +32 -0
- package/server/routes/session-routes.ts +560 -0
- package/server/routes/settings-routes.ts +108 -0
- package/server/routes/skills-routes.ts +52 -0
- package/server/routes/system-routes.ts +243 -0
- package/server/routes/webhook-routes.ts +135 -0
- package/server/routes.ts +44 -0
- package/server/security-middleware.ts +100 -0
- package/server/semantic-memory.ts +389 -0
- package/server/service.ts +733 -0
- package/server/session-folders.ts +107 -0
- package/server/session-git-info.ts +89 -0
- package/server/session-linear-issues.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-store.ts +169 -0
- package/server/session-types.ts +415 -0
- package/server/settings-manager.ts +120 -0
- package/server/shared-context.ts +401 -0
- package/server/skills-manager.ts +250 -0
- package/server/sub-session-manager.ts +263 -0
- package/server/terminal-manager.ts +185 -0
- package/server/update-checker.ts +114 -0
- package/server/usage-limits.ts +192 -0
- package/server/webhook-manager.ts +228 -0
- package/server/webhook-store.ts +168 -0
- package/server/webhook-types.ts +66 -0
- package/server/worktree-tracker.ts +84 -0
- 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
|
+
}
|