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,328 @@
1
+ import { execSync, type ExecSyncOptionsWithStringEncoding } from "node:child_process";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Types
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export interface ContainerConfig {
8
+ /** Docker image to use (e.g. "campfire-dev", "node:22-slim") */
9
+ image: string;
10
+ /** Container ports to expose (e.g. [3000, 8080]) */
11
+ ports: number[];
12
+ /** Extra volume mounts in "host:container[:opts]" format */
13
+ volumes?: string[];
14
+ /** Extra env vars to inject into the container */
15
+ env?: Record<string, string>;
16
+ }
17
+
18
+ export interface PortMapping {
19
+ containerPort: number;
20
+ hostPort: number;
21
+ }
22
+
23
+ export interface ContainerInfo {
24
+ containerId: string;
25
+ name: string;
26
+ image: string;
27
+ portMappings: PortMapping[];
28
+ hostCwd: string;
29
+ containerCwd: string;
30
+ state: "creating" | "running" | "stopped" | "removed";
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const EXEC_OPTS: ExecSyncOptionsWithStringEncoding = {
38
+ encoding: "utf-8",
39
+ timeout: 30_000,
40
+ };
41
+
42
+ function exec(cmd: string, opts?: ExecSyncOptionsWithStringEncoding): string {
43
+ return execSync(cmd, { ...EXEC_OPTS, ...opts }).trim();
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // ContainerManager
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export class ContainerManager {
51
+ private containers = new Map<string, ContainerInfo>();
52
+
53
+ /** Check whether Docker daemon is reachable. */
54
+ checkDocker(): boolean {
55
+ try {
56
+ exec("docker info --format '{{.ServerVersion}}'");
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ /** Return Docker version string, or null if unavailable. */
64
+ getDockerVersion(): string | null {
65
+ try {
66
+ return exec("docker version --format '{{.Server.Version}}'");
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /** List images available locally. Returns image:tag strings. */
73
+ listImages(): string[] {
74
+ try {
75
+ const raw = exec("docker images --format '{{.Repository}}:{{.Tag}}'");
76
+ if (!raw) return [];
77
+ return raw
78
+ .split("\n")
79
+ .filter((l) => l && !l.startsWith("<none>"))
80
+ .sort();
81
+ } catch {
82
+ return [];
83
+ }
84
+ }
85
+
86
+ /** Check if a specific image exists locally. */
87
+ imageExists(image: string): boolean {
88
+ try {
89
+ exec(`docker image inspect ${shellEscape(image)}`);
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Create and start a container for a session.
98
+ *
99
+ * - Mounts `~/.claude` read-only (auth)
100
+ * - Mounts `hostCwd` at `/workspace`
101
+ * - Publishes requested ports with auto-assigned host ports (`-p 0:PORT`)
102
+ */
103
+ createContainer(
104
+ sessionId: string,
105
+ hostCwd: string,
106
+ config: ContainerConfig,
107
+ ): ContainerInfo {
108
+ const name = `campfire-${sessionId.slice(0, 8)}`;
109
+ const homedir = process.env.HOME || process.env.USERPROFILE || "/root";
110
+
111
+ // Validate port numbers
112
+ for (const port of config.ports) {
113
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
114
+ throw new Error(`Invalid port number: ${port} (must be 1-65535)`);
115
+ }
116
+ }
117
+
118
+ // Build docker create args
119
+ const args: string[] = [
120
+ "docker", "create",
121
+ "--name", name,
122
+ // Ensure host.docker.internal resolves (automatic on Mac/Win Docker
123
+ // Desktop, but required explicitly on Linux)
124
+ "--add-host=host.docker.internal:host-gateway",
125
+ // Note: auth is seeded via `docker cp` (see claude-container-auth.ts /
126
+ // codex-container-auth.ts) rather than a bind mount, so the container
127
+ // gets a writable snapshot and cannot mutate the host's ~/.claude.
128
+ "-v", `${hostCwd}:/workspace`,
129
+ "-w", "/workspace",
130
+ ];
131
+
132
+ // Port mappings: -p 0:{containerPort}
133
+ for (const port of config.ports) {
134
+ args.push("-p", `0:${port}`);
135
+ }
136
+
137
+ // Extra volumes
138
+ if (config.volumes) {
139
+ for (const vol of config.volumes) {
140
+ args.push("-v", vol);
141
+ }
142
+ }
143
+
144
+ // Environment variables
145
+ if (config.env) {
146
+ for (const [k, v] of Object.entries(config.env)) {
147
+ args.push("-e", `${k}=${v}`);
148
+ }
149
+ }
150
+
151
+ // Image + default command (keep container alive)
152
+ args.push(config.image, "sleep", "infinity");
153
+
154
+ const info: ContainerInfo = {
155
+ containerId: "",
156
+ name,
157
+ image: config.image,
158
+ portMappings: [],
159
+ hostCwd,
160
+ containerCwd: "/workspace",
161
+ state: "creating",
162
+ };
163
+
164
+ try {
165
+ // Create
166
+ const containerId = exec(args.map(shellEscape).join(" "));
167
+ info.containerId = containerId;
168
+
169
+ // Start
170
+ exec(`docker start ${shellEscape(containerId)}`);
171
+ info.state = "running";
172
+
173
+ // Resolve actual port mappings
174
+ info.portMappings = this.resolvePortMappings(containerId, config.ports);
175
+
176
+ this.containers.set(sessionId, info);
177
+ console.log(
178
+ `[container-manager] Created container ${name} (${containerId.slice(0, 12)}) ` +
179
+ `ports: ${info.portMappings.map((p) => `${p.containerPort}->${p.hostPort}`).join(", ")}`,
180
+ );
181
+
182
+ return info;
183
+ } catch (e) {
184
+ // Cleanup partial creation
185
+ try { exec(`docker rm -f ${shellEscape(name)}`); } catch { /* ignore */ }
186
+ info.state = "removed";
187
+ throw new Error(
188
+ `Failed to create container: ${e instanceof Error ? e.message : String(e)}`,
189
+ );
190
+ }
191
+ }
192
+
193
+ /** Parse `docker port` output to get host port mappings. */
194
+ private resolvePortMappings(containerId: string, ports: number[]): PortMapping[] {
195
+ const mappings: PortMapping[] = [];
196
+ for (const containerPort of ports) {
197
+ try {
198
+ const raw = exec(
199
+ `docker port ${shellEscape(containerId)} ${containerPort}`,
200
+ );
201
+ // Output like "0.0.0.0:49152" or "[::]:49152"
202
+ const match = raw.match(/:(\d+)$/m);
203
+ if (match) {
204
+ mappings.push({
205
+ containerPort,
206
+ hostPort: parseInt(match[1], 10),
207
+ });
208
+ }
209
+ } catch {
210
+ console.warn(
211
+ `[container-manager] Could not resolve port ${containerPort} for ${containerId.slice(0, 12)}`,
212
+ );
213
+ }
214
+ }
215
+ return mappings;
216
+ }
217
+
218
+ /**
219
+ * Re-track a container under a new key (e.g. when the real sessionId
220
+ * is assigned after container creation).
221
+ */
222
+ retrack(containerId: string, newSessionId: string): void {
223
+ for (const [oldKey, info] of this.containers) {
224
+ if (info.containerId === containerId) {
225
+ this.containers.delete(oldKey);
226
+ this.containers.set(newSessionId, info);
227
+ return;
228
+ }
229
+ }
230
+ }
231
+
232
+ /** Stop and remove a container. */
233
+ removeContainer(sessionId: string): void {
234
+ const info = this.containers.get(sessionId);
235
+ if (!info) return;
236
+
237
+ try {
238
+ exec(`docker rm -f ${shellEscape(info.containerId)}`);
239
+ info.state = "removed";
240
+ console.log(
241
+ `[container-manager] Removed container ${info.name} (${info.containerId.slice(0, 12)})`,
242
+ );
243
+ } catch (e) {
244
+ console.warn(
245
+ `[container-manager] Failed to remove container ${info.name}:`,
246
+ e instanceof Error ? e.message : String(e),
247
+ );
248
+ }
249
+ this.containers.delete(sessionId);
250
+ }
251
+
252
+ /** Get container info for a session. */
253
+ getContainer(sessionId: string): ContainerInfo | undefined {
254
+ return this.containers.get(sessionId);
255
+ }
256
+
257
+ /** List all tracked containers. */
258
+ listContainers(): ContainerInfo[] {
259
+ return Array.from(this.containers.values());
260
+ }
261
+
262
+ /**
263
+ * Re-register a container that was persisted across a server restart.
264
+ * Verifies the container still exists in Docker before tracking it.
265
+ */
266
+ restoreContainer(sessionId: string, info: ContainerInfo): boolean {
267
+ try {
268
+ const state = exec(
269
+ `docker inspect --format '{{.State.Running}}' ${shellEscape(info.containerId)}`,
270
+ );
271
+ if (state === "true") {
272
+ info.state = "running";
273
+ } else {
274
+ info.state = "stopped";
275
+ }
276
+ this.containers.set(sessionId, info);
277
+ console.log(
278
+ `[container-manager] Restored container ${info.name} (${info.containerId.slice(0, 12)}) state=${info.state}`,
279
+ );
280
+ return true;
281
+ } catch {
282
+ // Container no longer exists in Docker
283
+ console.warn(
284
+ `[container-manager] Container ${info.name} (${info.containerId.slice(0, 12)}) no longer exists, skipping restore`,
285
+ );
286
+ return false;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Build the campfire-dev Docker image from the Dockerfile.dev.
292
+ * Returns the build output log. Throws on failure.
293
+ */
294
+ buildImage(dockerfilePath: string, tag: string = "campfire-dev:latest"): string {
295
+ const contextDir = dockerfilePath.replace(/\/[^/]+$/, "") || ".";
296
+ try {
297
+ const output = exec(
298
+ `docker build -t ${shellEscape(tag)} -f ${shellEscape(dockerfilePath)} ${shellEscape(contextDir)}`,
299
+ { encoding: "utf-8", timeout: 300_000 }, // 5 min for image builds
300
+ );
301
+ console.log(`[container-manager] Built image ${tag}`);
302
+ return output;
303
+ } catch (e) {
304
+ throw new Error(
305
+ `Failed to build image ${tag}: ${e instanceof Error ? e.message : String(e)}`,
306
+ );
307
+ }
308
+ }
309
+
310
+ /** Clean up all tracked containers (e.g. on server shutdown). */
311
+ cleanupAll(): void {
312
+ for (const [sessionId] of this.containers) {
313
+ this.removeContainer(sessionId);
314
+ }
315
+ }
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Shell escape helper
320
+ // ---------------------------------------------------------------------------
321
+
322
+ function shellEscape(s: string): string {
323
+ if (/^[a-zA-Z0-9._\-/:=@]+$/.test(s)) return s;
324
+ return `'${s.replace(/'/g, "'\\''")}'`;
325
+ }
326
+
327
+ // Singleton
328
+ export const containerManager = new ContainerManager();
@@ -0,0 +1,243 @@
1
+ import { Cron } from "croner";
2
+ import type { CronJob, CronJobExecution } from "./cron-types.js";
3
+ import type { CliLauncher, SdkSessionInfo } from "./cli-launcher.js";
4
+ import type { WsBridge } from "./ws-bridge.js";
5
+ import * as cronStore from "./cron-store.js";
6
+ import * as envManager from "./env-manager.js";
7
+ import * as sessionNames from "./session-names.js";
8
+
9
+ /** Max consecutive failures before auto-disabling a job */
10
+ const MAX_CONSECUTIVE_FAILURES = 5;
11
+ /** Max time to wait for CLI to connect (ms) */
12
+ const CLI_CONNECT_TIMEOUT_MS = 30_000;
13
+ /** Poll interval when waiting for CLI connection */
14
+ const CLI_CONNECT_POLL_MS = 500;
15
+
16
+ export class CronScheduler {
17
+ private timers = new Map<string, Cron>();
18
+ private launcher: CliLauncher;
19
+ private wsBridge: WsBridge;
20
+ /** In-memory execution history (last N per job) */
21
+ private executions = new Map<string, CronJobExecution[]>();
22
+ private static readonly MAX_EXECUTIONS_PER_JOB = 50;
23
+
24
+ constructor(launcher: CliLauncher, wsBridge: WsBridge) {
25
+ this.launcher = launcher;
26
+ this.wsBridge = wsBridge;
27
+ }
28
+
29
+ /** Start all enabled jobs from disk. Called once at server startup. */
30
+ startAll(): void {
31
+ const jobs = cronStore.listJobs();
32
+ let started = 0;
33
+ for (const job of jobs) {
34
+ if (job.enabled) {
35
+ this.scheduleJob(job);
36
+ started++;
37
+ }
38
+ }
39
+ if (started > 0) {
40
+ console.log(`[cron-scheduler] Started ${started} cron job(s)`);
41
+ }
42
+ }
43
+
44
+ /** Schedule (or reschedule) a single job. */
45
+ scheduleJob(job: CronJob): void {
46
+ this.stopJob(job.id);
47
+
48
+ if (!job.enabled) return;
49
+
50
+ try {
51
+ if (job.recurring) {
52
+ const cronTask = new Cron(job.schedule, {}, () => {
53
+ this.executeJob(job.id).catch((err) => {
54
+ console.error(`[cron-scheduler] Unhandled error in job "${job.name}":`, err);
55
+ });
56
+ });
57
+ this.timers.set(job.id, cronTask);
58
+ console.log(`[cron-scheduler] Scheduled "${job.name}" with cron "${job.schedule}"`);
59
+ } else {
60
+ // One-shot: schedule for the specified datetime
61
+ const targetTime = new Date(job.schedule);
62
+ if (targetTime.getTime() > Date.now()) {
63
+ const cronTask = new Cron(targetTime, () => {
64
+ this.executeJob(job.id)
65
+ .then(() => {
66
+ // Auto-disable after one-shot execution
67
+ cronStore.updateJob(job.id, { enabled: false });
68
+ this.timers.delete(job.id);
69
+ })
70
+ .catch((err) => {
71
+ console.error(`[cron-scheduler] Unhandled error in one-shot job "${job.name}":`, err);
72
+ });
73
+ });
74
+ this.timers.set(job.id, cronTask);
75
+ console.log(`[cron-scheduler] Scheduled one-shot "${job.name}" at ${targetTime.toISOString()}`);
76
+ } else {
77
+ console.log(`[cron-scheduler] Skipping one-shot "${job.name}" — target time is in the past`);
78
+ }
79
+ }
80
+ } catch (err) {
81
+ console.error(`[cron-scheduler] Failed to schedule "${job.name}":`, err);
82
+ }
83
+ }
84
+
85
+ /** Stop a job's timer. */
86
+ stopJob(jobId: string): void {
87
+ const timer = this.timers.get(jobId);
88
+ if (timer) {
89
+ timer.stop();
90
+ this.timers.delete(jobId);
91
+ }
92
+ }
93
+
94
+ /** Execute a job: create a session, send the prompt, track the result. */
95
+ async executeJob(jobId: string, opts?: { force?: boolean }): Promise<void> {
96
+ const job = cronStore.getJob(jobId);
97
+ if (!job) return;
98
+ if (!job.enabled && !opts?.force) return;
99
+
100
+ // Overlap prevention: skip if previous execution is still running
101
+ if (job.lastSessionId && this.launcher.isAlive(job.lastSessionId)) {
102
+ console.log(`[cron-scheduler] Skipping "${job.name}" — previous execution still running (${job.lastSessionId})`);
103
+ return;
104
+ }
105
+
106
+ console.log(`[cron-scheduler] Executing job "${job.name}" (${jobId})`);
107
+
108
+ const execution: CronJobExecution = {
109
+ sessionId: "",
110
+ jobId,
111
+ startedAt: Date.now(),
112
+ };
113
+
114
+ try {
115
+ // Resolve environment variables
116
+ let envVars: Record<string, string> | undefined;
117
+ if (job.envSlug) {
118
+ const env = envManager.getEnv(job.envSlug);
119
+ if (env) envVars = env.variables;
120
+ }
121
+
122
+ // Launch the session via CliLauncher
123
+ // For Codex, explicitly set sandbox and internet access for full autonomy
124
+ const sessionInfo = this.launcher.launch({
125
+ model: job.model,
126
+ permissionMode: job.permissionMode,
127
+ cwd: job.cwd,
128
+ env: envVars,
129
+ backendType: job.backendType,
130
+ codexInternetAccess: job.backendType === "codex" ? (job.codexInternetAccess ?? true) : undefined,
131
+ codexSandbox: job.backendType === "codex"
132
+ ? (job.permissionMode === "bypassPermissions" ? "danger-full-access" : "workspace-write")
133
+ : undefined,
134
+ });
135
+
136
+ execution.sessionId = sessionInfo.sessionId;
137
+
138
+ // Tag the session as cron-originated
139
+ sessionInfo.cronJobId = jobId;
140
+ sessionInfo.cronJobName = job.name;
141
+
142
+ // Set the session name
143
+ const runLabel = `⏰ ${job.name}`;
144
+ sessionNames.setName(sessionInfo.sessionId, runLabel);
145
+
146
+ // Wait for CLI to connect, then send the prompt
147
+ await this.waitForCLIConnection(sessionInfo.sessionId);
148
+
149
+ // Send the prompt with cron prefix for traceability
150
+ const fullPrompt = `[cron:${job.id} ${job.name}]\n\n${job.prompt}`;
151
+ this.wsBridge.injectUserMessage(sessionInfo.sessionId, fullPrompt);
152
+
153
+ // Update job tracking
154
+ cronStore.updateJob(jobId, {
155
+ lastRunAt: Date.now(),
156
+ lastSessionId: sessionInfo.sessionId,
157
+ totalRuns: job.totalRuns + 1,
158
+ consecutiveFailures: 0,
159
+ });
160
+
161
+ execution.success = true;
162
+ this.addExecution(jobId, execution);
163
+
164
+ } catch (err) {
165
+ console.error(`[cron-scheduler] Job "${job.name}" failed:`, err);
166
+ execution.error = err instanceof Error ? err.message : String(err);
167
+ execution.completedAt = Date.now();
168
+ this.addExecution(jobId, execution);
169
+
170
+ const failures = job.consecutiveFailures + 1;
171
+ const updates: Partial<CronJob> = {
172
+ consecutiveFailures: failures,
173
+ lastRunAt: Date.now(),
174
+ };
175
+
176
+ // Auto-disable after too many failures
177
+ if (failures >= MAX_CONSECUTIVE_FAILURES) {
178
+ updates.enabled = false;
179
+ this.stopJob(jobId);
180
+ console.warn(`[cron-scheduler] Job "${job.name}" disabled after ${failures} consecutive failures`);
181
+ }
182
+
183
+ cronStore.updateJob(jobId, updates);
184
+ }
185
+ }
186
+
187
+ /** Manual trigger (run now regardless of schedule, bypasses enabled check). */
188
+ executeJobManually(jobId: string): void {
189
+ this.executeJob(jobId, { force: true }).catch((err) => {
190
+ console.error(`[cron-scheduler] Manual execution of job "${jobId}" failed:`, err);
191
+ });
192
+ }
193
+
194
+ /** Wait for CLI to be connected (poll up to timeout). */
195
+ private async waitForCLIConnection(sessionId: string): Promise<void> {
196
+ const start = Date.now();
197
+
198
+ while (Date.now() - start < CLI_CONNECT_TIMEOUT_MS) {
199
+ const info = this.launcher.getSession(sessionId);
200
+ if (info && (info.state === "connected" || info.state === "running")) {
201
+ return;
202
+ }
203
+ if (info?.state === "exited") {
204
+ throw new Error(`CLI process exited before connecting (exit code: ${info.exitCode})`);
205
+ }
206
+ await new Promise((r) => setTimeout(r, CLI_CONNECT_POLL_MS));
207
+ }
208
+
209
+ throw new Error(`CLI process did not connect within ${CLI_CONNECT_TIMEOUT_MS / 1000}s`);
210
+ }
211
+
212
+ /** Get next run time for a job. */
213
+ getNextRunTime(jobId: string): Date | null {
214
+ const timer = this.timers.get(jobId);
215
+ if (!timer) return null;
216
+ return timer.nextRun() || null;
217
+ }
218
+
219
+ /** Get recent executions for a job. */
220
+ getExecutions(jobId: string): CronJobExecution[] {
221
+ return this.executions.get(jobId) || [];
222
+ }
223
+
224
+ private addExecution(jobId: string, execution: CronJobExecution): void {
225
+ if (!this.executions.has(jobId)) {
226
+ this.executions.set(jobId, []);
227
+ }
228
+ const list = this.executions.get(jobId)!;
229
+ list.push(execution);
230
+ if (list.length > CronScheduler.MAX_EXECUTIONS_PER_JOB) {
231
+ list.splice(0, list.length - CronScheduler.MAX_EXECUTIONS_PER_JOB);
232
+ }
233
+ }
234
+
235
+ /** Stop all timers (for graceful shutdown). */
236
+ destroy(): void {
237
+ for (const timer of this.timers.values()) {
238
+ timer.stop();
239
+ }
240
+ this.timers.clear();
241
+ this.executions.clear();
242
+ }
243
+ }