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,192 @@
1
+ import { execSync, execFileSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+
6
+ export interface UsageLimits {
7
+ five_hour: { utilization: number; resets_at: string | null } | null;
8
+ seven_day: { utilization: number; resets_at: string | null } | null;
9
+ extra_usage: {
10
+ is_enabled: boolean;
11
+ monthly_limit: number;
12
+ used_credits: number;
13
+ utilization: number | null;
14
+ } | null;
15
+ }
16
+
17
+ const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
18
+ const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
19
+
20
+ // In-memory cache (60s TTL)
21
+ const CACHE_DURATION_MS = 60 * 1000;
22
+ let cache: { data: UsageLimits; timestamp: number } | null = null;
23
+
24
+ interface OAuthCredentials {
25
+ accessToken: string;
26
+ refreshToken: string;
27
+ expiresAt: number;
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ function readRawCredentials(): { raw: string; parsed: Record<string, unknown>; oauth: OAuthCredentials } | null {
32
+ try {
33
+ if (process.platform === "win32") {
34
+ const home =
35
+ process.env.USERPROFILE || process.env.HOME || homedir() || "";
36
+ const credPath = join(home, ".claude", ".credentials.json");
37
+ if (!existsSync(credPath)) return null;
38
+ const raw = readFileSync(credPath, "utf-8");
39
+ const parsed = JSON.parse(raw);
40
+ if (!parsed?.claudeAiOauth?.accessToken) return null;
41
+ return { raw, parsed, oauth: parsed.claudeAiOauth };
42
+ }
43
+
44
+ const raw = execSync(
45
+ 'security find-generic-password -s "Claude Code-credentials" -w',
46
+ { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
47
+ ).trim();
48
+
49
+ const decoded = raw.startsWith("{")
50
+ ? raw
51
+ : Buffer.from(raw, "hex").toString("utf-8");
52
+
53
+ const parsed = JSON.parse(decoded);
54
+ if (!parsed?.claudeAiOauth?.accessToken) return null;
55
+ return { raw: decoded, parsed, oauth: parsed.claudeAiOauth };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ function writeCredentials(creds: Record<string, unknown>): void {
62
+ try {
63
+ const json = JSON.stringify(creds);
64
+ if (process.platform === "win32") {
65
+ const home =
66
+ process.env.USERPROFILE || process.env.HOME || homedir() || "";
67
+ const credPath = join(home, ".claude", ".credentials.json");
68
+ require("node:fs").writeFileSync(credPath, json, "utf-8");
69
+ } else {
70
+ execFileSync(
71
+ "security",
72
+ ["add-generic-password", "-U", "-s", "Claude Code-credentials", "-a", "Claude Code", "-w", json],
73
+ { timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
74
+ );
75
+ }
76
+ } catch {
77
+ // best-effort
78
+ }
79
+ }
80
+
81
+ async function refreshAccessToken(refreshToken: string): Promise<{
82
+ accessToken: string;
83
+ refreshToken: string;
84
+ expiresIn: number;
85
+ } | null> {
86
+ try {
87
+ const res = await fetch(OAUTH_TOKEN_URL, {
88
+ method: "POST",
89
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
90
+ body: new URLSearchParams({
91
+ grant_type: "refresh_token",
92
+ refresh_token: refreshToken,
93
+ client_id: OAUTH_CLIENT_ID,
94
+ }),
95
+ });
96
+ if (!res.ok) return null;
97
+ const data = await res.json();
98
+ return {
99
+ accessToken: data.access_token,
100
+ refreshToken: data.refresh_token,
101
+ expiresIn: data.expires_in,
102
+ };
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ export function getCredentials(): string | null {
109
+ const creds = readRawCredentials();
110
+ return creds?.oauth.accessToken ?? null;
111
+ }
112
+
113
+ async function getValidAccessToken(): Promise<string | null> {
114
+ const creds = readRawCredentials();
115
+ if (!creds) return null;
116
+
117
+ const { oauth } = creds;
118
+
119
+ // Token still valid (with 5min buffer)
120
+ if (oauth.expiresAt && Date.now() < oauth.expiresAt - 5 * 60 * 1000) {
121
+ return oauth.accessToken;
122
+ }
123
+
124
+ // Token expired — try to refresh
125
+ if (!oauth.refreshToken) return null;
126
+
127
+ const refreshed = await refreshAccessToken(oauth.refreshToken);
128
+ if (!refreshed) return null;
129
+
130
+ // Update stored credentials
131
+ creds.parsed.claudeAiOauth = {
132
+ ...oauth,
133
+ accessToken: refreshed.accessToken,
134
+ refreshToken: refreshed.refreshToken,
135
+ expiresAt: Date.now() + refreshed.expiresIn * 1000,
136
+ };
137
+ writeCredentials(creds.parsed);
138
+
139
+ return refreshed.accessToken;
140
+ }
141
+
142
+ export async function fetchUsageLimits(
143
+ token: string,
144
+ ): Promise<UsageLimits | null> {
145
+ try {
146
+ const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
147
+ method: "GET",
148
+ headers: {
149
+ Accept: "application/json",
150
+ "Content-Type": "application/json",
151
+ "User-Agent": "claude-code/2.1.39",
152
+ Authorization: `Bearer ${token}`,
153
+ "anthropic-beta": "oauth-2025-04-20",
154
+ },
155
+ });
156
+
157
+ if (!response.ok) return null;
158
+
159
+ const data = await response.json();
160
+ return {
161
+ five_hour: data.five_hour || null,
162
+ seven_day: data.seven_day || null,
163
+ extra_usage: data.extra_usage || null,
164
+ };
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ export async function getUsageLimits(): Promise<UsageLimits> {
171
+ const empty: UsageLimits = {
172
+ five_hour: null,
173
+ seven_day: null,
174
+ extra_usage: null,
175
+ };
176
+ try {
177
+ if (cache && Date.now() - cache.timestamp < CACHE_DURATION_MS) {
178
+ return cache.data;
179
+ }
180
+
181
+ const token = await getValidAccessToken();
182
+ if (!token) return empty;
183
+
184
+ const limits = await fetchUsageLimits(token);
185
+ if (!limits) return empty;
186
+
187
+ cache = { data: limits, timestamp: Date.now() };
188
+ return limits;
189
+ } catch {
190
+ return empty;
191
+ }
192
+ }
@@ -0,0 +1,228 @@
1
+ import { createHmac } from "node:crypto";
2
+ import * as webhookStore from "./webhook-store.js";
3
+ import type { WebhookConfig, WebhookEvent, WebhookPayload } from "./webhook-types.js";
4
+
5
+ export class WebhookManager {
6
+ // Cost thresholds to emit cost.threshold events (in USD)
7
+ private costThresholds = [0.50, 1.00, 2.00, 5.00, 10.00];
8
+ // Track which thresholds have been crossed per session to avoid re-emitting
9
+ private crossedThresholds = new Map<string, Set<number>>();
10
+
11
+ emit(event: WebhookEvent, sessionId: string, data: Record<string, unknown>): void {
12
+ // Load all webhooks, find matching ones, deliver asynchronously (fire-and-forget)
13
+ const webhooks = webhookStore.listWebhooks();
14
+ for (const webhook of webhooks) {
15
+ if (!webhook.enabled) continue;
16
+ if (!webhook.events.includes(event)) continue;
17
+ if (!this.matchesFilter(webhook, sessionId, data)) continue;
18
+
19
+ const payload: WebhookPayload = {
20
+ event,
21
+ timestamp: Date.now(),
22
+ sessionId,
23
+ data,
24
+ };
25
+
26
+ // Fire and forget — don't block the caller
27
+ this.deliver(webhook, payload).catch((err) => {
28
+ console.error(`[webhook-manager] Delivery failed for webhook ${webhook.id}:`, err);
29
+ });
30
+ }
31
+ }
32
+
33
+ /** Check cost thresholds and emit cost.threshold if crossed */
34
+ checkCostThreshold(sessionId: string, totalCostUsd: number): void {
35
+ if (!this.crossedThresholds.has(sessionId)) {
36
+ this.crossedThresholds.set(sessionId, new Set());
37
+ }
38
+ const crossed = this.crossedThresholds.get(sessionId)!;
39
+ for (const threshold of this.costThresholds) {
40
+ if (totalCostUsd >= threshold && !crossed.has(threshold)) {
41
+ crossed.add(threshold);
42
+ this.emit("cost.threshold", sessionId, {
43
+ threshold,
44
+ totalCostUsd,
45
+ });
46
+ }
47
+ }
48
+ }
49
+
50
+ /** Clean up threshold tracking when session ends */
51
+ clearSession(sessionId: string): void {
52
+ this.crossedThresholds.delete(sessionId);
53
+ }
54
+
55
+ private async deliver(webhook: WebhookConfig, payload: WebhookPayload, attempt = 1): Promise<void> {
56
+ const format = webhook.format || "generic";
57
+ let body: string;
58
+ const headers: Record<string, string> = {
59
+ "Content-Type": "application/json",
60
+ "User-Agent": "Campfire-Webhook/1.0",
61
+ "X-Campfire-Event": payload.event,
62
+ "X-Campfire-Delivery": `${webhook.id}-${Date.now()}`,
63
+ };
64
+
65
+ if (format === "slack") {
66
+ body = JSON.stringify(
67
+ WebhookManager.formatSlackPayload(payload.event, payload.sessionId, payload.data),
68
+ );
69
+ } else if (format === "openclaw") {
70
+ body = JSON.stringify(
71
+ WebhookManager.formatOpenClawPayload(payload.event, payload.sessionId, payload.data),
72
+ );
73
+ // OpenClaw uses Bearer token auth — use the webhook secret as the token
74
+ if (webhook.secret) {
75
+ headers["Authorization"] = `Bearer ${webhook.secret}`;
76
+ }
77
+ } else {
78
+ body = JSON.stringify(payload);
79
+ }
80
+
81
+ // HMAC-SHA256 signing if secret is configured (not used for openclaw format which uses Bearer)
82
+ if (webhook.secret && format !== "openclaw") {
83
+ headers["X-Campfire-Signature"] = this.sign(body, webhook.secret);
84
+ }
85
+
86
+ try {
87
+ const res = await fetch(webhook.url, {
88
+ method: "POST",
89
+ headers,
90
+ body,
91
+ signal: AbortSignal.timeout(10_000),
92
+ });
93
+
94
+ const success = res.ok;
95
+ webhookStore.updateWebhook(webhook.id, {
96
+ totalDeliveries: webhook.totalDeliveries + 1,
97
+ failedDeliveries: success ? webhook.failedDeliveries : webhook.failedDeliveries + 1,
98
+ lastDeliveryAt: Date.now(),
99
+ lastDeliverySuccess: success,
100
+ });
101
+
102
+ if (!success && attempt < 3) {
103
+ // Retry with exponential backoff: 1s, 5s, 15s
104
+ const delays = [1000, 5000, 15000];
105
+ const delay = delays[attempt - 1] || 15000;
106
+ console.log(`[webhook-manager] Webhook ${webhook.id} returned ${res.status}, retrying in ${delay}ms (attempt ${attempt + 1}/3)`);
107
+ await new Promise((r) => setTimeout(r, delay));
108
+ await this.deliver(webhook, payload, attempt + 1);
109
+ }
110
+ } catch (err) {
111
+ webhookStore.updateWebhook(webhook.id, {
112
+ totalDeliveries: webhook.totalDeliveries + 1,
113
+ failedDeliveries: webhook.failedDeliveries + 1,
114
+ lastDeliveryAt: Date.now(),
115
+ lastDeliverySuccess: false,
116
+ });
117
+
118
+ if (attempt < 3) {
119
+ const delays = [1000, 5000, 15000];
120
+ const delay = delays[attempt - 1] || 15000;
121
+ console.log(`[webhook-manager] Webhook ${webhook.id} delivery error, retrying in ${delay}ms (attempt ${attempt + 1}/3):`, err);
122
+ await new Promise((r) => setTimeout(r, delay));
123
+ await this.deliver(webhook, payload, attempt + 1);
124
+ }
125
+ }
126
+ }
127
+
128
+ private sign(payload: string, secret: string): string {
129
+ return "sha256=" + createHmac("sha256", secret).update(payload).digest("hex");
130
+ }
131
+
132
+ private matchesFilter(
133
+ webhook: WebhookConfig,
134
+ sessionId: string,
135
+ data: Record<string, unknown>,
136
+ ): boolean {
137
+ if (!webhook.sessionFilter) return true;
138
+
139
+ if (webhook.sessionFilter.backendType && data.backendType) {
140
+ if (webhook.sessionFilter.backendType !== data.backendType) return false;
141
+ }
142
+ if (webhook.sessionFilter.cwd && data.cwd) {
143
+ if (!(data.cwd as string).startsWith(webhook.sessionFilter.cwd)) return false;
144
+ }
145
+
146
+ return true;
147
+ }
148
+
149
+ /**
150
+ * Format a webhook payload as a Slack incoming webhook message.
151
+ * Can be used by webhook consumers to post to Slack.
152
+ */
153
+ static formatSlackPayload(
154
+ event: WebhookEvent,
155
+ sessionId: string,
156
+ data: Record<string, unknown>,
157
+ ): { text: string; blocks: Array<Record<string, unknown>> } {
158
+ const eventLabels: Record<WebhookEvent, string> = {
159
+ "session.created": "Session Started",
160
+ "session.completed": "Session Completed",
161
+ "session.failed": "Session Failed",
162
+ "permission.requested": "Permission Requested",
163
+ "permission.resolved": "Permission Resolved",
164
+ "turn.completed": "Turn Completed",
165
+ "cost.threshold": "Cost Threshold Reached",
166
+ };
167
+
168
+ const label = eventLabels[event] || event;
169
+ const cost = typeof data.totalCostUsd === "number" ? `$${data.totalCostUsd.toFixed(4)}` : "";
170
+ const model = (data.model as string) || "";
171
+
172
+ return {
173
+ text: `Campfire: ${label} — session ${sessionId.slice(0, 8)}`,
174
+ blocks: [
175
+ {
176
+ type: "section",
177
+ text: { type: "mrkdwn", text: `*${label}*` },
178
+ },
179
+ {
180
+ type: "section",
181
+ fields: [
182
+ { type: "mrkdwn", text: `*Session:* ${sessionId.slice(0, 8)}` },
183
+ ...(model ? [{ type: "mrkdwn", text: `*Model:* ${model}` }] : []),
184
+ ...(cost ? [{ type: "mrkdwn", text: `*Cost:* ${cost}` }] : []),
185
+ ],
186
+ },
187
+ ],
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Format a webhook payload as an OpenClaw /hooks/agent request.
193
+ * This allows Campfire events to trigger OpenClaw agent runs.
194
+ */
195
+ static formatOpenClawPayload(
196
+ event: WebhookEvent,
197
+ sessionId: string,
198
+ data: Record<string, unknown>,
199
+ ): {
200
+ message: string;
201
+ name: string;
202
+ wakeMode: string;
203
+ deliver: boolean;
204
+ } {
205
+ const eventLabels: Record<WebhookEvent, string> = {
206
+ "session.created": "Session Started",
207
+ "session.completed": "Session Completed",
208
+ "session.failed": "Session Failed",
209
+ "permission.requested": "Permission Requested",
210
+ "permission.resolved": "Permission Resolved",
211
+ "turn.completed": "Turn Completed",
212
+ "cost.threshold": "Cost Threshold Reached",
213
+ };
214
+
215
+ const label = eventLabels[event] || event;
216
+ const cost = typeof data.totalCostUsd === "number" ? ` | Cost: $${data.totalCostUsd.toFixed(4)}` : "";
217
+ const model = data.model ? ` | Model: ${data.model}` : "";
218
+ const backend = data.backendType ? ` | Backend: ${data.backendType}` : "";
219
+ const turns = typeof data.numTurns === "number" ? ` | Turns: ${data.numTurns}` : "";
220
+
221
+ return {
222
+ message: `[Campfire] ${label} — session ${sessionId.slice(0, 8)}${backend}${model}${cost}${turns}`,
223
+ name: "Campfire",
224
+ wakeMode: "now",
225
+ deliver: true,
226
+ };
227
+ }
228
+ }
@@ -0,0 +1,168 @@
1
+ import {
2
+ mkdirSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ unlinkSync,
7
+ existsSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import type { WebhookConfig, WebhookCreateInput } from "./webhook-types.js";
12
+ import { ALL_WEBHOOK_EVENTS } from "./webhook-types.js";
13
+
14
+ // ─── Paths ──────────────────────────────────────────────────────────────────
15
+
16
+ const CAMPFIRE_DIR = join(homedir(), ".campfire");
17
+ const WEBHOOKS_DIR = join(CAMPFIRE_DIR, "webhooks");
18
+
19
+ function ensureDir(): void {
20
+ mkdirSync(WEBHOOKS_DIR, { recursive: true });
21
+ }
22
+
23
+ function filePath(id: string): string {
24
+ return join(WEBHOOKS_DIR, `${id}.json`);
25
+ }
26
+
27
+ // ─── Helpers ────────────────────────────────────────────────────────────────
28
+
29
+ function slugify(name: string): string {
30
+ return name
31
+ .toLowerCase()
32
+ .replace(/\s+/g, "-")
33
+ .replace(/[^a-z0-9-]/g, "")
34
+ .replace(/-+/g, "-")
35
+ .replace(/^-|-$/g, "");
36
+ }
37
+
38
+ // ─── CRUD ───────────────────────────────────────────────────────────────────
39
+
40
+ export function listWebhooks(): WebhookConfig[] {
41
+ ensureDir();
42
+ try {
43
+ const files = readdirSync(WEBHOOKS_DIR).filter((f) => f.endsWith(".json"));
44
+ const webhooks: WebhookConfig[] = [];
45
+ for (const file of files) {
46
+ try {
47
+ const raw = readFileSync(join(WEBHOOKS_DIR, file), "utf-8");
48
+ webhooks.push(JSON.parse(raw));
49
+ } catch {
50
+ // Skip corrupt files
51
+ }
52
+ }
53
+ webhooks.sort((a, b) => a.name.localeCompare(b.name));
54
+ return webhooks;
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
59
+
60
+ export function getWebhook(id: string): WebhookConfig | null {
61
+ ensureDir();
62
+ try {
63
+ const raw = readFileSync(filePath(id), "utf-8");
64
+ return JSON.parse(raw) as WebhookConfig;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ export function createWebhook(data: WebhookCreateInput): WebhookConfig {
71
+ if (!data.name || !data.name.trim()) throw new Error("Webhook name is required");
72
+ if (!data.url || !data.url.trim()) throw new Error("Webhook URL is required");
73
+ if (!data.events || data.events.length === 0) throw new Error("At least one event is required");
74
+
75
+ // Validate event names against allowed events
76
+ for (const event of data.events) {
77
+ if (!ALL_WEBHOOK_EVENTS.includes(event)) {
78
+ throw new Error(`Invalid event: "${event}"`);
79
+ }
80
+ }
81
+
82
+ const id = slugify(data.name.trim());
83
+ if (!id) throw new Error("Webhook name must contain alphanumeric characters");
84
+
85
+ ensureDir();
86
+ if (existsSync(filePath(id))) {
87
+ throw new Error(`A webhook with a similar name already exists ("${id}")`);
88
+ }
89
+
90
+ const now = Date.now();
91
+ const webhook: WebhookConfig = {
92
+ id,
93
+ name: data.name.trim(),
94
+ url: data.url.trim(),
95
+ events: data.events,
96
+ secret: data.secret,
97
+ enabled: data.enabled ?? true,
98
+ format: data.format,
99
+ sessionFilter: data.sessionFilter,
100
+ createdAt: now,
101
+ updatedAt: now,
102
+ totalDeliveries: 0,
103
+ failedDeliveries: 0,
104
+ };
105
+ writeFileSync(filePath(id), JSON.stringify(webhook, null, 2), "utf-8");
106
+ return webhook;
107
+ }
108
+
109
+ export function updateWebhook(
110
+ id: string,
111
+ updates: Partial<WebhookConfig>,
112
+ ): WebhookConfig | null {
113
+ ensureDir();
114
+ const existing = getWebhook(id);
115
+ if (!existing) return null;
116
+
117
+ const newName = updates.name?.trim() || existing.name;
118
+ const newId = slugify(newName);
119
+ if (!newId) throw new Error("Webhook name must contain alphanumeric characters");
120
+
121
+ // If name changed, check for slug collision with a different webhook
122
+ if (newId !== id && existsSync(filePath(newId))) {
123
+ throw new Error(`A webhook with a similar name already exists ("${newId}")`);
124
+ }
125
+
126
+ // Validate events if provided
127
+ if (updates.events) {
128
+ if (updates.events.length === 0) throw new Error("At least one event is required");
129
+ for (const event of updates.events) {
130
+ if (!ALL_WEBHOOK_EVENTS.includes(event)) {
131
+ throw new Error(`Invalid event: "${event}"`);
132
+ }
133
+ }
134
+ }
135
+
136
+ const webhook: WebhookConfig = {
137
+ ...existing,
138
+ ...updates,
139
+ id: newId,
140
+ name: newName,
141
+ updatedAt: Date.now(),
142
+ // Preserve immutable fields
143
+ createdAt: existing.createdAt,
144
+ };
145
+
146
+ // If id changed, delete old file
147
+ if (newId !== id) {
148
+ try {
149
+ unlinkSync(filePath(id));
150
+ } catch {
151
+ /* ok */
152
+ }
153
+ }
154
+
155
+ writeFileSync(filePath(newId), JSON.stringify(webhook, null, 2), "utf-8");
156
+ return webhook;
157
+ }
158
+
159
+ export function deleteWebhook(id: string): boolean {
160
+ ensureDir();
161
+ if (!existsSync(filePath(id))) return false;
162
+ try {
163
+ unlinkSync(filePath(id));
164
+ return true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
@@ -0,0 +1,66 @@
1
+ import type { BackendType } from "./session-types.js";
2
+
3
+ export type WebhookEvent =
4
+ | "session.created"
5
+ | "session.completed"
6
+ | "session.failed"
7
+ | "permission.requested"
8
+ | "permission.resolved"
9
+ | "turn.completed"
10
+ | "cost.threshold";
11
+
12
+ export const ALL_WEBHOOK_EVENTS: WebhookEvent[] = [
13
+ "session.created",
14
+ "session.completed",
15
+ "session.failed",
16
+ "permission.requested",
17
+ "permission.resolved",
18
+ "turn.completed",
19
+ "cost.threshold",
20
+ ];
21
+
22
+ export interface WebhookConfig {
23
+ id: string;
24
+ name: string;
25
+ url: string;
26
+ events: WebhookEvent[];
27
+ secret?: string;
28
+ enabled: boolean;
29
+ /** Payload format: "generic" (default), "slack", or "openclaw" (POST to /hooks/agent). */
30
+ format?: WebhookFormat;
31
+ sessionFilter?: {
32
+ backendType?: BackendType;
33
+ cwd?: string;
34
+ };
35
+ createdAt: number;
36
+ updatedAt: number;
37
+ totalDeliveries: number;
38
+ failedDeliveries: number;
39
+ lastDeliveryAt?: number;
40
+ lastDeliverySuccess?: boolean;
41
+ }
42
+
43
+ export interface WebhookPayload {
44
+ event: WebhookEvent;
45
+ timestamp: number;
46
+ sessionId: string;
47
+ data: Record<string, unknown>;
48
+ }
49
+
50
+ /** Payload format for outbound webhook delivery. */
51
+ export type WebhookFormat = "generic" | "slack" | "openclaw";
52
+
53
+ export const ALL_WEBHOOK_FORMATS: WebhookFormat[] = ["generic", "slack", "openclaw"];
54
+
55
+ export interface WebhookCreateInput {
56
+ name: string;
57
+ url: string;
58
+ events: WebhookEvent[];
59
+ secret?: string;
60
+ enabled?: boolean;
61
+ format?: WebhookFormat;
62
+ sessionFilter?: {
63
+ backendType?: BackendType;
64
+ cwd?: string;
65
+ };
66
+ }