patchwork-os 0.2.0-alpha.3 → 0.2.0-alpha.31

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 (301) hide show
  1. package/README.bridge.md +6 -0
  2. package/README.md +40 -15
  3. package/deploy/bootstrap-vps.sh +184 -0
  4. package/deploy/deploy-dashboard.sh +174 -0
  5. package/deploy/deploy-landing.sh +79 -0
  6. package/dist/activationMetrics.d.ts +67 -0
  7. package/dist/activationMetrics.js +255 -0
  8. package/dist/activationMetrics.js.map +1 -0
  9. package/dist/approvalHttp.d.ts +24 -2
  10. package/dist/approvalHttp.js +150 -10
  11. package/dist/approvalHttp.js.map +1 -1
  12. package/dist/approvalQueue.d.ts +16 -1
  13. package/dist/approvalQueue.js +44 -3
  14. package/dist/approvalQueue.js.map +1 -1
  15. package/dist/automation.d.ts +20 -0
  16. package/dist/automation.js +54 -1
  17. package/dist/automation.js.map +1 -1
  18. package/dist/bridge.d.ts +2 -0
  19. package/dist/bridge.js +55 -130
  20. package/dist/bridge.js.map +1 -1
  21. package/dist/bridgeToken.js +57 -19
  22. package/dist/bridgeToken.js.map +1 -1
  23. package/dist/ccPermissions.js +6 -4
  24. package/dist/ccPermissions.js.map +1 -1
  25. package/dist/claudeOrchestrator.d.ts +1 -1
  26. package/dist/claudeOrchestrator.js +14 -8
  27. package/dist/claudeOrchestrator.js.map +1 -1
  28. package/dist/commands/launchd.d.ts +2 -0
  29. package/dist/commands/launchd.js +94 -0
  30. package/dist/commands/launchd.js.map +1 -0
  31. package/dist/commands/recipe.d.ts +258 -0
  32. package/dist/commands/recipe.js +1130 -0
  33. package/dist/commands/recipe.js.map +1 -0
  34. package/dist/commands/recipeInstall.d.ts +72 -0
  35. package/dist/commands/recipeInstall.js +339 -0
  36. package/dist/commands/recipeInstall.js.map +1 -0
  37. package/dist/config.d.ts +14 -1
  38. package/dist/config.js +99 -8
  39. package/dist/config.js.map +1 -1
  40. package/dist/connectors/baseConnector.d.ts +117 -0
  41. package/dist/connectors/baseConnector.js +213 -0
  42. package/dist/connectors/baseConnector.js.map +1 -0
  43. package/dist/connectors/confluence.d.ts +111 -0
  44. package/dist/connectors/confluence.js +406 -0
  45. package/dist/connectors/confluence.js.map +1 -0
  46. package/dist/connectors/datadog.d.ts +116 -0
  47. package/dist/connectors/datadog.js +385 -0
  48. package/dist/connectors/datadog.js.map +1 -0
  49. package/dist/connectors/fixtureLibrary.d.ts +21 -0
  50. package/dist/connectors/fixtureLibrary.js +70 -0
  51. package/dist/connectors/fixtureLibrary.js.map +1 -0
  52. package/dist/connectors/fixtureRecorder.d.ts +1 -0
  53. package/dist/connectors/fixtureRecorder.js +35 -0
  54. package/dist/connectors/fixtureRecorder.js.map +1 -0
  55. package/dist/connectors/github.d.ts +58 -8
  56. package/dist/connectors/github.js +312 -84
  57. package/dist/connectors/github.js.map +1 -1
  58. package/dist/connectors/gmail.d.ts +4 -1
  59. package/dist/connectors/gmail.js +79 -16
  60. package/dist/connectors/gmail.js.map +1 -1
  61. package/dist/connectors/googleCalendar.d.ts +60 -0
  62. package/dist/connectors/googleCalendar.js +345 -0
  63. package/dist/connectors/googleCalendar.js.map +1 -0
  64. package/dist/connectors/hubspot.d.ts +112 -0
  65. package/dist/connectors/hubspot.js +408 -0
  66. package/dist/connectors/hubspot.js.map +1 -0
  67. package/dist/connectors/intercom.d.ts +102 -0
  68. package/dist/connectors/intercom.js +402 -0
  69. package/dist/connectors/intercom.js.map +1 -0
  70. package/dist/connectors/jira.d.ts +98 -0
  71. package/dist/connectors/jira.js +379 -0
  72. package/dist/connectors/jira.js.map +1 -0
  73. package/dist/connectors/linear.d.ts +69 -19
  74. package/dist/connectors/linear.js +170 -129
  75. package/dist/connectors/linear.js.map +1 -1
  76. package/dist/connectors/mcpClient.d.ts +56 -0
  77. package/dist/connectors/mcpClient.js +189 -0
  78. package/dist/connectors/mcpClient.js.map +1 -0
  79. package/dist/connectors/mcpOAuth.d.ts +84 -0
  80. package/dist/connectors/mcpOAuth.js +389 -0
  81. package/dist/connectors/mcpOAuth.js.map +1 -0
  82. package/dist/connectors/mockConnector.d.ts +28 -0
  83. package/dist/connectors/mockConnector.js +81 -0
  84. package/dist/connectors/mockConnector.js.map +1 -0
  85. package/dist/connectors/notion.d.ts +143 -0
  86. package/dist/connectors/notion.js +424 -0
  87. package/dist/connectors/notion.js.map +1 -0
  88. package/dist/connectors/sentry.d.ts +17 -21
  89. package/dist/connectors/sentry.js +115 -131
  90. package/dist/connectors/sentry.js.map +1 -1
  91. package/dist/connectors/slack.d.ts +50 -0
  92. package/dist/connectors/slack.js +324 -0
  93. package/dist/connectors/slack.js.map +1 -0
  94. package/dist/connectors/stripe.d.ts +116 -0
  95. package/dist/connectors/stripe.js +379 -0
  96. package/dist/connectors/stripe.js.map +1 -0
  97. package/dist/connectors/tokenStorage.d.ts +35 -0
  98. package/dist/connectors/tokenStorage.js +459 -0
  99. package/dist/connectors/tokenStorage.js.map +1 -0
  100. package/dist/connectors/zendesk.d.ts +104 -0
  101. package/dist/connectors/zendesk.js +424 -0
  102. package/dist/connectors/zendesk.js.map +1 -0
  103. package/dist/drivers/gemini/index.d.ts +5 -1
  104. package/dist/drivers/gemini/index.js +39 -5
  105. package/dist/drivers/gemini/index.js.map +1 -1
  106. package/dist/drivers/index.d.ts +5 -0
  107. package/dist/drivers/index.js +1 -1
  108. package/dist/drivers/index.js.map +1 -1
  109. package/dist/featureFlags.d.ts +73 -0
  110. package/dist/featureFlags.js +203 -0
  111. package/dist/featureFlags.js.map +1 -0
  112. package/dist/fp/automationInterpreter.js +1 -0
  113. package/dist/fp/automationInterpreter.js.map +1 -1
  114. package/dist/fp/automationProgram.d.ts +1 -1
  115. package/dist/fp/automationProgram.js.map +1 -1
  116. package/dist/fp/policyParser.js +17 -0
  117. package/dist/fp/policyParser.js.map +1 -1
  118. package/dist/index.js +621 -61
  119. package/dist/index.js.map +1 -1
  120. package/dist/installGuard.d.ts +25 -0
  121. package/dist/installGuard.js +48 -0
  122. package/dist/installGuard.js.map +1 -0
  123. package/dist/oauth.d.ts +4 -1
  124. package/dist/oauth.js +50 -14
  125. package/dist/oauth.js.map +1 -1
  126. package/dist/patchworkConfig.d.ts +9 -0
  127. package/dist/patchworkConfig.js.map +1 -1
  128. package/dist/recipeOrchestration.d.ts +53 -0
  129. package/dist/recipeOrchestration.js +272 -0
  130. package/dist/recipeOrchestration.js.map +1 -0
  131. package/dist/recipes/RecipeOrchestrator.d.ts +40 -0
  132. package/dist/recipes/RecipeOrchestrator.js +51 -0
  133. package/dist/recipes/RecipeOrchestrator.js.map +1 -0
  134. package/dist/recipes/agentExecutor.d.ts +28 -0
  135. package/dist/recipes/agentExecutor.js +42 -0
  136. package/dist/recipes/agentExecutor.js.map +1 -0
  137. package/dist/recipes/chainedRunner.d.ts +140 -0
  138. package/dist/recipes/chainedRunner.js +539 -0
  139. package/dist/recipes/chainedRunner.js.map +1 -0
  140. package/dist/recipes/dependencyGraph.d.ts +39 -0
  141. package/dist/recipes/dependencyGraph.js +199 -0
  142. package/dist/recipes/dependencyGraph.js.map +1 -0
  143. package/dist/recipes/legacyRecipeCompat.d.ts +2 -0
  144. package/dist/recipes/legacyRecipeCompat.js +112 -0
  145. package/dist/recipes/legacyRecipeCompat.js.map +1 -0
  146. package/dist/recipes/manifest.d.ts +47 -0
  147. package/dist/recipes/manifest.js +141 -0
  148. package/dist/recipes/manifest.js.map +1 -0
  149. package/dist/recipes/nestedRecipeStep.d.ts +58 -0
  150. package/dist/recipes/nestedRecipeStep.js +95 -0
  151. package/dist/recipes/nestedRecipeStep.js.map +1 -0
  152. package/dist/recipes/outputRegistry.d.ts +28 -0
  153. package/dist/recipes/outputRegistry.js +52 -0
  154. package/dist/recipes/outputRegistry.js.map +1 -0
  155. package/dist/recipes/scheduler.d.ts +23 -7
  156. package/dist/recipes/scheduler.js +131 -41
  157. package/dist/recipes/scheduler.js.map +1 -1
  158. package/dist/recipes/schema.d.ts +17 -2
  159. package/dist/recipes/schemaGenerator.d.ts +28 -0
  160. package/dist/recipes/schemaGenerator.js +565 -0
  161. package/dist/recipes/schemaGenerator.js.map +1 -0
  162. package/dist/recipes/templateEngine.d.ts +62 -0
  163. package/dist/recipes/templateEngine.js +182 -0
  164. package/dist/recipes/templateEngine.js.map +1 -0
  165. package/dist/recipes/toolRegistry.d.ts +181 -0
  166. package/dist/recipes/toolRegistry.js +300 -0
  167. package/dist/recipes/toolRegistry.js.map +1 -0
  168. package/dist/recipes/tools/calendar.d.ts +6 -0
  169. package/dist/recipes/tools/calendar.js +61 -0
  170. package/dist/recipes/tools/calendar.js.map +1 -0
  171. package/dist/recipes/tools/confluence.d.ts +6 -0
  172. package/dist/recipes/tools/confluence.js +254 -0
  173. package/dist/recipes/tools/confluence.js.map +1 -0
  174. package/dist/recipes/tools/datadog.d.ts +6 -0
  175. package/dist/recipes/tools/datadog.js +239 -0
  176. package/dist/recipes/tools/datadog.js.map +1 -0
  177. package/dist/recipes/tools/diagnostics.d.ts +6 -0
  178. package/dist/recipes/tools/diagnostics.js +36 -0
  179. package/dist/recipes/tools/diagnostics.js.map +1 -0
  180. package/dist/recipes/tools/file.d.ts +6 -0
  181. package/dist/recipes/tools/file.js +170 -0
  182. package/dist/recipes/tools/file.js.map +1 -0
  183. package/dist/recipes/tools/git.d.ts +6 -0
  184. package/dist/recipes/tools/git.js +63 -0
  185. package/dist/recipes/tools/git.js.map +1 -0
  186. package/dist/recipes/tools/github.d.ts +6 -0
  187. package/dist/recipes/tools/github.js +91 -0
  188. package/dist/recipes/tools/github.js.map +1 -0
  189. package/dist/recipes/tools/gmail.d.ts +6 -0
  190. package/dist/recipes/tools/gmail.js +210 -0
  191. package/dist/recipes/tools/gmail.js.map +1 -0
  192. package/dist/recipes/tools/hubspot.d.ts +6 -0
  193. package/dist/recipes/tools/hubspot.js +232 -0
  194. package/dist/recipes/tools/hubspot.js.map +1 -0
  195. package/dist/recipes/tools/index.d.ts +22 -0
  196. package/dist/recipes/tools/index.js +25 -0
  197. package/dist/recipes/tools/index.js.map +1 -0
  198. package/dist/recipes/tools/intercom.d.ts +6 -0
  199. package/dist/recipes/tools/intercom.js +226 -0
  200. package/dist/recipes/tools/intercom.js.map +1 -0
  201. package/dist/recipes/tools/linear.d.ts +6 -0
  202. package/dist/recipes/tools/linear.js +83 -0
  203. package/dist/recipes/tools/linear.js.map +1 -0
  204. package/dist/recipes/tools/notion.d.ts +6 -0
  205. package/dist/recipes/tools/notion.js +278 -0
  206. package/dist/recipes/tools/notion.js.map +1 -0
  207. package/dist/recipes/tools/slack.d.ts +6 -0
  208. package/dist/recipes/tools/slack.js +72 -0
  209. package/dist/recipes/tools/slack.js.map +1 -0
  210. package/dist/recipes/tools/stripe.d.ts +6 -0
  211. package/dist/recipes/tools/stripe.js +265 -0
  212. package/dist/recipes/tools/stripe.js.map +1 -0
  213. package/dist/recipes/tools/zendesk.d.ts +6 -0
  214. package/dist/recipes/tools/zendesk.js +245 -0
  215. package/dist/recipes/tools/zendesk.js.map +1 -0
  216. package/dist/recipes/validation.d.ts +13 -0
  217. package/dist/recipes/validation.js +433 -0
  218. package/dist/recipes/validation.js.map +1 -0
  219. package/dist/recipes/yamlRunner.d.ts +87 -0
  220. package/dist/recipes/yamlRunner.js +693 -409
  221. package/dist/recipes/yamlRunner.js.map +1 -1
  222. package/dist/recipesHttp.d.ts +34 -6
  223. package/dist/recipesHttp.js +285 -15
  224. package/dist/recipesHttp.js.map +1 -1
  225. package/dist/riskTier.js +1 -0
  226. package/dist/riskTier.js.map +1 -1
  227. package/dist/runLog.d.ts +23 -0
  228. package/dist/runLog.js +56 -1
  229. package/dist/runLog.js.map +1 -1
  230. package/dist/schemas/dry-run-plan.v1.json +139 -0
  231. package/dist/schemas/recipe.v1.json +684 -0
  232. package/dist/server.d.ts +32 -1
  233. package/dist/server.js +980 -97
  234. package/dist/server.js.map +1 -1
  235. package/dist/streamableHttp.js +2 -0
  236. package/dist/streamableHttp.js.map +1 -1
  237. package/dist/tools/addLinearComment.d.ts +55 -0
  238. package/dist/tools/addLinearComment.js +72 -0
  239. package/dist/tools/addLinearComment.js.map +1 -0
  240. package/dist/tools/bridgeDoctor.js +2 -2
  241. package/dist/tools/bridgeDoctor.js.map +1 -1
  242. package/dist/tools/createLinearIssue.d.ts +84 -0
  243. package/dist/tools/createLinearIssue.js +146 -0
  244. package/dist/tools/createLinearIssue.js.map +1 -0
  245. package/dist/tools/fetchCalendarEvents.d.ts +94 -0
  246. package/dist/tools/fetchCalendarEvents.js +97 -0
  247. package/dist/tools/fetchCalendarEvents.js.map +1 -0
  248. package/dist/tools/fetchGithubIssue.d.ts +80 -0
  249. package/dist/tools/fetchGithubIssue.js +84 -0
  250. package/dist/tools/fetchGithubIssue.js.map +1 -0
  251. package/dist/tools/fetchGithubPR.d.ts +89 -0
  252. package/dist/tools/fetchGithubPR.js +96 -0
  253. package/dist/tools/fetchGithubPR.js.map +1 -0
  254. package/dist/tools/fetchSlackProfile.d.ts +43 -0
  255. package/dist/tools/fetchSlackProfile.js +46 -0
  256. package/dist/tools/fetchSlackProfile.js.map +1 -0
  257. package/dist/tools/getConnectorStatus.d.ts +58 -0
  258. package/dist/tools/getConnectorStatus.js +56 -0
  259. package/dist/tools/getConnectorStatus.js.map +1 -0
  260. package/dist/tools/github/actions.js +4 -2
  261. package/dist/tools/github/actions.js.map +1 -1
  262. package/dist/tools/github/composite.d.ts +339 -0
  263. package/dist/tools/github/composite.js +343 -0
  264. package/dist/tools/github/composite.js.map +1 -0
  265. package/dist/tools/github/index.d.ts +2 -1
  266. package/dist/tools/github/index.js +2 -1
  267. package/dist/tools/github/index.js.map +1 -1
  268. package/dist/tools/github/issues.js +8 -4
  269. package/dist/tools/github/issues.js.map +1 -1
  270. package/dist/tools/github/pr.d.ts +122 -0
  271. package/dist/tools/github/pr.js +195 -5
  272. package/dist/tools/github/pr.js.map +1 -1
  273. package/dist/tools/index.js +32 -1
  274. package/dist/tools/index.js.map +1 -1
  275. package/dist/tools/searchTools.js +1 -1
  276. package/dist/tools/searchTools.js.map +1 -1
  277. package/dist/tools/slackListChannels.d.ts +65 -0
  278. package/dist/tools/slackListChannels.js +70 -0
  279. package/dist/tools/slackListChannels.js.map +1 -0
  280. package/dist/tools/slackPostMessage.d.ts +57 -0
  281. package/dist/tools/slackPostMessage.js +77 -0
  282. package/dist/tools/slackPostMessage.js.map +1 -0
  283. package/dist/tools/testTraceToSource.js +2 -2
  284. package/dist/tools/testTraceToSource.js.map +1 -1
  285. package/dist/tools/updateLinearIssue.d.ts +89 -0
  286. package/dist/tools/updateLinearIssue.js +117 -0
  287. package/dist/tools/updateLinearIssue.js.map +1 -0
  288. package/dist/transport.d.ts +7 -1
  289. package/dist/transport.js +85 -11
  290. package/dist/transport.js.map +1 -1
  291. package/package.json +5 -2
  292. package/scripts/start-all.sh +56 -19
  293. package/templates/automation-policies/recipe-authoring.json +25 -0
  294. package/templates/automation-policy.example.json +6 -0
  295. package/templates/co.patchwork-os.bridge.plist +34 -0
  296. package/templates/recipes/ctx-loop-test.yaml +75 -0
  297. package/templates/recipes/lint-on-save.yaml +1 -2
  298. package/templates/recipes/morning-brief-slack.yaml +57 -0
  299. package/templates/recipes/morning-brief.yaml +14 -6
  300. package/templates/recipes/project-health-check.yaml +50 -0
  301. package/templates/recipes/sentry-to-linear.yaml +77 -0
