patchwarden 0.6.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/PatchWarden-Control-Tray.cmd +11 -0
  2. package/PatchWarden-Control.cmd +6 -0
  3. package/PatchWarden-Desktop.cmd +5 -0
  4. package/PatchWarden.cmd +1 -1
  5. package/README.en.md +112 -24
  6. package/README.md +36 -25
  7. package/Restart-PatchWarden-Control.cmd +6 -0
  8. package/Stop-PatchWarden.cmd +11 -0
  9. package/dist/agents/agentRouter.d.ts +40 -0
  10. package/dist/agents/agentRouter.js +95 -0
  11. package/dist/config.d.ts +1 -1
  12. package/dist/config.js +6 -2
  13. package/dist/controlCenter.d.ts +14 -0
  14. package/dist/controlCenter.js +2015 -0
  15. package/dist/doctor.js +35 -4
  16. package/dist/goal/acceptanceEngine.d.ts +62 -0
  17. package/dist/goal/acceptanceEngine.js +103 -0
  18. package/dist/goal/acceptanceTemplate.d.ts +10 -0
  19. package/dist/goal/acceptanceTemplate.js +104 -0
  20. package/dist/goal/goalGraph.d.ts +58 -0
  21. package/dist/goal/goalGraph.js +189 -0
  22. package/dist/goal/goalProgress.d.ts +81 -0
  23. package/dist/goal/goalProgress.js +216 -0
  24. package/dist/goal/goalStatus.d.ts +60 -0
  25. package/dist/goal/goalStatus.js +137 -0
  26. package/dist/goal/goalStore.d.ts +79 -0
  27. package/dist/goal/goalStore.js +211 -0
  28. package/dist/goal/handoffExport.d.ts +34 -0
  29. package/dist/goal/handoffExport.js +183 -0
  30. package/dist/goal/subgoalSync.d.ts +40 -0
  31. package/dist/goal/subgoalSync.js +72 -0
  32. package/dist/goal/worktreeManager.d.ts +96 -0
  33. package/dist/goal/worktreeManager.js +292 -0
  34. package/dist/logging.d.ts +44 -0
  35. package/dist/logging.js +68 -0
  36. package/dist/release/releaseGate.d.ts +99 -0
  37. package/dist/release/releaseGate.js +475 -0
  38. package/dist/runner/postTaskCleanup.d.ts +13 -0
  39. package/dist/runner/postTaskCleanup.js +147 -0
  40. package/dist/runner/runTask.js +50 -10
  41. package/dist/security/discoveryTokenStore.d.ts +62 -0
  42. package/dist/security/discoveryTokenStore.js +100 -0
  43. package/dist/security/toolInvocationGuard.d.ts +35 -0
  44. package/dist/security/toolInvocationGuard.js +153 -0
  45. package/dist/smoke-test.js +18 -10
  46. package/dist/taskRuntime.d.ts +17 -0
  47. package/dist/test/unit/acceptance-engine.test.d.ts +1 -0
  48. package/dist/test/unit/acceptance-engine.test.js +228 -0
  49. package/dist/test/unit/agent-router.test.d.ts +1 -0
  50. package/dist/test/unit/agent-router.test.js +287 -0
  51. package/dist/test/unit/audit-checks.test.d.ts +1 -0
  52. package/dist/test/unit/audit-checks.test.js +350 -0
  53. package/dist/test/unit/diagnose-task.test.d.ts +1 -0
  54. package/dist/test/unit/diagnose-task.test.js +457 -0
  55. package/dist/test/unit/discovery-token-store.test.d.ts +1 -0
  56. package/dist/test/unit/discovery-token-store.test.js +139 -0
  57. package/dist/test/unit/goal-graph.test.d.ts +1 -0
  58. package/dist/test/unit/goal-graph.test.js +298 -0
  59. package/dist/test/unit/goal-progress.test.d.ts +1 -0
  60. package/dist/test/unit/goal-progress.test.js +381 -0
  61. package/dist/test/unit/goal-status.test.d.ts +1 -0
  62. package/dist/test/unit/goal-status.test.js +215 -0
  63. package/dist/test/unit/goal-store.test.d.ts +1 -0
  64. package/dist/test/unit/goal-store.test.js +253 -0
  65. package/dist/test/unit/goal-subgoal-task.test.d.ts +1 -0
  66. package/dist/test/unit/goal-subgoal-task.test.js +55 -0
  67. package/dist/test/unit/goal-tools-registry.test.d.ts +1 -0
  68. package/dist/test/unit/goal-tools-registry.test.js +190 -0
  69. package/dist/test/unit/handoff-export.test.d.ts +1 -0
  70. package/dist/test/unit/handoff-export.test.js +263 -0
  71. package/dist/test/unit/invoke-discovered-tool.test.d.ts +1 -0
  72. package/dist/test/unit/invoke-discovered-tool.test.js +167 -0
  73. package/dist/test/unit/logging.test.js +127 -5
  74. package/dist/test/unit/post-task-cleanup.test.d.ts +1 -0
  75. package/dist/test/unit/post-task-cleanup.test.js +48 -0
  76. package/dist/test/unit/reconcile-tasks.test.d.ts +1 -0
  77. package/dist/test/unit/reconcile-tasks.test.js +456 -0
  78. package/dist/test/unit/release-gate.test.d.ts +1 -0
  79. package/dist/test/unit/release-gate.test.js +242 -0
  80. package/dist/test/unit/safe-views.test.d.ts +1 -0
  81. package/dist/test/unit/safe-views.test.js +171 -0
  82. package/dist/test/unit/schema-drift-check.test.d.ts +1 -0
  83. package/dist/test/unit/schema-drift-check.test.js +175 -0
  84. package/dist/test/unit/subgoal-sync.test.d.ts +1 -0
  85. package/dist/test/unit/subgoal-sync.test.js +183 -0
  86. package/dist/test/unit/tool-invocation-guard.test.d.ts +1 -0
  87. package/dist/test/unit/tool-invocation-guard.test.js +432 -0
  88. package/dist/test/unit/tool-usage-stats.test.d.ts +1 -0
  89. package/dist/test/unit/tool-usage-stats.test.js +300 -0
  90. package/dist/test/unit/toolSearch.test.d.ts +1 -0
  91. package/dist/test/unit/toolSearch.test.js +571 -0
  92. package/dist/test/unit/worktree-manager.test.d.ts +1 -0
  93. package/dist/test/unit/worktree-manager.test.js +176 -0
  94. package/dist/tools/auditTask.d.ts +103 -1
  95. package/dist/tools/auditTask.js +461 -8
  96. package/dist/tools/cancelTask.js +1 -1
  97. package/dist/tools/checkReleaseGate.d.ts +21 -0
  98. package/dist/tools/checkReleaseGate.js +22 -0
  99. package/dist/tools/createTask.d.ts +18 -2
  100. package/dist/tools/createTask.js +29 -1
  101. package/dist/tools/diagnoseTask.d.ts +45 -0
  102. package/dist/tools/diagnoseTask.js +347 -0
  103. package/dist/tools/discardWorktree.d.ts +24 -0
  104. package/dist/tools/discardWorktree.js +27 -0
  105. package/dist/tools/discoverTools.d.ts +11 -0
  106. package/dist/tools/discoverTools.js +39 -0
  107. package/dist/tools/explainTool.d.ts +11 -0
  108. package/dist/tools/explainTool.js +21 -0
  109. package/dist/tools/getTaskSummary.js +2 -1
  110. package/dist/tools/goalSubgoalTask.d.ts +51 -0
  111. package/dist/tools/goalSubgoalTask.js +110 -0
  112. package/dist/tools/healthCheck.d.ts +1 -0
  113. package/dist/tools/healthCheck.js +5 -1
  114. package/dist/tools/invokeDiscoveredTool.d.ts +37 -0
  115. package/dist/tools/invokeDiscoveredTool.js +112 -0
  116. package/dist/tools/listTasks.d.ts +3 -1
  117. package/dist/tools/listTasks.js +14 -1
  118. package/dist/tools/mergeWorktree.d.ts +24 -0
  119. package/dist/tools/mergeWorktree.js +27 -0
  120. package/dist/tools/reconcileTasks.d.ts +41 -0
  121. package/dist/tools/reconcileTasks.js +352 -0
  122. package/dist/tools/registry.js +550 -0
  123. package/dist/tools/safeStatus.d.ts +31 -1
  124. package/dist/tools/safeStatus.js +43 -1
  125. package/dist/tools/safeViews.d.ts +256 -0
  126. package/dist/tools/safeViews.js +250 -0
  127. package/dist/tools/schemaDriftCheck.d.ts +46 -0
  128. package/dist/tools/schemaDriftCheck.js +80 -0
  129. package/dist/tools/toolCatalog.d.ts +4 -3
  130. package/dist/tools/toolCatalog.js +33 -11
  131. package/dist/tools/toolRegistry.d.ts +61 -0
  132. package/dist/tools/toolRegistry.js +724 -0
  133. package/dist/tools/toolSearch.d.ts +110 -0
  134. package/dist/tools/toolSearch.js +331 -0
  135. package/dist/tools/toolUsageStats.d.ts +41 -0
  136. package/dist/tools/toolUsageStats.js +116 -0
  137. package/dist/tools/waitForTask.js +1 -0
  138. package/dist/version.d.ts +2 -2
  139. package/dist/version.js +2 -2
  140. package/dist/watcherStatus.d.ts +29 -0
  141. package/dist/watcherStatus.js +92 -1
  142. package/docs/control-center/README.md +33 -0
  143. package/docs/control-center/control-center-daily-driver.md +211 -0
  144. package/docs/control-center/control-center-mvp.md +205 -0
  145. package/docs/control-center/control-center-phase2.md +159 -0
  146. package/docs/demo.md +3 -0
  147. package/docs/release-v0.6.4.md +45 -0
  148. package/examples/openai-tunnel/README.md +5 -5
  149. package/examples/openai-tunnel/tunnel-client.example.yaml +3 -3
  150. package/package.json +25 -16
  151. package/scripts/README.md +47 -0
  152. package/scripts/{brand-check.js → checks/brand-check.js} +2 -2
  153. package/scripts/checks/control-center-smoke.js +1098 -0
  154. package/scripts/{control-smoke.js → checks/control-smoke.js} +19 -5
  155. package/scripts/{doctor-smoke.js → checks/doctor-smoke.js} +2 -1
  156. package/scripts/{http-mcp-smoke.js → checks/http-mcp-smoke.js} +2 -2
  157. package/scripts/{lifecycle-smoke.js → checks/lifecycle-smoke.js} +25 -21
  158. package/scripts/{mcp-manifest-check.js → checks/mcp-manifest-check.js} +54 -7
  159. package/scripts/{mcp-smoke.js → checks/mcp-smoke.js} +40 -11
  160. package/scripts/{package-manifest-check.js → checks/package-manifest-check.js} +2 -1
  161. package/scripts/{tunnel-supervisor-smoke.js → checks/tunnel-supervisor-smoke.js} +2 -2
  162. package/scripts/{unit-tests.js → checks/unit-tests.js} +1 -1
  163. package/scripts/{watcher-supervisor-smoke.js → checks/watcher-supervisor-smoke.js} +13 -7
  164. package/scripts/control/control-center-tray.ps1 +281 -0
  165. package/scripts/{get-patchwarden-health.ps1 → control/get-patchwarden-health.ps1} +3 -3
  166. package/scripts/{manage-patchwarden.ps1 → control/manage-patchwarden.ps1} +30 -4
  167. package/scripts/control/restart-control-center.ps1 +173 -0
  168. package/scripts/{restart-patchwarden.ps1 → control/restart-patchwarden.ps1} +1 -1
  169. package/scripts/control/start-control-center.ps1 +263 -0
  170. package/scripts/{start-patchwarden-tunnel.ps1 → control/start-patchwarden-tunnel.ps1} +48 -6
  171. package/scripts/control/stop-patchwarden.ps1 +114 -0
  172. package/scripts/launchers/Check-PatchWarden-Health.cmd +1 -1
  173. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +1 -1
  174. package/scripts/launchers/Restart-PatchWarden.cmd +1 -1
  175. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +1 -1
  176. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +1 -1
  177. package/scripts/{patchwarden-mcp-direct.cmd → mcp/patchwarden-mcp-direct.cmd} +1 -1
  178. package/scripts/{patchwarden-mcp-stdio.cmd → mcp/patchwarden-mcp-stdio.cmd} +1 -1
  179. package/scripts/{pack-clean.js → release/pack-clean.js} +9 -1
  180. package/src/agents/agentRouter.ts +149 -0
  181. package/src/config.ts +9 -3
  182. package/src/controlCenter.ts +2166 -0
  183. package/src/doctor.ts +40 -5
  184. package/src/goal/acceptanceEngine.ts +160 -0
  185. package/src/goal/acceptanceTemplate.ts +121 -0
  186. package/src/goal/goalGraph.ts +225 -0
  187. package/src/goal/goalProgress.ts +301 -0
  188. package/src/goal/goalStatus.ts +234 -0
  189. package/src/goal/goalStore.ts +306 -0
  190. package/src/goal/handoffExport.ts +211 -0
  191. package/src/goal/subgoalSync.ts +82 -0
  192. package/src/goal/worktreeManager.ts +404 -0
  193. package/src/logging.ts +91 -0
  194. package/src/release/releaseGate.ts +567 -0
  195. package/src/runner/postTaskCleanup.ts +154 -0
  196. package/src/runner/runTask.ts +49 -10
  197. package/src/security/discoveryTokenStore.ts +157 -0
  198. package/src/security/toolInvocationGuard.ts +251 -0
  199. package/src/smoke-test.ts +15 -7
  200. package/src/taskRuntime.ts +17 -0
  201. package/src/test/unit/acceptance-engine.test.ts +261 -0
  202. package/src/test/unit/agent-router.test.ts +342 -0
  203. package/src/test/unit/audit-checks.test.ts +567 -0
  204. package/src/test/unit/diagnose-task.test.ts +544 -0
  205. package/src/test/unit/discovery-token-store.test.ts +181 -0
  206. package/src/test/unit/goal-graph.test.ts +347 -0
  207. package/src/test/unit/goal-progress.test.ts +538 -0
  208. package/src/test/unit/goal-status.test.ts +270 -0
  209. package/src/test/unit/goal-store.test.ts +318 -0
  210. package/src/test/unit/goal-subgoal-task.test.ts +72 -0
  211. package/src/test/unit/goal-tools-registry.test.ts +243 -0
  212. package/src/test/unit/handoff-export.test.ts +295 -0
  213. package/src/test/unit/invoke-discovered-tool.test.ts +216 -0
  214. package/src/test/unit/logging.test.ts +177 -5
  215. package/src/test/unit/post-task-cleanup.test.ts +53 -0
  216. package/src/test/unit/reconcile-tasks.test.ts +551 -0
  217. package/src/test/unit/release-gate.test.ts +314 -0
  218. package/src/test/unit/safe-views.test.ts +184 -0
  219. package/src/test/unit/schema-drift-check.test.ts +258 -0
  220. package/src/test/unit/subgoal-sync.test.ts +236 -0
  221. package/src/test/unit/tool-invocation-guard.test.ts +542 -0
  222. package/src/test/unit/tool-usage-stats.test.ts +384 -0
  223. package/src/test/unit/toolSearch.test.ts +756 -0
  224. package/src/test/unit/worktree-manager.test.ts +247 -0
  225. package/src/tools/auditTask.ts +831 -402
  226. package/src/tools/cancelTask.ts +1 -1
  227. package/src/tools/checkReleaseGate.ts +45 -0
  228. package/src/tools/createTask.ts +64 -6
  229. package/src/tools/diagnoseTask.ts +460 -0
  230. package/src/tools/discardWorktree.ts +42 -0
  231. package/src/tools/discoverTools.ts +51 -0
  232. package/src/tools/explainTool.ts +26 -0
  233. package/src/tools/getTaskSummary.ts +2 -1
  234. package/src/tools/goalSubgoalTask.ts +170 -0
  235. package/src/tools/healthCheck.ts +4 -1
  236. package/src/tools/invokeDiscoveredTool.ts +163 -0
  237. package/src/tools/listTasks.ts +16 -2
  238. package/src/tools/mergeWorktree.ts +42 -0
  239. package/src/tools/reconcileTasks.ts +464 -0
  240. package/src/tools/registry.ts +618 -1
  241. package/src/tools/safeStatus.ts +73 -2
  242. package/src/tools/safeViews.ts +271 -0
  243. package/src/tools/schemaDriftCheck.ts +120 -0
  244. package/src/tools/toolCatalog.ts +34 -11
  245. package/src/tools/toolRegistry.ts +786 -0
  246. package/src/tools/toolSearch.ts +464 -0
  247. package/src/tools/toolUsageStats.ts +151 -0
  248. package/src/tools/waitForTask.ts +1 -0
  249. package/src/version.ts +2 -2
  250. package/src/watcherStatus.ts +97 -1
  251. package/ui/colors_and_type.css +141 -0
  252. package/ui/pages/audit.html +743 -0
  253. package/ui/pages/dashboard.html +1154 -0
  254. package/ui/pages/direct-sessions.html +652 -0
  255. package/ui/pages/logs.html +502 -0
  256. package/ui/pages/task-detail.html +1229 -0
  257. package/ui/pages/tasks.html +702 -0
  258. package/ui/pages/workspace.html +947 -0
  259. package/ui/partials/project-shell.html +362 -0
  260. package/ui/vendor/lucide.js +12 -0
  261. package/ui/vendor/tailwindcss-browser.js +947 -0
