patchwork-os 0.2.0-beta.2 → 0.2.0-beta.4

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 (261) hide show
  1. package/README.bridge.md +5 -5
  2. package/README.md +244 -30
  3. package/dist/activityLog.d.ts +6 -0
  4. package/dist/activityLog.js +10 -1
  5. package/dist/activityLog.js.map +1 -1
  6. package/dist/analyticsPrefs.d.ts +35 -2
  7. package/dist/analyticsPrefs.js +120 -21
  8. package/dist/analyticsPrefs.js.map +1 -1
  9. package/dist/analyticsSend.js +5 -1
  10. package/dist/analyticsSend.js.map +1 -1
  11. package/dist/approvalHttp.js +25 -8
  12. package/dist/approvalHttp.js.map +1 -1
  13. package/dist/approvalQueue.d.ts +44 -1
  14. package/dist/approvalQueue.js +117 -0
  15. package/dist/approvalQueue.js.map +1 -1
  16. package/dist/automation.d.ts +3 -3
  17. package/dist/automation.js +12 -5
  18. package/dist/automation.js.map +1 -1
  19. package/dist/bridge.d.ts +2 -0
  20. package/dist/bridge.js +140 -8
  21. package/dist/bridge.js.map +1 -1
  22. package/dist/bridgeLockDiscovery.d.ts +27 -1
  23. package/dist/bridgeLockDiscovery.js +38 -11
  24. package/dist/bridgeLockDiscovery.js.map +1 -1
  25. package/dist/claudeOrchestrator.js +27 -10
  26. package/dist/claudeOrchestrator.js.map +1 -1
  27. package/dist/commands/dashboard.js +8 -1
  28. package/dist/commands/dashboard.js.map +1 -1
  29. package/dist/commands/install.js +3 -0
  30. package/dist/commands/install.js.map +1 -1
  31. package/dist/commands/patchworkInit.d.ts +5 -0
  32. package/dist/commands/patchworkInit.js +89 -7
  33. package/dist/commands/patchworkInit.js.map +1 -1
  34. package/dist/commands/recipe.d.ts +51 -0
  35. package/dist/commands/recipe.js +353 -2
  36. package/dist/commands/recipe.js.map +1 -1
  37. package/dist/commands/recipeInstall.js +6 -3
  38. package/dist/commands/recipeInstall.js.map +1 -1
  39. package/dist/commands/task.js +2 -2
  40. package/dist/commands/task.js.map +1 -1
  41. package/dist/commitIssueLinkLog.d.ts +16 -0
  42. package/dist/commitIssueLinkLog.js +87 -4
  43. package/dist/commitIssueLinkLog.js.map +1 -1
  44. package/dist/config.d.ts +29 -3
  45. package/dist/config.js +77 -21
  46. package/dist/config.js.map +1 -1
  47. package/dist/connectorRoutes.js +1 -1
  48. package/dist/connectorRoutes.js.map +1 -1
  49. package/dist/connectors/asana.js +4 -3
  50. package/dist/connectors/asana.js.map +1 -1
  51. package/dist/connectors/confluence.js +35 -0
  52. package/dist/connectors/confluence.js.map +1 -1
  53. package/dist/connectors/datadog.js +33 -4
  54. package/dist/connectors/datadog.js.map +1 -1
  55. package/dist/connectors/discord.js +5 -4
  56. package/dist/connectors/discord.js.map +1 -1
  57. package/dist/connectors/gitlab.js +7 -1
  58. package/dist/connectors/gitlab.js.map +1 -1
  59. package/dist/connectors/mcpOAuth.js +71 -6
  60. package/dist/connectors/mcpOAuth.js.map +1 -1
  61. package/dist/connectors/slack.d.ts +1 -1
  62. package/dist/connectors/slack.js +56 -4
  63. package/dist/connectors/slack.js.map +1 -1
  64. package/dist/connectors/tokenStorage.js +56 -14
  65. package/dist/connectors/tokenStorage.js.map +1 -1
  66. package/dist/decisionTraceLog.d.ts +28 -0
  67. package/dist/decisionTraceLog.js +115 -7
  68. package/dist/decisionTraceLog.js.map +1 -1
  69. package/dist/drivers/claude/subprocess.js +22 -3
  70. package/dist/drivers/claude/subprocess.js.map +1 -1
  71. package/dist/drivers/gemini/index.js +19 -3
  72. package/dist/drivers/gemini/index.js.map +1 -1
  73. package/dist/extensionClient.d.ts +29 -4
  74. package/dist/extensionClient.js +26 -11
  75. package/dist/extensionClient.js.map +1 -1
  76. package/dist/featureFlags.d.ts +76 -0
  77. package/dist/featureFlags.js +153 -3
  78. package/dist/featureFlags.js.map +1 -1
  79. package/dist/fileLockSync.d.ts +67 -0
  80. package/dist/fileLockSync.js +126 -0
  81. package/dist/fileLockSync.js.map +1 -0
  82. package/dist/fp/automationInterpreter.d.ts +6 -0
  83. package/dist/fp/automationInterpreter.js +15 -2
  84. package/dist/fp/automationInterpreter.js.map +1 -1
  85. package/dist/fp/automationState.d.ts +1 -1
  86. package/dist/fp/automationState.js +10 -0
  87. package/dist/fp/automationState.js.map +1 -1
  88. package/dist/fp/commandDescription.js +7 -1
  89. package/dist/fp/commandDescription.js.map +1 -1
  90. package/dist/fsWatchWithFallback.d.ts +36 -0
  91. package/dist/fsWatchWithFallback.js +127 -0
  92. package/dist/fsWatchWithFallback.js.map +1 -0
  93. package/dist/index.js +797 -75
  94. package/dist/index.js.map +1 -1
  95. package/dist/installGuard.js +6 -2
  96. package/dist/installGuard.js.map +1 -1
  97. package/dist/lockfile.js +31 -4
  98. package/dist/lockfile.js.map +1 -1
  99. package/dist/patchworkConfig.js +13 -3
  100. package/dist/patchworkConfig.js.map +1 -1
  101. package/dist/pluginLoader.js +10 -1
  102. package/dist/pluginLoader.js.map +1 -1
  103. package/dist/pluginWatcher.js +6 -13
  104. package/dist/pluginWatcher.js.map +1 -1
  105. package/dist/preToolUseHook.js +3 -2
  106. package/dist/preToolUseHook.js.map +1 -1
  107. package/dist/processTree.d.ts +34 -0
  108. package/dist/processTree.js +105 -0
  109. package/dist/processTree.js.map +1 -0
  110. package/dist/prompts.js +3 -3
  111. package/dist/prompts.js.map +1 -1
  112. package/dist/recipeOrchestration.js +35 -1
  113. package/dist/recipeOrchestration.js.map +1 -1
  114. package/dist/recipeRoutes.d.ts +37 -0
  115. package/dist/recipeRoutes.js +236 -33
  116. package/dist/recipeRoutes.js.map +1 -1
  117. package/dist/recipes/agentExecutor.d.ts +25 -5
  118. package/dist/recipes/agentExecutor.js.map +1 -1
  119. package/dist/recipes/chainedRunner.js +16 -2
  120. package/dist/recipes/chainedRunner.js.map +1 -1
  121. package/dist/recipes/connectorPreflight.d.ts +53 -0
  122. package/dist/recipes/connectorPreflight.js +143 -0
  123. package/dist/recipes/connectorPreflight.js.map +1 -0
  124. package/dist/recipes/githubInstallSource.d.ts +62 -0
  125. package/dist/recipes/githubInstallSource.js +125 -0
  126. package/dist/recipes/githubInstallSource.js.map +1 -0
  127. package/dist/recipes/haltCategory.d.ts +80 -0
  128. package/dist/recipes/haltCategory.js +125 -0
  129. package/dist/recipes/haltCategory.js.map +1 -0
  130. package/dist/recipes/idempotencyKey.d.ts +126 -0
  131. package/dist/recipes/idempotencyKey.js +297 -0
  132. package/dist/recipes/idempotencyKey.js.map +1 -0
  133. package/dist/recipes/installer.js +48 -2
  134. package/dist/recipes/installer.js.map +1 -1
  135. package/dist/recipes/judgeSummary.d.ts +50 -0
  136. package/dist/recipes/judgeSummary.js +47 -0
  137. package/dist/recipes/judgeSummary.js.map +1 -0
  138. package/dist/recipes/judgeVerdict.d.ts +48 -0
  139. package/dist/recipes/judgeVerdict.js +174 -0
  140. package/dist/recipes/judgeVerdict.js.map +1 -0
  141. package/dist/recipes/migrations/index.d.ts +9 -0
  142. package/dist/recipes/migrations/index.js +133 -0
  143. package/dist/recipes/migrations/index.js.map +1 -1
  144. package/dist/recipes/parser.js +82 -4
  145. package/dist/recipes/parser.js.map +1 -1
  146. package/dist/recipes/runBudget.d.ts +70 -0
  147. package/dist/recipes/runBudget.js +109 -0
  148. package/dist/recipes/runBudget.js.map +1 -0
  149. package/dist/recipes/scheduler.d.ts +17 -0
  150. package/dist/recipes/scheduler.js +34 -2
  151. package/dist/recipes/scheduler.js.map +1 -1
  152. package/dist/recipes/schema.d.ts +30 -0
  153. package/dist/recipes/toolRegistry.js +19 -0
  154. package/dist/recipes/toolRegistry.js.map +1 -1
  155. package/dist/recipes/tools/http.d.ts +10 -0
  156. package/dist/recipes/tools/http.js +176 -0
  157. package/dist/recipes/tools/http.js.map +1 -0
  158. package/dist/recipes/tools/index.d.ts +1 -0
  159. package/dist/recipes/tools/index.js +1 -0
  160. package/dist/recipes/tools/index.js.map +1 -1
  161. package/dist/recipes/validation.js +1 -1
  162. package/dist/recipes/validation.js.map +1 -1
  163. package/dist/recipes/yamlRunner.d.ts +75 -8
  164. package/dist/recipes/yamlRunner.js +174 -28
  165. package/dist/recipes/yamlRunner.js.map +1 -1
  166. package/dist/resources.js +21 -13
  167. package/dist/resources.js.map +1 -1
  168. package/dist/runLog.d.ts +28 -0
  169. package/dist/runLog.js +19 -3
  170. package/dist/runLog.js.map +1 -1
  171. package/dist/sanitizeParsedJson.d.ts +39 -0
  172. package/dist/sanitizeParsedJson.js +55 -0
  173. package/dist/sanitizeParsedJson.js.map +1 -0
  174. package/dist/server.d.ts +79 -0
  175. package/dist/server.js +356 -3
  176. package/dist/server.js.map +1 -1
  177. package/dist/sessionCheckpoint.d.ts +8 -0
  178. package/dist/sessionCheckpoint.js +18 -2
  179. package/dist/sessionCheckpoint.js.map +1 -1
  180. package/dist/streamableHttp.js +17 -6
  181. package/dist/streamableHttp.js.map +1 -1
  182. package/dist/tools/bridgeDoctor.js +6 -2
  183. package/dist/tools/bridgeDoctor.js.map +1 -1
  184. package/dist/tools/detectUnusedCode.js +9 -7
  185. package/dist/tools/detectUnusedCode.js.map +1 -1
  186. package/dist/tools/editText.js +2 -1
  187. package/dist/tools/editText.js.map +1 -1
  188. package/dist/tools/fileOperations.js +2 -1
  189. package/dist/tools/fileOperations.js.map +1 -1
  190. package/dist/tools/fileWatcher.js +8 -2
  191. package/dist/tools/fileWatcher.js.map +1 -1
  192. package/dist/tools/fixAllLintErrors.js +10 -5
  193. package/dist/tools/fixAllLintErrors.js.map +1 -1
  194. package/dist/tools/formatDocument.js +10 -5
  195. package/dist/tools/formatDocument.js.map +1 -1
  196. package/dist/tools/getCodeCoverage.js +7 -3
  197. package/dist/tools/getCodeCoverage.js.map +1 -1
  198. package/dist/tools/handoffNote.js +2 -1
  199. package/dist/tools/handoffNote.js.map +1 -1
  200. package/dist/tools/headless/lspClient.js +3 -0
  201. package/dist/tools/headless/lspClient.js.map +1 -1
  202. package/dist/tools/lsp.js +17 -0
  203. package/dist/tools/lsp.js.map +1 -1
  204. package/dist/tools/openDiff.js +4 -1
  205. package/dist/tools/openDiff.js.map +1 -1
  206. package/dist/tools/openFile.js +4 -1
  207. package/dist/tools/openFile.js.map +1 -1
  208. package/dist/tools/organizeImports.js +5 -3
  209. package/dist/tools/organizeImports.js.map +1 -1
  210. package/dist/tools/previewEdit.js +7 -2
  211. package/dist/tools/previewEdit.js.map +1 -1
  212. package/dist/tools/recentTracesDigest.js +56 -11
  213. package/dist/tools/recentTracesDigest.js.map +1 -1
  214. package/dist/tools/refactorExtractFunction.js +4 -1
  215. package/dist/tools/refactorExtractFunction.js.map +1 -1
  216. package/dist/tools/refactorPreview.js +10 -2
  217. package/dist/tools/refactorPreview.js.map +1 -1
  218. package/dist/tools/replaceBlock.js +2 -1
  219. package/dist/tools/replaceBlock.js.map +1 -1
  220. package/dist/tools/searchAndReplace.js +2 -1
  221. package/dist/tools/searchAndReplace.js.map +1 -1
  222. package/dist/tools/spawnWorkspace.js +15 -7
  223. package/dist/tools/spawnWorkspace.js.map +1 -1
  224. package/dist/tools/testRunners/vitestJest.js +3 -1
  225. package/dist/tools/testRunners/vitestJest.js.map +1 -1
  226. package/dist/tools/transaction.js +4 -1
  227. package/dist/tools/transaction.js.map +1 -1
  228. package/dist/tools/utils.js +68 -8
  229. package/dist/tools/utils.js.map +1 -1
  230. package/dist/transport.d.ts +1 -1
  231. package/dist/transport.js +18 -4
  232. package/dist/transport.js.map +1 -1
  233. package/dist/winShim.d.ts +34 -0
  234. package/dist/winShim.js +94 -0
  235. package/dist/winShim.js.map +1 -0
  236. package/dist/writeFileAtomic.d.ts +23 -0
  237. package/dist/writeFileAtomic.js +94 -0
  238. package/dist/writeFileAtomic.js.map +1 -0
  239. package/package.json +17 -6
  240. package/scripts/postinstall.mjs +42 -2
  241. package/scripts/smoke/run-all.mjs +213 -0
  242. package/scripts/start-all.mjs +572 -0
  243. package/scripts/start-all.ps1 +209 -0
  244. package/scripts/start-all.sh +73 -17
  245. package/scripts/start-orchestrator.ps1 +158 -0
  246. package/scripts/start-remote.mjs +122 -0
  247. package/templates/automation-policies/recipe-authoring.json +1 -1
  248. package/templates/automation-policies/security-first.json +1 -1
  249. package/templates/automation-policies/strict-lint.json +1 -1
  250. package/templates/automation-policies/test-driven.json +1 -1
  251. package/templates/automation-policy.example.json +1 -1
  252. package/templates/co.patchwork-os.bridge.plist +1 -1
  253. package/templates/recipes/approval-queue-ui-test.yaml +1 -1
  254. package/templates/recipes/ctx-loop-test.yaml +1 -1
  255. package/templates/recipes/webhook/apple-watch-health-log.yaml +145 -0
  256. package/dist/commands/marketplace.d.ts +0 -16
  257. package/dist/commands/marketplace.js +0 -32
  258. package/dist/commands/marketplace.js.map +0 -1
  259. package/dist/recipes/legacyRecipeCompat.d.ts +0 -10
  260. package/dist/recipes/legacyRecipeCompat.js +0 -131
  261. package/dist/recipes/legacyRecipeCompat.js.map +0 -1