package/dist/server.js CHANGED
@@ -3,8 +3,10 @@ import http from "node:http";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { WebSocket, WebSocketServer as WsServer } from "ws";
6
- import { routeApprovalRequest } from "./approvalHttp.js";
6
+ import { computeSummary as computeActivationSummary, loadMetrics as loadActivationMetrics, } from "./activationMetrics.js";
7
+ import { handleApprovalsStream, routeApprovalRequest } from "./approvalHttp.js";
7
8
  import { getApprovalQueue } from "./approvalQueue.js";
9
+ import { saveBridgeConfigDriver } from "./config.js";
8
10
  import { timingSafeStringEqual } from "./crypto.js";
9
11
  import { renderDashboardHtml } from "./dashboard.js";
10
12
  import { loadConfig as loadPatchworkConfig, defaultConfigPath as patchworkConfigPath, saveConfig as savePatchworkConfig, } from "./patchworkConfig.js";
@@ -87,20 +89,38 @@ export class Server extends EventEmitter {
87
89
  readyFn = null;
88
90
  /** Set by bridge to provide task list data (sanitized — no raw prompts) */
89
91
  tasksFn = null;
92
+ /** Set by bridge to cancel a running/pending task by id. Returns true if found. */
93
+ cancelTaskFn = null;
90
94
  /** Patchwork: set by bridge to list installed recipes for the dashboard. */
91
95
  recipesFn = null;
96
+ /** Patchwork: set by bridge to load raw recipe source content by name. */
97
+ loadRecipeContentFn = null;
98
+ /** Patchwork: set by bridge to save raw recipe source content by name. */
99
+ saveRecipeContentFn = null;
92
100
  /** Patchwork: set by bridge to save a new recipe draft to disk. */
93
101
  saveRecipeFn = null;
94
102
  /** Patchwork: set by bridge to query the recipe run audit log. */
95
103
  runsFn = null;
104
+ /** Patchwork: set by bridge to fetch a single run by seq for the detail page. */
105
+ runDetailFn = null;
106
+ /** Patchwork: set by bridge to generate a dry-run plan for a recipe by name. */
107
+ runPlanFn = null;
96
108
  /** Patchwork: set by bridge to launch a named recipe via the orchestrator. */
97
109
  runRecipeFn = null;
98
110
  /** Patchwork: admin-controlled managed settings path (highest rule precedence). */
99
111
  managedSettingsPath = undefined;
112
+ /** Effective bridge config path to update when dashboard saves driver changes. */
113
+ bridgeConfigPath = undefined;
100
114
  /** Patchwork: live approval gate level — mutated by POST /settings, read by bridge per-session setup. */
101
115
  approvalGate = "off";
102
116
  /** Patchwork: outbound webhook URL for approval notifications (from dashboard.webhookUrl in config). */
103
117
  approvalWebhookUrl = undefined;
118
+ /** Patchwork: push relay service URL — when set, per-callId approval tokens are generated. */
119
+ pushServiceUrl = undefined;
120
+ /** Patchwork: bearer token for the push relay service. */
121
+ pushServiceToken = undefined;
122
+ /** Patchwork: public base URL of this bridge, embedded in push payloads as callback base. */
123
+ pushServiceBaseUrl = undefined;
104
124
  /** Patchwork: approval decision audit callback wired to activityLog.recordEvent. */
105
125
  onApprovalDecision = undefined;
106
126
  /** Patchwork: set by bridge to match + fire webhook-triggered recipes. */
@@ -127,6 +147,7 @@ export class Server extends EventEmitter {
127
147
  sessionDetailFn = null;
128
148
  /** Set by bridge to handle POST /launch-quick-task — invokes launchQuickTask tool in-process. */
129
149
  launchQuickTaskFn = null;
150
+ setRecipeEnabledFn = null;
130
151
  /**
131
152
  * Attach an OAuth 2.0 Authorization Server.
132
153
  * When set, the bridge exposes:
@@ -355,6 +376,144 @@ export class Server extends EventEmitter {
355
376
  res.end(JSON.stringify({ ok: true, v: PACKAGE_VERSION }));
356
377
  return;
357
378
  }
379
+ // ── Connector OAuth callbacks (unauthenticated — browser redirect from vendor) ──
380
+ if (parsedUrl.pathname === "/connections/github/callback" &&
381
+ req.method === "GET") {
382
+ void (async () => {
383
+ const { handleGithubCallback } = await import("./connectors/github.js");
384
+ const code = parsedUrl.searchParams.get("code");
385
+ const state = parsedUrl.searchParams.get("state");
386
+ const error = parsedUrl.searchParams.get("error");
387
+ const result = await handleGithubCallback(code, state, error);
388
+ res.writeHead(result.status, {
389
+ "Content-Type": result.contentType ?? "application/json",
390
+ });
391
+ res.end(result.body);
392
+ })();
393
+ return;
394
+ }
395
+ if (parsedUrl.pathname === "/connections/linear/callback" &&
396
+ req.method === "GET") {
397
+ void (async () => {
398
+ const { handleLinearCallback } = await import("./connectors/linear.js");
399
+ const code = parsedUrl.searchParams.get("code");
400
+ const state = parsedUrl.searchParams.get("state");
401
+ const error = parsedUrl.searchParams.get("error");
402
+ const result = await handleLinearCallback(code, state, error);
403
+ res.writeHead(result.status, {
404
+ "Content-Type": result.contentType ?? "application/json",
405
+ });
406
+ res.end(result.body);
407
+ })();
408
+ return;
409
+ }
410
+ if (parsedUrl.pathname === "/connections/sentry/callback" &&
411
+ req.method === "GET") {
412
+ void (async () => {
413
+ const { handleSentryCallback } = await import("./connectors/sentry.js");
414
+ const code = parsedUrl.searchParams.get("code");
415
+ const state = parsedUrl.searchParams.get("state");
416
+ const error = parsedUrl.searchParams.get("error");
417
+ const result = await handleSentryCallback(code, state, error);
418
+ res.writeHead(result.status, {
419
+ "Content-Type": result.contentType ?? "application/json",
420
+ });
421
+ res.end(result.body);
422
+ })();
423
+ return;
424
+ }
425
+ if (parsedUrl.pathname === "/connections/google-calendar/callback" &&
426
+ req.method === "GET") {
427
+ void (async () => {
428
+ const { handleCalendarCallback } = await import("./connectors/googleCalendar.js");
429
+ const code = parsedUrl.searchParams.get("code");
430
+ const state = parsedUrl.searchParams.get("state");
431
+ const error = parsedUrl.searchParams.get("error");
432
+ const result = await handleCalendarCallback(code, state, error);
433
+ res.writeHead(result.status, {
434
+ "Content-Type": result.contentType ?? "application/json",
435
+ });
436
+ res.end(result.body);
437
+ })();
438
+ return;
439
+ }
440
+ if (parsedUrl.pathname === "/connections/slack/callback" &&
441
+ req.method === "GET") {
442
+ void (async () => {
443
+ const { handleSlackCallback } = await import("./connectors/slack.js");
444
+ const code = parsedUrl.searchParams.get("code");
445
+ const state = parsedUrl.searchParams.get("state");
446
+ const error = parsedUrl.searchParams.get("error");
447
+ const result = await handleSlackCallback(code, state, error);
448
+ res.writeHead(result.status, {
449
+ "Content-Type": result.contentType ?? "application/json",
450
+ });
451
+ res.end(result.body);
452
+ })();
453
+ return;
454
+ }
455
+ if (parsedUrl.pathname === "/connections/gmail/callback" &&
456
+ req.method === "GET") {
457
+ void (async () => {
458
+ const { handleGmailCallback } = await import("./connectors/gmail.js");
459
+ const code = parsedUrl.searchParams.get("code");
460
+ const state = parsedUrl.searchParams.get("state");
461
+ const error = parsedUrl.searchParams.get("error");
462
+ const result = await handleGmailCallback(code, state, error);
463
+ res.writeHead(result.status, {
464
+ "Content-Type": result.contentType ?? "text/html",
465
+ });
466
+ res.end(result.body);
467
+ })();
468
+ return;
469
+ }
470
+ // ── /schemas/* — unauthenticated registry-derived JSON Schemas ────────
471
+ // Serves recipe.v1.json, dry-run-plan.v1.json, tools/<ns>.json so YAML-LSP
472
+ // editors can resolve `$schema:` headers against a running bridge. No
473
+ // secrets — schemas are generated from the tool registry.
474
+ if (parsedUrl.pathname?.startsWith("/schemas/") && req.method === "GET") {
475
+ try {
476
+ await import("./recipes/tools/index.js");
477
+ const { generateSchemaSet } = await import("./recipes/schemaGenerator.js");
478
+ const schemas = generateSchemaSet();
479
+ const rest = parsedUrl.pathname.slice("/schemas/".length);
480
+ let body;
481
+ if (rest === "recipe.v1.json") {
482
+ body = schemas.recipe;
483
+ }
484
+ else if (rest === "dry-run-plan.v1.json") {
485
+ body = schemas.dryRunPlan;
486
+ }
487
+ else if (rest.startsWith("tools/") && rest.endsWith(".json")) {
488
+ const ns = rest.slice("tools/".length, -".json".length);
489
+ body = schemas.namespaces[ns];
490
+ }
491
+ else if (rest === "" || rest === "index.json") {
492
+ body = {
493
+ recipe: "/schemas/recipe.v1.json",
494
+ dryRunPlan: "/schemas/dry-run-plan.v1.json",
495
+ tools: Object.keys(schemas.namespaces).map((ns) => `/schemas/tools/${ns}.json`),
496
+ };
497
+ }
498
+ if (body === undefined) {
499
+ res.writeHead(404, { "Content-Type": "application/json" });
500
+ res.end(JSON.stringify({ error: `schema not found: ${rest}` }));
501
+ return;
502
+ }
503
+ res.writeHead(200, {
504
+ "Content-Type": "application/schema+json",
505
+ "Cache-Control": "public, max-age=60",
506
+ });
507
+ res.end(JSON.stringify(body, null, 2));
508
+ }
509
+ catch (err) {
510
+ res.writeHead(500, { "Content-Type": "application/json" });
511
+ res.end(JSON.stringify({
512
+ error: err instanceof Error ? err.message : String(err),
513
+ }));
514
+ }
515
+ return;
516
+ }
358
517
  // ── Bearer token authentication ───────────────────────────────────────
359
518
  // All other HTTP endpoints require a valid Bearer token.
360
519
  // Accepts either:
@@ -372,8 +531,13 @@ export class Server extends EventEmitter {
372
531
  const oauthResolved = !isStaticToken && this.oauthServer
373
532
  ? this.oauthServer.resolveBearerToken(bearer)
374
533
  : null;
534
+ // Phone-path: approve/reject with x-approval-token bypass bearer check.
535
+ // The token itself is validated inside routeApprovalRequest via queue.validateToken.
536
+ const isPhoneApprovalPath = req.method === "POST" &&
537
+ /^\/(approve|reject)\/[A-Za-z0-9-]+$/.test(parsedUrl.pathname) &&
538
+ !!req.headers["x-approval-token"];
375
539
  // oauthResolved is the bridge token if the OAuth token is valid; null otherwise
376
- if (!isStaticToken && !oauthResolved) {
540
+ if (!isStaticToken && !oauthResolved && !isPhoneApprovalPath) {
377
541
  // RFC 6750: only include error= when a token was actually presented but invalid
378
542
  const tokenPresented = bearer.length > 0;
379
543
  const wwwAuth = this.oauthServer && this.oauthIssuerUrl
@@ -607,6 +771,28 @@ export class Server extends EventEmitter {
607
771
  }
608
772
  return;
609
773
  }
774
+ const cancelMatch = parsedUrl.pathname?.match(/^\/tasks\/([^/]+)\/cancel$/);
775
+ if (cancelMatch && req.method === "POST") {
776
+ const taskId = cancelMatch[1];
777
+ try {
778
+ const found = this.cancelTaskFn?.(taskId) ?? false;
779
+ if (!found) {
780
+ res.writeHead(404, { "Content-Type": "application/json" });
781
+ res.end(JSON.stringify({ error: "task not found or already terminal" }));
782
+ }
783
+ else {
784
+ res.writeHead(200, { "Content-Type": "application/json" });
785
+ res.end(JSON.stringify({ ok: true }));
786
+ }
787
+ }
788
+ catch (err) {
789
+ res.writeHead(500, { "Content-Type": "application/json" });
790
+ res.end(JSON.stringify({
791
+ error: err instanceof Error ? err.message : String(err),
792
+ }));
793
+ }
794
+ return;
795
+ }
610
796
  if (parsedUrl.pathname?.startsWith("/hooks/") && req.method === "POST") {
611
797
  const hookPath = parsedUrl.pathname.substring("/hooks".length);
612
798
  const chunks = [];
@@ -675,21 +861,6 @@ export class Server extends EventEmitter {
675
861
  })();
676
862
  return;
677
863
  }
678
- if (parsedUrl.pathname === "/connections/gmail/callback" &&
679
- req.method === "GET") {
680
- void (async () => {
681
- const { handleGmailCallback } = await import("./connectors/gmail.js");
682
- const code = parsedUrl.searchParams.get("code");
683
- const state = parsedUrl.searchParams.get("state");
684
- const error = parsedUrl.searchParams.get("error");
685
- const result = await handleGmailCallback(code, state, error);
686
- res.writeHead(result.status, {
687
- "Content-Type": result.contentType ?? "text/html",
688
- });
689
- res.end(result.body);
690
- })();
691
- return;
692
- }
693
864
  if (parsedUrl.pathname === "/connections/gmail" &&
694
865
  req.method === "DELETE") {
695
866
  void (async () => {
@@ -714,48 +885,81 @@ export class Server extends EventEmitter {
714
885
  })();
715
886
  return;
716
887
  }
717
- if (parsedUrl.pathname === "/connections/github/test" &&
718
- req.method === "POST") {
888
+ // ── GitHub MCP connector routes ─────────────────────────────────────
889
+ if (parsedUrl.pathname === "/connections/github/auth" &&
890
+ req.method === "GET") {
719
891
  void (async () => {
720
- const { getStatus } = await import("./connectors/github.js");
721
- const s = getStatus();
722
- if (s.connected) {
723
- res.writeHead(200, { "Content-Type": "application/json" });
724
- res.end(JSON.stringify({
725
- ok: true,
726
- message: `Connected as ${s.user ?? "unknown"}`,
727
- }));
892
+ const { handleGithubAuthorize } = await import("./connectors/github.js");
893
+ const result = await handleGithubAuthorize();
894
+ if (result.redirect) {
895
+ res.writeHead(302, { Location: result.redirect });
896
+ res.end();
728
897
  }
729
898
  else {
730
- res.writeHead(200, { "Content-Type": "application/json" });
731
- res.end(JSON.stringify({
732
- ok: false,
733
- message: "Not connected — run: gh auth login",
734
- }));
899
+ res.writeHead(result.status, {
900
+ "Content-Type": result.contentType ?? "application/json",
901
+ });
902
+ res.end(result.body);
735
903
  }
736
904
  })();
737
905
  return;
738
906
  }
739
- // ── Sentry connector routes ──────────────────────────────────────────
740
- if (parsedUrl.pathname === "/connections/sentry/connect" &&
907
+ if (parsedUrl.pathname === "/connections/github/test" &&
741
908
  req.method === "POST") {
742
- const chunks = [];
743
- req.on("data", (c) => chunks.push(c));
744
- req.on("end", () => {
745
- void (async () => {
746
- const { handleSentryConnect } = await import("./connectors/sentry.js");
747
- let body = {};
748
- try {
749
- body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
750
- }
751
- catch { }
752
- const result = await handleSentryConnect(body);
909
+ void (async () => {
910
+ const { handleGithubTest } = await import("./connectors/github.js");
911
+ const result = await handleGithubTest();
912
+ res.writeHead(result.status, {
913
+ "Content-Type": result.contentType ?? "application/json",
914
+ });
915
+ res.end(result.body);
916
+ })();
917
+ return;
918
+ }
919
+ if (parsedUrl.pathname === "/connections/github" &&
920
+ req.method === "DELETE") {
921
+ void (async () => {
922
+ const { handleGithubDisconnect } = await import("./connectors/github.js");
923
+ const result = await handleGithubDisconnect();
924
+ res.writeHead(result.status, {
925
+ "Content-Type": result.contentType ?? "application/json",
926
+ });
927
+ res.end(result.body);
928
+ })();
929
+ return;
930
+ }
931
+ // ── Sentry MCP connector routes ─────────────────────────────────────
932
+ if (parsedUrl.pathname === "/connections/sentry/auth" &&
933
+ req.method === "GET") {
934
+ void (async () => {
935
+ const { handleSentryAuthorize } = await import("./connectors/sentry.js");
936
+ const result = await handleSentryAuthorize();
937
+ if (result.redirect) {
938
+ res.writeHead(302, { Location: result.redirect });
939
+ res.end();
940
+ }
941
+ else {
753
942
  res.writeHead(result.status, {
754
943
  "Content-Type": result.contentType ?? "application/json",
755
944
  });
756
945
  res.end(result.body);
757
- })();
758
- });
946
+ }
947
+ })();
948
+ return;
949
+ }
950
+ if (parsedUrl.pathname === "/connections/sentry/callback" &&
951
+ req.method === "GET") {
952
+ void (async () => {
953
+ const { handleSentryCallback } = await import("./connectors/sentry.js");
954
+ const code = parsedUrl.searchParams.get("code");
955
+ const state = parsedUrl.searchParams.get("state");
956
+ const error = parsedUrl.searchParams.get("error");
957
+ const result = await handleSentryCallback(code, state, error);
958
+ res.writeHead(result.status, {
959
+ "Content-Type": result.contentType ?? "application/json",
960
+ });
961
+ res.end(result.body);
962
+ })();
759
963
  return;
760
964
  }
761
965
  if (parsedUrl.pathname === "/connections/sentry/test" &&
@@ -774,7 +978,7 @@ export class Server extends EventEmitter {
774
978
  req.method === "DELETE") {
775
979
  void (async () => {
776
980
  const { handleSentryDisconnect } = await import("./connectors/sentry.js");
777
- const result = handleSentryDisconnect();
981
+ const result = await handleSentryDisconnect();
778
982
  res.writeHead(result.status, {
779
983
  "Content-Type": result.contentType ?? "application/json",
780
984
  });
@@ -782,26 +986,38 @@ export class Server extends EventEmitter {
782
986
  })();
783
987
  return;
784
988
  }
785
- // ── Linear connector routes ─────────────────────────────────────────
786
- if (parsedUrl.pathname === "/connections/linear/connect" &&
787
- req.method === "POST") {
788
- const chunks = [];
789
- req.on("data", (c) => chunks.push(c));
790
- req.on("end", () => {
791
- void (async () => {
792
- const { handleLinearConnect } = await import("./connectors/linear.js");
793
- let body = {};
794
- try {
795
- body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
796
- }
797
- catch { }
798
- const result = await handleLinearConnect(body);
989
+ // ── Linear MCP connector routes ─────────────────────────────────────
990
+ if (parsedUrl.pathname === "/connections/linear/auth" &&
991
+ req.method === "GET") {
992
+ void (async () => {
993
+ const { handleLinearAuthorize } = await import("./connectors/linear.js");
994
+ const result = await handleLinearAuthorize();
995
+ if (result.redirect) {
996
+ res.writeHead(302, { Location: result.redirect });
997
+ res.end();
998
+ }
999
+ else {
799
1000
  res.writeHead(result.status, {
800
1001
  "Content-Type": result.contentType ?? "application/json",
801
1002
  });
802
1003
  res.end(result.body);
803
- })();
804
- });
1004
+ }
1005
+ })();
1006
+ return;
1007
+ }
1008
+ if (parsedUrl.pathname === "/connections/linear/callback" &&
1009
+ req.method === "GET") {
1010
+ void (async () => {
1011
+ const { handleLinearCallback } = await import("./connectors/linear.js");
1012
+ const code = parsedUrl.searchParams.get("code");
1013
+ const state = parsedUrl.searchParams.get("state");
1014
+ const error = parsedUrl.searchParams.get("error");
1015
+ const result = await handleLinearCallback(code, state, error);
1016
+ res.writeHead(result.status, {
1017
+ "Content-Type": result.contentType ?? "application/json",
1018
+ });
1019
+ res.end(result.body);
1020
+ })();
805
1021
  return;
806
1022
  }
807
1023
  if (parsedUrl.pathname === "/connections/linear/test" &&
@@ -820,7 +1036,7 @@ export class Server extends EventEmitter {
820
1036
  req.method === "DELETE") {
821
1037
  void (async () => {
822
1038
  const { handleLinearDisconnect } = await import("./connectors/linear.js");
823
- const result = handleLinearDisconnect();
1039
+ const result = await handleLinearDisconnect();
824
1040
  res.writeHead(result.status, {
825
1041
  "Content-Type": result.contentType ?? "application/json",
826
1042
  });
@@ -828,33 +1044,402 @@ export class Server extends EventEmitter {
828
1044
  })();
829
1045
  return;
830
1046
  }
831
- // ── Inbox routes ────────────────────────────────────────────────────
832
- if (parsedUrl.pathname === "/inbox" && req.method === "GET") {
1047
+ // ── Slack connector routes ──────────────────────────────────────
1048
+ if ((parsedUrl.pathname === "/connections/slack/auth" ||
1049
+ parsedUrl.pathname === "/connections/slack/authorize") &&
1050
+ req.method === "GET") {
1051
+ const { handleSlackAuthorize } = await import("./connectors/slack.js");
1052
+ const result = handleSlackAuthorize();
1053
+ if (result.redirect) {
1054
+ res.writeHead(302, { Location: result.redirect });
1055
+ res.end();
1056
+ }
1057
+ else {
1058
+ res.writeHead(result.status, {
1059
+ "Content-Type": result.contentType ?? "application/json",
1060
+ });
1061
+ res.end(result.body);
1062
+ }
1063
+ return;
1064
+ }
1065
+ if (parsedUrl.pathname === "/connections/slack/test" &&
1066
+ req.method === "POST") {
833
1067
  void (async () => {
834
- try {
835
- const { readdir, readFile, stat } = await import("node:fs/promises");
836
- const { existsSync } = await import("node:fs");
837
- const inboxDir = path.join(os.homedir(), ".patchwork", "inbox");
838
- if (!existsSync(inboxDir)) {
839
- res.writeHead(200, { "Content-Type": "application/json" });
840
- res.end(JSON.stringify({ items: [] }));
841
- return;
842
- }
843
- const files = (await readdir(inboxDir)).filter((f) => f.endsWith(".md"));
844
- const items = await Promise.all(files.map(async (name) => {
845
- const filePath = path.join(inboxDir, name);
846
- const [content, stats] = await Promise.all([
847
- readFile(filePath, "utf8"),
848
- stat(filePath),
849
- ]);
850
- const stripped = content
851
- .split("\n")
852
- .filter((l) => !l.startsWith("#"))
853
- .join("\n")
854
- .trim();
855
- return {
856
- name,
857
- path: filePath,
1068
+ const { handleSlackTest } = await import("./connectors/slack.js");
1069
+ const result = await handleSlackTest();
1070
+ res.writeHead(result.status, {
1071
+ "Content-Type": result.contentType ?? "application/json",
1072
+ });
1073
+ res.end(result.body);
1074
+ })();
1075
+ return;
1076
+ }
1077
+ if (parsedUrl.pathname === "/connections/slack" &&
1078
+ req.method === "DELETE") {
1079
+ const { handleSlackDisconnect } = await import("./connectors/slack.js");
1080
+ const result = handleSlackDisconnect();
1081
+ res.writeHead(result.status, {
1082
+ "Content-Type": result.contentType ?? "application/json",
1083
+ });
1084
+ res.end(result.body);
1085
+ return;
1086
+ }
1087
+ // ── Notion routes ──────────────────────────────────────────────
1088
+ if (parsedUrl.pathname === "/connections/notion/connect" &&
1089
+ req.method === "POST") {
1090
+ const chunks = [];
1091
+ req.on("data", (c) => chunks.push(c));
1092
+ req.on("end", () => {
1093
+ void (async () => {
1094
+ const { handleNotionConnect } = await import("./connectors/notion.js");
1095
+ const result = await handleNotionConnect(Buffer.concat(chunks).toString("utf-8"));
1096
+ res.writeHead(result.status, {
1097
+ "Content-Type": result.contentType ?? "application/json",
1098
+ });
1099
+ res.end(result.body);
1100
+ })();
1101
+ });
1102
+ return;
1103
+ }
1104
+ if (parsedUrl.pathname === "/connections/notion/test" &&
1105
+ req.method === "POST") {
1106
+ void (async () => {
1107
+ const { handleNotionTest } = await import("./connectors/notion.js");
1108
+ const result = await handleNotionTest();
1109
+ res.writeHead(result.status, {
1110
+ "Content-Type": result.contentType ?? "application/json",
1111
+ });
1112
+ res.end(result.body);
1113
+ })();
1114
+ return;
1115
+ }
1116
+ if (parsedUrl.pathname === "/connections/notion" &&
1117
+ req.method === "DELETE") {
1118
+ const { handleNotionDisconnect } = await import("./connectors/notion.js");
1119
+ const result = handleNotionDisconnect();
1120
+ res.writeHead(result.status, {
1121
+ "Content-Type": result.contentType ?? "application/json",
1122
+ });
1123
+ res.end(result.body);
1124
+ return;
1125
+ }
1126
+ // ── Confluence routes ───────────────────────────────────────────
1127
+ if (parsedUrl.pathname === "/connections/confluence/connect" &&
1128
+ req.method === "POST") {
1129
+ const chunks = [];
1130
+ req.on("data", (c) => chunks.push(c));
1131
+ req.on("end", () => {
1132
+ void (async () => {
1133
+ const { handleConfluenceConnect } = await import("./connectors/confluence.js");
1134
+ const result = await handleConfluenceConnect(Buffer.concat(chunks).toString("utf-8"));
1135
+ res.writeHead(result.status, {
1136
+ "Content-Type": result.contentType ?? "application/json",
1137
+ });
1138
+ res.end(result.body);
1139
+ })();
1140
+ });
1141
+ return;
1142
+ }
1143
+ if (parsedUrl.pathname === "/connections/confluence/test" &&
1144
+ req.method === "POST") {
1145
+ void (async () => {
1146
+ const { handleConfluenceTest } = await import("./connectors/confluence.js");
1147
+ const result = await handleConfluenceTest();
1148
+ res.writeHead(result.status, {
1149
+ "Content-Type": result.contentType ?? "application/json",
1150
+ });
1151
+ res.end(result.body);
1152
+ })();
1153
+ return;
1154
+ }
1155
+ if (parsedUrl.pathname === "/connections/confluence" &&
1156
+ req.method === "DELETE") {
1157
+ const { handleConfluenceDisconnect } = await import("./connectors/confluence.js");
1158
+ const result = handleConfluenceDisconnect();
1159
+ res.writeHead(result.status, {
1160
+ "Content-Type": result.contentType ?? "application/json",
1161
+ });
1162
+ res.end(result.body);
1163
+ return;
1164
+ }
1165
+ // ── Zendesk routes ──────────────────────────────────────────────
1166
+ if (parsedUrl.pathname === "/connections/zendesk/connect" &&
1167
+ req.method === "POST") {
1168
+ const chunks = [];
1169
+ req.on("data", (c) => chunks.push(c));
1170
+ req.on("end", () => {
1171
+ void (async () => {
1172
+ const { handleZendeskConnect } = await import("./connectors/zendesk.js");
1173
+ const result = await handleZendeskConnect(Buffer.concat(chunks).toString("utf-8"));
1174
+ res.writeHead(result.status, {
1175
+ "Content-Type": result.contentType ?? "application/json",
1176
+ });
1177
+ res.end(result.body);
1178
+ })();
1179
+ });
1180
+ return;
1181
+ }
1182
+ if (parsedUrl.pathname === "/connections/zendesk/test" &&
1183
+ req.method === "POST") {
1184
+ void (async () => {
1185
+ const { handleZendeskTest } = await import("./connectors/zendesk.js");
1186
+ const result = await handleZendeskTest();
1187
+ res.writeHead(result.status, {
1188
+ "Content-Type": result.contentType ?? "application/json",
1189
+ });
1190
+ res.end(result.body);
1191
+ })();
1192
+ return;
1193
+ }
1194
+ if (parsedUrl.pathname === "/connections/zendesk" &&
1195
+ req.method === "DELETE") {
1196
+ const { handleZendeskDisconnect } = await import("./connectors/zendesk.js");
1197
+ const result = handleZendeskDisconnect();
1198
+ res.writeHead(result.status, {
1199
+ "Content-Type": result.contentType ?? "application/json",
1200
+ });
1201
+ res.end(result.body);
1202
+ return;
1203
+ }
1204
+ // ── Intercom routes ─────────────────────────────────────────────
1205
+ if (parsedUrl.pathname === "/connections/intercom/connect" &&
1206
+ req.method === "POST") {
1207
+ const chunks = [];
1208
+ req.on("data", (c) => chunks.push(c));
1209
+ req.on("end", () => {
1210
+ void (async () => {
1211
+ const { handleIntercomConnect } = await import("./connectors/intercom.js");
1212
+ const result = await handleIntercomConnect(Buffer.concat(chunks).toString("utf-8"));
1213
+ res.writeHead(result.status, {
1214
+ "Content-Type": result.contentType ?? "application/json",
1215
+ });
1216
+ res.end(result.body);
1217
+ })();
1218
+ });
1219
+ return;
1220
+ }
1221
+ if (parsedUrl.pathname === "/connections/intercom/test" &&
1222
+ req.method === "POST") {
1223
+ void (async () => {
1224
+ const { handleIntercomTest } = await import("./connectors/intercom.js");
1225
+ const result = await handleIntercomTest();
1226
+ res.writeHead(result.status, {
1227
+ "Content-Type": result.contentType ?? "application/json",
1228
+ });
1229
+ res.end(result.body);
1230
+ })();
1231
+ return;
1232
+ }
1233
+ if (parsedUrl.pathname === "/connections/intercom" &&
1234
+ req.method === "DELETE") {
1235
+ const { handleIntercomDisconnect } = await import("./connectors/intercom.js");
1236
+ const result = handleIntercomDisconnect();
1237
+ res.writeHead(result.status, {
1238
+ "Content-Type": result.contentType ?? "application/json",
1239
+ });
1240
+ res.end(result.body);
1241
+ return;
1242
+ }
1243
+ // ── HubSpot routes ─────────────────────────────────────────────
1244
+ if (parsedUrl.pathname === "/connections/hubspot/connect" &&
1245
+ req.method === "POST") {
1246
+ const chunks = [];
1247
+ req.on("data", (c) => chunks.push(c));
1248
+ req.on("end", () => {
1249
+ void (async () => {
1250
+ const { handleHubSpotConnect } = await import("./connectors/hubspot.js");
1251
+ const result = await handleHubSpotConnect(Buffer.concat(chunks).toString("utf-8"));
1252
+ res.writeHead(result.status, {
1253
+ "Content-Type": result.contentType ?? "application/json",
1254
+ });
1255
+ res.end(result.body);
1256
+ })();
1257
+ });
1258
+ return;
1259
+ }
1260
+ if (parsedUrl.pathname === "/connections/hubspot/test" &&
1261
+ req.method === "POST") {
1262
+ const { handleHubSpotTest } = await import("./connectors/hubspot.js");
1263
+ const result = await handleHubSpotTest();
1264
+ res.writeHead(result.status, {
1265
+ "Content-Type": result.contentType ?? "application/json",
1266
+ });
1267
+ res.end(result.body);
1268
+ return;
1269
+ }
1270
+ if (parsedUrl.pathname === "/connections/hubspot" &&
1271
+ req.method === "DELETE") {
1272
+ const { handleHubSpotDisconnect } = await import("./connectors/hubspot.js");
1273
+ const result = handleHubSpotDisconnect();
1274
+ res.writeHead(result.status, {
1275
+ "Content-Type": result.contentType ?? "application/json",
1276
+ });
1277
+ res.end(result.body);
1278
+ return;
1279
+ }
1280
+ // ── Datadog routes ─────────────────────────────────────────────
1281
+ if (parsedUrl.pathname === "/connections/datadog/connect" &&
1282
+ req.method === "POST") {
1283
+ const chunks = [];
1284
+ req.on("data", (c) => chunks.push(c));
1285
+ req.on("end", () => {
1286
+ void (async () => {
1287
+ const { handleDatadogConnect } = await import("./connectors/datadog.js");
1288
+ const result = await handleDatadogConnect(Buffer.concat(chunks).toString("utf-8"));
1289
+ res.writeHead(result.status, {
1290
+ "Content-Type": result.contentType ?? "application/json",
1291
+ });
1292
+ res.end(result.body);
1293
+ })();
1294
+ });
1295
+ return;
1296
+ }
1297
+ if (parsedUrl.pathname === "/connections/datadog/test" &&
1298
+ req.method === "POST") {
1299
+ void (async () => {
1300
+ const { handleDatadogTest } = await import("./connectors/datadog.js");
1301
+ const result = await handleDatadogTest();
1302
+ res.writeHead(result.status, {
1303
+ "Content-Type": result.contentType ?? "application/json",
1304
+ });
1305
+ res.end(result.body);
1306
+ })();
1307
+ return;
1308
+ }
1309
+ if (parsedUrl.pathname === "/connections/datadog" &&
1310
+ req.method === "DELETE") {
1311
+ const { handleDatadogDisconnect } = await import("./connectors/datadog.js");
1312
+ const result = handleDatadogDisconnect();
1313
+ res.writeHead(result.status, {
1314
+ "Content-Type": result.contentType ?? "application/json",
1315
+ });
1316
+ res.end(result.body);
1317
+ return;
1318
+ }
1319
+ // ── Stripe routes ───────────────────────────────────────────────
1320
+ if (parsedUrl.pathname === "/connections/stripe/connect" &&
1321
+ req.method === "POST") {
1322
+ let body = "";
1323
+ req.on("data", (chunk) => {
1324
+ body += chunk.toString();
1325
+ });
1326
+ req.on("end", () => {
1327
+ void (async () => {
1328
+ const { handleStripeConnect } = await import("./connectors/stripe.js");
1329
+ const result = await handleStripeConnect(body);
1330
+ res.writeHead(result.status, {
1331
+ "Content-Type": result.contentType ?? "application/json",
1332
+ });
1333
+ res.end(result.body);
1334
+ })();
1335
+ });
1336
+ return;
1337
+ }
1338
+ if (parsedUrl.pathname === "/connections/stripe/test" &&
1339
+ req.method === "POST") {
1340
+ const { handleStripeTest } = await import("./connectors/stripe.js");
1341
+ const result = await handleStripeTest();
1342
+ res.writeHead(result.status, {
1343
+ "Content-Type": result.contentType ?? "application/json",
1344
+ });
1345
+ res.end(result.body);
1346
+ return;
1347
+ }
1348
+ if (parsedUrl.pathname === "/connections/stripe" &&
1349
+ req.method === "DELETE") {
1350
+ const { handleStripeDisconnect } = await import("./connectors/stripe.js");
1351
+ const result = handleStripeDisconnect();
1352
+ res.writeHead(result.status, {
1353
+ "Content-Type": result.contentType ?? "application/json",
1354
+ });
1355
+ res.end(result.body);
1356
+ return;
1357
+ }
1358
+ // ── Google Calendar routes ──────────────────────────────────────
1359
+ if (parsedUrl.pathname === "/connections/google-calendar/auth" &&
1360
+ req.method === "GET") {
1361
+ void (async () => {
1362
+ const { handleCalendarAuthRedirect } = await import("./connectors/googleCalendar.js");
1363
+ const result = handleCalendarAuthRedirect();
1364
+ if (result.redirect) {
1365
+ res.writeHead(302, { Location: result.redirect });
1366
+ res.end();
1367
+ }
1368
+ else {
1369
+ res.writeHead(result.status, {
1370
+ "Content-Type": result.contentType ?? "application/json",
1371
+ });
1372
+ res.end(result.body);
1373
+ }
1374
+ })();
1375
+ return;
1376
+ }
1377
+ if (parsedUrl.pathname === "/connections/google-calendar/callback" &&
1378
+ req.method === "GET") {
1379
+ void (async () => {
1380
+ const { handleCalendarCallback } = await import("./connectors/googleCalendar.js");
1381
+ const code = parsedUrl.searchParams.get("code");
1382
+ const state = parsedUrl.searchParams.get("state");
1383
+ const error = parsedUrl.searchParams.get("error");
1384
+ const result = await handleCalendarCallback(code, state, error);
1385
+ res.writeHead(result.status, {
1386
+ "Content-Type": result.contentType ?? "application/json",
1387
+ });
1388
+ res.end(result.body);
1389
+ })();
1390
+ return;
1391
+ }
1392
+ if (parsedUrl.pathname === "/connections/google-calendar/test" &&
1393
+ req.method === "POST") {
1394
+ void (async () => {
1395
+ const { handleCalendarTest } = await import("./connectors/googleCalendar.js");
1396
+ const result = await handleCalendarTest();
1397
+ res.writeHead(result.status, {
1398
+ "Content-Type": result.contentType ?? "application/json",
1399
+ });
1400
+ res.end(result.body);
1401
+ })();
1402
+ return;
1403
+ }
1404
+ if (parsedUrl.pathname === "/connections/google-calendar" &&
1405
+ req.method === "DELETE") {
1406
+ void (async () => {
1407
+ const { handleCalendarDisconnect } = await import("./connectors/googleCalendar.js");
1408
+ const result = await handleCalendarDisconnect();
1409
+ res.writeHead(result.status, {
1410
+ "Content-Type": result.contentType ?? "application/json",
1411
+ });
1412
+ res.end(result.body);
1413
+ })();
1414
+ return;
1415
+ }
1416
+ // ── Inbox routes ────────────────────────────────────────────────────
1417
+ if (parsedUrl.pathname === "/inbox" && req.method === "GET") {
1418
+ void (async () => {
1419
+ try {
1420
+ const { readdir, readFile, stat } = await import("node:fs/promises");
1421
+ const { existsSync } = await import("node:fs");
1422
+ const inboxDir = path.join(os.homedir(), ".patchwork", "inbox");
1423
+ if (!existsSync(inboxDir)) {
1424
+ res.writeHead(200, { "Content-Type": "application/json" });
1425
+ res.end(JSON.stringify({ items: [] }));
1426
+ return;
1427
+ }
1428
+ const files = (await readdir(inboxDir)).filter((f) => f.endsWith(".md"));
1429
+ const items = await Promise.all(files.map(async (name) => {
1430
+ const filePath = path.join(inboxDir, name);
1431
+ const [content, stats] = await Promise.all([
1432
+ readFile(filePath, "utf8"),
1433
+ stat(filePath),
1434
+ ]);
1435
+ const stripped = content
1436
+ .split("\n")
1437
+ .filter((l) => !l.startsWith("#"))
1438
+ .join("\n")
1439
+ .trim();
1440
+ return {
1441
+ name,
1442
+ path: filePath,
858
1443
  modifiedAt: stats.mtime.toISOString(),
859
1444
  preview: stripped.slice(0, 200),
860
1445
  };
@@ -914,6 +1499,47 @@ export class Server extends EventEmitter {
914
1499
  return;
915
1500
  }
916
1501
  // ── End inbox routes ─────────────────────────────────────────────────
1502
+ const recipeNameRunMatch = req.method === "POST"
1503
+ ? /^\/recipes\/([^/]+)\/run$/.exec(parsedUrl.pathname)
1504
+ : null;
1505
+ if (recipeNameRunMatch) {
1506
+ const nameFromPath = decodeURIComponent(recipeNameRunMatch[1] ?? "");
1507
+ const chunks = [];
1508
+ req.on("data", (c) => chunks.push(c));
1509
+ req.on("end", () => {
1510
+ void (async () => {
1511
+ try {
1512
+ const body = Buffer.concat(chunks).toString("utf-8");
1513
+ const parsed = body
1514
+ ? JSON.parse(body)
1515
+ : {};
1516
+ const vars = parsed.vars &&
1517
+ typeof parsed.vars === "object" &&
1518
+ !Array.isArray(parsed.vars)
1519
+ ? parsed.vars
1520
+ : undefined;
1521
+ if (!this.runRecipeFn) {
1522
+ res.writeHead(503, { "Content-Type": "application/json" });
1523
+ res.end(JSON.stringify({
1524
+ ok: false,
1525
+ error: "Recipe execution unavailable — requires --claude-driver subprocess",
1526
+ }));
1527
+ return;
1528
+ }
1529
+ const result = await this.runRecipeFn(nameFromPath, vars);
1530
+ res.writeHead(result.ok ? 200 : 400, {
1531
+ "Content-Type": "application/json",
1532
+ });
1533
+ res.end(JSON.stringify(result));
1534
+ }
1535
+ catch {
1536
+ res.writeHead(400, { "Content-Type": "application/json" });
1537
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
1538
+ }
1539
+ })();
1540
+ });
1541
+ return;
1542
+ }
917
1543
  if (parsedUrl.pathname === "/recipes/run" && req.method === "POST") {
918
1544
  const chunks = [];
919
1545
  req.on("data", (c) => chunks.push(c));
@@ -923,6 +1549,11 @@ export class Server extends EventEmitter {
923
1549
  const body = Buffer.concat(chunks).toString("utf-8");
924
1550
  const parsed = JSON.parse(body || "{}");
925
1551
  const name = parsed.name;
1552
+ const vars = parsed.vars &&
1553
+ typeof parsed.vars === "object" &&
1554
+ !Array.isArray(parsed.vars)
1555
+ ? parsed.vars
1556
+ : undefined;
926
1557
  if (typeof name !== "string" || !name) {
927
1558
  res.writeHead(400, { "Content-Type": "application/json" });
928
1559
  res.end(JSON.stringify({ ok: false, error: "name required" }));
@@ -936,7 +1567,7 @@ export class Server extends EventEmitter {
936
1567
  }));
937
1568
  return;
938
1569
  }
939
- const result = await this.runRecipeFn(name);
1570
+ const result = await this.runRecipeFn(name, vars);
940
1571
  res.writeHead(result.ok ? 200 : 400, {
941
1572
  "Content-Type": "application/json",
942
1573
  });
@@ -950,6 +1581,22 @@ export class Server extends EventEmitter {
950
1581
  });
951
1582
  return;
952
1583
  }
1584
+ if (parsedUrl.pathname === "/activation-metrics" &&
1585
+ req.method === "GET") {
1586
+ try {
1587
+ const metrics = loadActivationMetrics();
1588
+ const summary = computeActivationSummary(metrics);
1589
+ res.writeHead(200, { "Content-Type": "application/json" });
1590
+ res.end(JSON.stringify({ metrics, summary }));
1591
+ }
1592
+ catch (err) {
1593
+ res.writeHead(500, { "Content-Type": "application/json" });
1594
+ res.end(JSON.stringify({
1595
+ error: err instanceof Error ? err.message : String(err),
1596
+ }));
1597
+ }
1598
+ return;
1599
+ }
953
1600
  if (parsedUrl.pathname === "/runs" && req.method === "GET") {
954
1601
  try {
955
1602
  const sp = parsedUrl.searchParams;
@@ -978,6 +1625,62 @@ export class Server extends EventEmitter {
978
1625
  }
979
1626
  return;
980
1627
  }
1628
+ // GET /runs/:seq — single run detail (includes stepResults if present)
1629
+ const runDetailMatch = req.method === "GET"
1630
+ ? /^\/runs\/(\d+)$/.exec(parsedUrl.pathname)
1631
+ : null;
1632
+ if (runDetailMatch?.[1]) {
1633
+ const seq = Number.parseInt(runDetailMatch[1], 10);
1634
+ try {
1635
+ const run = this.runDetailFn?.(seq) ?? null;
1636
+ if (!run) {
1637
+ res.writeHead(404, { "Content-Type": "application/json" });
1638
+ res.end(JSON.stringify({ error: "not_found" }));
1639
+ }
1640
+ else {
1641
+ res.writeHead(200, { "Content-Type": "application/json" });
1642
+ res.end(JSON.stringify({ run }));
1643
+ }
1644
+ }
1645
+ catch (err) {
1646
+ res.writeHead(500, { "Content-Type": "application/json" });
1647
+ res.end(JSON.stringify({
1648
+ error: err instanceof Error ? err.message : String(err),
1649
+ }));
1650
+ }
1651
+ return;
1652
+ }
1653
+ // GET /runs/:seq/plan — dry-run plan for the recipe that produced this run
1654
+ const runPlanMatch = req.method === "GET"
1655
+ ? /^\/runs\/(\d+)\/plan$/.exec(parsedUrl.pathname)
1656
+ : null;
1657
+ if (runPlanMatch?.[1]) {
1658
+ const seq = Number.parseInt(runPlanMatch[1], 10);
1659
+ try {
1660
+ const run = this.runDetailFn?.(seq) ?? null;
1661
+ if (!run) {
1662
+ res.writeHead(404, { "Content-Type": "application/json" });
1663
+ res.end(JSON.stringify({ error: "run_not_found" }));
1664
+ return;
1665
+ }
1666
+ if (!this.runPlanFn) {
1667
+ res.writeHead(503, { "Content-Type": "application/json" });
1668
+ res.end(JSON.stringify({ error: "plan_unavailable" }));
1669
+ return;
1670
+ }
1671
+ const recipeName = run.recipeName;
1672
+ const plan = await this.runPlanFn(recipeName);
1673
+ res.writeHead(200, { "Content-Type": "application/json" });
1674
+ res.end(JSON.stringify({ plan }));
1675
+ }
1676
+ catch (err) {
1677
+ const msg = err instanceof Error ? err.message : String(err);
1678
+ const status = msg.includes("not found") || msg.includes("ENOENT") ? 404 : 500;
1679
+ res.writeHead(status, { "Content-Type": "application/json" });
1680
+ res.end(JSON.stringify({ error: msg }));
1681
+ }
1682
+ return;
1683
+ }
981
1684
  if (req.url === "/recipes" && req.method === "POST") {
982
1685
  const chunks = [];
983
1686
  req.on("data", (c) => chunks.push(c));
@@ -1011,6 +1714,94 @@ export class Server extends EventEmitter {
1011
1714
  });
1012
1715
  return;
1013
1716
  }
1717
+ const recipePatchMatch = /^\/recipes\/([^/]+)$/.exec(parsedUrl.pathname);
1718
+ if (recipePatchMatch && req.method === "PATCH") {
1719
+ const name = decodeURIComponent(recipePatchMatch[1]);
1720
+ const chunks = [];
1721
+ req.on("data", (c) => chunks.push(c));
1722
+ req.on("end", () => {
1723
+ try {
1724
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
1725
+ if (typeof body.enabled !== "boolean") {
1726
+ res.writeHead(400, { "Content-Type": "application/json" });
1727
+ res.end(JSON.stringify({
1728
+ ok: false,
1729
+ error: "enabled (boolean) required",
1730
+ }));
1731
+ return;
1732
+ }
1733
+ if (!this.setRecipeEnabledFn) {
1734
+ res.writeHead(503, { "Content-Type": "application/json" });
1735
+ res.end(JSON.stringify({ ok: false, error: "Not available" }));
1736
+ return;
1737
+ }
1738
+ const result = this.setRecipeEnabledFn(name, body.enabled);
1739
+ res.writeHead(result.ok ? 200 : 400, {
1740
+ "Content-Type": "application/json",
1741
+ });
1742
+ res.end(JSON.stringify(result));
1743
+ }
1744
+ catch {
1745
+ res.writeHead(400, { "Content-Type": "application/json" });
1746
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
1747
+ }
1748
+ });
1749
+ return;
1750
+ }
1751
+ const recipeContentMatch = /^\/recipes\/([^/]+)$/.exec(parsedUrl.pathname);
1752
+ if (recipeContentMatch && req.method === "GET") {
1753
+ const name = decodeURIComponent(recipeContentMatch[1]);
1754
+ if (!this.loadRecipeContentFn) {
1755
+ res.writeHead(503, { "Content-Type": "application/json" });
1756
+ res.end(JSON.stringify({ ok: false, error: "Recipe content unavailable" }));
1757
+ return;
1758
+ }
1759
+ const result = this.loadRecipeContentFn(name);
1760
+ if (!result) {
1761
+ res.writeHead(404, { "Content-Type": "application/json" });
1762
+ res.end(JSON.stringify({ ok: false, error: "Recipe not found" }));
1763
+ return;
1764
+ }
1765
+ res.writeHead(200, { "Content-Type": "application/json" });
1766
+ res.end(JSON.stringify(result));
1767
+ return;
1768
+ }
1769
+ if (recipeContentMatch && req.method === "PUT") {
1770
+ const name = decodeURIComponent(recipeContentMatch[1]);
1771
+ const chunks = [];
1772
+ req.on("data", (c) => chunks.push(c));
1773
+ req.on("end", () => {
1774
+ try {
1775
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
1776
+ if (typeof body.content !== "string") {
1777
+ res.writeHead(400, { "Content-Type": "application/json" });
1778
+ res.end(JSON.stringify({
1779
+ ok: false,
1780
+ error: "content (string) required",
1781
+ }));
1782
+ return;
1783
+ }
1784
+ if (!this.saveRecipeContentFn) {
1785
+ res.writeHead(503, { "Content-Type": "application/json" });
1786
+ res.end(JSON.stringify({
1787
+ ok: false,
1788
+ error: "Recipe content saving unavailable",
1789
+ }));
1790
+ return;
1791
+ }
1792
+ const result = this.saveRecipeContentFn(name, body.content);
1793
+ res.writeHead(result.ok ? 200 : 400, {
1794
+ "Content-Type": "application/json",
1795
+ });
1796
+ res.end(JSON.stringify(result));
1797
+ }
1798
+ catch {
1799
+ res.writeHead(400, { "Content-Type": "application/json" });
1800
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
1801
+ }
1802
+ });
1803
+ return;
1804
+ }
1014
1805
  if (req.url === "/recipes" && req.method === "GET") {
1015
1806
  try {
1016
1807
  const data = this.recipesFn?.() ?? { recipesDir: null, recipes: [] };
@@ -1071,8 +1862,11 @@ export class Server extends EventEmitter {
1071
1862
  req.on("end", () => {
1072
1863
  try {
1073
1864
  const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
1074
- const raw = body.webhookUrl?.trim() ?? "";
1075
- if (raw !== "" && !/^https:\/\/.+/.test(raw)) {
1865
+ const hasWebhookUpdate = body.webhookUrl !== undefined;
1866
+ const raw = hasWebhookUpdate
1867
+ ? (body.webhookUrl?.trim() ?? "")
1868
+ : undefined;
1869
+ if (raw !== undefined && raw !== "" && !/^https:\/\/.+/.test(raw)) {
1076
1870
  res.writeHead(400, { "Content-Type": "application/json" });
1077
1871
  res.end(JSON.stringify({ error: "webhookUrl must be HTTPS" }));
1078
1872
  return;
@@ -1094,16 +1888,94 @@ export class Server extends EventEmitter {
1094
1888
  port: cfg.dashboard?.port ?? 3000,
1095
1889
  requireApproval: cfg.dashboard?.requireApproval ?? ["high"],
1096
1890
  pushNotifications: cfg.dashboard?.pushNotifications ?? false,
1097
- webhookUrl: raw || undefined,
1891
+ webhookUrl: hasWebhookUpdate
1892
+ ? raw || undefined
1893
+ : cfg.dashboard?.webhookUrl,
1098
1894
  };
1099
1895
  if (gateRaw !== undefined) {
1100
1896
  cfg.approvalGate = gateRaw;
1101
1897
  this.approvalGate = gateRaw;
1102
1898
  }
1899
+ const driverRaw = body.driver;
1900
+ if (driverRaw !== undefined) {
1901
+ const validDrivers = [
1902
+ "subprocess",
1903
+ "api",
1904
+ "openai",
1905
+ "grok",
1906
+ "gemini",
1907
+ "none",
1908
+ ];
1909
+ if (!validDrivers.includes(driverRaw)) {
1910
+ res.writeHead(400, { "Content-Type": "application/json" });
1911
+ res.end(JSON.stringify({
1912
+ error: `driver must be one of: ${validDrivers.join(", ")}`,
1913
+ }));
1914
+ return;
1915
+ }
1916
+ const driver = driverRaw;
1917
+ cfg.driver = driver;
1918
+ saveBridgeConfigDriver(driver, this.bridgeConfigPath);
1919
+ }
1920
+ if (body.model !== undefined) {
1921
+ const validModels = [
1922
+ "claude",
1923
+ "openai",
1924
+ "gemini",
1925
+ "grok",
1926
+ "local",
1927
+ ];
1928
+ if (!validModels.includes(body.model)) {
1929
+ res.writeHead(400, { "Content-Type": "application/json" });
1930
+ res.end(JSON.stringify({
1931
+ error: `model must be one of: ${validModels.join(", ")}`,
1932
+ }));
1933
+ return;
1934
+ }
1935
+ cfg.model = body.model;
1936
+ if (body.model === "local") {
1937
+ if (body.localEndpoint !== undefined)
1938
+ cfg.localEndpoint = body.localEndpoint.trim() || undefined;
1939
+ if (body.localModel !== undefined)
1940
+ cfg.localModel = body.localModel.trim() || undefined;
1941
+ }
1942
+ }
1943
+ if (body.apiKey) {
1944
+ const { provider, key } = body.apiKey;
1945
+ const validProviders = ["anthropic", "openai", "google", "xai"];
1946
+ if (!validProviders.includes(provider) ||
1947
+ typeof key !== "string") {
1948
+ res.writeHead(400, { "Content-Type": "application/json" });
1949
+ res.end(JSON.stringify({ error: "Invalid apiKey provider or key" }));
1950
+ return;
1951
+ }
1952
+ cfg.apiKeys = { ...cfg.apiKeys, [provider]: key || undefined };
1953
+ }
1103
1954
  savePatchworkConfig(cfg, configPath);
1104
- this.approvalWebhookUrl = raw || undefined;
1955
+ if (hasWebhookUpdate) {
1956
+ this.approvalWebhookUrl = raw || undefined;
1957
+ }
1958
+ if (body.pushServiceUrl !== undefined) {
1959
+ const pushUrl = body.pushServiceUrl.trim();
1960
+ if (pushUrl && !pushUrl.startsWith("https://")) {
1961
+ res.writeHead(400, { "Content-Type": "application/json" });
1962
+ res.end(JSON.stringify({ error: "pushServiceUrl must be HTTPS" }));
1963
+ return;
1964
+ }
1965
+ this.pushServiceUrl = pushUrl || undefined;
1966
+ }
1967
+ if (body.pushServiceToken !== undefined) {
1968
+ this.pushServiceToken = body.pushServiceToken.trim() || undefined;
1969
+ }
1970
+ if (body.pushServiceBaseUrl !== undefined) {
1971
+ this.pushServiceBaseUrl =
1972
+ body.pushServiceBaseUrl.trim() || undefined;
1973
+ }
1974
+ const restartRequired = driverRaw !== undefined ||
1975
+ body.apiKey !== undefined ||
1976
+ body.model !== undefined;
1105
1977
  res.writeHead(200, { "Content-Type": "application/json" });
1106
- res.end(JSON.stringify({ ok: true }));
1978
+ res.end(JSON.stringify({ ok: true, restartRequired }));
1107
1979
  }
1108
1980
  catch (err) {
1109
1981
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -1167,6 +2039,11 @@ export class Server extends EventEmitter {
1167
2039
  }
1168
2040
  return;
1169
2041
  }
2042
+ // SSE stream for live approval queue updates.
2043
+ if (parsedUrl.pathname === "/approvals/stream" && req.method === "GET") {
2044
+ handleApprovalsStream(res, { queue: getApprovalQueue() }, parsedUrl.searchParams.get("session"));
2045
+ return;
2046
+ }
1170
2047
  // Patchwork approval surface — PreToolUse hook + dashboard approve/reject.
1171
2048
  // Bearer auth already checked above.
1172
2049
  if (parsedUrl.pathname === "/approvals" ||
@@ -1192,6 +2069,7 @@ export class Server extends EventEmitter {
1192
2069
  path: parsedUrl.pathname,
1193
2070
  body: parsedBody,
1194
2071
  query: parsedUrl.searchParams,
2072
+ approvalToken: req.headers["x-approval-token"],
1195
2073
  }, {
1196
2074
  queue: getApprovalQueue(),
1197
2075
  workspace: process.cwd(),
@@ -1199,6 +2077,9 @@ export class Server extends EventEmitter {
1199
2077
  onDecision: this.onApprovalDecision,
1200
2078
  webhookUrl: this.approvalWebhookUrl,
1201
2079
  approvalGate: this.approvalGate,
2080
+ pushServiceUrl: this.pushServiceUrl,
2081
+ pushServiceToken: this.pushServiceToken,
2082
+ pushServiceBaseUrl: this.pushServiceBaseUrl,
1202
2083
  });
1203
2084
  res.writeHead(result.status, {
1204
2085
  "Content-Type": "application/json",
@@ -1397,6 +2278,8 @@ export class Server extends EventEmitter {
1397
2278
  ws.on("error", (err) => {
1398
2279
  this.logger.error(`WebSocket client error: ${err.message}`);
1399
2280
  });
2281
+ ws.remoteAddr =
2282
+ req.socket.remoteAddress;
1400
2283
  this.emit("connection", ws);
1401
2284
  });
1402
2285
  }