@@ -0,0 +1,2166 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PatchWarden Control Center — local HTTP dashboard service.
4
+ *
5
+ * Binds to 127.0.0.1 only. Serves the static UI from `ui/` and exposes a set
6
+ * of fault-tolerant JSON APIs for inspecting runtime state and driving
7
+ * `scripts/control/manage-patchwarden.ps1` for process lifecycle.
8
+ *
9
+ * Run: node dist/controlCenter.js
10
+ * or: npm run start:control
11
+ *
12
+ * Port override: PATCHWARDEN_CONTROL_PORT=<n> or --port <n>
13
+ */
14
+
15
+ import { createServer, get as httpGet, type IncomingMessage, type ServerResponse } from "node:http";
16
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
17
+ import { delimiter, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { randomUUID } from "node:crypto";
20
+ import { spawn, execFile } from "node:child_process";
21
+ import { homedir } from "node:os";
22
+ import {
23
+ getTasksDir,
24
+ getDirectSessionsDir,
25
+ loadConfig,
26
+ resolveWorkspaceRoot,
27
+ type PatchWardenConfig,
28
+ } from "./config.js";
29
+ import { listTasks, type TaskEntry } from "./tools/listTasks.js";
30
+ import type { AcceptanceStatus } from "./tools/createTask.js";
31
+ import { listAgents, type AgentAvailability } from "./tools/listAgents.js";
32
+ import { readWatcherStatus, type WatcherStatusSnapshot } from "./watcherStatus.js";
33
+ import { redactSensitiveContent } from "./security/contentRedaction.js";
34
+ import { guardWorkspacePath } from "./security/pathGuard.js";
35
+ import { auditTask } from "./tools/auditTask.js";
36
+ import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "./version.js";
37
+
38
+ // ── Paths ─────────────────────────────────────────────────────────
39
+
40
+ const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
41
+ const uiRoot = join(projectRoot, "ui");
42
+ const manageScriptPath = join(projectRoot, "scripts", "control", "manage-patchwarden.ps1");
43
+ const PAGE_ALIASES: Record<string, string> = {
44
+ "/dashboard.html": "pages/dashboard.html",
45
+ "/tasks.html": "pages/tasks.html",
46
+ "/workspace.html": "pages/workspace.html",
47
+ "/audit.html": "pages/audit.html",
48
+ "/task-detail.html": "pages/task-detail.html",
49
+ "/direct-sessions.html": "pages/direct-sessions.html",
50
+ "/logs.html": "pages/logs.html",
51
+ };
52
+
53
+ // ── Config (fault-tolerant bootstrap) ─────────────────────────────
54
+
55
+ function createFallbackConfig(): PatchWardenConfig {
56
+ return {
57
+ workspaceRoot: process.cwd(),
58
+ plansDir: ".patchwarden/plans",
59
+ tasksDir: ".patchwarden/tasks",
60
+ assessmentsDir: ".patchwarden/assessments",
61
+ assessmentTtlSeconds: 3600,
62
+ agents: {},
63
+ allowedTestCommands: [],
64
+ repoAllowedTestCommands: {},
65
+ maxReadFileBytes: 200_000,
66
+ defaultTaskTimeoutSeconds: 900,
67
+ maxTaskTimeoutSeconds: 3600,
68
+ watcherStaleSeconds: 30,
69
+ toolProfile: "full",
70
+ enableDirectProfile: false,
71
+ directSessionsDir: ".patchwarden/direct-sessions",
72
+ directSessionTtlSeconds: 3600,
73
+ directMaxPatchBytes: 200_000,
74
+ directMaxFileBytes: 500_000,
75
+ };
76
+ }
77
+
78
+ let config: PatchWardenConfig;
79
+ try {
80
+ config = loadConfig();
81
+ } catch (err) {
82
+ console.error(
83
+ `[control-center] WARNING: Failed to load config (${errorMessage(err)}). Using fallback defaults.`
84
+ );
85
+ config = createFallbackConfig();
86
+ }
87
+
88
+ // ── Control token (in-memory only) ────────────────────────────────
89
+
90
+ const controlToken = randomUUID();
91
+
92
+ // ── Port resolution ───────────────────────────────────────────────
93
+
94
+ function resolvePort(): number {
95
+ const argv = process.argv.slice(2);
96
+ for (let i = 0; i < argv.length; i++) {
97
+ const arg = argv[i];
98
+ if (arg === "--port" && i + 1 < argv.length) {
99
+ const n = parseInt(argv[i + 1], 10);
100
+ if (Number.isFinite(n) && n >= 0) return n;
101
+ }
102
+ const m = arg.match(/^--port=(\d+)$/);
103
+ if (m) {
104
+ const n = parseInt(m[1], 10);
105
+ if (Number.isFinite(n) && n >= 0) return n;
106
+ }
107
+ }
108
+ const envPort = parseInt(process.env.PATCHWARDEN_CONTROL_PORT || "", 10);
109
+ if (Number.isFinite(envPort) && envPort >= 0) return envPort;
110
+ return 8090;
111
+ }
112
+
113
+ const port = resolvePort();
114
+ const host = "127.0.0.1";
115
+
116
+ // Core/Direct probe base URLs — overridable for tests so the smoke test does
117
+ // not depend on the real 8080/8081 ports being free on the host.
118
+ const CORE_BASE_URL = process.env.PATCHWARDEN_CORE_URL || "http://127.0.0.1:8080";
119
+ const DIRECT_BASE_URL = process.env.PATCHWARDEN_DIRECT_URL || "http://127.0.0.1:8081";
120
+ const DEFAULT_TUNNEL_CLIENT_EXE = "D:\\ai_agent\\tunnel-client-v0.0.9--context-conduit-topaz-windows-amd64\\tunnel-client.exe";
121
+ const CONTROL_CENTER_FAVICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
122
+ <rect width="64" height="64" rx="14" fill="#0a0e14"/>
123
+ <path d="M32 52s17-8 17-27V14L32 8 15 14v11c0 19 17 27 17 27z" fill="#111820" stroke="#2dd4a8" stroke-width="4" stroke-linejoin="round"/>
124
+ <path d="M24 32l6 6 12-14" fill="none" stroke="#2dd4a8" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
125
+ </svg>`;
126
+
127
+ // ── Helpers ───────────────────────────────────────────────────────
128
+
129
+ function errorMessage(err: unknown): string {
130
+ return err instanceof Error ? err.message : String(err);
131
+ }
132
+
133
+ function getRuntimeRoot(direct: boolean): string {
134
+ const base =
135
+ process.platform === "win32" && process.env.LOCALAPPDATA
136
+ ? join(process.env.LOCALAPPDATA, "patchwarden")
137
+ : join(homedir(), ".patchwarden");
138
+ return join(base, direct ? "runtime-direct" : "runtime");
139
+ }
140
+
141
+ function getControlCenterLogDir(): string {
142
+ // Test/local override: when set to an absolute path, use it directly so the
143
+ // smoke test can redirect status/events/log files into a sandbox-writable
144
+ // directory under the project root instead of LOCALAPPDATA.
145
+ const override = process.env.PATCHWARDEN_CONTROL_LOG_DIR;
146
+ if (override && isAbsolute(override)) return override;
147
+ const base =
148
+ process.platform === "win32" && process.env.LOCALAPPDATA
149
+ ? join(process.env.LOCALAPPDATA, "patchwarden")
150
+ : join(homedir(), ".patchwarden");
151
+ return join(base, "control-center");
152
+ }
153
+
154
+ // Status + events files live alongside the control-center logs so the launcher
155
+ // can discover a running instance without probing the port blindly.
156
+ const controlCenterStatusPath = join(getControlCenterLogDir(), "control-center-status.json");
157
+ const controlCenterEventsPath = join(getControlCenterLogDir(), "control-center-events.jsonl");
158
+ const MAX_EVENT_LINES = 2000;
159
+
160
+ const ALLOWED_LOG_TAILS = new Set([100, 300, 1000]);
161
+
162
+ function resolveTailParam(value: string | null): number {
163
+ if (value === null) return 100;
164
+ const n = parseInt(value, 10);
165
+ if (Number.isFinite(n) && ALLOWED_LOG_TAILS.has(n)) return n;
166
+ return 100;
167
+ }
168
+
169
+ const CONTENT_TYPES: Record<string, string> = {
170
+ ".html": "text/html; charset=utf-8",
171
+ ".css": "text/css; charset=utf-8",
172
+ ".js": "application/javascript; charset=utf-8",
173
+ ".mjs": "application/javascript; charset=utf-8",
174
+ ".json": "application/json; charset=utf-8",
175
+ ".svg": "image/svg+xml",
176
+ ".png": "image/png",
177
+ ".jpg": "image/jpeg",
178
+ ".jpeg": "image/jpeg",
179
+ ".gif": "image/gif",
180
+ ".ico": "image/x-icon",
181
+ ".woff": "font/woff",
182
+ ".woff2": "font/woff2",
183
+ ".map": "application/json; charset=utf-8",
184
+ ".txt": "text/plain; charset=utf-8",
185
+ };
186
+
187
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
188
+ let payload: string;
189
+ try {
190
+ payload = JSON.stringify(body);
191
+ } catch (err) {
192
+ payload = JSON.stringify({ error: `serialization failed: ${errorMessage(err)}` });
193
+ status = 500;
194
+ }
195
+ res.writeHead(status, {
196
+ "Content-Type": "application/json; charset=utf-8",
197
+ "Cache-Control": "no-store",
198
+ "X-Content-Type-Options": "nosniff",
199
+ });
200
+ res.end(payload);
201
+ }
202
+
203
+ function checkControlToken(req: IncomingMessage): boolean {
204
+ const header = req.headers["x-patchwarden-control-token"];
205
+ const provided = Array.isArray(header) ? header[0] : header;
206
+ if (typeof provided !== "string" || provided.length === 0) return false;
207
+ return provided === controlToken;
208
+ }
209
+
210
+ function readBody(req: IncomingMessage): Promise<unknown | null> {
211
+ return new Promise((resolve) => {
212
+ let total = 0;
213
+ const chunks: Buffer[] = [];
214
+ let aborted = false;
215
+ req.on("data", (chunk: Buffer) => {
216
+ if (aborted) return;
217
+ total += chunk.length;
218
+ if (total > 1024 * 1024) {
219
+ aborted = true;
220
+ try { req.destroy(); } catch { /* ignore */ }
221
+ resolve(null);
222
+ return;
223
+ }
224
+ chunks.push(chunk);
225
+ });
226
+ req.on("end", () => {
227
+ if (aborted) return;
228
+ if (chunks.length === 0) {
229
+ resolve(null);
230
+ return;
231
+ }
232
+ const text = Buffer.concat(chunks).toString("utf-8");
233
+ try {
234
+ resolve(JSON.parse(text));
235
+ } catch {
236
+ resolve(null);
237
+ }
238
+ });
239
+ req.on("error", () => resolve(null));
240
+ });
241
+ }
242
+
243
+ function readJsonFileSafe<T = unknown>(filePath: string): T | null {
244
+ if (!existsSync(filePath)) return null;
245
+ try {
246
+ const raw = readFileSync(filePath, "utf-8");
247
+ return JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw) as T;
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
252
+
253
+ function readTextFileSafe(filePath: string): string | null {
254
+ if (!existsSync(filePath)) return null;
255
+ try {
256
+ return readFileSync(filePath, "utf-8");
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+
262
+ function readFileTail(filePath: string, lines = 100): string {
263
+ if (!existsSync(filePath)) return "";
264
+ const content = readFileSync(filePath, "utf-8");
265
+ const allLines = content.split(/\r?\n/);
266
+ if (allLines.length > 0 && allLines[allLines.length - 1] === "") allLines.pop();
267
+ return allLines.slice(-lines).join("\n");
268
+ }
269
+
270
+ function findLatestLog(dir: string, pattern: RegExp): string | null {
271
+ if (!existsSync(dir)) return null;
272
+ let entries;
273
+ try {
274
+ entries = readdirSync(dir, { withFileTypes: true });
275
+ } catch {
276
+ return null;
277
+ }
278
+ const files = entries.filter((e) => e.isFile() && pattern.test(e.name));
279
+ if (files.length === 0) return null;
280
+ let latestName = files[0].name;
281
+ let latestMtime = -1;
282
+ for (const f of files) {
283
+ try {
284
+ const m = statSync(join(dir, f.name)).mtimeMs;
285
+ if (m > latestMtime) {
286
+ latestMtime = m;
287
+ latestName = f.name;
288
+ }
289
+ } catch {
290
+ /* keep current */
291
+ }
292
+ }
293
+ return join(dir, latestName);
294
+ }
295
+
296
+ // ── Health probing ────────────────────────────────────────────────
297
+
298
+ interface HealthProbe {
299
+ available: boolean;
300
+ status: number | null;
301
+ reason: string | null;
302
+ }
303
+
304
+ function probeHealthStatus(targetUrl: string): Promise<HealthProbe> {
305
+ return new Promise((resolve) => {
306
+ const controller = new AbortController();
307
+ let settled = false;
308
+ const finish = (result: HealthProbe) => {
309
+ if (settled) return;
310
+ settled = true;
311
+ clearTimeout(timer);
312
+ resolve(result);
313
+ };
314
+ const timer = setTimeout(() => {
315
+ try { controller.abort(); } catch { /* ignore */ }
316
+ finish({ available: false, status: null, reason: "timeout after 2000ms" });
317
+ }, 2000);
318
+ try {
319
+ const req = httpGet(targetUrl, { signal: controller.signal }, (resp) => {
320
+ resp.resume();
321
+ const status = resp.statusCode ?? 0;
322
+ // Only 2xx counts as available. A 404 from an unrelated service (or a
323
+ // 4xx/5xx from the real service) must NOT be treated as healthy.
324
+ if (status >= 200 && status < 300) {
325
+ finish({ available: true, status, reason: null });
326
+ } else {
327
+ finish({ available: false, status, reason: `unexpected status ${status}` });
328
+ }
329
+ });
330
+ req.on("error", (err) => {
331
+ finish({ available: false, status: null, reason: err.message });
332
+ });
333
+ } catch (err) {
334
+ finish({ available: false, status: null, reason: errorMessage(err) });
335
+ }
336
+ });
337
+ }
338
+
339
+ interface RuntimeHealth {
340
+ available: boolean;
341
+ reason: string | null;
342
+ healthz: { status: number } | null;
343
+ readyz: { status: number } | null;
344
+ }
345
+
346
+ async function probeRuntimeHealth(baseUrl: string): Promise<RuntimeHealth> {
347
+ const [h, r] = await Promise.all([
348
+ probeHealthStatus(`${baseUrl}/healthz`),
349
+ probeHealthStatus(`${baseUrl}/readyz`),
350
+ ]);
351
+ if (h.available && r.available && h.status !== null && r.status !== null) {
352
+ return { available: true, reason: null, healthz: { status: h.status }, readyz: { status: r.status } };
353
+ }
354
+ const failed = !h.available ? h : r;
355
+ return { available: false, reason: failed.reason ?? "unavailable", healthz: null, readyz: null };
356
+ }
357
+
358
+ // ── Runtime file readers ──────────────────────────────────────────
359
+
360
+ function readTunnelStatus(direct: boolean): Record<string, unknown> {
361
+ const filePath = join(getRuntimeRoot(direct), "tunnel-status.json");
362
+ if (!existsSync(filePath)) return { observed: false };
363
+ try {
364
+ const data = readJsonFileSafe<Record<string, unknown>>(filePath);
365
+ if (data === null) return { observed: true, error: "invalid JSON" };
366
+ return { observed: true, ...data };
367
+ } catch (err) {
368
+ return { observed: true, error: errorMessage(err) };
369
+ }
370
+ }
371
+
372
+ interface ToolManifestSummary {
373
+ tool_profile: string | null;
374
+ tool_count: number | null;
375
+ schema_epoch: string | null;
376
+ tool_manifest_sha256: string | null;
377
+ tool_names: string[] | null;
378
+ }
379
+
380
+ function readToolManifest(direct: boolean): ToolManifestSummary {
381
+ const empty: ToolManifestSummary = {
382
+ tool_profile: null,
383
+ tool_count: null,
384
+ schema_epoch: null,
385
+ tool_manifest_sha256: null,
386
+ tool_names: null,
387
+ };
388
+ const filePath = join(getRuntimeRoot(direct), "tool-manifest.json");
389
+ if (!existsSync(filePath)) return empty;
390
+ const data = readJsonFileSafe<Record<string, unknown>>(filePath);
391
+ if (!data) return empty;
392
+ return {
393
+ tool_profile: typeof data.tool_profile === "string" ? data.tool_profile : null,
394
+ tool_count: typeof data.tool_count === "number" ? data.tool_count : null,
395
+ schema_epoch: typeof data.schema_epoch === "string" ? data.schema_epoch : null,
396
+ tool_manifest_sha256: typeof data.tool_manifest_sha256 === "string" ? data.tool_manifest_sha256 : null,
397
+ tool_names: Array.isArray(data.tool_names) ? (data.tool_names as string[]) : null,
398
+ };
399
+ }
400
+
401
+ function readTunnelUrl(direct: boolean): { url: string | null; reason: string | null } {
402
+ const filePath = join(getRuntimeRoot(direct), "tunnel-health-url.txt");
403
+ if (!existsSync(filePath)) return { url: null, reason: "tunnel-health-url.txt not found" };
404
+ try {
405
+ const content = readFileSync(filePath, "utf-8").trim();
406
+ if (!content) return { url: null, reason: "tunnel-health-url.txt is empty" };
407
+ return { url: content, reason: null };
408
+ } catch (err) {
409
+ return { url: null, reason: errorMessage(err) };
410
+ }
411
+ }
412
+
413
+ // ── Safe wrappers around reusable modules ─────────────────────────
414
+
415
+ function readWatcherStatusSafe(): WatcherStatusSnapshot {
416
+ try {
417
+ return readWatcherStatus(config);
418
+ } catch (err) {
419
+ return {
420
+ status: "unreadable",
421
+ available: false,
422
+ stale_after_seconds: config.watcherStaleSeconds,
423
+ last_heartbeat_at: null,
424
+ heartbeat_age_seconds: null,
425
+ heartbeat_pid: null,
426
+ instance_id: null,
427
+ launcher_pid: null,
428
+ reason: errorMessage(err),
429
+ activity: null,
430
+ };
431
+ }
432
+ }
433
+
434
+ function listAgentsSafe(): AgentAvailability[] {
435
+ try {
436
+ return listAgents().agents;
437
+ } catch {
438
+ return [];
439
+ }
440
+ }
441
+
442
+ function resolveWorkspaceRootSafe(): string | null {
443
+ try {
444
+ return resolveWorkspaceRoot(config);
445
+ } catch {
446
+ return null;
447
+ }
448
+ }
449
+
450
+ interface StatusTasks {
451
+ tasks: unknown[];
452
+ total: number;
453
+ active: number;
454
+ stale: number;
455
+ stale_task_ids: string[];
456
+ reason: string | null;
457
+ }
458
+
459
+ // ── Stale task classification ─────────────────────────────────────
460
+
461
+ interface StaleClassification {
462
+ is_stale: boolean;
463
+ stale_reasons: string[];
464
+ }
465
+
466
+ const TERMINAL_TASK_STATUSES = new Set([
467
+ "done",
468
+ "done_by_agent",
469
+ "failed",
470
+ "failed_verification",
471
+ "failed_scope_violation",
472
+ "failed_policy_violation",
473
+ "canceled",
474
+ "timeout",
475
+ ]);
476
+
477
+ /**
478
+ * Classify a task as stale based on Phase 2 rules:
479
+ * - status=running but last_heartbeat_at exceeds threshold
480
+ * - phase=collecting_artifacts exceeds threshold
481
+ * - current_command=null AND watcher currently healthy
482
+ * - task last_heartbeat_at significantly earlier than current watcher heartbeat
483
+ *
484
+ * Only pending/running tasks can be stale; terminal tasks are never stale.
485
+ */
486
+ function classifyStaleTask(
487
+ task: TaskEntry,
488
+ watcher: WatcherStatusSnapshot,
489
+ nowMs = Date.now()
490
+ ): StaleClassification {
491
+ const reasons: string[] = [];
492
+ if (TERMINAL_TASK_STATUSES.has(task.status)) {
493
+ return { is_stale: false, stale_reasons: reasons };
494
+ }
495
+ // Only pending/running are candidates for staleness.
496
+ if (task.status !== "pending" && task.status !== "running") {
497
+ return { is_stale: false, stale_reasons: reasons };
498
+ }
499
+
500
+ const staleThresholdMs = config.watcherStaleSeconds * 1000;
501
+ const hbMs = Date.parse(task.last_heartbeat_at || "");
502
+ const heartbeatAgeMs = Number.isFinite(hbMs) ? Math.max(0, nowMs - hbMs) : null;
503
+
504
+ // Rule 1: running with stale heartbeat
505
+ if (task.status === "running" && heartbeatAgeMs !== null && heartbeatAgeMs > staleThresholdMs) {
506
+ reasons.push("heartbeat_stale");
507
+ }
508
+
509
+ // Rule 2: collecting_artifacts phase exceeds threshold
510
+ if (task.phase === "collecting_artifacts" && heartbeatAgeMs !== null && heartbeatAgeMs > staleThresholdMs) {
511
+ reasons.push("collecting_artifacts_stale");
512
+ }
513
+
514
+ // Rule 3: running with no current_command while watcher is healthy
515
+ if (
516
+ task.status === "running" &&
517
+ (task.current_command === null || task.current_command === "") &&
518
+ watcher.status === "healthy"
519
+ ) {
520
+ reasons.push("running_no_command_watcher_healthy");
521
+ }
522
+
523
+ // Rule 4: task heartbeat significantly earlier than watcher heartbeat
524
+ if (heartbeatAgeMs !== null && watcher.last_heartbeat_at) {
525
+ const watcherHbMs = Date.parse(watcher.last_heartbeat_at);
526
+ if (Number.isFinite(watcherHbMs)) {
527
+ const gapMs = watcherHbMs - hbMs;
528
+ // Task heartbeat is "significantly earlier" than watcher heartbeat when
529
+ // the task has not heartbeat for at least 2x the stale threshold while
530
+ // the watcher is alive.
531
+ if (gapMs > staleThresholdMs * 2 && watcher.status === "healthy") {
532
+ reasons.push("heartbeat_far_behind_watcher");
533
+ }
534
+ }
535
+ }
536
+
537
+ return { is_stale: reasons.length > 0, stale_reasons: reasons };
538
+ }
539
+
540
+ function augmentTaskWithStale(task: TaskEntry, watcher: WatcherStatusSnapshot, nowMs = Date.now()): TaskEntry & StaleClassification {
541
+ const cls = classifyStaleTask(task, watcher, nowMs);
542
+ return { ...task, is_stale: cls.is_stale, stale_reasons: cls.stale_reasons };
543
+ }
544
+
545
+ function listTasksForStatus(): StatusTasks {
546
+ try {
547
+ const result = listTasks({ limit: 100 });
548
+ const watcher = result.watcher;
549
+ const now = Date.now();
550
+ let active = 0;
551
+ let stale = 0;
552
+ const staleTaskIds: string[] = [];
553
+ const augmented = result.tasks.map((t) => {
554
+ const a = augmentTaskWithStale(t, watcher, now);
555
+ if (t.status === "pending" || t.status === "running") active++;
556
+ if (a.is_stale) {
557
+ stale++;
558
+ staleTaskIds.push(t.task_id);
559
+ }
560
+ return a;
561
+ });
562
+ return { tasks: augmented, total: result.total, active, stale, stale_task_ids: staleTaskIds, reason: null };
563
+ } catch (err) {
564
+ return { tasks: [], total: 0, active: 0, stale: 0, stale_task_ids: [], reason: errorMessage(err) };
565
+ }
566
+ }
567
+
568
+ // ── manage-patchwarden.ps1 invocation ─────────────────────────────
569
+
570
+ interface ManageResult {
571
+ exitCode: number;
572
+ stdout: string;
573
+ stderr: string;
574
+ }
575
+
576
+ function runManageAction(action: string, mode: string): Promise<ManageResult> {
577
+ return new Promise((resolveP, rejectP) => {
578
+ let child;
579
+ try {
580
+ child = spawn(
581
+ "powershell.exe",
582
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", manageScriptPath, action, mode, "-Background"],
583
+ { cwd: projectRoot, windowsHide: true }
584
+ );
585
+ } catch (err) {
586
+ rejectP(err);
587
+ return;
588
+ }
589
+ let stdout = "";
590
+ let stderr = "";
591
+ let settled = false;
592
+ const timer = setTimeout(() => {
593
+ if (settled) return;
594
+ settled = true;
595
+ try { child.kill(); } catch { /* ignore */ }
596
+ rejectP(new Error(`manage-patchwarden.ps1 timed out after 60s (action=${action}, mode=${mode})`));
597
+ }, 60_000);
598
+ child.stdout.on("data", (d: Buffer) => {
599
+ stdout += d.toString("utf-8");
600
+ });
601
+ child.stderr.on("data", (d: Buffer) => {
602
+ stderr += d.toString("utf-8");
603
+ });
604
+ child.on("error", (err) => {
605
+ if (settled) return;
606
+ settled = true;
607
+ clearTimeout(timer);
608
+ rejectP(err);
609
+ });
610
+ child.on("close", (code) => {
611
+ if (settled) return;
612
+ settled = true;
613
+ clearTimeout(timer);
614
+ resolveP({ exitCode: code ?? -1, stdout, stderr });
615
+ });
616
+ });
617
+ }
618
+
619
+ // ── Static file serving ───────────────────────────────────────────
620
+
621
+ function serveStatic(res: ServerResponse, urlPath: string): void {
622
+ let candidate = "";
623
+ try {
624
+ const normalized = urlPath.startsWith("/") ? urlPath.slice(1) : urlPath;
625
+ let decoded: string;
626
+ try {
627
+ decoded = decodeURIComponent(normalized);
628
+ } catch {
629
+ sendJson(res, 400, { error: "Invalid path encoding" });
630
+ return;
631
+ }
632
+ if (decoded.includes("\0")) {
633
+ sendJson(res, 400, { error: "Invalid path" });
634
+ return;
635
+ }
636
+ const segments = decoded.split("/").filter(Boolean);
637
+ if (segments.some((s) => s === "..")) {
638
+ sendJson(res, 403, { error: "Forbidden" });
639
+ return;
640
+ }
641
+ candidate = resolve(uiRoot, ...segments);
642
+ const rel = relative(uiRoot, candidate);
643
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
644
+ sendJson(res, 403, { error: "Forbidden" });
645
+ return;
646
+ }
647
+ } catch (err) {
648
+ sendJson(res, 500, { error: errorMessage(err) });
649
+ return;
650
+ }
651
+ try {
652
+ if (!existsSync(candidate) || !statSync(candidate).isFile()) {
653
+ sendJson(res, 404, { error: "Not found" });
654
+ return;
655
+ }
656
+ const ext = extname(candidate).toLowerCase();
657
+ const contentType = CONTENT_TYPES[ext] || "application/octet-stream";
658
+ const content = readFileSync(candidate);
659
+ res.writeHead(200, { "Content-Type": contentType });
660
+ res.end(content);
661
+ } catch (err) {
662
+ sendJson(res, 500, { error: errorMessage(err) });
663
+ }
664
+ }
665
+
666
+ function serveFavicon(res: ServerResponse): void {
667
+ res.writeHead(200, {
668
+ "Content-Type": "image/svg+xml; charset=utf-8",
669
+ "Cache-Control": "public, max-age=86400",
670
+ "X-Content-Type-Options": "nosniff",
671
+ });
672
+ res.end(CONTROL_CENTER_FAVICON);
673
+ }
674
+
675
+ // ── Control Center status file + activity timeline ────────────────
676
+
677
+ interface ControlCenterStatusFile {
678
+ pid: number;
679
+ port: number;
680
+ started_at: string;
681
+ url: string;
682
+ version: string;
683
+ }
684
+
685
+ function writeStatusFile(): void {
686
+ const status: ControlCenterStatusFile = {
687
+ pid: process.pid,
688
+ port,
689
+ started_at: new Date().toISOString(),
690
+ url: `http://${host}:${port}/`,
691
+ version: PATCHWARDEN_VERSION,
692
+ };
693
+ try {
694
+ mkdirSync(getControlCenterLogDir(), { recursive: true });
695
+ writeFileSync(controlCenterStatusPath, JSON.stringify(status, null, 2), "utf-8");
696
+ } catch (err) {
697
+ console.error(`[control-center] Failed to write status file: ${errorMessage(err)}`);
698
+ }
699
+ }
700
+
701
+ function removeStatusFile(): void {
702
+ try {
703
+ if (existsSync(controlCenterStatusPath)) {
704
+ unlinkSync(controlCenterStatusPath);
705
+ }
706
+ } catch (err) {
707
+ console.error(`[control-center] Failed to remove status file: ${errorMessage(err)}`);
708
+ }
709
+ }
710
+
711
+ interface ControlCenterEvent {
712
+ timestamp: string;
713
+ type: string;
714
+ payload?: Record<string, unknown>;
715
+ }
716
+
717
+ /**
718
+ * Append a single event line to the JSONL timeline. Best-effort: a write
719
+ * failure is logged but never crashes the server. Trims the file to
720
+ * MAX_EVENT_LINES lazily when it grows past 1.5x the cap, so we don't pay the
721
+ * trim cost on every event.
722
+ */
723
+ function recordEvent(type: string, payload?: Record<string, unknown>): void {
724
+ const event: ControlCenterEvent = {
725
+ timestamp: new Date().toISOString(),
726
+ type,
727
+ payload,
728
+ };
729
+ try {
730
+ mkdirSync(getControlCenterLogDir(), { recursive: true });
731
+ appendFileSync(controlCenterEventsPath, JSON.stringify(event) + "\n", "utf-8");
732
+ } catch (err) {
733
+ console.error(`[control-center] Failed to write event: ${errorMessage(err)}`);
734
+ }
735
+ // Lazy trim: only when the file grows well past the cap.
736
+ try {
737
+ const stat = statSync(controlCenterEventsPath);
738
+ if (stat.size > 512 * 1024) {
739
+ trimEventsFile();
740
+ }
741
+ } catch {
742
+ /* ignore */
743
+ }
744
+ }
745
+
746
+ function trimEventsFile(): void {
747
+ try {
748
+ if (!existsSync(controlCenterEventsPath)) return;
749
+ const raw = readFileSync(controlCenterEventsPath, "utf-8");
750
+ const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
751
+ if (lines.length <= MAX_EVENT_LINES) return;
752
+ const trimmed = lines.slice(lines.length - MAX_EVENT_LINES);
753
+ writeFileSync(controlCenterEventsPath, trimmed.join("\n") + "\n", "utf-8");
754
+ } catch (err) {
755
+ console.error(`[control-center] Failed to trim events file: ${errorMessage(err)}`);
756
+ }
757
+ }
758
+
759
+ function readEvents(limit: number): ControlCenterEvent[] {
760
+ if (!existsSync(controlCenterEventsPath)) return [];
761
+ try {
762
+ const raw = readFileSync(controlCenterEventsPath, "utf-8");
763
+ const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
764
+ const sliced = lines.slice(Math.max(0, lines.length - limit));
765
+ const events: ControlCenterEvent[] = [];
766
+ for (const line of sliced) {
767
+ try {
768
+ events.push(JSON.parse(line) as ControlCenterEvent);
769
+ } catch {
770
+ /* skip malformed line */
771
+ }
772
+ }
773
+ return events;
774
+ } catch {
775
+ return [];
776
+ }
777
+ }
778
+
779
+ // ── Health suggestions ────────────────────────────────────────────
780
+
781
+ interface Suggestion {
782
+ code: string;
783
+ severity: "info" | "warning" | "error";
784
+ message: string;
785
+ action?: string;
786
+ link?: string;
787
+ }
788
+
789
+ interface StatusSnapshotForSuggestions {
790
+ core: RuntimeHealth;
791
+ direct: RuntimeHealth;
792
+ watcher: WatcherStatusSnapshot;
793
+ tunnel: { core: Record<string, unknown>; direct: Record<string, unknown> };
794
+ agents: AgentAvailability[];
795
+ tasks: StatusTasks;
796
+ }
797
+
798
+ function buildSuggestions(s: StatusSnapshotForSuggestions): Suggestion[] {
799
+ const out: Suggestion[] = [];
800
+
801
+ if (!s.core.available) {
802
+ out.push({
803
+ code: "core_stopped",
804
+ severity: "warning",
805
+ message: "Core 未运行,建议启动 Core profile",
806
+ action: "/api/core/start",
807
+ });
808
+ }
809
+ if (!s.direct.available) {
810
+ out.push({
811
+ code: "direct_stopped",
812
+ severity: "warning",
813
+ message: "Direct 未运行,建议启动 Direct profile",
814
+ action: "/api/direct/start",
815
+ });
816
+ }
817
+
818
+ if (s.watcher.status === "stale" || s.watcher.status === "unreadable") {
819
+ out.push({
820
+ code: "watcher_stale",
821
+ severity: "error",
822
+ message: "Watcher 处于 " + s.watcher.status + " 状态,建议 Restart All",
823
+ action: "/api/restart-all",
824
+ });
825
+ }
826
+
827
+ if (s.tasks.stale > 0) {
828
+ out.push({
829
+ code: "stale_task",
830
+ severity: "warning",
831
+ message: "存在 " + s.tasks.stale + " 个 stale 任务,建议查看并 reconcile",
832
+ link: "/pages/tasks.html?filter=stale",
833
+ });
834
+ }
835
+
836
+ const coreTunnelReady = !!(s.tunnel.core && s.tunnel.core.ready);
837
+ const directTunnelReady = !!(s.tunnel.direct && s.tunnel.direct.ready);
838
+ if (!coreTunnelReady || !directTunnelReady) {
839
+ out.push({
840
+ code: "tunnel_not_ready",
841
+ severity: "warning",
842
+ message: "Tunnel 未就绪,建议重启 profile 或检查代理",
843
+ action: "/api/restart-all",
844
+ });
845
+ }
846
+
847
+ const missingAgents = s.agents.filter((a) => !a.available);
848
+ if (missingAgents.length > 0) {
849
+ out.push({
850
+ code: "agent_missing",
851
+ severity: "info",
852
+ message: "Agent 未就绪:" + missingAgents.map((a) => a.name).join(", ") + "(请检查 opencode/claude 路径)",
853
+ });
854
+ }
855
+
856
+ return out;
857
+ }
858
+
859
+ // ── Observed state-change detection (drives activity timeline) ────
860
+
861
+ interface StatusSnapshotDigest {
862
+ core_available: boolean;
863
+ direct_available: boolean;
864
+ watcher_status: string;
865
+ task_statuses: Record<string, string>;
866
+ }
867
+
868
+ let lastStatusDigest: StatusSnapshotDigest | null = null;
869
+
870
+ function buildStatusDigest(s: StatusSnapshotForSuggestions): StatusSnapshotDigest {
871
+ const task_statuses: Record<string, string> = {};
872
+ for (const t of s.tasks.tasks) {
873
+ const entry = t as TaskEntry & StaleClassification;
874
+ task_statuses[entry.task_id] = entry.status;
875
+ }
876
+ return {
877
+ core_available: s.core.available,
878
+ direct_available: s.direct.available,
879
+ watcher_status: s.watcher.status,
880
+ task_statuses,
881
+ };
882
+ }
883
+
884
+ function diffAndRecordEvents(prev: StatusSnapshotDigest, curr: StatusSnapshotDigest): void {
885
+ if (prev.core_available !== curr.core_available) {
886
+ recordEvent("core.status_changed", { from: prev.core_available, to: curr.core_available });
887
+ }
888
+ if (prev.direct_available !== curr.direct_available) {
889
+ recordEvent("direct.status_changed", { from: prev.direct_available, to: curr.direct_available });
890
+ }
891
+ if (prev.watcher_status !== curr.watcher_status) {
892
+ recordEvent("watcher.status_changed", { from: prev.watcher_status, to: curr.watcher_status });
893
+ }
894
+ for (const [taskId, newStatus] of Object.entries(curr.task_statuses)) {
895
+ const oldStatus = prev.task_statuses[taskId];
896
+ if (oldStatus && oldStatus !== newStatus) {
897
+ recordEvent("task.status_changed", { task_id: taskId, from: oldStatus, to: newStatus });
898
+ }
899
+ }
900
+ }
901
+
902
+ // ── API handlers ──────────────────────────────────────────────────
903
+
904
+ async function handleStatus(res: ServerResponse): Promise<void> {
905
+ try {
906
+ const [coreHealth, directHealth, watcher, tunnelCore, tunnelDirect, toolsCore, toolsDirect, agents, workspaceRoot, tasks] = await Promise.all([
907
+ probeRuntimeHealth(CORE_BASE_URL).catch((err): RuntimeHealth => ({
908
+ available: false,
909
+ reason: errorMessage(err),
910
+ healthz: null,
911
+ readyz: null,
912
+ })),
913
+ probeRuntimeHealth(DIRECT_BASE_URL).catch((err): RuntimeHealth => ({
914
+ available: false,
915
+ reason: errorMessage(err),
916
+ healthz: null,
917
+ readyz: null,
918
+ })),
919
+ Promise.resolve(readWatcherStatusSafe()),
920
+ Promise.resolve(readTunnelStatus(false)),
921
+ Promise.resolve(readTunnelStatus(true)),
922
+ Promise.resolve(readToolManifest(false)),
923
+ Promise.resolve(readToolManifest(true)),
924
+ Promise.resolve(listAgentsSafe()),
925
+ Promise.resolve(resolveWorkspaceRootSafe()),
926
+ Promise.resolve(listTasksForStatus()),
927
+ ]);
928
+ const snapshotForSuggestions: StatusSnapshotForSuggestions = {
929
+ core: coreHealth,
930
+ direct: directHealth,
931
+ watcher,
932
+ tunnel: { core: tunnelCore, direct: tunnelDirect },
933
+ agents,
934
+ tasks,
935
+ };
936
+ const suggestions = buildSuggestions(snapshotForSuggestions);
937
+ const tunnelClientExe = findTunnelClientExecutable();
938
+
939
+ // Diff against the previous poll to record observed state-change events.
940
+ // This is the only place that observes Core/Direct/watcher/task transitions
941
+ // (the control center is otherwise stateless and pull-driven).
942
+ const digest = buildStatusDigest(snapshotForSuggestions);
943
+ if (lastStatusDigest) {
944
+ diffAndRecordEvents(lastStatusDigest, digest);
945
+ }
946
+ lastStatusDigest = digest;
947
+
948
+ sendJson(res, 200, {
949
+ core: coreHealth,
950
+ direct: directHealth,
951
+ watcher,
952
+ tunnel: { core: tunnelCore, direct: tunnelDirect },
953
+ tools: { core: toolsCore, direct: toolsDirect },
954
+ agents,
955
+ workspace_root: workspaceRoot,
956
+ tasks,
957
+ suggestions,
958
+ setup: {
959
+ tunnel_client: {
960
+ available: tunnelClientExe !== null,
961
+ path: tunnelClientExe,
962
+ default_path: DEFAULT_TUNNEL_CLIENT_EXE,
963
+ },
964
+ workspace_root: workspaceRoot,
965
+ watcher: {
966
+ status: watcher.status,
967
+ available: watcher.available,
968
+ reason: watcher.reason,
969
+ },
970
+ },
971
+ });
972
+ } catch (err) {
973
+ sendJson(res, 200, { error: errorMessage(err), partial: true });
974
+ }
975
+ }
976
+
977
+ function handleTasks(res: ServerResponse): void {
978
+ try {
979
+ const result = listTasks({ limit: 100 });
980
+ const watcher = result.watcher;
981
+ const now = Date.now();
982
+ const augmented = result.tasks.map((t) => augmentTaskWithStale(t, watcher, now));
983
+ const staleCount = augmented.filter((t) => t.is_stale).length;
984
+ sendJson(res, 200, {
985
+ tasks: augmented,
986
+ total: result.total,
987
+ returned: augmented.length,
988
+ watcher,
989
+ stale_count: staleCount,
990
+ });
991
+ } catch (err) {
992
+ sendJson(res, 200, {
993
+ tasks: [],
994
+ total: 0,
995
+ returned: 0,
996
+ watcher: null,
997
+ stale_count: 0,
998
+ error: errorMessage(err),
999
+ });
1000
+ }
1001
+ }
1002
+
1003
+ function handleStaleTasks(res: ServerResponse): void {
1004
+ try {
1005
+ const result = listTasks({ limit: 100 });
1006
+ const watcher = result.watcher;
1007
+ const now = Date.now();
1008
+ const staleTasks = result.tasks
1009
+ .map((t) => augmentTaskWithStale(t, watcher, now))
1010
+ .filter((t) => t.is_stale);
1011
+ sendJson(res, 200, {
1012
+ stale_tasks: staleTasks,
1013
+ total: staleTasks.length,
1014
+ watcher,
1015
+ stale_threshold_seconds: config.watcherStaleSeconds,
1016
+ reason: null,
1017
+ });
1018
+ } catch (err) {
1019
+ sendJson(res, 200, { stale_tasks: [], total: 0, reason: errorMessage(err) });
1020
+ }
1021
+ }
1022
+
1023
+ /**
1024
+ * Reconcile a stale task. Does NOT delete the task. Reads the task files,
1025
+ * decides whether it is safe to mark the task as stale/archived, writes a
1026
+ * reconcile record, and (when safe) annotates status.json with reconcile
1027
+ * metadata. The task status enum is never changed — only metadata is added.
1028
+ */
1029
+ function handleReconcile(res: ServerResponse, taskId: string): void {
1030
+ try {
1031
+ if (
1032
+ taskId === "." ||
1033
+ taskId === ".." ||
1034
+ taskId.includes("/") ||
1035
+ taskId.includes("\\") ||
1036
+ taskId.includes("\0")
1037
+ ) {
1038
+ sendJson(res, 400, { error: "Invalid task id" });
1039
+ return;
1040
+ }
1041
+ const tasksDir = getTasksDir(config);
1042
+ const taskDir = join(tasksDir, taskId);
1043
+ if (!existsSync(taskDir) || !statSync(taskDir).isDirectory()) {
1044
+ sendJson(res, 404, { error: "Task not found" });
1045
+ return;
1046
+ }
1047
+
1048
+ const statusPath = join(taskDir, "status.json");
1049
+ const runtimePath = join(taskDir, "runtime.json");
1050
+ const statusData = readJsonFileSafe<Record<string, unknown>>(statusPath) ?? {};
1051
+ const runtimeData = readJsonFileSafe<Record<string, unknown>>(runtimePath) ?? {};
1052
+
1053
+ const watcher = readWatcherStatusSafe();
1054
+ const VALID_ACCEPTANCE = ["pending", "accepted", "rejected", "needs_fix", "blocked"];
1055
+ const taskStatus = String(statusData.status || "pending");
1056
+ const taskAcceptanceStatus = taskStatus === "done_by_agent"
1057
+ ? (typeof statusData.acceptance_status === "string" && VALID_ACCEPTANCE.includes(statusData.acceptance_status) ? (statusData.acceptance_status as AcceptanceStatus) : "pending" as AcceptanceStatus)
1058
+ : null;
1059
+ const taskEntry: TaskEntry = {
1060
+ task_id: taskId,
1061
+ plan_id: String(statusData.plan_id || ""),
1062
+ title: "",
1063
+ agent: String(statusData.agent || ""),
1064
+ status: taskStatus as TaskEntry["status"],
1065
+ phase: String(runtimeData.phase || statusData.phase || "queued") as TaskEntry["phase"],
1066
+ acceptance_status: taskAcceptanceStatus,
1067
+ created_at: String(statusData.created_at || ""),
1068
+ updated_at: String(statusData.updated_at || ""),
1069
+ workspace_root: String(statusData.workspace_root || config.workspaceRoot),
1070
+ repo_path: String(statusData.repo_path || "."),
1071
+ resolved_repo_path: String(statusData.resolved_repo_path || statusData.repo_path || config.workspaceRoot),
1072
+ test_command: String(statusData.test_command || ""),
1073
+ verify_commands: Array.isArray(statusData.verify_commands) ? (statusData.verify_commands as string[]) : [],
1074
+ error: statusData.error ? String(statusData.error) : null,
1075
+ last_heartbeat_at: String(runtimeData.last_heartbeat_at || statusData.last_heartbeat_at || statusData.updated_at || ""),
1076
+ current_command: runtimeData.current_command === undefined ? null : String(runtimeData.current_command || "") || null,
1077
+ timeout_seconds: Number(statusData.timeout_seconds) || config.defaultTaskTimeoutSeconds,
1078
+ pending_reason: null,
1079
+ watcher_status: watcher.status,
1080
+ };
1081
+
1082
+ const cls = classifyStaleTask(taskEntry, watcher);
1083
+ const isTerminal = TERMINAL_TASK_STATUSES.has(taskEntry.status);
1084
+
1085
+ // Safe to mark stale/archived when:
1086
+ // - terminal status -> archive (already finished)
1087
+ // - stale AND watcher is not actively driving it (no current_command OR watcher not healthy)
1088
+ let decision: "marked_stale" | "marked_archived" | "no_action";
1089
+ let safe = false;
1090
+ if (isTerminal) {
1091
+ decision = "marked_archived";
1092
+ safe = true;
1093
+ } else if (
1094
+ cls.is_stale &&
1095
+ (taskEntry.current_command === null || taskEntry.current_command === "" || watcher.status !== "healthy")
1096
+ ) {
1097
+ decision = "marked_stale";
1098
+ safe = true;
1099
+ } else {
1100
+ decision = "no_action";
1101
+ safe = false;
1102
+ }
1103
+
1104
+ const reconciledAt = new Date().toISOString();
1105
+ const reconcileRecord = {
1106
+ task_id: taskId,
1107
+ reconciled_at: reconciledAt,
1108
+ decision,
1109
+ safe,
1110
+ previous_status: taskEntry.status,
1111
+ previous_phase: taskEntry.phase,
1112
+ is_stale: cls.is_stale,
1113
+ stale_reasons: cls.stale_reasons,
1114
+ watcher_status: watcher.status,
1115
+ watcher_last_heartbeat_at: watcher.last_heartbeat_at,
1116
+ task_last_heartbeat_at: taskEntry.last_heartbeat_at || null,
1117
+ task_current_command: taskEntry.current_command,
1118
+ notes:
1119
+ decision === "no_action"
1120
+ ? "Task does not currently qualify for safe reconcile (still actively running or watcher is healthy)."
1121
+ : "Task annotated with reconcile metadata; original status preserved. No files were deleted.",
1122
+ };
1123
+
1124
+ // Write the reconcile record artifact.
1125
+ try {
1126
+ writeFileSync(join(taskDir, "reconcile.json"), JSON.stringify(reconcileRecord, null, 2), "utf-8");
1127
+ } catch (writeErr) {
1128
+ sendJson(res, 500, { error: `Failed to write reconcile record: ${errorMessage(writeErr)}` });
1129
+ return;
1130
+ }
1131
+
1132
+ // Annotate status.json with reconcile metadata (do not mutate status enum).
1133
+ if (safe) {
1134
+ const annotated = {
1135
+ ...statusData,
1136
+ reconcile_state: decision === "marked_archived" ? "archived" : "stale",
1137
+ reconciled_at: reconciledAt,
1138
+ };
1139
+ try {
1140
+ writeFileSync(statusPath, JSON.stringify(annotated, null, 2), "utf-8");
1141
+ } catch (writeErr) {
1142
+ sendJson(res, 500, { error: `Failed to annotate status.json: ${errorMessage(writeErr)}` });
1143
+ return;
1144
+ }
1145
+ }
1146
+
1147
+ recordEvent("task.reconciled", {
1148
+ task_id: taskId,
1149
+ decision,
1150
+ safe,
1151
+ previous_status: taskEntry.status,
1152
+ is_stale: cls.is_stale,
1153
+ });
1154
+ sendJson(res, 200, reconcileRecord);
1155
+ } catch (err) {
1156
+ sendJson(res, 500, { error: errorMessage(err) });
1157
+ }
1158
+ }
1159
+
1160
+ function handleTaskDetail(res: ServerResponse, taskId: string): void {
1161
+ try {
1162
+ const tasksDir = getTasksDir(config);
1163
+ const taskDir = join(tasksDir, taskId);
1164
+ if (!existsSync(taskDir) || !statSync(taskDir).isDirectory()) {
1165
+ sendJson(res, 404, { error: "Task not found" });
1166
+ return;
1167
+ }
1168
+
1169
+ const statusData = readJsonFileSafe<Record<string, unknown>>(join(taskDir, "status.json"));
1170
+ const runtimeData = readJsonFileSafe<Record<string, unknown>>(join(taskDir, "runtime.json"));
1171
+ const resultData = readJsonFileSafe<Record<string, unknown>>(join(taskDir, "result.json"));
1172
+ const auditData = readJsonFileSafe<Record<string, unknown>>(join(taskDir, "audit.json"));
1173
+ const verifyData = readJsonFileSafe<Record<string, unknown>>(join(taskDir, "verify.json"));
1174
+ const changedFiles = readJsonFileSafe<Record<string, unknown>>(join(taskDir, "changed-files.json"));
1175
+ const fileStats = readJsonFileSafe<Record<string, unknown>>(join(taskDir, "file-stats.json"));
1176
+ const reconcileData = readJsonFileSafe<Record<string, unknown>>(join(taskDir, "reconcile.json"));
1177
+
1178
+ // independent-review.md is the primary audit artifact (written by audit_task)
1179
+ const reviewPath = join(taskDir, "independent-review.md");
1180
+ let independentReview: { verdict: string | null; content: string | null } = { verdict: null, content: null };
1181
+ if (existsSync(reviewPath)) {
1182
+ const content = readTextFileSafe(reviewPath) ?? "";
1183
+ independentReview = { verdict: parseReviewVerdict(content), content };
1184
+ }
1185
+
1186
+ // Verification summary from verify.json
1187
+ const verificationSummary = verifyData
1188
+ ? {
1189
+ status: verifyData.status ?? null,
1190
+ commands: Array.isArray(verifyData.commands) ? verifyData.commands : null,
1191
+ checked_at: fileMtimeIso(join(taskDir, "verify.json")),
1192
+ }
1193
+ : null;
1194
+
1195
+ // Warnings / errors collected from status.error, result.warnings, error.log
1196
+ const warnings: string[] = [];
1197
+ const errors: string[] = [];
1198
+ if (statusData && statusData.error) errors.push(String(statusData.error));
1199
+ if (resultData && Array.isArray(resultData.warnings)) {
1200
+ for (const w of resultData.warnings) warnings.push(String(w));
1201
+ }
1202
+ if (resultData && resultData.error) errors.push(String(resultData.error));
1203
+ const errorLog = readJsonFileSafe<Record<string, unknown>>(join(taskDir, "error.log"));
1204
+ if (errorLog && errorLog.message) errors.push(String(errorLog.message));
1205
+
1206
+ // Stale classification (best-effort, using watcher snapshot)
1207
+ let stale: StaleClassification | null = null;
1208
+ if (statusData) {
1209
+ const watcher = readWatcherStatusSafe();
1210
+ const VALID_ACCEPTANCE2 = ["pending", "accepted", "rejected", "needs_fix", "blocked"];
1211
+ const taskStatus2 = String(statusData.status || "pending");
1212
+ const taskAcceptanceStatus2 = taskStatus2 === "done_by_agent"
1213
+ ? (typeof statusData.acceptance_status === "string" && VALID_ACCEPTANCE2.includes(statusData.acceptance_status) ? (statusData.acceptance_status as AcceptanceStatus) : "pending" as AcceptanceStatus)
1214
+ : null;
1215
+ const entry: TaskEntry = {
1216
+ task_id: taskId,
1217
+ plan_id: String(statusData.plan_id || ""),
1218
+ title: "",
1219
+ agent: String(statusData.agent || ""),
1220
+ status: taskStatus2 as TaskEntry["status"],
1221
+ phase: String(runtimeData?.phase || statusData.phase || "queued") as TaskEntry["phase"],
1222
+ acceptance_status: taskAcceptanceStatus2,
1223
+ created_at: String(statusData.created_at || ""),
1224
+ updated_at: String(statusData.updated_at || ""),
1225
+ workspace_root: String(statusData.workspace_root || config.workspaceRoot),
1226
+ repo_path: String(statusData.repo_path || "."),
1227
+ resolved_repo_path: String(statusData.resolved_repo_path || statusData.repo_path || config.workspaceRoot),
1228
+ test_command: String(statusData.test_command || ""),
1229
+ verify_commands: Array.isArray(statusData.verify_commands) ? (statusData.verify_commands as string[]) : [],
1230
+ error: statusData.error ? String(statusData.error) : null,
1231
+ last_heartbeat_at: String(runtimeData?.last_heartbeat_at || statusData.last_heartbeat_at || statusData.updated_at || ""),
1232
+ current_command: runtimeData?.current_command === undefined ? null : String(runtimeData.current_command || "") || null,
1233
+ timeout_seconds: Number(statusData.timeout_seconds) || config.defaultTaskTimeoutSeconds,
1234
+ pending_reason: null,
1235
+ watcher_status: watcher.status,
1236
+ };
1237
+ stale = classifyStaleTask(entry, watcher);
1238
+ }
1239
+
1240
+ sendJson(res, 200, {
1241
+ task_id: taskId,
1242
+ status: statusData,
1243
+ runtime: runtimeData,
1244
+ result: resultData,
1245
+ audit: auditData,
1246
+ independent_review: independentReview,
1247
+ diff_patch: readTextFileSafe(join(taskDir, "diff.patch")),
1248
+ test_log: readTextFileSafe(join(taskDir, "test.log")) ?? readTextFileSafe(join(taskDir, "test-log.txt")),
1249
+ verify_log: readTextFileSafe(join(taskDir, "verify.log")),
1250
+ changed_files: changedFiles,
1251
+ file_stats: fileStats,
1252
+ verification_summary: verificationSummary,
1253
+ warnings,
1254
+ errors,
1255
+ stale,
1256
+ reconcile: reconcileData,
1257
+ task_dir: taskDir,
1258
+ });
1259
+ } catch (err) {
1260
+ sendJson(res, 200, { task_id: taskId, error: errorMessage(err) });
1261
+ }
1262
+ }
1263
+
1264
+ /**
1265
+ * Run audit_task for a task. Only safe to delegate when the task directory
1266
+ * exists and the task is in a terminal state (auditing a running task mid-flight
1267
+ * would race with the watcher writing artifacts).
1268
+ */
1269
+ function handleTaskAudit(res: ServerResponse, taskId: string): void {
1270
+ try {
1271
+ if (
1272
+ taskId === "." ||
1273
+ taskId === ".." ||
1274
+ taskId.includes("/") ||
1275
+ taskId.includes("\\") ||
1276
+ taskId.includes("\0")
1277
+ ) {
1278
+ sendJson(res, 400, { error: "Invalid task id" });
1279
+ return;
1280
+ }
1281
+ const tasksDir = getTasksDir(config);
1282
+ const taskDir = join(tasksDir, taskId);
1283
+ if (!existsSync(taskDir) || !statSync(taskDir).isDirectory()) {
1284
+ sendJson(res, 404, { error: "Task not found" });
1285
+ return;
1286
+ }
1287
+ const statusData = readJsonFileSafe<Record<string, unknown>>(join(taskDir, "status.json"));
1288
+ const taskStatus = statusData ? String(statusData.status || "") : "";
1289
+ if (!TERMINAL_TASK_STATUSES.has(taskStatus as string)) {
1290
+ sendJson(res, 409, {
1291
+ error: "Task is not in a terminal state; audit_task can only run safely after completion.",
1292
+ status: taskStatus || "unknown",
1293
+ });
1294
+ return;
1295
+ }
1296
+ const output = auditTask(taskId);
1297
+ recordEvent("task.audited", { task_id: taskId, previous_status: taskStatus });
1298
+ sendJson(res, 200, { ok: true, audit: output });
1299
+ } catch (err) {
1300
+ sendJson(res, 500, { error: errorMessage(err) });
1301
+ }
1302
+ }
1303
+
1304
+ function handleOpenTaskFolder(res: ServerResponse, taskId: string): void {
1305
+ try {
1306
+ if (
1307
+ taskId === "." ||
1308
+ taskId === ".." ||
1309
+ taskId.includes("/") ||
1310
+ taskId.includes("\\") ||
1311
+ taskId.includes("\0")
1312
+ ) {
1313
+ sendJson(res, 400, { error: "Invalid task id" });
1314
+ return;
1315
+ }
1316
+ const tasksDir = getTasksDir(config);
1317
+ const taskDir = join(tasksDir, taskId);
1318
+ if (!existsSync(taskDir) || !statSync(taskDir).isDirectory()) {
1319
+ sendJson(res, 404, { error: "Task not found" });
1320
+ return;
1321
+ }
1322
+ let cmd: string;
1323
+ if (process.platform === "win32") {
1324
+ cmd = "explorer.exe";
1325
+ } else if (process.platform === "darwin") {
1326
+ cmd = "open";
1327
+ } else {
1328
+ cmd = "xdg-open";
1329
+ }
1330
+ try {
1331
+ const child = spawn(cmd, [taskDir], { detached: true, stdio: "ignore" });
1332
+ child.on("error", () => { /* ignore spawn errors */ });
1333
+ child.unref();
1334
+ } catch {
1335
+ /* ignore */
1336
+ }
1337
+ sendJson(res, 200, { ok: true, path: taskDir });
1338
+ } catch (err) {
1339
+ sendJson(res, 500, { error: errorMessage(err) });
1340
+ }
1341
+ }
1342
+
1343
+ type LogCategory = "core" | "direct" | "watcher" | "control-center";
1344
+
1345
+ function handleLogs(res: ServerResponse, category: LogCategory, tailLines: number): void {
1346
+ try {
1347
+ let dir: string;
1348
+ let stdoutPath: string;
1349
+ let stderrPath: string;
1350
+ let stdoutExists: boolean;
1351
+ let stderrExists: boolean;
1352
+
1353
+ if (category === "control-center") {
1354
+ dir = getControlCenterLogDir();
1355
+ stdoutPath = join(dir, "control-center.stdout.log");
1356
+ stderrPath = join(dir, "control-center.stderr.log");
1357
+ stdoutExists = existsSync(stdoutPath);
1358
+ stderrExists = existsSync(stderrPath);
1359
+ } else if (category === "watcher") {
1360
+ dir = getRuntimeRoot(false);
1361
+ const sp = findLatestLog(dir, /^watcher-.*\.stdout\.log$/);
1362
+ const ep = findLatestLog(dir, /^watcher-.*\.stderr\.log$/);
1363
+ stdoutPath = sp ?? "";
1364
+ stderrPath = ep ?? "";
1365
+ stdoutExists = sp !== null;
1366
+ stderrExists = ep !== null;
1367
+ } else {
1368
+ // core | direct -> tunnel client logs in the matching runtime dir
1369
+ dir = getRuntimeRoot(category === "direct");
1370
+ stdoutPath = join(dir, "tunnel-client.stdout.log");
1371
+ stderrPath = join(dir, "tunnel-client.stderr.log");
1372
+ stdoutExists = existsSync(stdoutPath);
1373
+ stderrExists = existsSync(stderrPath);
1374
+ }
1375
+
1376
+ if (!stdoutExists && !stderrExists) {
1377
+ sendJson(res, 200, {
1378
+ stdout: "",
1379
+ stderr: "",
1380
+ category,
1381
+ tail: tailLines,
1382
+ reason: "log file not found",
1383
+ });
1384
+ return;
1385
+ }
1386
+
1387
+ const stdoutRaw = stdoutExists ? readFileTail(stdoutPath, tailLines) : "";
1388
+ const stderrRaw = stderrExists ? readFileTail(stderrPath, tailLines) : "";
1389
+ const stdout = redactSensitiveContent(stdoutRaw).content;
1390
+ const stderr = redactSensitiveContent(stderrRaw).content;
1391
+ sendJson(res, 200, { stdout, stderr, category, tail: tailLines, reason: null });
1392
+ } catch (err) {
1393
+ sendJson(res, 200, { stdout: "", stderr: "", category, tail: tailLines, reason: errorMessage(err) });
1394
+ }
1395
+ }
1396
+
1397
+ function handleWorkspace(res: ServerResponse): void {
1398
+ let workspaceRoot: string | null = null;
1399
+ let directories: string[] = [];
1400
+ let agents: AgentAvailability[] = [];
1401
+ let configSummary: { toolProfile: string | null; allowedTestCommandsCount: number; enableDirectProfile: boolean } | null = null;
1402
+
1403
+ try {
1404
+ workspaceRoot = resolveWorkspaceRoot(config);
1405
+ } catch {
1406
+ workspaceRoot = null;
1407
+ }
1408
+ if (workspaceRoot) {
1409
+ try {
1410
+ directories = readdirSync(workspaceRoot, { withFileTypes: true })
1411
+ .filter((e) => e.isDirectory())
1412
+ .map((e) => e.name)
1413
+ .sort();
1414
+ } catch {
1415
+ directories = [];
1416
+ }
1417
+ }
1418
+ try {
1419
+ agents = listAgents().agents;
1420
+ } catch {
1421
+ agents = [];
1422
+ }
1423
+ try {
1424
+ configSummary = {
1425
+ toolProfile: config.toolProfile ?? null,
1426
+ allowedTestCommandsCount: config.allowedTestCommands.length,
1427
+ enableDirectProfile: config.enableDirectProfile ?? false,
1428
+ };
1429
+ } catch {
1430
+ configSummary = null;
1431
+ }
1432
+ sendJson(res, 200, { workspace_root: workspaceRoot, directories, agents, config: configSummary });
1433
+ }
1434
+
1435
+ /**
1436
+ * On-demand `git status --short` for a single repo under workspaceRoot.
1437
+ * The repo parameter is resolved against workspaceRoot and must stay inside it;
1438
+ * any path traversal attempt is rejected with 400. This intentionally does NOT
1439
+ * run a full workspace scan — only the repo the user clicked is inspected.
1440
+ */
1441
+ function handleWorkspaceRepoStatus(res: ServerResponse, repoParam: string): void {
1442
+ try {
1443
+ let workspaceRoot: string;
1444
+ try {
1445
+ workspaceRoot = resolveWorkspaceRoot(config);
1446
+ } catch (err) {
1447
+ sendJson(res, 500, { error: `workspace root unavailable: ${errorMessage(err)}` });
1448
+ return;
1449
+ }
1450
+
1451
+ // Reject obvious traversal in the raw parameter before resolving.
1452
+ if (repoParam.includes("\0") || repoParam.includes("..")) {
1453
+ sendJson(res, 400, { error: "Invalid repo path: traversal segments are not allowed" });
1454
+ return;
1455
+ }
1456
+
1457
+ let repoAbs: string;
1458
+ try {
1459
+ // guardWorkspacePath rejects absolute paths outside workspace and any
1460
+ // resolved path that escapes workspaceRoot.
1461
+ repoAbs = guardWorkspacePath(repoParam || ".", workspaceRoot);
1462
+ } catch (err) {
1463
+ sendJson(res, 400, { error: `Invalid repo path: ${errorMessage(err)}` });
1464
+ return;
1465
+ }
1466
+
1467
+ if (!existsSync(repoAbs) || !statSync(repoAbs).isDirectory()) {
1468
+ sendJson(res, 404, { error: "Repo directory not found", repo_path: repoParam });
1469
+ return;
1470
+ }
1471
+
1472
+ // Only `git status --short` is permitted; no arbitrary git subcommand.
1473
+ // Timeout guards against a hung git prompt (e.g. credential dialog).
1474
+ execFile(
1475
+ "git",
1476
+ ["status", "--short"],
1477
+ { cwd: repoAbs, maxBuffer: 1024 * 1024, timeout: 8000, windowsHide: true, encoding: "utf-8" },
1478
+ (err, stdout, stderr) => {
1479
+ if (err) {
1480
+ // Not a git repo, git missing, or git errored — return a structured
1481
+ // failure rather than 500 so the UI can render it gracefully.
1482
+ sendJson(res, 200, {
1483
+ repo_path: repoParam,
1484
+ resolved_repo_path: repoAbs,
1485
+ is_git_repo: false,
1486
+ changed_files_count: 0,
1487
+ untracked_count: 0,
1488
+ modified_count: 0,
1489
+ is_clean: true,
1490
+ short_status: "",
1491
+ error: errorMessage(err),
1492
+ stderr: stderr ? String(stderr).slice(0, 500) : "",
1493
+ });
1494
+ return;
1495
+ }
1496
+ const text = String(stdout);
1497
+ const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
1498
+ let modified = 0;
1499
+ let untracked = 0;
1500
+ for (const line of lines) {
1501
+ const xy = line.slice(0, 2);
1502
+ if (xy === "??") untracked++;
1503
+ else modified++;
1504
+ }
1505
+ sendJson(res, 200, {
1506
+ repo_path: repoParam,
1507
+ resolved_repo_path: repoAbs,
1508
+ is_git_repo: true,
1509
+ changed_files_count: lines.length,
1510
+ untracked_count: untracked,
1511
+ modified_count: modified,
1512
+ is_clean: lines.length === 0,
1513
+ short_status: text,
1514
+ error: null,
1515
+ });
1516
+ }
1517
+ );
1518
+ } catch (err) {
1519
+ sendJson(res, 500, { error: errorMessage(err) });
1520
+ }
1521
+ }
1522
+
1523
+ // ── Direct sessions ───────────────────────────────────────────────
1524
+
1525
+ interface DirectSessionSummary {
1526
+ session_id: string;
1527
+ repo_path: string;
1528
+ resolved_repo_path: string;
1529
+ created_at: string;
1530
+ expires_at: string;
1531
+ finalized: boolean;
1532
+ finalized_at: string | null;
1533
+ audited: boolean;
1534
+ changed_files_total: number | null;
1535
+ verification_summary: unknown | null;
1536
+ audit_decision: string | null;
1537
+ audit_checked_at: string | null;
1538
+ title: string;
1539
+ }
1540
+
1541
+ function readDirectSessionSummary(sessionDir: string, sessionId: string): DirectSessionSummary | null {
1542
+ const sessionFile = join(sessionDir, "session.json");
1543
+ if (!existsSync(sessionFile)) return null;
1544
+ const data = readJsonFileSafe<Record<string, unknown>>(sessionFile);
1545
+ if (!data) return null;
1546
+
1547
+ // summary.json holds the finalized change summary (changed_files_total, etc.)
1548
+ const summaryFile = join(sessionDir, "summary.json");
1549
+ const summary = readJsonFileSafe<Record<string, unknown>>(summaryFile);
1550
+ const changedFilesTotal = summary
1551
+ ? typeof summary.changed_files_total === "number"
1552
+ ? summary.changed_files_total
1553
+ : null
1554
+ : null;
1555
+
1556
+ // audit.json (written by audit_session) holds the audit decision
1557
+ const auditFile = join(sessionDir, "audit.json");
1558
+ const audit = readJsonFileSafe<Record<string, unknown>>(auditFile);
1559
+ const auditDecision = audit
1560
+ ? typeof audit.decision === "string"
1561
+ ? audit.decision
1562
+ : typeof audit.verdict === "string"
1563
+ ? audit.verdict
1564
+ : null
1565
+ : null;
1566
+ const auditCheckedAt = audit
1567
+ ? typeof audit.checked_at === "string"
1568
+ ? audit.checked_at
1569
+ : fileMtimeIso(auditFile)
1570
+ : null;
1571
+
1572
+ // verification summary: read from session.json verification_runs (last run)
1573
+ let verificationSummary: unknown | null = null;
1574
+ if (Array.isArray(data.verification_runs) && data.verification_runs.length > 0) {
1575
+ const runs = data.verification_runs as Array<Record<string, unknown>>;
1576
+ verificationSummary = runs[runs.length - 1];
1577
+ }
1578
+
1579
+ return {
1580
+ session_id: sessionId,
1581
+ repo_path: typeof data.repo_path === "string" ? data.repo_path : "",
1582
+ resolved_repo_path: typeof data.resolved_repo_path === "string" ? data.resolved_repo_path : "",
1583
+ created_at: typeof data.created_at === "string" ? data.created_at : "",
1584
+ expires_at: typeof data.expires_at === "string" ? data.expires_at : "",
1585
+ finalized: Boolean(data.finalized),
1586
+ finalized_at: typeof data.finalized_at === "string" ? data.finalized_at : null,
1587
+ audited: Boolean(data.audited),
1588
+ changed_files_total: changedFilesTotal,
1589
+ verification_summary: verificationSummary,
1590
+ audit_decision: auditDecision,
1591
+ audit_checked_at: auditCheckedAt,
1592
+ title: typeof data.title === "string" ? data.title : "",
1593
+ };
1594
+ }
1595
+
1596
+ function handleDirectSessions(res: ServerResponse): void {
1597
+ try {
1598
+ const sessionsDir = getDirectSessionsDir(config);
1599
+ if (!existsSync(sessionsDir)) {
1600
+ // Directory missing -> empty list, never 500.
1601
+ sendJson(res, 200, { sessions: [], total: 0, reason: null });
1602
+ return;
1603
+ }
1604
+ let entries: import("node:fs").Dirent[] = [];
1605
+ try {
1606
+ entries = readdirSync(sessionsDir, { withFileTypes: true }).filter((e) => e.isDirectory());
1607
+ } catch (err) {
1608
+ sendJson(res, 200, { sessions: [], total: 0, reason: errorMessage(err) });
1609
+ return;
1610
+ }
1611
+ const summaries: DirectSessionSummary[] = [];
1612
+ for (const entry of entries) {
1613
+ const summary = readDirectSessionSummary(join(sessionsDir, entry.name), entry.name);
1614
+ if (summary) summaries.push(summary);
1615
+ }
1616
+ // Sort by created_at descending.
1617
+ summaries.sort((a, b) => b.created_at.localeCompare(a.created_at));
1618
+ sendJson(res, 200, { sessions: summaries, total: summaries.length, reason: null });
1619
+ } catch (err) {
1620
+ sendJson(res, 200, { sessions: [], total: 0, reason: errorMessage(err) });
1621
+ }
1622
+ }
1623
+
1624
+ function handleDirectSessionDetail(res: ServerResponse, sessionId: string): void {
1625
+ try {
1626
+ if (
1627
+ sessionId === "." ||
1628
+ sessionId === ".." ||
1629
+ sessionId.includes("/") ||
1630
+ sessionId.includes("\\") ||
1631
+ sessionId.includes("\0")
1632
+ ) {
1633
+ sendJson(res, 400, { error: "Invalid session id" });
1634
+ return;
1635
+ }
1636
+ const sessionsDir = getDirectSessionsDir(config);
1637
+ const sessionDir = join(sessionsDir, sessionId);
1638
+ if (!existsSync(sessionDir) || !statSync(sessionDir).isDirectory()) {
1639
+ sendJson(res, 404, { error: "Direct session not found" });
1640
+ return;
1641
+ }
1642
+ const summary = readDirectSessionSummary(sessionDir, sessionId);
1643
+ sendJson(res, 200, {
1644
+ session_id: sessionId,
1645
+ summary,
1646
+ session: readJsonFileSafe(join(sessionDir, "session.json")),
1647
+ summary_md: readTextFileSafe(join(sessionDir, "summary.md")),
1648
+ diff_patch: readTextFileSafe(join(sessionDir, "diff.patch")),
1649
+ audit_json: readJsonFileSafe(join(sessionDir, "audit.json")),
1650
+ audit_md: readTextFileSafe(join(sessionDir, "audit.md")),
1651
+ changed_files: readJsonFileSafe(join(sessionDir, "changed-files.json")),
1652
+ });
1653
+ } catch (err) {
1654
+ sendJson(res, 200, { session_id: sessionId, error: errorMessage(err) });
1655
+ }
1656
+ }
1657
+
1658
+ function parseReviewVerdict(content: string): string | null {
1659
+ // independent-review.md format: "**Verdict**: PASS" (case-insensitive)
1660
+ const m = content.match(/\*\*Verdict\*\*\s*:\s*([A-Za-z]+)/);
1661
+ return m ? m[1].toLowerCase() : null;
1662
+ }
1663
+
1664
+ function fileMtimeIso(filePath: string): string | null {
1665
+ try {
1666
+ const m = statSync(filePath).mtime;
1667
+ return m ? m.toISOString() : null;
1668
+ } catch {
1669
+ return null;
1670
+ }
1671
+ }
1672
+
1673
+ function handleAudit(res: ServerResponse): void {
1674
+ try {
1675
+ const audits: Array<Record<string, unknown>> = [];
1676
+
1677
+ // 1. tasks/*/independent-review.md (written by audit_task — the primary audit artifact)
1678
+ // 2. tasks/*/audit.json (legacy/explicit JSON audit, if present)
1679
+ const tasksDir = getTasksDir(config);
1680
+ if (existsSync(tasksDir)) {
1681
+ let taskEntries: import("node:fs").Dirent[] = [];
1682
+ try {
1683
+ taskEntries = readdirSync(tasksDir, { withFileTypes: true }).filter((e) => e.isDirectory());
1684
+ } catch {
1685
+ taskEntries = [];
1686
+ }
1687
+ for (const entry of taskEntries) {
1688
+ const taskDir = join(tasksDir, entry.name);
1689
+
1690
+ // independent-review.md
1691
+ const reviewFile = join(taskDir, "independent-review.md");
1692
+ if (existsSync(reviewFile)) {
1693
+ const content = readTextFileSafe(reviewFile) ?? "";
1694
+ audits.push({
1695
+ task_id: entry.name,
1696
+ source: "independent-review.md",
1697
+ verdict: parseReviewVerdict(content),
1698
+ checked_at: fileMtimeIso(reviewFile),
1699
+ content_excerpt: content.slice(0, 500),
1700
+ });
1701
+ }
1702
+
1703
+ // audit.json (explicit JSON audit if present)
1704
+ const auditFile = join(taskDir, "audit.json");
1705
+ if (existsSync(auditFile)) {
1706
+ const data = readJsonFileSafe<Record<string, unknown>>(auditFile);
1707
+ if (data) {
1708
+ audits.push({
1709
+ task_id: entry.name,
1710
+ source: "audit.json",
1711
+ checked_at: data.checked_at ?? fileMtimeIso(auditFile),
1712
+ ...data,
1713
+ });
1714
+ }
1715
+ }
1716
+ }
1717
+ }
1718
+
1719
+ // 3. direct-sessions/*/audit.json (written by Direct audit_session)
1720
+ const sessionsDir = getDirectSessionsDir(config);
1721
+ if (existsSync(sessionsDir)) {
1722
+ let sessionEntries: import("node:fs").Dirent[] = [];
1723
+ try {
1724
+ sessionEntries = readdirSync(sessionsDir, { withFileTypes: true }).filter((e) => e.isDirectory());
1725
+ } catch {
1726
+ sessionEntries = [];
1727
+ }
1728
+ for (const entry of sessionEntries) {
1729
+ const auditFile = join(sessionsDir, entry.name, "audit.json");
1730
+ if (!existsSync(auditFile)) continue;
1731
+ const data = readJsonFileSafe<Record<string, unknown>>(auditFile);
1732
+ if (data) {
1733
+ audits.push({
1734
+ source: "direct-session",
1735
+ session_id: data.session_id ?? entry.name,
1736
+ checked_at: fileMtimeIso(auditFile),
1737
+ ...data,
1738
+ });
1739
+ }
1740
+ }
1741
+ }
1742
+
1743
+ // Sort by checked_at descending (missing timestamps sort last).
1744
+ audits.sort((a, b) => {
1745
+ const ac = String(a.checked_at ?? "");
1746
+ const bc = String(b.checked_at ?? "");
1747
+ return bc.localeCompare(ac);
1748
+ });
1749
+ const limited = audits.slice(0, 50);
1750
+ sendJson(res, 200, { audits: limited, total: limited.length });
1751
+ } catch (err) {
1752
+ sendJson(res, 200, { audits: [], reason: errorMessage(err) });
1753
+ }
1754
+ }
1755
+
1756
+ function handleTunnelUiUrl(res: ServerResponse): void {
1757
+ sendJson(res, 200, {
1758
+ core: readTunnelUrl(false),
1759
+ direct: readTunnelUrl(true),
1760
+ });
1761
+ }
1762
+
1763
+ type ControlMode = "core" | "direct";
1764
+
1765
+ function selectedControlModes(mode: string): ControlMode[] {
1766
+ if (mode === "core" || mode === "direct") return [mode];
1767
+ return ["core", "direct"];
1768
+ }
1769
+
1770
+ function launcherPathForMode(mode: ControlMode): string {
1771
+ const launcherName = mode === "direct" ? "Start-PatchWarden-Direct-Tunnel.cmd" : "Start-PatchWarden-Tunnel.cmd";
1772
+ return join(projectRoot, "scripts", "launchers", launcherName);
1773
+ }
1774
+
1775
+ function findExecutableOnPath(fileName: string): string | null {
1776
+ const pathValue = process.env.PATH || "";
1777
+ const extensions =
1778
+ process.platform === "win32"
1779
+ ? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)
1780
+ : [""];
1781
+ for (const entry of pathValue.split(delimiter)) {
1782
+ if (!entry) continue;
1783
+ const direct = join(entry, fileName);
1784
+ if (existsSync(direct)) return direct;
1785
+ if (process.platform === "win32" && !extname(fileName)) {
1786
+ for (const ext of extensions) {
1787
+ const candidate = join(entry, fileName + ext.toLowerCase());
1788
+ if (existsSync(candidate)) return candidate;
1789
+ }
1790
+ }
1791
+ }
1792
+ return null;
1793
+ }
1794
+
1795
+ function findTunnelClientExecutable(): string | null {
1796
+ if (process.env.PATCHWARDEN_CONTROL_FORCE_MISSING_TUNNEL_CLIENT === "1") return null;
1797
+ const explicit = process.env.TUNNEL_CLIENT_EXE || process.env.PATCHWARDEN_TUNNEL_CLIENT_EXE;
1798
+ if (explicit && existsSync(explicit)) return explicit;
1799
+ const fromPath = findExecutableOnPath("tunnel-client.exe") ?? findExecutableOnPath("tunnel-client");
1800
+ if (fromPath) return fromPath;
1801
+
1802
+ const candidates = [
1803
+ DEFAULT_TUNNEL_CLIENT_EXE,
1804
+ process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "patchwarden", "tunnel-client.exe") : null,
1805
+ process.env.APPDATA ? join(process.env.APPDATA, "tunnel-client", "tunnel-client.exe") : null,
1806
+ join(homedir(), "tunnel-client", "tunnel-client.exe"),
1807
+ ].filter((v): v is string => typeof v === "string" && v.length > 0);
1808
+ for (const candidate of candidates) {
1809
+ if (existsSync(candidate)) return candidate;
1810
+ }
1811
+ return null;
1812
+ }
1813
+
1814
+ function preflightManageAction(action: string, mode: string): { status: number; body: Record<string, unknown> } | null {
1815
+ if (action !== "start" && action !== "restart") return null;
1816
+
1817
+ const missingLaunchers = selectedControlModes(mode)
1818
+ .map((m) => ({ mode: m, path: launcherPathForMode(m) }))
1819
+ .filter((entry) => !existsSync(entry.path));
1820
+ const tunnelClient = findTunnelClientExecutable();
1821
+ const missing: string[] = [];
1822
+ if (!tunnelClient) missing.push("tunnel-client.exe");
1823
+ for (const entry of missingLaunchers) missing.push(`${entry.mode} launcher`);
1824
+
1825
+ if (missing.length === 0) return null;
1826
+
1827
+ return {
1828
+ status: 409,
1829
+ body: {
1830
+ ok: false,
1831
+ action,
1832
+ mode,
1833
+ error:
1834
+ "Control Center preflight failed. Start/restart from the Web UI is non-interactive, so required runtime dependencies must be available before launching.",
1835
+ missing,
1836
+ next_steps: [
1837
+ "Install tunnel-client.exe or set TUNNEL_CLIENT_EXE / PATCHWARDEN_TUNNEL_CLIENT_EXE to its full path.",
1838
+ `Default checked path: ${DEFAULT_TUNNEL_CLIENT_EXE}`,
1839
+ "Verify the launcher files under scripts/launchers are present.",
1840
+ "Then retry from PatchWarden Control Center or PatchWarden-Control-Tray.cmd.",
1841
+ ],
1842
+ },
1843
+ };
1844
+ }
1845
+
1846
+ async function handleManageAction(res: ServerResponse, action: string, mode: string): Promise<void> {
1847
+ try {
1848
+ const preflight = preflightManageAction(action, mode);
1849
+ if (preflight) {
1850
+ recordEvent("manage." + mode + "." + action + ".preflight_failed", {
1851
+ missing: preflight.body.missing,
1852
+ });
1853
+ sendJson(res, preflight.status, preflight.body);
1854
+ return;
1855
+ }
1856
+ const result = await runManageAction(action, mode);
1857
+ recordEvent("manage." + mode + "." + action, {
1858
+ exit_code: result.exitCode,
1859
+ ok: result.exitCode === 0,
1860
+ });
1861
+ sendJson(res, 200, {
1862
+ ok: result.exitCode === 0,
1863
+ exitCode: result.exitCode,
1864
+ stdout: result.stdout,
1865
+ stderr: result.stderr,
1866
+ });
1867
+ } catch (err) {
1868
+ recordEvent("manage." + mode + "." + action + ".failed", { error: errorMessage(err) });
1869
+ sendJson(res, 500, { error: errorMessage(err) });
1870
+ }
1871
+ }
1872
+
1873
+ function handleEvents(res: ServerResponse, limit: number): void {
1874
+ const events = readEvents(limit);
1875
+ sendJson(res, 200, {
1876
+ events,
1877
+ total: events.length,
1878
+ limit,
1879
+ });
1880
+ }
1881
+
1882
+ function handleOpenLogsFolder(res: ServerResponse): void {
1883
+ try {
1884
+ const target = getRuntimeRoot(false);
1885
+ let cmd: string;
1886
+ if (process.platform === "win32") {
1887
+ cmd = "explorer.exe";
1888
+ } else if (process.platform === "darwin") {
1889
+ cmd = "open";
1890
+ } else {
1891
+ cmd = "xdg-open";
1892
+ }
1893
+ try {
1894
+ const child = spawn(cmd, [target], { detached: true, stdio: "ignore" });
1895
+ child.on("error", () => { /* ignore spawn errors */ });
1896
+ child.unref();
1897
+ } catch {
1898
+ /* ignore */
1899
+ }
1900
+ sendJson(res, 200, { ok: true, path: target });
1901
+ } catch (err) {
1902
+ sendJson(res, 500, { error: errorMessage(err) });
1903
+ }
1904
+ }
1905
+
1906
+ // ── Request router ────────────────────────────────────────────────
1907
+
1908
+ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
1909
+ const parsedUrl = new URL(req.url || "/", `http://${req.headers.host || host}`);
1910
+ const pathname = parsedUrl.pathname;
1911
+ const method = (req.method || "GET").toUpperCase();
1912
+
1913
+ // Static routes
1914
+ if (method === "GET" && pathname === "/") {
1915
+ serveStatic(res, "pages/dashboard.html");
1916
+ return;
1917
+ }
1918
+ const pageAlias = PAGE_ALIASES[pathname];
1919
+ if (method === "GET" && pageAlias) {
1920
+ serveStatic(res, pageAlias);
1921
+ return;
1922
+ }
1923
+ if (method === "GET" && pathname === "/control-token.json") {
1924
+ sendJson(res, 200, { token: controlToken });
1925
+ return;
1926
+ }
1927
+ if (method === "GET" && pathname === "/favicon.ico") {
1928
+ serveFavicon(res);
1929
+ return;
1930
+ }
1931
+ if (
1932
+ method === "GET" &&
1933
+ (pathname === "/colors_and_type.css" ||
1934
+ pathname.startsWith("/pages/") ||
1935
+ pathname.startsWith("/partials/") ||
1936
+ pathname.startsWith("/vendor/"))
1937
+ ) {
1938
+ serveStatic(res, pathname);
1939
+ return;
1940
+ }
1941
+
1942
+ // GET API routes
1943
+ if (method === "GET" && pathname === "/api/status") {
1944
+ await handleStatus(res);
1945
+ return;
1946
+ }
1947
+ if (method === "GET" && pathname === "/api/tasks") {
1948
+ handleTasks(res);
1949
+ return;
1950
+ }
1951
+ if (method === "GET" && pathname === "/api/tasks/stale") {
1952
+ handleStaleTasks(res);
1953
+ return;
1954
+ }
1955
+ const taskMatch = pathname.match(/^\/api\/tasks\/([^/]+)$/);
1956
+ if (method === "GET" && taskMatch) {
1957
+ let taskId: string;
1958
+ try {
1959
+ taskId = decodeURIComponent(taskMatch[1]);
1960
+ } catch {
1961
+ taskId = taskMatch[1];
1962
+ }
1963
+ if (
1964
+ taskId === "." ||
1965
+ taskId === ".." ||
1966
+ taskId.includes("/") ||
1967
+ taskId.includes("\\") ||
1968
+ taskId.includes("\0")
1969
+ ) {
1970
+ sendJson(res, 400, { error: "Invalid task id" });
1971
+ return;
1972
+ }
1973
+ handleTaskDetail(res, taskId);
1974
+ return;
1975
+ }
1976
+ // Logs: /api/logs/<category>?tail=<100|300|1000>
1977
+ const logsMatch = pathname.match(/^\/api\/logs\/([a-z-]+)$/);
1978
+ if (method === "GET" && logsMatch) {
1979
+ const rawCat = logsMatch[1];
1980
+ const category = rawCat === "core" || rawCat === "direct" || rawCat === "watcher" || rawCat === "control-center"
1981
+ ? (rawCat as LogCategory)
1982
+ : null;
1983
+ if (!category) {
1984
+ sendJson(res, 404, { error: "Unknown log category" });
1985
+ return;
1986
+ }
1987
+ const tail = resolveTailParam(parsedUrl.searchParams.get("tail"));
1988
+ handleLogs(res, category, tail);
1989
+ return;
1990
+ }
1991
+ if (method === "GET" && pathname === "/api/workspace") {
1992
+ handleWorkspace(res);
1993
+ return;
1994
+ }
1995
+ // On-demand git status for a single repo (path-traversal safe).
1996
+ // The repo segment is URL-decoded; traversal is rejected by guardWorkspacePath.
1997
+ const workspaceRepoMatch = pathname.match(/^\/api\/workspace\/([^/]+(?:\/[^/]+)*)\/status$/);
1998
+ if (method === "GET" && workspaceRepoMatch) {
1999
+ let repoParam: string;
2000
+ try {
2001
+ repoParam = decodeURIComponent(workspaceRepoMatch[1]);
2002
+ } catch {
2003
+ sendJson(res, 400, { error: "Invalid repo path encoding" });
2004
+ return;
2005
+ }
2006
+ handleWorkspaceRepoStatus(res, repoParam);
2007
+ return;
2008
+ }
2009
+ if (method === "GET" && pathname === "/api/direct-sessions") {
2010
+ handleDirectSessions(res);
2011
+ return;
2012
+ }
2013
+ const directSessionMatch = pathname.match(/^\/api\/direct-sessions\/([^/]+)$/);
2014
+ if (method === "GET" && directSessionMatch) {
2015
+ let sessionId: string;
2016
+ try {
2017
+ sessionId = decodeURIComponent(directSessionMatch[1]);
2018
+ } catch {
2019
+ sessionId = directSessionMatch[1];
2020
+ }
2021
+ handleDirectSessionDetail(res, sessionId);
2022
+ return;
2023
+ }
2024
+ if (method === "GET" && pathname === "/api/audit") {
2025
+ handleAudit(res);
2026
+ return;
2027
+ }
2028
+ if (method === "GET" && pathname === "/api/tunnel-ui-url") {
2029
+ handleTunnelUiUrl(res);
2030
+ return;
2031
+ }
2032
+ if (method === "GET" && pathname === "/api/events") {
2033
+ const limitParam = parsedUrl.searchParams.get("limit");
2034
+ let limit = 100;
2035
+ if (limitParam !== null) {
2036
+ const n = parseInt(limitParam, 10);
2037
+ if (Number.isFinite(n) && n > 0 && n <= 1000) limit = n;
2038
+ }
2039
+ handleEvents(res, limit);
2040
+ return;
2041
+ }
2042
+ if (method === "GET" && pathname === "/api/control-center-status") {
2043
+ // Public read of the status file (used by tray/launcher to confirm identity).
2044
+ if (!existsSync(controlCenterStatusPath)) {
2045
+ sendJson(res, 200, { running: false });
2046
+ return;
2047
+ }
2048
+ const data = readJsonFileSafe<ControlCenterStatusFile>(controlCenterStatusPath);
2049
+ if (!data) {
2050
+ sendJson(res, 200, { running: false });
2051
+ return;
2052
+ }
2053
+ sendJson(res, 200, { running: true, ...data });
2054
+ return;
2055
+ }
2056
+
2057
+ // POST API routes (all require control token)
2058
+ if (method === "POST") {
2059
+ await readBody(req); // drain optional body
2060
+ if (!checkControlToken(req)) {
2061
+ sendJson(res, 403, { error: "Missing or invalid control token" });
2062
+ return;
2063
+ }
2064
+ if (pathname === "/api/start-all") return handleManageAction(res, "start", "all");
2065
+ if (pathname === "/api/stop-all") return handleManageAction(res, "stop", "all");
2066
+ if (pathname === "/api/restart-all") return handleManageAction(res, "restart", "all");
2067
+ if (pathname === "/api/core/start") return handleManageAction(res, "start", "core");
2068
+ if (pathname === "/api/core/stop") return handleManageAction(res, "stop", "core");
2069
+ if (pathname === "/api/direct/start") return handleManageAction(res, "start", "direct");
2070
+ if (pathname === "/api/direct/stop") return handleManageAction(res, "stop", "direct");
2071
+ if (pathname === "/api/open-logs-folder") {
2072
+ handleOpenLogsFolder(res);
2073
+ return;
2074
+ }
2075
+ // POST /api/tasks/:taskId/reconcile (token already validated above)
2076
+ const reconcileMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/reconcile$/);
2077
+ if (reconcileMatch) {
2078
+ let taskId: string;
2079
+ try {
2080
+ taskId = decodeURIComponent(reconcileMatch[1]);
2081
+ } catch {
2082
+ taskId = reconcileMatch[1];
2083
+ }
2084
+ handleReconcile(res, taskId);
2085
+ return;
2086
+ }
2087
+ // POST /api/tasks/:taskId/audit — run audit_task safely
2088
+ const auditMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/audit$/);
2089
+ if (auditMatch) {
2090
+ let taskId: string;
2091
+ try {
2092
+ taskId = decodeURIComponent(auditMatch[1]);
2093
+ } catch {
2094
+ taskId = auditMatch[1];
2095
+ }
2096
+ handleTaskAudit(res, taskId);
2097
+ return;
2098
+ }
2099
+ // POST /api/tasks/:taskId/open-folder — open task folder in file explorer
2100
+ const openFolderMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/open-folder$/);
2101
+ if (openFolderMatch) {
2102
+ let taskId: string;
2103
+ try {
2104
+ taskId = decodeURIComponent(openFolderMatch[1]);
2105
+ } catch {
2106
+ taskId = openFolderMatch[1];
2107
+ }
2108
+ handleOpenTaskFolder(res, taskId);
2109
+ return;
2110
+ }
2111
+ sendJson(res, 404, { error: "Not found" });
2112
+ return;
2113
+ }
2114
+
2115
+ sendJson(res, 404, { error: "Not found" });
2116
+ }
2117
+
2118
+ // ── Server bootstrap ──────────────────────────────────────────────
2119
+
2120
+ const server = createServer((req, res) => {
2121
+ handleRequest(req, res).catch((err) => {
2122
+ if (!res.headersSent) {
2123
+ sendJson(res, 500, { error: errorMessage(err) });
2124
+ } else {
2125
+ try { res.end(); } catch { /* ignore */ }
2126
+ }
2127
+ });
2128
+ });
2129
+
2130
+ server.on("error", (err) => {
2131
+ console.error(`[control-center] Server error: ${errorMessage(err)}`);
2132
+ process.exit(1);
2133
+ });
2134
+
2135
+ server.listen(port, host, () => {
2136
+ const addr = server.address();
2137
+ const formatted = addr && typeof addr === "object" ? `http://${addr.address}:${addr.port}/` : `http://${host}:${port}/`;
2138
+ console.error(`[control-center] PatchWarden v${PATCHWARDEN_VERSION} (schema epoch ${TOOL_SCHEMA_EPOCH})`);
2139
+ console.error(`[control-center] Workspace: ${config.workspaceRoot}`);
2140
+ console.error(`[control-center] Listening: ${formatted}`);
2141
+ console.error(`[control-center] Bound to 127.0.0.1 only — not exposed to network`);
2142
+ // Persist status file so the launcher can detect a running instance and
2143
+ // open the browser without spawning a second server.
2144
+ writeStatusFile();
2145
+ recordEvent("control_center.started", {
2146
+ pid: process.pid,
2147
+ port,
2148
+ url: formatted,
2149
+ version: PATCHWARDEN_VERSION,
2150
+ });
2151
+ });
2152
+
2153
+ function shutdown(): void {
2154
+ console.error("[control-center] Shutting down...");
2155
+ recordEvent("control_center.stopped", { pid: process.pid });
2156
+ removeStatusFile();
2157
+ server.close(() => {
2158
+ try { process.exit(0); } catch { /* ignore */ }
2159
+ });
2160
+ setTimeout(() => {
2161
+ try { process.exit(0); } catch { /* ignore */ }
2162
+ }, 3000).unref();
2163
+ }
2164
+
2165
+ process.on("SIGINT", shutdown);
2166
+ process.on("SIGTERM", shutdown);