@@ -0,0 +1,572 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Cross-platform orchestrator: bridge + Claude + dashboard + health monitor.
4
+ * Replaces start-all.sh for native Windows, and works identically on macOS/Linux.
5
+ *
6
+ * Usage:
7
+ * node scripts/start-all.mjs [options]
8
+ * npm run start-all:node -- --workspace /my/project
9
+ *
10
+ * Options:
11
+ * --workspace <path> Directory to open in Claude (default: current directory)
12
+ * --full Register all ~170 tools (git, terminal, file ops, HTTP, GitHub)
13
+ * --no-dashboard Skip the Patchwork dashboard
14
+ * --dashboard-port <N> Dashboard port (default: 3200)
15
+ * --bridge-port <N> Bridge port (auto-assigned if omitted)
16
+ * --notify <topic> ntfy.sh topic for push notifications
17
+ * --no-remote Skip starting claude remote-control
18
+ * --automation-policy <path> Path to automation policy JSON
19
+ * --driver <name> AI driver (default: subprocess)
20
+ */
21
+
22
+ import { execFileSync, spawn } from "node:child_process";
23
+ import fs from "node:fs";
24
+ import net from "node:net";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ // ── Arg parsing ───────────────────────────────────────────────────────────────
30
+ const args = process.argv.slice(2);
31
+ function flag(name) {
32
+ const i = args.indexOf(name);
33
+ if (i === -1) return null;
34
+ return args[i + 1] ?? true;
35
+ }
36
+ function boolFlag(name) {
37
+ return args.includes(name);
38
+ }
39
+
40
+ const WORKSPACE = path.resolve(flag("--workspace") || ".");
41
+ const FULL_MODE = boolFlag("--full");
42
+ const NO_DASHBOARD = boolFlag("--no-dashboard");
43
+ const NO_REMOTE = boolFlag("--no-remote");
44
+ const DASHBOARD_PORT = parseInt(flag("--dashboard-port") || "3200", 10);
45
+ const BRIDGE_PORT = parseInt(flag("--bridge-port") || "0", 10);
46
+ const NTFY_TOPIC = flag("--notify") || "";
47
+ const AUTO_POLICY = flag("--automation-policy") || "";
48
+ const DRIVER = flag("--driver") || "subprocess";
49
+
50
+ if (!fs.existsSync(WORKSPACE)) {
51
+ console.error(`Error: workspace directory not found: ${WORKSPACE}`);
52
+ process.exit(1);
53
+ }
54
+
55
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
56
+ const BRIDGE_DIR = path.resolve(SCRIPT_DIR, "..");
57
+ const DASH_DIR = path.join(BRIDGE_DIR, "dashboard");
58
+ const DIST_INDEX = path.join(BRIDGE_DIR, "dist", "index.js");
59
+ const IS_WIN = process.platform === "win32";
60
+
61
+ // ── Security helpers ──────────────────────────────────────────────────────────
62
+ // On Windows `\` is the path separator (e.g. `C:\Program Files\nodejs\node.exe`),
63
+ // not a shell-injection vector. cmd.exe's actual metacharacters are
64
+ // `^ & < > | ( )` — `\` does not need escaping. Including it in the regex
65
+ // would reject every legitimate Windows path that spawnProc validates
66
+ // (process.execPath, npm.cmd, npx.cmd, etc.) and fail-stop the whole script.
67
+ // Same fix that PR #525 applied to vscode-extension/src/bridgeProcess.ts.
68
+ const SHELL_METACHARACTERS = IS_WIN
69
+ ? /[;&|`$(){}[\]<>"'\n\r]/
70
+ : /[;&|`$(){}[\]<>"'\\\n\r]/;
71
+
72
+ /**
73
+ * Validate that a command path is safe to execute.
74
+ * Prevents command injection by checking for shell metacharacters.
75
+ * @param {string} cmdPath - Path to validate
76
+ * @param {string} label - Label for error messages
77
+ * @throws {Error} If path contains shell metacharacters or is empty
78
+ */
79
+ function validateCommandPath(cmdPath, label) {
80
+ if (!cmdPath || typeof cmdPath !== "string") {
81
+ throw new Error(`${label}: command path is empty or invalid`);
82
+ }
83
+ if (SHELL_METACHARACTERS.test(cmdPath)) {
84
+ throw new Error(
85
+ `${label}: command path contains shell metacharacters: ${cmdPath}`,
86
+ );
87
+ }
88
+ // Additional check: on Windows, .cmd files are allowed but must not have spaces without proper quoting
89
+ if (IS_WIN && cmdPath.endsWith(".cmd") && cmdPath.includes(" ")) {
90
+ // This is handled by using cmd.exe explicitly, not shell:true
91
+ // We validate the path doesn't have injection chars above
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Validate command arguments don't contain injection vectors.
97
+ * @param {string[]} args - Arguments to validate
98
+ * @param {string} label - Label for error messages
99
+ */
100
+ function validateCommandArgs(args, label) {
101
+ if (!Array.isArray(args)) {
102
+ throw new Error(`${label}: arguments must be an array`);
103
+ }
104
+ for (const arg of args) {
105
+ if (typeof arg !== "string") {
106
+ throw new Error(`${label}: all arguments must be strings`);
107
+ }
108
+ }
109
+ }
110
+
111
+ // ── Colour helpers ────────────────────────────────────────────────────────────
112
+ const C = {
113
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
114
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
115
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
116
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
117
+ grey: (s) => `\x1b[90m${s}\x1b[0m`,
118
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
119
+ };
120
+
121
+ function ts() {
122
+ return new Date().toLocaleTimeString();
123
+ }
124
+ function log(label, msg, col = C.cyan) {
125
+ process.stdout.write(`${C.grey(ts())} ${col(`[${label}]`)} ${msg}\n`);
126
+ }
127
+
128
+ // ── ntfy notifications ────────────────────────────────────────────────────────
129
+ let lastNotifyMs = 0;
130
+ const NOTIFY_COOLDOWN_MS = 60_000;
131
+
132
+ function notify(msg, priority = "default") {
133
+ log("notify", msg);
134
+ if (!NTFY_TOPIC) return;
135
+ const now = Date.now();
136
+ if (priority !== "high" && now - lastNotifyMs < NOTIFY_COOLDOWN_MS) return;
137
+ lastNotifyMs = now;
138
+ // Fire-and-forget via fetch (Node 18+)
139
+ fetch(`https://ntfy.sh/${NTFY_TOPIC}`, {
140
+ method: "POST",
141
+ body: msg,
142
+ headers: { Title: "Claude IDE Bridge", Priority: priority },
143
+ signal: AbortSignal.timeout(10_000),
144
+ }).catch(() => {});
145
+ }
146
+
147
+ // ── Process registry ──────────────────────────────────────────────────────────
148
+ const procs = new Map(); // name → ChildProcess
149
+
150
+ function spawnProc(name, cmd, cmdArgs, opts = {}) {
151
+ // Validate command and arguments to prevent injection attacks
152
+ validateCommandPath(cmd, `spawnProc[${name}]`);
153
+ validateCommandArgs(cmdArgs, `spawnProc[${name}]`);
154
+
155
+ // shell:false everywhere — on Windows we always invoke cmd.exe explicitly
156
+ // for .cmd shim resolution, so shell:true would only widen the attack
157
+ // surface by interpolating env-derived paths into a shell string.
158
+ const child = spawn(cmd, cmdArgs, {
159
+ stdio: ["ignore", "pipe", "pipe"],
160
+ shell: false,
161
+ cwd: opts.cwd ?? BRIDGE_DIR,
162
+ env: { ...process.env, ...opts.env },
163
+ });
164
+
165
+ child.stdout?.on("data", (d) => {
166
+ for (const l of d.toString().split("\n").filter(Boolean))
167
+ log(name, l, C.grey);
168
+ });
169
+ child.stderr?.on("data", (d) => {
170
+ for (const l of d.toString().split("\n").filter(Boolean))
171
+ log(name, l, C.yellow);
172
+ });
173
+ child.on("error", (err) => log(name, `spawn error: ${err.message}`, C.red));
174
+
175
+ procs.set(name, child);
176
+ return child;
177
+ }
178
+
179
+ function killProc(name) {
180
+ const p = procs.get(name);
181
+ if (!p || p.exitCode !== null) return;
182
+ try {
183
+ if (IS_WIN) p.kill();
184
+ else p.kill("SIGTERM");
185
+ } catch {
186
+ /* best-effort */
187
+ }
188
+ procs.delete(name);
189
+ }
190
+
191
+ function killAll() {
192
+ for (const name of [...procs.keys()]) killProc(name);
193
+ }
194
+
195
+ // ── Cleanup on exit ───────────────────────────────────────────────────────────
196
+ let cleanedUp = false;
197
+ function cleanup() {
198
+ if (cleanedUp) return;
199
+ cleanedUp = true;
200
+ log("orchestrator", "Stopping all processes...", C.yellow);
201
+ killAll();
202
+ }
203
+
204
+ process.on("exit", cleanup);
205
+ process.on("SIGINT", () => {
206
+ cleanup();
207
+ process.exit(0);
208
+ });
209
+ process.on("SIGTERM", () => {
210
+ cleanup();
211
+ process.exit(0);
212
+ });
213
+
214
+ // ── Wait helpers ──────────────────────────────────────────────────────────────
215
+ function sleep(ms) {
216
+ return new Promise((r) => setTimeout(r, ms));
217
+ }
218
+
219
+ function waitForLock(cfgDir, port, timeoutMs = 30_000) {
220
+ return new Promise((resolve) => {
221
+ const lockPath = path.join(cfgDir, "ide", `${port}.lock`);
222
+ const deadline = Date.now() + timeoutMs;
223
+ const poll = setInterval(() => {
224
+ if (fs.existsSync(lockPath)) {
225
+ clearInterval(poll);
226
+ resolve(lockPath);
227
+ return;
228
+ }
229
+ if (Date.now() > deadline) {
230
+ clearInterval(poll);
231
+ resolve(null);
232
+ }
233
+ }, 150);
234
+ });
235
+ }
236
+
237
+ // Find the lock file written by the newly spawned bridge (any new lock in ide/).
238
+ function waitForNewLock(cfgDir, knownLocks, timeoutMs = 30_000) {
239
+ return new Promise((resolve) => {
240
+ const ideDir = path.join(cfgDir, "ide");
241
+ const deadline = Date.now() + timeoutMs;
242
+ const poll = setInterval(() => {
243
+ let locks = [];
244
+ try {
245
+ locks = fs.readdirSync(ideDir).filter((f) => f.endsWith(".lock"));
246
+ } catch {}
247
+ const newLock = locks.find((l) => !knownLocks.has(l));
248
+ if (newLock) {
249
+ clearInterval(poll);
250
+ resolve(path.join(ideDir, newLock));
251
+ return;
252
+ }
253
+ if (Date.now() > deadline) {
254
+ clearInterval(poll);
255
+ resolve(null);
256
+ }
257
+ }, 200);
258
+ });
259
+ }
260
+
261
+ function waitForPort(port, timeoutMs = 60_000) {
262
+ return new Promise((resolve) => {
263
+ const deadline = Date.now() + timeoutMs;
264
+ function attempt() {
265
+ const sock = net.createConnection({ port, host: "127.0.0.1" });
266
+ sock.on("connect", () => {
267
+ sock.destroy();
268
+ resolve(true);
269
+ });
270
+ sock.on("error", () => {
271
+ if (Date.now() > deadline) {
272
+ resolve(false);
273
+ return;
274
+ }
275
+ setTimeout(attempt, 500);
276
+ });
277
+ }
278
+ attempt();
279
+ });
280
+ }
281
+
282
+ function readLock(lockPath) {
283
+ try {
284
+ return JSON.parse(fs.readFileSync(lockPath, "utf-8"));
285
+ } catch {
286
+ return null;
287
+ }
288
+ }
289
+
290
+ // ── Resolve bridge binary ─────────────────────────────────────────────────────
291
+ // Prefer dist/index.js when available (npm install); fallback to src via tsx for local dev.
292
+ function bridgeBin() {
293
+ if (fs.existsSync(DIST_INDEX)) return [process.execPath, [DIST_INDEX]];
294
+ const srcIndex = path.join(BRIDGE_DIR, "src", "index.ts");
295
+ if (fs.existsSync(srcIndex)) {
296
+ // On Windows the spawnProc helper uses shell:false, so we must point at
297
+ // the .cmd shim directly — bare "npx" would ENOENT.
298
+ const npx = IS_WIN ? "npx.cmd" : "npx";
299
+ return [npx, ["tsx", srcIndex]];
300
+ }
301
+ console.error("Error: dist/index.js not found. Run 'npm run build' first.");
302
+ process.exit(1);
303
+ }
304
+
305
+ // ── Cross-platform browser open ───────────────────────────────────────────────
306
+ function openBrowser(url) {
307
+ try {
308
+ if (IS_WIN) {
309
+ spawn("cmd.exe", ["/c", "start", url], { stdio: "ignore", shell: false });
310
+ } else if (process.platform === "darwin") {
311
+ spawn("open", [url], { stdio: "ignore" });
312
+ } else {
313
+ spawn("xdg-open", [url], { stdio: "ignore" });
314
+ }
315
+ } catch {
316
+ /* best-effort */
317
+ }
318
+ }
319
+
320
+ // ── Dependency checks ─────────────────────────────────────────────────────────
321
+ function probe(bin) {
322
+ try {
323
+ execFileSync(IS_WIN ? "where" : "which", [bin], {
324
+ stdio: "pipe",
325
+ timeout: 3_000,
326
+ });
327
+ return true;
328
+ } catch {
329
+ return false;
330
+ }
331
+ }
332
+
333
+ if (!probe("claude")) {
334
+ console.error(
335
+ "Error: claude CLI not found on PATH. Install from https://docs.anthropic.com/en/docs/claude-code",
336
+ );
337
+ process.exit(1);
338
+ }
339
+
340
+ // ── Banner ────────────────────────────────────────────────────────────────────
341
+ console.log(
342
+ C.bold("\n=== Claude IDE Bridge — Cross-Platform Orchestrator ==="),
343
+ );
344
+ console.log(` Workspace : ${WORKSPACE}`);
345
+ console.log(
346
+ ` Tools : ${FULL_MODE ? "full (~170)" : "slim (27 IDE-only)"}`,
347
+ );
348
+ if (!NO_DASHBOARD)
349
+ console.log(` Dashboard : http://localhost:${DASHBOARD_PORT}`);
350
+ if (NTFY_TOPIC) console.log(` Notify : ntfy.sh/${NTFY_TOPIC}`);
351
+ console.log(` Ctrl+C : stop everything\n`);
352
+
353
+ // ── State ─────────────────────────────────────────────────────────────────────
354
+ const CLAUDE_CFG =
355
+ process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
356
+ const IDE_DIR = path.join(CLAUDE_CFG, "ide");
357
+ fs.mkdirSync(IDE_DIR, { recursive: true });
358
+
359
+ let lockPath = null;
360
+ let restartCount = 0;
361
+ let restartDelayMs = 5_000;
362
+ let lastStartMs = 0;
363
+
364
+ // ── Build bridge spawn args ───────────────────────────────────────────────────
365
+ function buildBridgeArgs() {
366
+ const [bin, prefix] = bridgeBin();
367
+ const extra = [
368
+ "--workspace",
369
+ WORKSPACE,
370
+ "--driver",
371
+ DRIVER,
372
+ ...(BRIDGE_PORT > 0 ? ["--port", String(BRIDGE_PORT)] : []),
373
+ ...(FULL_MODE ? ["--full"] : []),
374
+ ...(AUTO_POLICY
375
+ ? ["--automation", "--automation-policy", AUTO_POLICY]
376
+ : []),
377
+ ];
378
+ return { bin, args: [...prefix, ...extra] };
379
+ }
380
+
381
+ // ── Start bridge ──────────────────────────────────────────────────────────────
382
+ async function startBridge() {
383
+ const existingLocks = new Set(
384
+ fs.existsSync(IDE_DIR)
385
+ ? fs.readdirSync(IDE_DIR).filter((f) => f.endsWith(".lock"))
386
+ : [],
387
+ );
388
+
389
+ const { bin, args: bArgs } = buildBridgeArgs();
390
+ log("bridge", `Starting: ${path.basename(bin)} ${bArgs.slice(-4).join(" ")}`);
391
+ lastStartMs = Date.now();
392
+
393
+ spawnProc("bridge", bin, bArgs);
394
+
395
+ // Wait for lock file (bridge writes it before accepting connections)
396
+ const newLock =
397
+ BRIDGE_PORT > 0
398
+ ? await waitForLock(CLAUDE_CFG, BRIDGE_PORT)
399
+ : await waitForNewLock(CLAUDE_CFG, existingLocks);
400
+
401
+ if (!newLock) {
402
+ log(
403
+ "bridge",
404
+ "Lock file not written after 30s — bridge failed to start",
405
+ C.red,
406
+ );
407
+ notify("Bridge failed to start!", "high");
408
+ return null;
409
+ }
410
+
411
+ lockPath = newLock;
412
+ const content = readLock(lockPath);
413
+ const port = content?.port ?? parseInt(path.basename(lockPath, ".lock"), 10);
414
+
415
+ log("bridge", `Ready on port ${port}`, C.green);
416
+ notify(`Bridge started on port ${port}`);
417
+ return port;
418
+ }
419
+
420
+ // ── Start Claude --ide ────────────────────────────────────────────────────────
421
+ function startClaude(sessionId) {
422
+ const extraArgs = sessionId ? ["--resume", sessionId] : [];
423
+ log("claude", "Starting claude --ide");
424
+ spawnProc(
425
+ "claude",
426
+ IS_WIN ? "cmd.exe" : "claude",
427
+ IS_WIN ? ["/c", "claude", "--ide", ...extraArgs] : ["--ide", ...extraArgs],
428
+ { env: { CLAUDE_CODE_IDE_SKIP_VALID_CHECK: "true" } },
429
+ );
430
+ }
431
+
432
+ // ── Start remote-control ──────────────────────────────────────────────────────
433
+ function startRemote() {
434
+ if (NO_REMOTE) return;
435
+ log("remote", "Starting claude remote-control --spawn=session");
436
+ spawnProc(
437
+ "remote",
438
+ IS_WIN ? "cmd.exe" : "claude",
439
+ IS_WIN
440
+ ? ["/c", "claude", "remote-control", "--spawn=session"]
441
+ : ["remote-control", "--spawn=session"],
442
+ );
443
+ }
444
+
445
+ // ── Load ~/.patchwork/.env into process.env ───────────────────────────────────
446
+ function loadPatchworkEnv() {
447
+ const envPath = path.join(os.homedir(), ".patchwork", ".env");
448
+ if (!fs.existsSync(envPath)) return;
449
+ for (const line of fs.readFileSync(envPath, "utf-8").split("\n")) {
450
+ const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
451
+ if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
452
+ }
453
+ }
454
+ loadPatchworkEnv();
455
+
456
+ // ── Start dashboard ───────────────────────────────────────────────────────────
457
+ async function startDashboard(bridgePort) {
458
+ if (NO_DASHBOARD) return;
459
+ if (!fs.existsSync(path.join(DASH_DIR, "node_modules"))) {
460
+ log("dashboard", "node_modules not found — installing...", C.yellow);
461
+ try {
462
+ execFileSync(
463
+ IS_WIN ? "cmd.exe" : "npm",
464
+ IS_WIN
465
+ ? ["/c", "npm", "install", "--prefer-offline"]
466
+ : ["install", "--prefer-offline"],
467
+ { cwd: DASH_DIR, stdio: "inherit" },
468
+ );
469
+ } catch {
470
+ log(
471
+ "dashboard",
472
+ "npm install failed — pass --no-dashboard to skip",
473
+ C.red,
474
+ );
475
+ return;
476
+ }
477
+ }
478
+
479
+ const dashEnv = {
480
+ PATCHWORK_BRIDGE_PORT: String(bridgePort),
481
+ ...(process.env.DASHBOARD_PASSWORD
482
+ ? { DASHBOARD_PASSWORD: process.env.DASHBOARD_PASSWORD }
483
+ : {}),
484
+ ...(process.env.DASHBOARD_SESSION_SECRET
485
+ ? { DASHBOARD_SESSION_SECRET: process.env.DASHBOARD_SESSION_SECRET }
486
+ : {}),
487
+ };
488
+
489
+ log("dashboard", `Starting on http://localhost:${DASHBOARD_PORT}`);
490
+ spawnProc(
491
+ "dashboard",
492
+ IS_WIN ? "cmd.exe" : "npx",
493
+ IS_WIN
494
+ ? ["/c", "npx", "next", "dev", "-p", String(DASHBOARD_PORT)]
495
+ : ["next", "dev", "-p", String(DASHBOARD_PORT)],
496
+ { cwd: DASH_DIR, env: dashEnv },
497
+ );
498
+
499
+ const ready = await waitForPort(DASHBOARD_PORT, 60_000);
500
+ if (ready) {
501
+ log(
502
+ "dashboard",
503
+ `Ready — opening http://localhost:${DASHBOARD_PORT}`,
504
+ C.green,
505
+ );
506
+ openBrowser(`http://localhost:${DASHBOARD_PORT}`);
507
+ } else {
508
+ log(
509
+ "dashboard",
510
+ `Did not respond within 60s — open http://localhost:${DASHBOARD_PORT} manually`,
511
+ C.yellow,
512
+ );
513
+ }
514
+ }
515
+
516
+ // ── Health monitor ────────────────────────────────────────────────────────────
517
+ const sessionId = null;
518
+
519
+ async function restartAll() {
520
+ log("health", "Bridge lock file gone — restarting...", C.yellow);
521
+ notify("Bridge died! Restarting...", "high");
522
+
523
+ killProc("bridge");
524
+ killProc("claude");
525
+ killProc("remote");
526
+ await sleep(2_000); // let processes wind down
527
+
528
+ // Exponential backoff on rapid restarts
529
+ const uptime = Date.now() - lastStartMs;
530
+ if (uptime < 60_000) {
531
+ log(
532
+ "health",
533
+ `Crashed quickly (${Math.round(uptime / 1000)}s) — backing off ${restartDelayMs / 1000}s (restart #${restartCount})`,
534
+ C.yellow,
535
+ );
536
+ await sleep(restartDelayMs);
537
+ restartDelayMs = Math.min(restartDelayMs * 2, 300_000);
538
+ restartCount++;
539
+ } else {
540
+ restartDelayMs = 5_000;
541
+ restartCount = 0;
542
+ }
543
+
544
+ const port = await startBridge();
545
+ if (!port) return;
546
+
547
+ startClaude(sessionId); // --resume if we have a session UUID
548
+ startRemote();
549
+ }
550
+
551
+ function startHealthMonitor() {
552
+ setInterval(async () => {
553
+ if (!lockPath) return;
554
+ if (!fs.existsSync(lockPath)) await restartAll();
555
+ }, 10_000);
556
+ }
557
+
558
+ // ── Main ──────────────────────────────────────────────────────────────────────
559
+ const bridgePort = await startBridge();
560
+ if (!bridgePort) {
561
+ cleanup();
562
+ process.exit(1);
563
+ }
564
+
565
+ startClaude();
566
+ startRemote();
567
+ startHealthMonitor();
568
+ await startDashboard(bridgePort);
569
+
570
+ // Keep process alive (health monitor runs on setInterval)
571
+ log("orchestrator", "All processes started. Ctrl+C to stop.", C.green);
572
+ await new Promise(() => {}); // never resolves — process lives until Ctrl+C