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,184 @@
1
+ /**
2
+ * OrchestratorExecutor — Runs pipelines by sequentially creating sessions for each stage.
3
+ *
4
+ * Each stage:
5
+ * 1. Creates a new session via the REST API internal helper
6
+ * 2. Sends the stage prompt (optionally with previous stage context)
7
+ * 3. Waits for the session to complete (polls status)
8
+ * 4. Records the result and moves to the next stage
9
+ */
10
+
11
+ import { randomUUID } from "node:crypto";
12
+ import type { Pipeline, PipelineRun, StageResult } from "./orchestrator-types.js";
13
+ import { saveRun } from "./orchestrator-store.js";
14
+
15
+ interface SessionCreateFn {
16
+ (opts: {
17
+ cwd: string;
18
+ backend: string;
19
+ model?: string;
20
+ permissionMode?: string;
21
+ prompt?: string;
22
+ }): Promise<{ sessionId: string } | null>;
23
+ }
24
+
25
+ interface SessionStatusFn {
26
+ (sessionId: string): { status: string; cost?: number; lastMessage?: string } | null;
27
+ }
28
+
29
+ // Active runs so we can cancel them
30
+ const activeRuns = new Map<string, { cancelled: boolean }>();
31
+
32
+ export function cancelRun(runId: string): boolean {
33
+ const run = activeRuns.get(runId);
34
+ if (run) {
35
+ run.cancelled = true;
36
+ return true;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ export function isRunActive(runId: string): boolean {
42
+ return activeRuns.has(runId);
43
+ }
44
+
45
+ export async function executePipeline(
46
+ pipeline: Pipeline,
47
+ createSession: SessionCreateFn,
48
+ getSessionStatus: SessionStatusFn,
49
+ ): Promise<PipelineRun> {
50
+ const runId = randomUUID();
51
+ const run: PipelineRun = {
52
+ id: runId,
53
+ pipelineId: pipeline.id,
54
+ pipelineName: pipeline.name,
55
+ cwd: pipeline.cwd,
56
+ status: "running",
57
+ stageResults: pipeline.stages.map((s) => ({
58
+ stageId: s.id,
59
+ status: "pending",
60
+ })),
61
+ startedAt: Date.now(),
62
+ totalCostUsd: 0,
63
+ totalDurationMs: 0,
64
+ };
65
+
66
+ const control = { cancelled: false };
67
+ activeRuns.set(runId, control);
68
+ saveRun(run);
69
+
70
+ let previousOutput = "";
71
+
72
+ for (let i = 0; i < pipeline.stages.length; i++) {
73
+ if (control.cancelled) {
74
+ // Mark remaining stages as skipped
75
+ for (let j = i; j < pipeline.stages.length; j++) {
76
+ run.stageResults[j].status = "skipped";
77
+ }
78
+ run.status = "cancelled";
79
+ break;
80
+ }
81
+
82
+ const stage = pipeline.stages[i];
83
+ const result = run.stageResults[i];
84
+ result.status = "running";
85
+ result.startedAt = Date.now();
86
+ saveRun(run);
87
+
88
+ try {
89
+ // Build the prompt, optionally including previous stage output
90
+ let fullPrompt = stage.prompt;
91
+ if (stage.inheritContext && previousOutput) {
92
+ fullPrompt = `Context from previous stage:\n\n${previousOutput}\n\n---\n\n${stage.prompt}`;
93
+ }
94
+
95
+ // Create a session for this stage
96
+ const session = await createSession({
97
+ cwd: pipeline.cwd,
98
+ backend: stage.backend,
99
+ model: stage.model,
100
+ permissionMode: stage.permissionMode || "bypassPermissions",
101
+ prompt: fullPrompt,
102
+ });
103
+
104
+ if (!session) {
105
+ result.status = "failed";
106
+ result.error = "Failed to create session";
107
+ result.completedAt = Date.now();
108
+ result.durationMs = result.completedAt - (result.startedAt || result.completedAt);
109
+ run.status = "failed";
110
+ saveRun(run);
111
+ break;
112
+ }
113
+
114
+ result.sessionId = session.sessionId;
115
+ saveRun(run);
116
+
117
+ // Poll for session completion (max 30 minutes per stage)
118
+ const maxWait = 30 * 60 * 1000;
119
+ const pollInterval = 3000;
120
+ const deadline = Date.now() + maxWait;
121
+
122
+ while (Date.now() < deadline && !control.cancelled) {
123
+ await sleep(pollInterval);
124
+
125
+ const status = getSessionStatus(session.sessionId);
126
+ if (!status) continue;
127
+
128
+ if (status.status === "idle" || status.status === "completed") {
129
+ result.status = "completed";
130
+ result.costUsd = status.cost || 0;
131
+ result.outputSummary = status.lastMessage?.slice(0, 500) || "";
132
+ previousOutput = result.outputSummary;
133
+ break;
134
+ }
135
+
136
+ if (status.status === "error" || status.status === "failed") {
137
+ result.status = "failed";
138
+ result.error = status.lastMessage || "Session failed";
139
+ break;
140
+ }
141
+ }
142
+
143
+ if (result.status === "running") {
144
+ // Timed out or cancelled
145
+ result.status = control.cancelled ? "skipped" : "failed";
146
+ result.error = control.cancelled ? "Cancelled" : "Timed out (30 min)";
147
+ }
148
+ } catch (err) {
149
+ result.status = "failed";
150
+ result.error = err instanceof Error ? err.message : String(err);
151
+ }
152
+
153
+ result.completedAt = Date.now();
154
+ result.durationMs = result.completedAt - (result.startedAt || result.completedAt);
155
+ run.totalCostUsd += result.costUsd || 0;
156
+
157
+ if (result.status === "failed") {
158
+ // Mark remaining stages as skipped
159
+ for (let j = i + 1; j < pipeline.stages.length; j++) {
160
+ run.stageResults[j].status = "skipped";
161
+ }
162
+ run.status = "failed";
163
+ saveRun(run);
164
+ break;
165
+ }
166
+
167
+ saveRun(run);
168
+ }
169
+
170
+ if (run.status === "running") {
171
+ run.status = "completed";
172
+ }
173
+
174
+ run.completedAt = Date.now();
175
+ run.totalDurationMs = run.completedAt - run.startedAt;
176
+ activeRuns.delete(runId);
177
+ saveRun(run);
178
+
179
+ return run;
180
+ }
181
+
182
+ function sleep(ms: number): Promise<void> {
183
+ return new Promise((resolve) => setTimeout(resolve, ms));
184
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * OrchestratorStore — File-based persistence for pipelines and runs.
3
+ */
4
+
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import type { Pipeline, PipelineRun } from "./orchestrator-types.js";
9
+
10
+ const STORE_DIR = join(homedir(), ".campfire", "orchestrator");
11
+ const PIPELINES_DIR = join(STORE_DIR, "pipelines");
12
+ const RUNS_DIR = join(STORE_DIR, "runs");
13
+
14
+ function ensureDirs(): void {
15
+ mkdirSync(PIPELINES_DIR, { recursive: true });
16
+ mkdirSync(RUNS_DIR, { recursive: true });
17
+ }
18
+
19
+ // ─── Pipelines ─────────────────────────────────────────────────────────────
20
+
21
+ export function listPipelines(): Pipeline[] {
22
+ ensureDirs();
23
+ try {
24
+ const files = readdirSync(PIPELINES_DIR).filter((f) => f.endsWith(".json"));
25
+ return files
26
+ .map((f) => {
27
+ try {
28
+ return JSON.parse(readFileSync(join(PIPELINES_DIR, f), "utf-8")) as Pipeline;
29
+ } catch {
30
+ return null;
31
+ }
32
+ })
33
+ .filter((p): p is Pipeline => p !== null)
34
+ .sort((a, b) => b.updatedAt - a.updatedAt);
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ export function getPipeline(id: string): Pipeline | null {
41
+ ensureDirs();
42
+ try {
43
+ return JSON.parse(readFileSync(join(PIPELINES_DIR, `${id}.json`), "utf-8")) as Pipeline;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export function savePipeline(pipeline: Pipeline): void {
50
+ ensureDirs();
51
+ writeFileSync(join(PIPELINES_DIR, `${pipeline.id}.json`), JSON.stringify(pipeline, null, 2));
52
+ }
53
+
54
+ export function deletePipeline(id: string): boolean {
55
+ try {
56
+ unlinkSync(join(PIPELINES_DIR, `${id}.json`));
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ // ─── Runs ──────────────────────────────────────────────────────────────────
64
+
65
+ export function listRuns(pipelineId?: string): PipelineRun[] {
66
+ ensureDirs();
67
+ try {
68
+ const files = readdirSync(RUNS_DIR).filter((f) => f.endsWith(".json"));
69
+ return files
70
+ .map((f) => {
71
+ try {
72
+ return JSON.parse(readFileSync(join(RUNS_DIR, f), "utf-8")) as PipelineRun;
73
+ } catch {
74
+ return null;
75
+ }
76
+ })
77
+ .filter((r): r is PipelineRun => r !== null)
78
+ .filter((r) => !pipelineId || r.pipelineId === pipelineId)
79
+ .sort((a, b) => b.startedAt - a.startedAt);
80
+ } catch {
81
+ return [];
82
+ }
83
+ }
84
+
85
+ export function getRun(id: string): PipelineRun | null {
86
+ ensureDirs();
87
+ try {
88
+ return JSON.parse(readFileSync(join(RUNS_DIR, `${id}.json`), "utf-8")) as PipelineRun;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ export function saveRun(run: PipelineRun): void {
95
+ ensureDirs();
96
+ writeFileSync(join(RUNS_DIR, `${run.id}.json`), JSON.stringify(run, null, 2));
97
+ }
98
+
99
+ export function deleteRun(id: string): boolean {
100
+ try {
101
+ unlinkSync(join(RUNS_DIR, `${id}.json`));
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Orchestrator Types — Multi-stage pipeline engine for chaining AI sessions.
3
+ *
4
+ * A Pipeline defines an ordered sequence of Stages (e.g., implement → test → review).
5
+ * Each stage spawns a child session and passes context from the previous stage.
6
+ */
7
+
8
+ export interface PipelineStage {
9
+ /** Unique stage ID (uuid) */
10
+ id: string;
11
+ /** Display name, e.g. "Implement feature" */
12
+ name: string;
13
+ /** Prompt to send to the agent for this stage */
14
+ prompt: string;
15
+ /** Backend to use: "claude" | "codex" | "goose" | "aider" */
16
+ backend: string;
17
+ /** Model override (optional) */
18
+ model?: string;
19
+ /** Permission mode for this stage */
20
+ permissionMode?: string;
21
+ /** Whether to pass the previous stage's output as context */
22
+ inheritContext?: boolean;
23
+ }
24
+
25
+ export interface Pipeline {
26
+ /** Unique pipeline ID (uuid) */
27
+ id: string;
28
+ /** Display name */
29
+ name: string;
30
+ /** Description of what this pipeline does */
31
+ description?: string;
32
+ /** Working directory */
33
+ cwd: string;
34
+ /** Ordered list of stages */
35
+ stages: PipelineStage[];
36
+ /** Created timestamp (ms) */
37
+ createdAt: number;
38
+ /** Last modified timestamp (ms) */
39
+ updatedAt: number;
40
+ }
41
+
42
+ export type StageStatus = "pending" | "running" | "completed" | "failed" | "skipped";
43
+
44
+ export interface StageResult {
45
+ stageId: string;
46
+ status: StageStatus;
47
+ /** Child session ID created for this stage */
48
+ sessionId?: string;
49
+ /** Stage start time (ms) */
50
+ startedAt?: number;
51
+ /** Stage end time (ms) */
52
+ completedAt?: number;
53
+ /** Duration in ms */
54
+ durationMs?: number;
55
+ /** Cost for this stage */
56
+ costUsd?: number;
57
+ /** Error message if failed */
58
+ error?: string;
59
+ /** Summary output from the session (last assistant message) */
60
+ outputSummary?: string;
61
+ }
62
+
63
+ export type PipelineRunStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
64
+
65
+ export interface PipelineRun {
66
+ /** Unique run ID */
67
+ id: string;
68
+ /** Pipeline ID */
69
+ pipelineId: string;
70
+ /** Pipeline name snapshot */
71
+ pipelineName: string;
72
+ /** Working directory */
73
+ cwd: string;
74
+ /** Overall run status */
75
+ status: PipelineRunStatus;
76
+ /** Per-stage results */
77
+ stageResults: StageResult[];
78
+ /** Run start time */
79
+ startedAt: number;
80
+ /** Run end time */
81
+ completedAt?: number;
82
+ /** Total cost across all stages */
83
+ totalCostUsd: number;
84
+ /** Total duration */
85
+ totalDurationMs: number;
86
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * PATH discovery and binary resolution for service environments.
3
+ *
4
+ * When Campfire runs as a macOS launchd or Linux systemd service, it inherits
5
+ * a restricted PATH that omits directories from version managers (nvm, fnm, volta,
6
+ * mise, etc.) and user-local installs (~/.local/bin, ~/.cargo/bin). This module
7
+ * captures the user's real shell PATH at runtime and provides binary resolution
8
+ * that works regardless of how the server was started.
9
+ */
10
+
11
+ import { execSync } from "node:child_process";
12
+ import { existsSync, readdirSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ /**
17
+ * Capture the user's full interactive shell PATH by spawning a login shell.
18
+ * This picks up all version manager initializations (nvm, fnm, volta, mise, etc.).
19
+ * Falls back to probing common directories if shell sourcing fails.
20
+ */
21
+ export function captureUserShellPath(): string {
22
+ try {
23
+ const shell = process.env.SHELL || "/bin/bash";
24
+ const captured = execSync(
25
+ `${shell} -lic 'echo "___PATH_START___$PATH___PATH_END___"'`,
26
+ {
27
+ encoding: "utf-8",
28
+ timeout: 10_000,
29
+ env: { HOME: homedir(), USER: process.env.USER, SHELL: shell },
30
+ },
31
+ );
32
+ const match = captured.match(/___PATH_START___(.+)___PATH_END___/);
33
+ if (match?.[1]) {
34
+ return match[1];
35
+ }
36
+ } catch {
37
+ // Shell sourcing failed (timeout, compinit prompt, etc.)
38
+ }
39
+
40
+ return buildFallbackPath();
41
+ }
42
+
43
+ /**
44
+ * Build a PATH by probing common binary installation directories.
45
+ * Used as fallback when shell-sourcing fails.
46
+ */
47
+ export function buildFallbackPath(): string {
48
+ const home = homedir();
49
+ const candidates = [
50
+ // Standard system paths
51
+ "/opt/homebrew/bin",
52
+ "/opt/homebrew/sbin",
53
+ "/usr/local/bin",
54
+ "/usr/bin",
55
+ "/bin",
56
+ "/usr/sbin",
57
+ "/sbin",
58
+ // Bun
59
+ join(home, ".bun", "bin"),
60
+ // Claude CLI / user-local installs
61
+ join(home, ".local", "bin"),
62
+ // Cargo / Rust
63
+ join(home, ".cargo", "bin"),
64
+ // Volta (Node version manager)
65
+ join(home, ".volta", "bin"),
66
+ // mise (formerly rtx)
67
+ join(home, ".local", "share", "mise", "shims"),
68
+ // pyenv
69
+ join(home, ".pyenv", "bin"),
70
+ join(home, ".pyenv", "shims"),
71
+ // Go
72
+ join(home, "go", "bin"),
73
+ "/usr/local/go/bin",
74
+ // Deno
75
+ join(home, ".deno", "bin"),
76
+ ];
77
+
78
+ // Probe nvm-managed node versions
79
+ const nvmDir = process.env.NVM_DIR || join(home, ".nvm");
80
+ const nvmVersionsDir = join(nvmDir, "versions", "node");
81
+ if (existsSync(nvmVersionsDir)) {
82
+ try {
83
+ for (const v of readdirSync(nvmVersionsDir)) {
84
+ candidates.push(join(nvmVersionsDir, v, "bin"));
85
+ }
86
+ } catch { /* ignore */ }
87
+ }
88
+
89
+ // fnm (Fast Node Manager) — versions stored in fnm multishell or XDG data
90
+ const fnmDir = join(home, "Library", "Application Support", "fnm", "node-versions");
91
+ if (existsSync(fnmDir)) {
92
+ try {
93
+ for (const v of readdirSync(fnmDir)) {
94
+ candidates.push(join(fnmDir, v, "installation", "bin"));
95
+ }
96
+ } catch { /* ignore */ }
97
+ }
98
+
99
+ return [...new Set(candidates.filter((dir) => existsSync(dir)))].join(":");
100
+ }
101
+
102
+ // ─── Enriched PATH (cached) ───────────────────────────────────────────────────
103
+
104
+ let _cachedPath: string | null = null;
105
+
106
+ /**
107
+ * Returns an enriched PATH that merges the user's shell PATH (or probed common
108
+ * directories) with the current process PATH. Deduplicates entries.
109
+ * Result is cached after the first call.
110
+ */
111
+ export function getEnrichedPath(): string {
112
+ if (_cachedPath) return _cachedPath;
113
+
114
+ const currentPath = process.env.PATH || "";
115
+ const userPath = captureUserShellPath();
116
+
117
+ // Merge: user shell PATH first (takes precedence), then current process PATH
118
+ const allDirs = [...userPath.split(":"), ...currentPath.split(":")];
119
+ const seen = new Set<string>();
120
+ const deduped: string[] = [];
121
+ for (const dir of allDirs) {
122
+ if (dir && !seen.has(dir)) {
123
+ seen.add(dir);
124
+ deduped.push(dir);
125
+ }
126
+ }
127
+
128
+ _cachedPath = deduped.join(":");
129
+ return _cachedPath;
130
+ }
131
+
132
+ /** Reset the cached PATH (for testing). */
133
+ export function _resetPathCache(): void {
134
+ _cachedPath = null;
135
+ }
136
+
137
+ // ─── Binary resolution ────────────────────────────────────────────────────────
138
+
139
+ /**
140
+ * Resolve a binary name to an absolute path using the enriched PATH.
141
+ * Returns null if the binary is not found anywhere.
142
+ */
143
+ export function resolveBinary(name: string): string | null {
144
+ if (name.startsWith("/")) {
145
+ return existsSync(name) ? name : null;
146
+ }
147
+
148
+ const enrichedPath = getEnrichedPath();
149
+ try {
150
+ const resolved = execSync(`which ${name.replace(/[^a-zA-Z0-9._@/-]/g, "")}`, {
151
+
152
+ encoding: "utf-8",
153
+ timeout: 5_000,
154
+ env: { ...process.env, PATH: enrichedPath },
155
+ }).trim();
156
+ return resolved || null;
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Returns a PATH string suitable for embedding in service definitions
164
+ * (plist/systemd unit). Captures the user's shell PATH at install time.
165
+ */
166
+ export function getServicePath(): string {
167
+ return getEnrichedPath();
168
+ }
@@ -0,0 +1,162 @@
1
+ import { fetchPRInfoAsync, computeAdaptiveTTL, type GitHubPRInfo } from "./github-pr.js";
2
+ import type { WsBridge } from "./ws-bridge.js";
3
+
4
+ // ─── Types ───────────────────────────────────────────────────────────────────
5
+
6
+ interface WatchedPR {
7
+ cwd: string;
8
+ branch: string;
9
+ /** Sessions interested in this PR (same cwd:branch may be shared) */
10
+ sessionIds: Set<string>;
11
+ lastData: GitHubPRInfo | null;
12
+ timer: ReturnType<typeof setTimeout> | null;
13
+ lastFetchTime: number;
14
+ currentInterval: number;
15
+ fetching: boolean;
16
+ }
17
+
18
+ // ─── PR Poller ───────────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Server-side poller that fetches GitHub PR status at adaptive intervals
22
+ * and pushes updates to browsers via WebSocket.
23
+ *
24
+ * One timer per unique cwd:branch — shared across sessions on the same branch.
25
+ */
26
+ export class PRPoller {
27
+ private watched = new Map<string, WatchedPR>();
28
+ private wsBridge: WsBridge;
29
+ /** Reverse index: sessionId → cwd:branch key (a session can only watch one PR at a time) */
30
+ private sessionToKey = new Map<string, string>();
31
+
32
+ constructor(wsBridge: WsBridge) {
33
+ this.wsBridge = wsBridge;
34
+ }
35
+
36
+ /**
37
+ * Start watching a PR for a session.
38
+ * Returns cached data immediately if available.
39
+ * Triggers an async fetch if cache is stale or missing.
40
+ */
41
+ watch(sessionId: string, cwd: string, branch: string): GitHubPRInfo | null {
42
+ const key = `${cwd}:${branch}`;
43
+
44
+ // If this session was watching a different PR, unregister from the old one
45
+ const prevKey = this.sessionToKey.get(sessionId);
46
+ if (prevKey && prevKey !== key) {
47
+ this.unwatchKey(sessionId, prevKey);
48
+ }
49
+ this.sessionToKey.set(sessionId, key);
50
+
51
+ const existing = this.watched.get(key);
52
+ if (existing) {
53
+ existing.sessionIds.add(sessionId);
54
+ // If cache is stale, trigger a refresh
55
+ if (Date.now() - existing.lastFetchTime > existing.currentInterval) {
56
+ this.fetchAndBroadcast(key);
57
+ }
58
+ return existing.lastData;
59
+ }
60
+
61
+ // New watch — create entry and fetch immediately
62
+ const entry: WatchedPR = {
63
+ cwd,
64
+ branch,
65
+ sessionIds: new Set([sessionId]),
66
+ lastData: null,
67
+ timer: null,
68
+ lastFetchTime: 0,
69
+ currentInterval: 10_000, // start aggressive for fast initial load
70
+ fetching: false,
71
+ };
72
+ this.watched.set(key, entry);
73
+ this.fetchAndBroadcast(key);
74
+
75
+ return null;
76
+ }
77
+
78
+ /** Stop watching for a specific session. */
79
+ unwatch(sessionId: string): void {
80
+ const key = this.sessionToKey.get(sessionId);
81
+ if (!key) return;
82
+ this.sessionToKey.delete(sessionId);
83
+ this.unwatchKey(sessionId, key);
84
+ }
85
+
86
+ /** Get current cached data for a cwd:branch pair (for REST fallback). */
87
+ getCached(cwd: string, branch: string): { available: boolean; pr: GitHubPRInfo | null } | null {
88
+ const key = `${cwd}:${branch}`;
89
+ const entry = this.watched.get(key);
90
+ if (!entry) return null;
91
+ return { available: true, pr: entry.lastData };
92
+ }
93
+
94
+ /** Stop all timers (for testing / cleanup). */
95
+ destroy(): void {
96
+ for (const entry of this.watched.values()) {
97
+ if (entry.timer) clearTimeout(entry.timer);
98
+ }
99
+ this.watched.clear();
100
+ this.sessionToKey.clear();
101
+ }
102
+
103
+ // ─── Internal ──────────────────────────────────────────────────────────
104
+
105
+ private unwatchKey(sessionId: string, key: string): void {
106
+ const entry = this.watched.get(key);
107
+ if (!entry) return;
108
+ entry.sessionIds.delete(sessionId);
109
+ if (entry.sessionIds.size === 0) {
110
+ if (entry.timer) clearTimeout(entry.timer);
111
+ this.watched.delete(key);
112
+ }
113
+ }
114
+
115
+ private async fetchAndBroadcast(key: string): Promise<void> {
116
+ const entry = this.watched.get(key);
117
+ if (!entry) return;
118
+
119
+ // Prevent concurrent fetches for the same key
120
+ if (entry.fetching) return;
121
+ entry.fetching = true;
122
+
123
+ // Clear existing timer (will be rescheduled after fetch)
124
+ if (entry.timer) {
125
+ clearTimeout(entry.timer);
126
+ entry.timer = null;
127
+ }
128
+
129
+ try {
130
+ const prInfo = await fetchPRInfoAsync(entry.cwd, entry.branch);
131
+ // Re-check entry still exists (may have been unwatched during async fetch)
132
+ const current = this.watched.get(key);
133
+ if (!current) return;
134
+
135
+ current.lastData = prInfo;
136
+ current.lastFetchTime = Date.now();
137
+ current.currentInterval = computeAdaptiveTTL(prInfo);
138
+
139
+ // Push to all sessions watching this PR
140
+ for (const sessionId of current.sessionIds) {
141
+ this.wsBridge.broadcastToSession(sessionId, {
142
+ type: "pr_status_update",
143
+ pr: prInfo,
144
+ available: true,
145
+ });
146
+ }
147
+ } catch {
148
+ // On error, use a moderate interval
149
+ const current = this.watched.get(key);
150
+ if (current) {
151
+ current.currentInterval = 30_000;
152
+ }
153
+ } finally {
154
+ const current = this.watched.get(key);
155
+ if (current) {
156
+ current.fetching = false;
157
+ // Schedule next fetch
158
+ current.timer = setTimeout(() => this.fetchAndBroadcast(key), current.currentInterval);
159
+ }
160
+ }
161
+ }
162
+ }