@intrect/openswarm 0.15.0 → 0.17.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 (323) hide show
  1. package/README.md +4 -3
  2. package/dist/adapters/agenticLoop.d.ts +2 -0
  3. package/dist/adapters/agenticLoop.d.ts.map +1 -1
  4. package/dist/adapters/agenticLoop.js +8 -5
  5. package/dist/adapters/agenticLoop.js.map +1 -1
  6. package/dist/adapters/codexResponses.d.ts.map +1 -1
  7. package/dist/adapters/codexResponses.js +1 -0
  8. package/dist/adapters/codexResponses.js.map +1 -1
  9. package/dist/adapters/gpt.d.ts.map +1 -1
  10. package/dist/adapters/gpt.js +1 -0
  11. package/dist/adapters/gpt.js.map +1 -1
  12. package/dist/adapters/local.d.ts.map +1 -1
  13. package/dist/adapters/local.js +1 -0
  14. package/dist/adapters/local.js.map +1 -1
  15. package/dist/adapters/openrouter.d.ts.map +1 -1
  16. package/dist/adapters/openrouter.js +1 -0
  17. package/dist/adapters/openrouter.js.map +1 -1
  18. package/dist/adapters/tools.d.ts +2 -0
  19. package/dist/adapters/tools.d.ts.map +1 -1
  20. package/dist/adapters/tools.js +7 -0
  21. package/dist/adapters/tools.js.map +1 -1
  22. package/dist/adapters/types.d.ts +2 -0
  23. package/dist/adapters/types.d.ts.map +1 -1
  24. package/dist/automation/autonomousRunner.d.ts +5 -5
  25. package/dist/automation/autonomousRunner.d.ts.map +1 -1
  26. package/dist/automation/autonomousRunner.js +7 -9
  27. package/dist/automation/autonomousRunner.js.map +1 -1
  28. package/dist/automation/ciWorker.d.ts.map +1 -1
  29. package/dist/automation/ciWorker.js +16 -11
  30. package/dist/automation/ciWorker.js.map +1 -1
  31. package/dist/automation/dailyReporter.d.ts.map +1 -1
  32. package/dist/automation/dailyReporter.js +7 -2
  33. package/dist/automation/dailyReporter.js.map +1 -1
  34. package/dist/automation/longRunningMonitor.d.ts.map +1 -1
  35. package/dist/automation/longRunningMonitor.js +17 -27
  36. package/dist/automation/longRunningMonitor.js.map +1 -1
  37. package/dist/automation/prProcessor.d.ts.map +1 -1
  38. package/dist/automation/prProcessor.js +103 -24
  39. package/dist/automation/prProcessor.js.map +1 -1
  40. package/dist/automation/runnerState.d.ts +0 -6
  41. package/dist/automation/runnerState.d.ts.map +1 -1
  42. package/dist/automation/runnerState.js +16 -31
  43. package/dist/automation/runnerState.js.map +1 -1
  44. package/dist/automation/runnerTypes.d.ts +0 -2
  45. package/dist/automation/runnerTypes.d.ts.map +1 -1
  46. package/dist/automation/scheduler.d.ts.map +1 -1
  47. package/dist/automation/scheduler.js +76 -76
  48. package/dist/automation/scheduler.js.map +1 -1
  49. package/dist/automation/taskSource.d.ts +1 -1
  50. package/dist/automation/taskSource.d.ts.map +1 -1
  51. package/dist/automation/taskSource.js +95 -8
  52. package/dist/automation/taskSource.js.map +1 -1
  53. package/dist/automation/workerAuditLog.d.ts.map +1 -1
  54. package/dist/automation/workerAuditLog.js +4 -1
  55. package/dist/automation/workerAuditLog.js.map +1 -1
  56. package/dist/cli/authHandler.d.ts.map +1 -1
  57. package/dist/cli/authHandler.js +10 -12
  58. package/dist/cli/authHandler.js.map +1 -1
  59. package/dist/cli/checkHandler.d.ts.map +1 -1
  60. package/dist/cli/checkHandler.js +12 -10
  61. package/dist/cli/checkHandler.js.map +1 -1
  62. package/dist/cli/daemon.d.ts.map +1 -1
  63. package/dist/cli/daemon.js +24 -9
  64. package/dist/cli/daemon.js.map +1 -1
  65. package/dist/cli/fixCommand.d.ts.map +1 -1
  66. package/dist/cli/fixCommand.js +10 -0
  67. package/dist/cli/fixCommand.js.map +1 -1
  68. package/dist/cli/initWizard.d.ts.map +1 -1
  69. package/dist/cli/initWizard.js +6 -1
  70. package/dist/cli/initWizard.js.map +1 -1
  71. package/dist/cli/projectHandler.d.ts.map +1 -1
  72. package/dist/cli/projectHandler.js +3 -2
  73. package/dist/cli/projectHandler.js.map +1 -1
  74. package/dist/cli/promptHandler.d.ts.map +1 -1
  75. package/dist/cli/promptHandler.js +21 -10
  76. package/dist/cli/promptHandler.js.map +1 -1
  77. package/dist/cli/reviewAudit.d.ts +2 -2
  78. package/dist/cli/reviewAudit.js +3 -3
  79. package/dist/cli/reviewAudit.js.map +1 -1
  80. package/dist/cli/reviewMaxCommand.d.ts.map +1 -1
  81. package/dist/cli/reviewMaxCommand.js +10 -2
  82. package/dist/cli/reviewMaxCommand.js.map +1 -1
  83. package/dist/cli/scheduleCommand.d.ts.map +1 -1
  84. package/dist/cli/scheduleCommand.js +20 -5
  85. package/dist/cli/scheduleCommand.js.map +1 -1
  86. package/dist/cli.js +15 -4
  87. package/dist/cli.js.map +1 -1
  88. package/dist/core/config.d.ts +33 -14
  89. package/dist/core/config.d.ts.map +1 -1
  90. package/dist/core/config.js +18 -3
  91. package/dist/core/config.js.map +1 -1
  92. package/dist/core/service.d.ts.map +1 -1
  93. package/dist/core/service.js +0 -2
  94. package/dist/core/service.js.map +1 -1
  95. package/dist/core/types.d.ts +0 -2
  96. package/dist/core/types.d.ts.map +1 -1
  97. package/dist/discord/discordCore.d.ts.map +1 -1
  98. package/dist/discord/discordCore.js +20 -4
  99. package/dist/discord/discordCore.js.map +1 -1
  100. package/dist/discord/discordPair.js +12 -0
  101. package/dist/discord/discordPair.js.map +1 -1
  102. package/dist/github/github.d.ts +1 -1
  103. package/dist/github/github.d.ts.map +1 -1
  104. package/dist/github/github.js +41 -11
  105. package/dist/github/github.js.map +1 -1
  106. package/dist/index.js +15 -6
  107. package/dist/index.js.map +1 -1
  108. package/dist/issues/graphql/resolvers.d.ts +4 -4
  109. package/dist/issues/graphql/resolvers.d.ts.map +1 -1
  110. package/dist/issues/graphql/resolvers.js +43 -3
  111. package/dist/issues/graphql/resolvers.js.map +1 -1
  112. package/dist/issues/graphql/server.d.ts.map +1 -1
  113. package/dist/issues/graphql/server.js +76 -5
  114. package/dist/issues/graphql/server.js.map +1 -1
  115. package/dist/issues/graphql/typeDefs.d.ts +1 -1
  116. package/dist/issues/graphql/typeDefs.d.ts.map +1 -1
  117. package/dist/issues/graphql/typeDefs.js +0 -5
  118. package/dist/issues/graphql/typeDefs.js.map +1 -1
  119. package/dist/issues/issueBoardHtml.d.ts +1 -1
  120. package/dist/issues/issueBoardHtml.d.ts.map +1 -1
  121. package/dist/issues/issueBoardHtml.js +40 -20
  122. package/dist/issues/issueBoardHtml.js.map +1 -1
  123. package/dist/issues/linearBridge.d.ts +1 -1
  124. package/dist/issues/linearBridge.d.ts.map +1 -1
  125. package/dist/issues/linearBridge.js +14 -5
  126. package/dist/issues/linearBridge.js.map +1 -1
  127. package/dist/issues/memoryBridge.d.ts.map +1 -1
  128. package/dist/issues/memoryBridge.js +23 -11
  129. package/dist/issues/memoryBridge.js.map +1 -1
  130. package/dist/issues/schema.d.ts +3 -3
  131. package/dist/issues/sqliteStore.d.ts +3 -0
  132. package/dist/issues/sqliteStore.d.ts.map +1 -1
  133. package/dist/issues/sqliteStore.js +124 -19
  134. package/dist/issues/sqliteStore.js.map +1 -1
  135. package/dist/knowledge/analyzer.d.ts.map +1 -1
  136. package/dist/knowledge/analyzer.js +13 -1
  137. package/dist/knowledge/analyzer.js.map +1 -1
  138. package/dist/knowledge/graphqlExporter.d.ts.map +1 -1
  139. package/dist/knowledge/graphqlExporter.js +38 -9
  140. package/dist/knowledge/graphqlExporter.js.map +1 -1
  141. package/dist/knowledge/scanner.d.ts.map +1 -1
  142. package/dist/knowledge/scanner.js +69 -26
  143. package/dist/knowledge/scanner.js.map +1 -1
  144. package/dist/linear/linear.d.ts.map +1 -1
  145. package/dist/linear/linear.js +11 -11
  146. package/dist/linear/linear.js.map +1 -1
  147. package/dist/linear/projectUpdater.js +12 -2
  148. package/dist/linear/projectUpdater.js.map +1 -1
  149. package/dist/locale/en.d.ts.map +1 -1
  150. package/dist/locale/en.js +4 -0
  151. package/dist/locale/en.js.map +1 -1
  152. package/dist/locale/index.d.ts +7 -2
  153. package/dist/locale/index.d.ts.map +1 -1
  154. package/dist/locale/index.js.map +1 -1
  155. package/dist/locale/ko.d.ts.map +1 -1
  156. package/dist/locale/ko.js +4 -0
  157. package/dist/locale/ko.js.map +1 -1
  158. package/dist/locale/prompts/en.d.ts.map +1 -1
  159. package/dist/locale/prompts/en.js +111 -42
  160. package/dist/locale/prompts/en.js.map +1 -1
  161. package/dist/locale/prompts/ko.d.ts.map +1 -1
  162. package/dist/locale/prompts/ko.js +111 -42
  163. package/dist/locale/prompts/ko.js.map +1 -1
  164. package/dist/locale/types.d.ts +4 -0
  165. package/dist/locale/types.d.ts.map +1 -1
  166. package/dist/mcp/mcpClient.d.ts.map +1 -1
  167. package/dist/mcp/mcpClient.js +68 -6
  168. package/dist/mcp/mcpClient.js.map +1 -1
  169. package/dist/mcp/memoryServer.js +3 -0
  170. package/dist/mcp/memoryServer.js.map +1 -1
  171. package/dist/memory/codex.d.ts.map +1 -1
  172. package/dist/memory/codex.js +3 -2
  173. package/dist/memory/codex.js.map +1 -1
  174. package/dist/memory/compaction.d.ts.map +1 -1
  175. package/dist/memory/compaction.js +36 -6
  176. package/dist/memory/compaction.js.map +1 -1
  177. package/dist/memory/memoryCore.d.ts +1 -0
  178. package/dist/memory/memoryCore.d.ts.map +1 -1
  179. package/dist/memory/memoryCore.js +43 -25
  180. package/dist/memory/memoryCore.js.map +1 -1
  181. package/dist/memory/memoryOps.d.ts.map +1 -1
  182. package/dist/memory/memoryOps.js +54 -58
  183. package/dist/memory/memoryOps.js.map +1 -1
  184. package/dist/notify/notifier.d.ts.map +1 -1
  185. package/dist/notify/notifier.js +31 -7
  186. package/dist/notify/notifier.js.map +1 -1
  187. package/dist/orchestration/conflictDetector.d.ts.map +1 -1
  188. package/dist/orchestration/conflictDetector.js +15 -4
  189. package/dist/orchestration/conflictDetector.js.map +1 -1
  190. package/dist/orchestration/decisionEngine.d.ts +32 -2
  191. package/dist/orchestration/decisionEngine.d.ts.map +1 -1
  192. package/dist/orchestration/decisionEngine.js +80 -41
  193. package/dist/orchestration/decisionEngine.js.map +1 -1
  194. package/dist/orchestration/taskParser.d.ts.map +1 -1
  195. package/dist/orchestration/taskParser.js +21 -3
  196. package/dist/orchestration/taskParser.js.map +1 -1
  197. package/dist/orchestration/taskScheduler.d.ts.map +1 -1
  198. package/dist/orchestration/taskScheduler.js +47 -19
  199. package/dist/orchestration/taskScheduler.js.map +1 -1
  200. package/dist/orchestration/workflow.d.ts.map +1 -1
  201. package/dist/orchestration/workflow.js +22 -5
  202. package/dist/orchestration/workflow.js.map +1 -1
  203. package/dist/registry/bsDetector.js +2 -2
  204. package/dist/registry/bsDetector.js.map +1 -1
  205. package/dist/registry/entityScanner.d.ts.map +1 -1
  206. package/dist/registry/entityScanner.js +25 -6
  207. package/dist/registry/entityScanner.js.map +1 -1
  208. package/dist/registry/graphql/resolvers.d.ts +23 -8
  209. package/dist/registry/graphql/resolvers.d.ts.map +1 -1
  210. package/dist/registry/graphql/resolvers.js +126 -32
  211. package/dist/registry/graphql/resolvers.js.map +1 -1
  212. package/dist/registry/graphql/typeDefs.d.ts +1 -1
  213. package/dist/registry/graphql/typeDefs.d.ts.map +1 -1
  214. package/dist/registry/graphql/typeDefs.js +9 -8
  215. package/dist/registry/graphql/typeDefs.js.map +1 -1
  216. package/dist/registry/issueBridge.d.ts +1 -1
  217. package/dist/registry/issueBridge.d.ts.map +1 -1
  218. package/dist/registry/issueBridge.js +2 -2
  219. package/dist/registry/issueBridge.js.map +1 -1
  220. package/dist/registry/sqliteStore.d.ts +2 -2
  221. package/dist/registry/sqliteStore.d.ts.map +1 -1
  222. package/dist/registry/sqliteStore.js +24 -10
  223. package/dist/registry/sqliteStore.js.map +1 -1
  224. package/dist/runners/cliRunner.d.ts.map +1 -1
  225. package/dist/runners/cliRunner.js +42 -19
  226. package/dist/runners/cliRunner.js.map +1 -1
  227. package/dist/support/apiCache.d.ts +15 -1
  228. package/dist/support/apiCache.d.ts.map +1 -1
  229. package/dist/support/apiCache.js +42 -7
  230. package/dist/support/apiCache.js.map +1 -1
  231. package/dist/support/chatBackend.d.ts.map +1 -1
  232. package/dist/support/chatBackend.js +2 -3
  233. package/dist/support/chatBackend.js.map +1 -1
  234. package/dist/support/chatSession.d.ts.map +1 -1
  235. package/dist/support/chatSession.js +21 -3
  236. package/dist/support/chatSession.js.map +1 -1
  237. package/dist/support/dashboardHtml.d.ts +1 -1
  238. package/dist/support/dashboardHtml.d.ts.map +1 -1
  239. package/dist/support/dashboardHtml.js +19 -14
  240. package/dist/support/dashboardHtml.js.map +1 -1
  241. package/dist/support/delete-beliefs.d.ts +3 -1
  242. package/dist/support/delete-beliefs.d.ts.map +1 -1
  243. package/dist/support/delete-beliefs.js +18 -7
  244. package/dist/support/delete-beliefs.js.map +1 -1
  245. package/dist/support/editParser.d.ts.map +1 -1
  246. package/dist/support/editParser.js +46 -12
  247. package/dist/support/editParser.js.map +1 -1
  248. package/dist/support/gitTracker.d.ts +2 -2
  249. package/dist/support/gitTracker.d.ts.map +1 -1
  250. package/dist/support/gitTracker.js +10 -5
  251. package/dist/support/gitTracker.js.map +1 -1
  252. package/dist/support/planner.d.ts.map +1 -1
  253. package/dist/support/planner.js +1 -0
  254. package/dist/support/planner.js.map +1 -1
  255. package/dist/support/projectMapper.d.ts.map +1 -1
  256. package/dist/support/projectMapper.js +21 -16
  257. package/dist/support/projectMapper.js.map +1 -1
  258. package/dist/support/rateLimiter.d.ts.map +1 -1
  259. package/dist/support/rateLimiter.js +3 -0
  260. package/dist/support/rateLimiter.js.map +1 -1
  261. package/dist/support/rollback.d.ts.map +1 -1
  262. package/dist/support/rollback.js +57 -8
  263. package/dist/support/rollback.js.map +1 -1
  264. package/dist/support/stuckDetector.d.ts.map +1 -1
  265. package/dist/support/stuckDetector.js +8 -6
  266. package/dist/support/stuckDetector.js.map +1 -1
  267. package/dist/support/timeWindow.d.ts +1 -1
  268. package/dist/support/timeWindow.d.ts.map +1 -1
  269. package/dist/support/timeWindow.js +18 -7
  270. package/dist/support/timeWindow.js.map +1 -1
  271. package/dist/support/web.d.ts.map +1 -1
  272. package/dist/support/web.js +872 -791
  273. package/dist/support/web.js.map +1 -1
  274. package/dist/support/workflowLinear.d.ts.map +1 -1
  275. package/dist/support/workflowLinear.js +8 -6
  276. package/dist/support/workflowLinear.js.map +1 -1
  277. package/dist/support/worktreeManager.d.ts.map +1 -1
  278. package/dist/support/worktreeManager.js +33 -9
  279. package/dist/support/worktreeManager.js.map +1 -1
  280. package/dist/taskState/store.d.ts +7 -2
  281. package/dist/taskState/store.d.ts.map +1 -1
  282. package/dist/taskState/store.js +77 -21
  283. package/dist/taskState/store.js.map +1 -1
  284. package/dist/telemetry/telemetry.d.ts.map +1 -1
  285. package/dist/telemetry/telemetry.js +18 -4
  286. package/dist/telemetry/telemetry.js.map +1 -1
  287. package/dist/tui/components/ChatInput.d.ts +1 -0
  288. package/dist/tui/components/ChatInput.d.ts.map +1 -1
  289. package/dist/tui/components/ChatInput.js +14 -1
  290. package/dist/tui/components/ChatInput.js.map +1 -1
  291. package/dist/tui/components/SubagentTree.d.ts.map +1 -1
  292. package/dist/tui/components/SubagentTree.js +14 -2
  293. package/dist/tui/components/SubagentTree.js.map +1 -1
  294. package/dist/tui/hooks/useMonitor.d.ts.map +1 -1
  295. package/dist/tui/hooks/useMonitor.js +5 -0
  296. package/dist/tui/hooks/useMonitor.js.map +1 -1
  297. package/dist/tui/hooks/usePipelineEvents.d.ts.map +1 -1
  298. package/dist/tui/hooks/usePipelineEvents.js +12 -2
  299. package/dist/tui/hooks/usePipelineEvents.js.map +1 -1
  300. package/dist/tui/markdown.d.ts.map +1 -1
  301. package/dist/tui/markdown.js +11 -3
  302. package/dist/tui/markdown.js.map +1 -1
  303. package/dist/tui/monitorApi.d.ts.map +1 -1
  304. package/dist/tui/monitorApi.js +21 -0
  305. package/dist/tui/monitorApi.js.map +1 -1
  306. package/dist/tui/monitorRows.d.ts +2 -2
  307. package/dist/tui/monitorRows.d.ts.map +1 -1
  308. package/dist/tui/monitorRows.js +7 -1
  309. package/dist/tui/monitorRows.js.map +1 -1
  310. package/dist/tui/panels/ChatPanel.d.ts.map +1 -1
  311. package/dist/tui/panels/ChatPanel.js +30 -3
  312. package/dist/tui/panels/ChatPanel.js.map +1 -1
  313. package/dist/tui/sse.d.ts +1 -0
  314. package/dist/tui/sse.d.ts.map +1 -1
  315. package/dist/tui/sse.js +6 -1
  316. package/dist/tui/sse.js.map +1 -1
  317. package/dist/tui/subagentTree.d.ts.map +1 -1
  318. package/dist/tui/subagentTree.js +16 -5
  319. package/dist/tui/subagentTree.js.map +1 -1
  320. package/dist/tui/tabs.d.ts.map +1 -1
  321. package/dist/tui/tabs.js +7 -1
  322. package/dist/tui/tabs.js.map +1 -1
  323. package/package.json +1 -1
@@ -27,6 +27,14 @@ import { ISSUE_BOARD_HTML } from '../issues/issueBoardHtml.js';
27
27
  import { createSubIssuesWithDependencies, getTaskSource } from '../automation/runnerExecution.js';
28
28
  let server = null;
29
29
  let runnerRef;
30
+ const MAX_REQUEST_BODY_BYTES = 1024 * 1024;
31
+ class HttpError extends Error {
32
+ statusCode;
33
+ constructor(statusCode, message) {
34
+ super(message);
35
+ this.statusCode = statusCode;
36
+ }
37
+ }
30
38
  // CORS origin allowlist — hostname-strict match (no substring/prefix pitfalls)
31
39
  function isAllowedOrigin(origin) {
32
40
  let url;
@@ -61,6 +69,38 @@ function safeErrorMessage(err) {
61
69
  }
62
70
  return 'Internal error';
63
71
  }
72
+ function isLoopbackAddress(address) {
73
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
74
+ }
75
+ function extractBearerToken(header) {
76
+ if (!header)
77
+ return null;
78
+ // Linear-time parse (no regex): 'Bearer' + one space/tab + token. A
79
+ // backtracking /^Bearer\s+(.+)$/ is polynomial on adversarial whitespace runs.
80
+ const prefix = header.slice(0, 7).toLowerCase();
81
+ if (prefix !== 'bearer ' && prefix !== 'bearer\t')
82
+ return null;
83
+ return header.slice(7).trim() || null;
84
+ }
85
+ function isAuthorizedMutation(req) {
86
+ if (isLoopbackAddress(req.socket.remoteAddress))
87
+ return true;
88
+ const configuredToken = process.env.OPENSWARM_WEB_TOKEN?.trim();
89
+ if (!configuredToken)
90
+ return false;
91
+ const presentedToken = extractBearerToken(req.headers.authorization) ||
92
+ (Array.isArray(req.headers['x-openswarm-token'])
93
+ ? req.headers['x-openswarm-token'][0]
94
+ : req.headers['x-openswarm-token']);
95
+ return presentedToken === configuredToken;
96
+ }
97
+ function isMutatingApiRequest(pathname, method) {
98
+ return pathname.startsWith('/api/') && ['DELETE', 'PATCH', 'POST', 'PUT'].includes(method ?? '');
99
+ }
100
+ function writeJson(res, statusCode, body) {
101
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
102
+ res.end(JSON.stringify(body));
103
+ }
64
104
  const execTasks = new Map();
65
105
  function cleanupExecTask(taskId) {
66
106
  setTimeout(() => { execTasks.delete(taskId); }, 3600000); // 1 hour
@@ -244,10 +284,34 @@ export function setWebRunner(runner) {
244
284
  }
245
285
  // Read POST body helper
246
286
  function readBody(req) {
247
- return new Promise((resolve) => {
287
+ return new Promise((resolve, reject) => {
248
288
  let data = '';
249
- req.on('data', (chunk) => { data += chunk.toString(); });
250
- req.on('end', () => resolve(data));
289
+ let totalBytes = 0;
290
+ let settled = false;
291
+ const fail = (statusCode, message) => {
292
+ if (settled)
293
+ return;
294
+ settled = true;
295
+ reject(new HttpError(statusCode, message));
296
+ };
297
+ req.on('data', (chunk) => {
298
+ if (settled)
299
+ return;
300
+ totalBytes += chunk.length;
301
+ if (totalBytes > MAX_REQUEST_BODY_BYTES) {
302
+ fail(413, 'Request body too large');
303
+ return;
304
+ }
305
+ data += chunk.toString('utf-8');
306
+ });
307
+ req.on('end', () => {
308
+ if (settled)
309
+ return;
310
+ settled = true;
311
+ resolve(data);
312
+ });
313
+ req.on('aborted', () => fail(400, 'Request body aborted'));
314
+ req.on('error', () => fail(400, 'Request body error'));
251
315
  });
252
316
  }
253
317
  // Start web server
@@ -258,890 +322,907 @@ export async function startWebServer(port = 3847) {
258
322
  }
259
323
  return new Promise((resolve, reject) => {
260
324
  server = createServer(async (req, res) => {
261
- const url = req.url?.split('?')[0] || '/';
262
- // CORS: allow localhost, Tauri webview, and Tailscale network
263
- const origin = req.headers.origin;
264
- if (origin && isAllowedOrigin(origin)) {
265
- res.setHeader('Access-Control-Allow-Origin', origin);
266
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE');
267
- }
268
- // ---- GraphQL API (이슈 트래커) ----
269
- if (isGraphQLRequest(req.url)) {
270
- await handleGraphQL(req, res);
271
- // ---- Issue Board ----
272
- }
273
- else if (url === '/issues') {
274
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
275
- res.end(ISSUE_BOARD_HTML);
276
- // ---- Dashboard ----
277
- }
278
- else if (url === '/' || url === '/index.html') {
279
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
280
- res.end(DASHBOARD_HTML);
281
- // ---- SSE stream ----
282
- }
283
- else if (url === '/api/events') {
284
- const skipReplay = req.url?.includes('skipReplay=1') ?? false;
285
- res.writeHead(200, {
286
- 'Content-Type': 'text/event-stream',
287
- 'Cache-Control': 'no-cache',
288
- 'Connection': 'keep-alive',
289
- });
290
- res.write(':connected\n\n');
291
- addSSEClient(res, skipReplay);
292
- // ---- Stats ----
293
- }
294
- else if (url === '/api/stats') {
295
- const stats = runnerRef?.getStats();
296
- const state = runnerRef?.getState();
297
- const adapters = runnerRef?.getAdapterSummary();
298
- res.writeHead(200, { 'Content-Type': 'application/json' });
299
- res.end(JSON.stringify({
300
- runningTasks: stats?.schedulerStats?.running ?? 0,
301
- queuedTasks: stats?.schedulerStats?.queued ?? 0,
302
- completedToday: stats?.schedulerStats?.completed ?? 0,
303
- uptime: state?.startedAt ? Date.now() - state.startedAt : 0,
304
- isRunning: stats?.isRunning ?? false,
305
- sseClients: getActiveSSECount(),
306
- adapters,
307
- turboMode: stats?.turboMode ?? false,
308
- turboExpiresAt: stats?.turboExpiresAt ?? null,
309
- dailyPace: stats?.dailyPace ?? null,
310
- }));
311
- // ---- Tasks ----
312
- }
313
- else if (url === '/api/tasks') {
314
- const running = runnerRef?.getRunningTasks() ?? [];
315
- const queued = runnerRef?.getQueuedTasks() ?? [];
316
- res.writeHead(200, { 'Content-Type': 'application/json' });
317
- res.end(JSON.stringify({ running, queued }));
318
- // ---- Pipeline GET (detailed pipeline stages) ----
319
- }
320
- else if (url === '/api/pipeline') {
321
- const stages = getStageBuffer();
322
- res.writeHead(200, { 'Content-Type': 'application/json' });
323
- res.end(JSON.stringify({ stages }));
324
- // ---- Rate Limiter Metrics GET ----
325
- }
326
- else if (url === '/api/rate-limits') {
327
- const metrics = getRateLimiterMetrics();
328
- res.writeHead(200, { 'Content-Type': 'application/json' });
329
- res.end(JSON.stringify(metrics));
330
- // ---- Projects GET (pinned + active projects) ----
331
- }
332
- else if (url === '/api/projects' && req.method === 'GET') {
333
- const enabledPaths = new Set(runnerRef?.getEnabledProjects() ?? []);
334
- const taskInfo = runnerRef?.getProjectsInfo() ?? [];
335
- const byPath = new Map(taskInfo.filter(p => p.path).map(p => [p.path, p]));
336
- // Fallback: match by project name (for tasks not yet executed → path not cached)
337
- const byName = new Map(taskInfo.map(p => [p.name, p]));
338
- // Start with pinned projects
339
- const allPaths = new Set(pinnedProjects);
340
- // Auto-include enabled projects and projects with active tasks
341
- for (const path of enabledPaths)
342
- allPaths.add(path);
343
- for (const info of taskInfo) {
344
- if (info.path && (info.running.length > 0 || info.queued.length > 0)) {
345
- allPaths.add(info.path);
346
- }
325
+ try {
326
+ const requestUrl = new URL(req.url ?? '/', 'http://localhost');
327
+ const url = requestUrl.pathname;
328
+ // CORS: allow localhost, Tauri webview, and Tailscale network
329
+ const origin = req.headers.origin;
330
+ if (origin && isAllowedOrigin(origin)) {
331
+ res.setHeader('Access-Control-Allow-Origin', origin);
332
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE');
333
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-OpenSwarm-Token');
347
334
  }
348
- const result = await Promise.all(Array.from(allPaths).map(async (p) => {
349
- const dirName = p.split('/').pop() ?? p;
350
- const info = byPath.get(p) ?? byName.get(dirName);
351
- const gitInfo = await getProjectGitInfo(p);
352
- return {
353
- path: p,
354
- name: dirName,
355
- enabled: enabledPaths.has(p),
356
- pinned: pinnedProjects.has(p),
357
- running: info?.running ?? [],
358
- queued: info?.queued ?? [],
359
- pending: info?.pending ?? [],
360
- git: gitInfo.git,
361
- prs: gitInfo.prs,
362
- };
363
- }));
364
- result.sort((a, b) => {
365
- const aActive = a.running.length + a.queued.length + a.pending.length;
366
- const bActive = b.running.length + b.queued.length + b.pending.length;
367
- if (aActive !== bActive)
368
- return bActive - aActive;
369
- return a.name.localeCompare(b.name);
370
- });
371
- res.writeHead(200, { 'Content-Type': 'application/json' });
372
- res.end(JSON.stringify(result));
373
- // ---- Local projects for picker ----
374
- }
375
- else if (url === '/api/local-projects' && req.method === 'GET') {
376
- const configPaths = runnerRef?.getAllowedProjects() ?? [];
377
- const allBasePaths = [...new Set([...configPaths, ...customBasePaths])];
378
- try {
379
- const locals = await scanLocalProjects(allBasePaths);
380
- const SKIP = ['/node_modules/', '/.git/', '/dist/', '/build/', '/__pycache__/', '/venv/', '/.venv/'];
381
- const filtered = locals.filter(l => !SKIP.some(s => l.path.includes(s)));
335
+ if (req.method === 'OPTIONS') {
336
+ res.writeHead(204);
337
+ res.end();
338
+ return;
339
+ }
340
+ if (isMutatingApiRequest(url, req.method) && !isAuthorizedMutation(req)) {
341
+ writeJson(res, 403, { error: 'Forbidden' });
342
+ return;
343
+ }
344
+ // ---- GraphQL API (이슈 트래커) ----
345
+ if (isGraphQLRequest(req.url)) {
346
+ await handleGraphQL(req, res);
347
+ // ---- Issue Board ----
348
+ }
349
+ else if (url === '/issues') {
350
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
351
+ res.end(ISSUE_BOARD_HTML);
352
+ // ---- Dashboard ----
353
+ }
354
+ else if (url === '/' || url === '/index.html') {
355
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
356
+ res.end(DASHBOARD_HTML);
357
+ // ---- SSE stream ----
358
+ }
359
+ else if (url === '/api/events') {
360
+ const skipReplay = req.url?.includes('skipReplay=1') ?? false;
361
+ res.writeHead(200, {
362
+ 'Content-Type': 'text/event-stream',
363
+ 'Cache-Control': 'no-cache',
364
+ 'Connection': 'keep-alive',
365
+ });
366
+ res.write(':connected\n\n');
367
+ addSSEClient(res, skipReplay);
368
+ // ---- Stats ----
369
+ }
370
+ else if (url === '/api/stats') {
371
+ const stats = runnerRef?.getStats();
372
+ const state = runnerRef?.getState();
373
+ const adapters = runnerRef?.getAdapterSummary();
382
374
  res.writeHead(200, { 'Content-Type': 'application/json' });
383
- res.end(JSON.stringify(filtered.map(l => ({ path: l.path, name: l.name, pinned: pinnedProjects.has(l.path) }))));
375
+ res.end(JSON.stringify({
376
+ runningTasks: stats?.schedulerStats?.running ?? 0,
377
+ queuedTasks: stats?.schedulerStats?.queued ?? 0,
378
+ completedToday: stats?.schedulerStats?.completed ?? 0,
379
+ uptime: state?.startedAt ? Date.now() - state.startedAt : 0,
380
+ isRunning: stats?.isRunning ?? false,
381
+ sseClients: getActiveSSECount(),
382
+ adapters,
383
+ turboMode: stats?.turboMode ?? false,
384
+ turboExpiresAt: stats?.turboExpiresAt ?? null,
385
+ dailyPace: stats?.dailyPace ?? null,
386
+ }));
387
+ // ---- Tasks ----
384
388
  }
385
- catch (e) {
386
- res.writeHead(500, { 'Content-Type': 'application/json' });
387
- res.end(JSON.stringify({ error: safeErrorMessage(e) }));
389
+ else if (url === '/api/tasks') {
390
+ const running = runnerRef?.getRunningTasks() ?? [];
391
+ const queued = runnerRef?.getQueuedTasks() ?? [];
392
+ res.writeHead(200, { 'Content-Type': 'application/json' });
393
+ res.end(JSON.stringify({ running, queued }));
394
+ // ---- Pipeline GET (detailed pipeline stages) ----
388
395
  }
389
- // ---- Pin project ----
390
- }
391
- else if (url === '/api/projects/pin' && req.method === 'POST') {
392
- const body = await readBody(req);
393
- try {
394
- const { projectPath } = JSON.parse(body);
395
- if (typeof projectPath === 'string' && projectPath) {
396
- pinnedProjects.add(projectPath);
397
- // R6: an explicit pin is a deliberate re-enable — clear the denylist so it isn't
398
- // skipped again by setWebRunner on the next restart.
399
- removedConfigPaths.delete(projectPath);
400
- saveReposConfig();
401
- // Seed path cache so Linear project name matches immediately
402
- const name = projectPath.split('/').pop();
403
- if (name && runnerRef)
404
- runnerRef.registerProjectPath(name, projectPath);
405
- }
396
+ else if (url === '/api/pipeline') {
397
+ const stages = getStageBuffer();
406
398
  res.writeHead(200, { 'Content-Type': 'application/json' });
407
- res.end(JSON.stringify({ ok: true }));
399
+ res.end(JSON.stringify({ stages }));
400
+ // ---- Rate Limiter Metrics GET ----
408
401
  }
409
- catch {
410
- res.writeHead(400).end(JSON.stringify({ error: 'Invalid JSON' }));
402
+ else if (url === '/api/rate-limits') {
403
+ const metrics = getRateLimiterMetrics();
404
+ res.writeHead(200, { 'Content-Type': 'application/json' });
405
+ res.end(JSON.stringify(metrics));
406
+ // ---- Projects GET (pinned + active projects) ----
411
407
  }
412
- // ---- Unpin project ----
413
- }
414
- else if (url === '/api/projects/unpin' && req.method === 'POST') {
415
- const body = await readBody(req);
416
- try {
417
- const { projectPath } = JSON.parse(body);
418
- if (typeof projectPath === 'string') {
419
- pinnedProjects.delete(projectPath);
420
- // Also disable the project so it doesn't reappear via enabledPaths
421
- runnerRef?.disableProject(projectPath);
422
- saveReposConfig();
408
+ else if (url === '/api/projects' && req.method === 'GET') {
409
+ const enabledPaths = new Set(runnerRef?.getEnabledProjects() ?? []);
410
+ const taskInfo = runnerRef?.getProjectsInfo() ?? [];
411
+ const byPath = new Map(taskInfo.filter(p => p.path).map(p => [p.path, p]));
412
+ // Fallback: match by project name (for tasks not yet executed → path not cached)
413
+ const byName = new Map(taskInfo.map(p => [p.name, p]));
414
+ // Start with pinned projects
415
+ const allPaths = new Set(pinnedProjects);
416
+ // Auto-include enabled projects and projects with active tasks
417
+ for (const path of enabledPaths)
418
+ allPaths.add(path);
419
+ for (const info of taskInfo) {
420
+ if (info.path && (info.running.length > 0 || info.queued.length > 0)) {
421
+ allPaths.add(info.path);
422
+ }
423
423
  }
424
+ const result = await Promise.all(Array.from(allPaths).map(async (p) => {
425
+ const dirName = p.split('/').pop() ?? p;
426
+ const info = byPath.get(p) ?? byName.get(dirName);
427
+ const gitInfo = await getProjectGitInfo(p);
428
+ return {
429
+ path: p,
430
+ name: dirName,
431
+ enabled: enabledPaths.has(p),
432
+ pinned: pinnedProjects.has(p),
433
+ running: info?.running ?? [],
434
+ queued: info?.queued ?? [],
435
+ pending: info?.pending ?? [],
436
+ git: gitInfo.git,
437
+ prs: gitInfo.prs,
438
+ };
439
+ }));
440
+ result.sort((a, b) => {
441
+ const aActive = a.running.length + a.queued.length + a.pending.length;
442
+ const bActive = b.running.length + b.queued.length + b.pending.length;
443
+ if (aActive !== bActive)
444
+ return bActive - aActive;
445
+ return a.name.localeCompare(b.name);
446
+ });
424
447
  res.writeHead(200, { 'Content-Type': 'application/json' });
425
- res.end(JSON.stringify({ ok: true }));
448
+ res.end(JSON.stringify(result));
449
+ // ---- Local projects for picker ----
426
450
  }
427
- catch {
428
- res.writeHead(400).end(JSON.stringify({ error: 'Invalid JSON' }));
451
+ else if (url === '/api/local-projects' && req.method === 'GET') {
452
+ const configPaths = runnerRef?.getAllowedProjects() ?? [];
453
+ const allBasePaths = [...new Set([...configPaths, ...customBasePaths])];
454
+ try {
455
+ const locals = await scanLocalProjects(allBasePaths);
456
+ const SKIP = ['/node_modules/', '/.git/', '/dist/', '/build/', '/__pycache__/', '/venv/', '/.venv/'];
457
+ const filtered = locals.filter(l => !SKIP.some(s => l.path.includes(s)));
458
+ res.writeHead(200, { 'Content-Type': 'application/json' });
459
+ res.end(JSON.stringify(filtered.map(l => ({ path: l.path, name: l.name, pinned: pinnedProjects.has(l.path) }))));
460
+ }
461
+ catch (e) {
462
+ res.writeHead(500, { 'Content-Type': 'application/json' });
463
+ res.end(JSON.stringify({ error: safeErrorMessage(e) }));
464
+ }
465
+ // ---- Pin project ----
429
466
  }
430
- // ---- Projects Toggle ----
431
- }
432
- else if (url === '/api/projects/toggle' && req.method === 'POST') {
433
- const body = await readBody(req);
434
- try {
435
- const { projectPath, enabled } = JSON.parse(body);
436
- if (typeof projectPath === 'string' && typeof enabled === 'boolean') {
437
- if (enabled) {
438
- removedConfigPaths.delete(projectPath); // R6: explicit enable clears the denylist
439
- runnerRef?.enableProject(projectPath);
467
+ else if (url === '/api/projects/pin' && req.method === 'POST') {
468
+ const body = await readBody(req);
469
+ try {
470
+ const { projectPath } = JSON.parse(body);
471
+ if (typeof projectPath === 'string' && projectPath) {
472
+ pinnedProjects.add(projectPath);
473
+ // R6: an explicit pin is a deliberate re-enable — clear the denylist so it isn't
474
+ // skipped again by setWebRunner on the next restart.
475
+ removedConfigPaths.delete(projectPath);
476
+ saveReposConfig();
477
+ // Seed path cache so Linear project name matches immediately
478
+ const name = projectPath.split('/').pop();
479
+ if (name && runnerRef)
480
+ runnerRef.registerProjectPath(name, projectPath);
440
481
  }
441
- else {
482
+ res.writeHead(200, { 'Content-Type': 'application/json' });
483
+ res.end(JSON.stringify({ ok: true }));
484
+ }
485
+ catch {
486
+ res.writeHead(400).end(JSON.stringify({ error: 'Invalid JSON' }));
487
+ }
488
+ // ---- Unpin project ----
489
+ }
490
+ else if (url === '/api/projects/unpin' && req.method === 'POST') {
491
+ const body = await readBody(req);
492
+ try {
493
+ const { projectPath } = JSON.parse(body);
494
+ if (typeof projectPath === 'string') {
495
+ pinnedProjects.delete(projectPath);
496
+ // Also disable the project so it doesn't reappear via enabledPaths
442
497
  runnerRef?.disableProject(projectPath);
498
+ saveReposConfig();
443
499
  }
444
- saveReposConfig();
445
- broadcastEvent({ type: 'project:toggled', data: { projectPath, enabled } });
500
+ res.writeHead(200, { 'Content-Type': 'application/json' });
501
+ res.end(JSON.stringify({ ok: true }));
446
502
  }
447
- res.writeHead(200, { 'Content-Type': 'application/json' });
448
- res.end(JSON.stringify({ ok: true }));
449
- }
450
- catch {
451
- res.writeHead(400, { 'Content-Type': 'application/json' });
452
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
503
+ catch {
504
+ res.writeHead(400).end(JSON.stringify({ error: 'Invalid JSON' }));
505
+ }
506
+ // ---- Projects Toggle ----
453
507
  }
454
- // ---- Move Issue to Todo ----
455
- }
456
- else if (url === '/api/issue/move-to-todo' && req.method === 'POST') {
457
- const body = await readBody(req);
458
- try {
459
- const { issueId } = JSON.parse(body);
460
- if (!issueId) {
508
+ else if (url === '/api/projects/toggle' && req.method === 'POST') {
509
+ const body = await readBody(req);
510
+ try {
511
+ const { projectPath, enabled } = JSON.parse(body);
512
+ if (typeof projectPath === 'string' && typeof enabled === 'boolean') {
513
+ if (enabled) {
514
+ removedConfigPaths.delete(projectPath); // R6: explicit enable clears the denylist
515
+ runnerRef?.enableProject(projectPath);
516
+ }
517
+ else {
518
+ runnerRef?.disableProject(projectPath);
519
+ }
520
+ saveReposConfig();
521
+ broadcastEvent({ type: 'project:toggled', data: { projectPath, enabled } });
522
+ }
523
+ res.writeHead(200, { 'Content-Type': 'application/json' });
524
+ res.end(JSON.stringify({ ok: true }));
525
+ }
526
+ catch {
461
527
  res.writeHead(400, { 'Content-Type': 'application/json' });
462
- res.end(JSON.stringify({ error: 'Missing issueId' }));
463
- return;
528
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
464
529
  }
465
- // Import linear dynamically to avoid circular deps
466
- const linearModule = await import('../linear/index.js');
467
- await linearModule.updateIssueState(issueId, 'Todo');
468
- res.writeHead(200, { 'Content-Type': 'application/json' });
469
- res.end(JSON.stringify({ ok: true }));
470
- }
471
- catch (error) {
472
- console.error('[Web] Failed to move issue to Todo:', error);
473
- res.writeHead(500, { 'Content-Type': 'application/json' });
474
- res.end(JSON.stringify({ error: safeErrorMessage(error) }));
530
+ // ---- Move Issue to Todo ----
475
531
  }
476
- // ---- Heartbeat (manual trigger) ----
477
- }
478
- else if (url === '/api/heartbeat' && req.method === 'POST') {
479
- res.writeHead(202, { 'Content-Type': 'application/json' });
480
- res.end(JSON.stringify({ ok: true }));
481
- // Non-blocking heartbeat
482
- runnerRef?.heartbeat().catch((e) => console.error('[Web] Heartbeat error:', e));
483
- // ---- Provider toggle ----
484
- }
485
- else if (url === '/api/provider' && req.method === 'POST') {
486
- const body = await readBody(req);
487
- try {
488
- const { provider } = JSON.parse(body);
489
- // Validate against the live adapter registry rather than a hardcoded list,
490
- // so the dashboard's provider buttons (incl. claude / codex-responses) never
491
- // drift out of sync with what's actually registered.
492
- if (!isKnownAdapter(provider)) {
493
- res.writeHead(400, { 'Content-Type': 'application/json' });
494
- res.end(JSON.stringify({ error: `Invalid provider "${provider}". Valid: ${listAdapterNames().join(', ')}` }));
495
- return;
532
+ else if (url === '/api/issue/move-to-todo' && req.method === 'POST') {
533
+ const body = await readBody(req);
534
+ try {
535
+ const { issueId } = JSON.parse(body);
536
+ if (!issueId) {
537
+ res.writeHead(400, { 'Content-Type': 'application/json' });
538
+ res.end(JSON.stringify({ error: 'Missing issueId' }));
539
+ return;
540
+ }
541
+ // Import linear dynamically to avoid circular deps
542
+ const linearModule = await import('../linear/index.js');
543
+ await linearModule.updateIssueState(issueId, 'Todo');
544
+ res.writeHead(200, { 'Content-Type': 'application/json' });
545
+ res.end(JSON.stringify({ ok: true }));
496
546
  }
497
- setDefaultAdapter(provider);
498
- runnerRef?.switchProvider(provider);
499
- broadcastEvent({
500
- type: 'log',
501
- data: { taskId: 'system', stage: 'provider', line: `Provider switched to ${provider}` },
502
- });
503
- res.writeHead(200, { 'Content-Type': 'application/json' });
504
- res.end(JSON.stringify({ ok: true, provider }));
547
+ catch (error) {
548
+ console.error('[Web] Failed to move issue to Todo:', error);
549
+ res.writeHead(500, { 'Content-Type': 'application/json' });
550
+ res.end(JSON.stringify({ error: safeErrorMessage(error) }));
551
+ }
552
+ // ---- Heartbeat (manual trigger) ----
505
553
  }
506
- catch {
507
- res.writeHead(400, { 'Content-Type': 'application/json' });
508
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
554
+ else if (url === '/api/heartbeat' && req.method === 'POST') {
555
+ res.writeHead(202, { 'Content-Type': 'application/json' });
556
+ res.end(JSON.stringify({ ok: true }));
557
+ // Non-blocking heartbeat
558
+ runnerRef?.heartbeat().catch((e) => console.error('[Web] Heartbeat error:', e));
559
+ // ---- Provider toggle ----
509
560
  }
510
- // ---- Turbo Mode Toggle ----
511
- }
512
- else if (url === '/api/turbo' && req.method === 'POST') {
513
- const body = await readBody(req);
514
- try {
515
- const { enabled } = JSON.parse(body);
516
- if (typeof enabled !== 'boolean') {
561
+ else if (url === '/api/provider' && req.method === 'POST') {
562
+ const body = await readBody(req);
563
+ try {
564
+ const { provider } = JSON.parse(body);
565
+ // Validate against the live adapter registry rather than a hardcoded list,
566
+ // so the dashboard's provider buttons (incl. claude / codex-responses) never
567
+ // drift out of sync with what's actually registered.
568
+ if (!isKnownAdapter(provider)) {
569
+ res.writeHead(400, { 'Content-Type': 'application/json' });
570
+ res.end(JSON.stringify({ error: `Invalid provider "${provider}". Valid: ${listAdapterNames().join(', ')}` }));
571
+ return;
572
+ }
573
+ setDefaultAdapter(provider);
574
+ runnerRef?.switchProvider(provider);
575
+ broadcastEvent({
576
+ type: 'log',
577
+ data: { taskId: 'system', stage: 'provider', line: `Provider switched to ${provider}` },
578
+ });
579
+ res.writeHead(200, { 'Content-Type': 'application/json' });
580
+ res.end(JSON.stringify({ ok: true, provider }));
581
+ }
582
+ catch {
517
583
  res.writeHead(400, { 'Content-Type': 'application/json' });
518
- res.end(JSON.stringify({ error: 'enabled must be boolean' }));
519
- return;
584
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
520
585
  }
521
- runnerRef?.setTurboMode(enabled);
522
- res.writeHead(200, { 'Content-Type': 'application/json' });
523
- res.end(JSON.stringify({ ok: true, turboMode: enabled }));
586
+ // ---- Turbo Mode Toggle ----
524
587
  }
525
- catch {
526
- res.writeHead(400, { 'Content-Type': 'application/json' });
527
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
588
+ else if (url === '/api/turbo' && req.method === 'POST') {
589
+ const body = await readBody(req);
590
+ try {
591
+ const { enabled } = JSON.parse(body);
592
+ if (typeof enabled !== 'boolean') {
593
+ res.writeHead(400, { 'Content-Type': 'application/json' });
594
+ res.end(JSON.stringify({ error: 'enabled must be boolean' }));
595
+ return;
596
+ }
597
+ runnerRef?.setTurboMode(enabled);
598
+ res.writeHead(200, { 'Content-Type': 'application/json' });
599
+ res.end(JSON.stringify({ ok: true, turboMode: enabled }));
600
+ }
601
+ catch {
602
+ res.writeHead(400, { 'Content-Type': 'application/json' });
603
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
604
+ }
605
+ // ---- PR Processor Status ----
528
606
  }
529
- // ---- PR Processor Status ----
530
- }
531
- else if (url === '/api/pr-processor-status' && req.method === 'GET') {
532
- try {
533
- const { getPRProcessor } = await import('../core/service.js');
534
- const processor = getPRProcessor();
535
- const status = processor ? processor.getStatus() : null;
536
- res.writeHead(200, { 'Content-Type': 'application/json' });
537
- res.end(JSON.stringify(status));
607
+ else if (url === '/api/pr-processor-status' && req.method === 'GET') {
608
+ try {
609
+ const { getPRProcessor } = await import('../core/service.js');
610
+ const processor = getPRProcessor();
611
+ const status = processor ? processor.getStatus() : null;
612
+ res.writeHead(200, { 'Content-Type': 'application/json' });
613
+ res.end(JSON.stringify(status));
614
+ }
615
+ catch (error) {
616
+ res.writeHead(500, { 'Content-Type': 'application/json' });
617
+ res.end(JSON.stringify({ error: safeErrorMessage(error) }));
618
+ }
619
+ // ---- Trigger PR Processor ----
538
620
  }
539
- catch (error) {
540
- res.writeHead(500, { 'Content-Type': 'application/json' });
541
- res.end(JSON.stringify({ error: safeErrorMessage(error) }));
621
+ else if (url === '/api/trigger-pr-processor' && req.method === 'POST') {
622
+ try {
623
+ const { getPRProcessor } = await import('../core/service.js');
624
+ const processor = getPRProcessor();
625
+ if (!processor) {
626
+ res.writeHead(404, { 'Content-Type': 'application/json' });
627
+ res.end(JSON.stringify({ error: 'PR Processor not initialized' }));
628
+ return;
629
+ }
630
+ res.writeHead(202, { 'Content-Type': 'application/json' });
631
+ res.end(JSON.stringify({ ok: true }));
632
+ // Non-blocking PR processing
633
+ processor.processPRs().catch((e) => console.error('[Web] PR Processor error:', e));
634
+ }
635
+ catch (error) {
636
+ res.writeHead(500, { 'Content-Type': 'application/json' });
637
+ res.end(JSON.stringify({ error: safeErrorMessage(error) }));
638
+ }
639
+ // ---- CI Worker Status ----
542
640
  }
543
- // ---- Trigger PR Processor ----
544
- }
545
- else if (url === '/api/trigger-pr-processor' && req.method === 'POST') {
546
- try {
547
- const { getPRProcessor } = await import('../core/service.js');
548
- const processor = getPRProcessor();
549
- if (!processor) {
550
- res.writeHead(404, { 'Content-Type': 'application/json' });
551
- res.end(JSON.stringify({ error: 'PR Processor not initialized' }));
552
- return;
641
+ else if (url === '/api/ci-worker-status' && req.method === 'GET') {
642
+ try {
643
+ const { getCIWorkerStatus } = await import('../automation/ciWorker.js');
644
+ const status = getCIWorkerStatus();
645
+ res.writeHead(200, { 'Content-Type': 'application/json' });
646
+ res.end(JSON.stringify(status));
553
647
  }
554
- res.writeHead(202, { 'Content-Type': 'application/json' });
555
- res.end(JSON.stringify({ ok: true }));
556
- // Non-blocking PR processing
557
- processor.processPRs().catch((e) => console.error('[Web] PR Processor error:', e));
648
+ catch (error) {
649
+ res.writeHead(500, { 'Content-Type': 'application/json' });
650
+ res.end(JSON.stringify({ error: safeErrorMessage(error) }));
651
+ }
652
+ // ---- Stuck/Failed Issues ----
558
653
  }
559
- catch (error) {
560
- res.writeHead(500, { 'Content-Type': 'application/json' });
561
- res.end(JSON.stringify({ error: safeErrorMessage(error) }));
654
+ else if (url === '/api/stuck-issues' && req.method === 'GET') {
655
+ try {
656
+ const linearModule = await import('../linear/index.js');
657
+ const result = await linearModule.getStuckIssues();
658
+ res.writeHead(200, { 'Content-Type': 'application/json' });
659
+ res.end(JSON.stringify(result));
660
+ }
661
+ catch (error) {
662
+ console.error('[Web] Failed to fetch stuck issues:', error);
663
+ res.writeHead(500, { 'Content-Type': 'application/json' });
664
+ res.end(JSON.stringify({ error: safeErrorMessage(error) }));
665
+ }
666
+ // ---- Chat history ----
562
667
  }
563
- // ---- CI Worker Status ----
564
- }
565
- else if (url === '/api/ci-worker-status' && req.method === 'GET') {
566
- try {
567
- const { getCIWorkerStatus } = await import('../automation/ciWorker.js');
568
- const status = getCIWorkerStatus();
668
+ else if (url === '/api/chat/history' && req.method === 'GET') {
669
+ const buf = getChatBuffer();
670
+ const history = buf
671
+ .filter((ev) => ev.type === 'chat:user' || ev.type === 'chat:agent')
672
+ .map(ev => ({
673
+ role: ev.type === 'chat:user' ? 'user' : 'agent',
674
+ text: ev.data.text,
675
+ ts: ev.data.ts,
676
+ }));
569
677
  res.writeHead(200, { 'Content-Type': 'application/json' });
570
- res.end(JSON.stringify(status));
678
+ res.end(JSON.stringify(history.slice(-50)));
679
+ // ---- Log buffer snapshot ----
571
680
  }
572
- catch (error) {
573
- res.writeHead(500, { 'Content-Type': 'application/json' });
574
- res.end(JSON.stringify({ error: safeErrorMessage(error) }));
575
- }
576
- // ---- Stuck/Failed Issues ----
577
- }
578
- else if (url === '/api/stuck-issues' && req.method === 'GET') {
579
- try {
580
- const linearModule = await import('../linear/index.js');
581
- const result = await linearModule.getStuckIssues();
681
+ else if (url === '/api/logs' && req.method === 'GET') {
682
+ // 성능 최적화: 최근 200개 로그만 반환 (응답 시간 단축)
683
+ const allLogs = getLogBuffer();
684
+ const recentLogs = allLogs.slice(-200);
582
685
  res.writeHead(200, { 'Content-Type': 'application/json' });
583
- res.end(JSON.stringify(result));
584
- }
585
- catch (error) {
586
- console.error('[Web] Failed to fetch stuck issues:', error);
587
- res.writeHead(500, { 'Content-Type': 'application/json' });
588
- res.end(JSON.stringify({ error: safeErrorMessage(error) }));
589
- }
590
- // ---- Chat history ----
591
- }
592
- else if (url === '/api/chat/history' && req.method === 'GET') {
593
- const buf = getChatBuffer();
594
- const history = buf
595
- .filter((ev) => ev.type === 'chat:user' || ev.type === 'chat:agent')
596
- .map(ev => ({
597
- role: ev.type === 'chat:user' ? 'user' : 'agent',
598
- text: ev.data.text,
599
- ts: ev.data.ts,
600
- }));
601
- res.writeHead(200, { 'Content-Type': 'application/json' });
602
- res.end(JSON.stringify(history.slice(-50)));
603
- // ---- Log buffer snapshot ----
604
- }
605
- else if (url === '/api/logs' && req.method === 'GET') {
606
- // 성능 최적화: 최근 200개 로그만 반환 (응답 시간 단축)
607
- const allLogs = getLogBuffer();
608
- const recentLogs = allLogs.slice(-200);
609
- res.writeHead(200, { 'Content-Type': 'application/json' });
610
- res.end(JSON.stringify(recentLogs));
611
- // ---- Stage buffer snapshot ----
612
- }
613
- else if (url === '/api/stages' && req.method === 'GET') {
614
- // 성능 최적화: 최근 100개 스테이지만 반환 (응답 시간 단축)
615
- const allStages = getStageBuffer();
616
- const recentStages = allStages.slice(-100);
617
- res.writeHead(200, { 'Content-Type': 'application/json' });
618
- res.end(JSON.stringify(recentStages));
619
- // ---- Chat message ----
620
- }
621
- else if (url === '/api/chat' && req.method === 'POST') {
622
- const body = await readBody(req);
623
- let message;
624
- try {
625
- message = JSON.parse(body).message?.trim();
626
- }
627
- catch {
628
- res.writeHead(400, { 'Content-Type': 'application/json' });
629
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
630
- return;
631
- }
632
- if (!message) {
633
- res.writeHead(400, { 'Content-Type': 'application/json' });
634
- res.end(JSON.stringify({ error: 'Empty message' }));
635
- return;
686
+ res.end(JSON.stringify(recentLogs));
687
+ // ---- Stage buffer snapshot ----
636
688
  }
637
- broadcastEvent({ type: 'chat:user', data: { text: message, ts: Date.now() } });
638
- // Build context-aware prompt (including previous conversation)
639
- const stats = runnerRef?.getStats();
640
- const projects = runnerRef?.getProjectsInfo() ?? [];
641
- const enabled = projects.filter(p => p.enabled).length;
642
- const state = runnerRef?.getState();
643
- const uptimeSec = state?.startedAt ? Math.floor((Date.now() - state.startedAt) / 1000) : 0;
644
- // 1. Short-term memory: recent chat buffer
645
- const chatBuf = getChatBuffer()
646
- .filter((ev) => ev.type === 'chat:user' || ev.type === 'chat:agent');
647
- const recentHistory = chatBuf.slice(-11, -1);
648
- const historyBlock = recentHistory.length > 0
649
- ? recentHistory.map(m => (m.type === 'chat:user' ? 'User' : 'OpenSwarm') + ': ' + m.data.text).join('\n\n')
650
- : '';
651
- // 2. Long-term memory: semantic search (shared with Discord)
652
- const memories = await memory.searchMemory(message, {
653
- types: ['journal'],
654
- repo: 'chat', // Shared repo for both Discord and Dashboard
655
- limit: 5,
656
- minSimilarity: 0.4,
657
- minTrust: 0.5,
658
- });
659
- const memoryContext = memories.length > 0
660
- ? '## Relevant Past Discussions\n' + memories.map(m => `- ${m.content.replace(/^Q: |^A: /g, '')}`).join('\n')
661
- : '';
662
- const provider = runnerRef?.getAdapterSummary().defaultAdapter ?? 'codex';
663
- const model = runnerRef?.getAdapterSummary().worker?.model ?? getDefaultChatModel(provider);
664
- const contextPrompt = [
665
- 'You are OpenSwarm, an autonomous code development supervisor.',
666
- 'You manage a fleet of coding agents that autonomously work on Linear issues.',
667
- `Current chat provider: ${provider}`,
668
- `Current chat model: ${model}`,
669
- '',
670
- 'Current system status:',
671
- '- Running tasks: ' + (stats?.schedulerStats?.running ?? 0),
672
- '- Queued tasks: ' + (stats?.schedulerStats?.queued ?? 0),
673
- '- Completed today: ' + (stats?.schedulerStats?.completed ?? 0),
674
- '- Active repos: ' + enabled + '/' + projects.length,
675
- '- Uptime: ' + uptimeSec + 's',
676
- '',
677
- ...(historyBlock ? [
678
- 'Conversation history (most recent first):',
679
- historyBlock,
680
- '',
681
- ] : []),
682
- ...(memoryContext ? [memoryContext, ''] : []),
683
- 'Answer the user concisely and helpfully in the same language they use. Use the status data above if relevant.',
684
- '',
685
- 'User: ' + message,
686
- ].join('\n');
687
- const result = await runChatCompletion({
688
- prompt: contextPrompt,
689
- provider,
690
- model,
691
- cwd: process.cwd(),
692
- timeoutMs: 180000,
693
- }).catch((error) => ({
694
- response: `[Error: ${error.message}]`,
695
- provider,
696
- model,
697
- cost: undefined,
698
- tokens: undefined,
699
- }));
700
- const response = result.response;
701
- if (result.cost !== undefined) {
702
- console.log(`[Web Chat] Cost: ${formatCost({
703
- costUsd: result.cost,
704
- inputTokens: result.tokens ?? 0,
705
- outputTokens: 0,
706
- cacheReadTokens: 0,
707
- cacheCreationTokens: 0,
708
- durationMs: 0,
709
- model: result.model,
710
- })}`);
711
- }
712
- broadcastEvent({ type: 'chat:agent', data: { text: response, ts: Date.now() } });
713
- // 3. Save conversation to long-term memory
714
- await memory.saveConversation('dashboard', // channelId (fixed for dashboard)
715
- 'dashboard-user', // userId
716
- 'User', // userName
717
- message, response);
718
- console.log(`[Dashboard Chat] Saved to memory (${message.length} + ${response.length} chars)`);
719
- res.writeHead(200, { 'Content-Type': 'application/json' });
720
- res.end(JSON.stringify({ ok: true, response, provider: result.provider, model: result.model }));
721
- // ---- Service control: status ----
722
- }
723
- else if (url === '/api/service/status' && req.method === 'GET') {
724
- try {
725
- const result = await new Promise((resolve) => {
726
- execFile('systemctl', ['--user', 'is-active', 'openswarm'], (_err, stdout) => {
727
- resolve(stdout.trim());
728
- });
729
- });
689
+ else if (url === '/api/stages' && req.method === 'GET') {
690
+ // 성능 최적화: 최근 100개 스테이지만 반환 (응답 시간 단축)
691
+ const allStages = getStageBuffer();
692
+ const recentStages = allStages.slice(-100);
730
693
  res.writeHead(200, { 'Content-Type': 'application/json' });
731
- res.end(JSON.stringify({ status: result }));
732
- }
733
- catch {
734
- res.writeHead(500, { 'Content-Type': 'application/json' });
735
- res.end(JSON.stringify({ status: 'unknown' }));
694
+ res.end(JSON.stringify(recentStages));
695
+ // ---- Chat message ----
736
696
  }
737
- // ---- Service control: stop ----
738
- }
739
- else if (url === '/api/service/stop' && req.method === 'POST') {
740
- try {
741
- await new Promise((resolve, reject) => {
742
- execFile('systemctl', ['--user', 'stop', 'openswarm'], (err) => {
743
- if (err)
744
- reject(err);
745
- else
746
- resolve();
747
- });
697
+ else if (url === '/api/chat' && req.method === 'POST') {
698
+ const body = await readBody(req);
699
+ let message;
700
+ try {
701
+ message = JSON.parse(body).message?.trim();
702
+ }
703
+ catch {
704
+ res.writeHead(400, { 'Content-Type': 'application/json' });
705
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
706
+ return;
707
+ }
708
+ if (!message) {
709
+ res.writeHead(400, { 'Content-Type': 'application/json' });
710
+ res.end(JSON.stringify({ error: 'Empty message' }));
711
+ return;
712
+ }
713
+ broadcastEvent({ type: 'chat:user', data: { text: message, ts: Date.now() } });
714
+ // Build context-aware prompt (including previous conversation)
715
+ const stats = runnerRef?.getStats();
716
+ const projects = runnerRef?.getProjectsInfo() ?? [];
717
+ const enabled = projects.filter(p => p.enabled).length;
718
+ const state = runnerRef?.getState();
719
+ const uptimeSec = state?.startedAt ? Math.floor((Date.now() - state.startedAt) / 1000) : 0;
720
+ // 1. Short-term memory: recent chat buffer
721
+ const chatBuf = getChatBuffer()
722
+ .filter((ev) => ev.type === 'chat:user' || ev.type === 'chat:agent');
723
+ const recentHistory = chatBuf.slice(-11, -1);
724
+ const historyBlock = recentHistory.length > 0
725
+ ? recentHistory.map(m => (m.type === 'chat:user' ? 'User' : 'OpenSwarm') + ': ' + m.data.text).join('\n\n')
726
+ : '';
727
+ // 2. Long-term memory: semantic search (shared with Discord)
728
+ const memories = await memory.searchMemory(message, {
729
+ types: ['journal'],
730
+ repo: 'chat', // Shared repo for both Discord and Dashboard
731
+ limit: 5,
732
+ minSimilarity: 0.4,
733
+ minTrust: 0.5,
748
734
  });
735
+ const memoryContext = memories.length > 0
736
+ ? '## Relevant Past Discussions\n' + memories.map(m => `- ${m.content.replace(/^Q: |^A: /g, '')}`).join('\n')
737
+ : '';
738
+ const provider = runnerRef?.getAdapterSummary().defaultAdapter ?? 'codex';
739
+ const model = runnerRef?.getAdapterSummary().worker?.model ?? getDefaultChatModel(provider);
740
+ const contextPrompt = [
741
+ 'You are OpenSwarm, an autonomous code development supervisor.',
742
+ 'You manage a fleet of coding agents that autonomously work on Linear issues.',
743
+ `Current chat provider: ${provider}`,
744
+ `Current chat model: ${model}`,
745
+ '',
746
+ 'Current system status:',
747
+ '- Running tasks: ' + (stats?.schedulerStats?.running ?? 0),
748
+ '- Queued tasks: ' + (stats?.schedulerStats?.queued ?? 0),
749
+ '- Completed today: ' + (stats?.schedulerStats?.completed ?? 0),
750
+ '- Active repos: ' + enabled + '/' + projects.length,
751
+ '- Uptime: ' + uptimeSec + 's',
752
+ '',
753
+ ...(historyBlock ? [
754
+ 'Conversation history (most recent first):',
755
+ historyBlock,
756
+ '',
757
+ ] : []),
758
+ ...(memoryContext ? [memoryContext, ''] : []),
759
+ 'Answer the user concisely and helpfully in the same language they use. Use the status data above if relevant.',
760
+ '',
761
+ 'User: ' + message,
762
+ ].join('\n');
763
+ const result = await runChatCompletion({
764
+ prompt: contextPrompt,
765
+ provider,
766
+ model,
767
+ cwd: process.cwd(),
768
+ timeoutMs: 180000,
769
+ }).catch((error) => ({
770
+ response: `[Error: ${error.message}]`,
771
+ provider,
772
+ model,
773
+ cost: undefined,
774
+ tokens: undefined,
775
+ }));
776
+ const response = result.response;
777
+ if (result.cost !== undefined) {
778
+ console.log(`[Web Chat] Cost: ${formatCost({
779
+ costUsd: result.cost,
780
+ inputTokens: result.tokens ?? 0,
781
+ outputTokens: 0,
782
+ cacheReadTokens: 0,
783
+ cacheCreationTokens: 0,
784
+ durationMs: 0,
785
+ model: result.model,
786
+ })}`);
787
+ }
788
+ broadcastEvent({ type: 'chat:agent', data: { text: response, ts: Date.now() } });
789
+ // 3. Save conversation to long-term memory
790
+ await memory.saveConversation('dashboard', // channelId (fixed for dashboard)
791
+ 'dashboard-user', // userId
792
+ 'User', // userName
793
+ message, response);
794
+ console.log(`[Dashboard Chat] Saved to memory (${message.length} + ${response.length} chars)`);
749
795
  res.writeHead(200, { 'Content-Type': 'application/json' });
750
- res.end(JSON.stringify({ ok: true }));
796
+ res.end(JSON.stringify({ ok: true, response, provider: result.provider, model: result.model }));
797
+ // ---- Service control: status ----
751
798
  }
752
- catch (e) {
753
- res.writeHead(500, { 'Content-Type': 'application/json' });
754
- res.end(JSON.stringify({ error: safeErrorMessage(e) }));
755
- }
756
- // ---- Service control: restart ----
757
- }
758
- else if (url === '/api/service/restart' && req.method === 'POST') {
759
- try {
760
- await new Promise((resolve, reject) => {
761
- execFile('systemctl', ['--user', 'restart', 'openswarm'], (err) => {
762
- if (err)
763
- reject(err);
764
- else
765
- resolve();
799
+ else if (url === '/api/service/status' && req.method === 'GET') {
800
+ try {
801
+ const result = await new Promise((resolve) => {
802
+ execFile('systemctl', ['--user', 'is-active', 'openswarm'], (_err, stdout) => {
803
+ resolve(stdout.trim());
804
+ });
766
805
  });
767
- });
768
- res.writeHead(200, { 'Content-Type': 'application/json' });
769
- res.end(JSON.stringify({ ok: true }));
770
- }
771
- catch (e) {
772
- res.writeHead(500, { 'Content-Type': 'application/json' });
773
- res.end(JSON.stringify({ error: safeErrorMessage(e) }));
774
- }
775
- // ---- Knowledge Graph: project health ----
776
- }
777
- else if (url.startsWith('/api/knowledge/') && req.method === 'GET') {
778
- const projectSlug = url.replace('/api/knowledge/', '').split('?')[0];
779
- if (!projectSlug) {
780
- res.writeHead(400, { 'Content-Type': 'application/json' });
781
- res.end(JSON.stringify({ error: 'Missing project slug' }));
806
+ res.writeHead(200, { 'Content-Type': 'application/json' });
807
+ res.end(JSON.stringify({ status: result }));
808
+ }
809
+ catch {
810
+ res.writeHead(500, { 'Content-Type': 'application/json' });
811
+ res.end(JSON.stringify({ status: 'unknown' }));
812
+ }
813
+ // ---- Service control: stop ----
782
814
  }
783
- else {
815
+ else if (url === '/api/service/stop' && req.method === 'POST') {
784
816
  try {
785
- const graph = await getGraph(projectSlug);
786
- if (!graph) {
787
- res.writeHead(404, { 'Content-Type': 'application/json' });
788
- res.end(JSON.stringify({ error: 'Graph not found. Run a scan first.' }));
789
- }
790
- else {
791
- const health = getProjectHealth(graph);
792
- res.writeHead(200, { 'Content-Type': 'application/json' });
793
- res.end(JSON.stringify({
794
- slug: projectSlug,
795
- scannedAt: graph.scannedAt,
796
- nodeCount: graph.nodeCount,
797
- edgeCount: graph.edgeCount,
798
- ...health,
799
- }));
800
- }
817
+ await new Promise((resolve, reject) => {
818
+ execFile('systemctl', ['--user', 'stop', 'openswarm'], (err) => {
819
+ if (err)
820
+ reject(err);
821
+ else
822
+ resolve();
823
+ });
824
+ });
825
+ res.writeHead(200, { 'Content-Type': 'application/json' });
826
+ res.end(JSON.stringify({ ok: true }));
801
827
  }
802
828
  catch (e) {
803
829
  res.writeHead(500, { 'Content-Type': 'application/json' });
804
830
  res.end(JSON.stringify({ error: safeErrorMessage(e) }));
805
831
  }
832
+ // ---- Service control: restart ----
806
833
  }
807
- // ---- Knowledge Graph: list all ----
808
- }
809
- else if (url === '/api/knowledge' && req.method === 'GET') {
810
- try {
811
- const slugs = await listGraphs();
812
- const result = [];
813
- for (const slug of slugs) {
814
- const graph = await getGraph(slug);
815
- if (graph) {
816
- result.push({
817
- slug,
818
- nodeCount: graph.nodeCount,
819
- edgeCount: graph.edgeCount,
820
- scannedAt: graph.scannedAt,
821
- summary: graph.buildSummary(),
834
+ else if (url === '/api/service/restart' && req.method === 'POST') {
835
+ try {
836
+ await new Promise((resolve, reject) => {
837
+ execFile('systemctl', ['--user', 'restart', 'openswarm'], (err) => {
838
+ if (err)
839
+ reject(err);
840
+ else
841
+ resolve();
822
842
  });
823
- }
843
+ });
844
+ res.writeHead(200, { 'Content-Type': 'application/json' });
845
+ res.end(JSON.stringify({ ok: true }));
824
846
  }
825
- res.writeHead(200, { 'Content-Type': 'application/json' });
826
- res.end(JSON.stringify(result));
827
- }
828
- catch (e) {
829
- res.writeHead(500, { 'Content-Type': 'application/json' });
830
- res.end(JSON.stringify({ error: safeErrorMessage(e) }));
847
+ catch (e) {
848
+ res.writeHead(500, { 'Content-Type': 'application/json' });
849
+ res.end(JSON.stringify({ error: safeErrorMessage(e) }));
850
+ }
851
+ // ---- Knowledge Graph: project health ----
831
852
  }
832
- // ---- Knowledge Graph: trigger scan ----
833
- }
834
- else if (url === '/api/knowledge/scan' && req.method === 'POST') {
835
- const body = await readBody(req);
836
- try {
837
- const { projectPath } = JSON.parse(body);
838
- if (!projectPath) {
853
+ else if (url.startsWith('/api/knowledge/') && req.method === 'GET') {
854
+ const projectSlug = url.replace('/api/knowledge/', '').split('?')[0];
855
+ if (!projectSlug) {
839
856
  res.writeHead(400, { 'Content-Type': 'application/json' });
840
- res.end(JSON.stringify({ error: 'Missing projectPath' }));
857
+ res.end(JSON.stringify({ error: 'Missing project slug' }));
841
858
  }
842
859
  else {
843
- const resolvedPath = projectPath.replace('~', homedir());
844
- res.writeHead(202, { 'Content-Type': 'application/json' });
845
- res.end(JSON.stringify({ ok: true, slug: toProjectSlug(resolvedPath) }));
846
- // Non-blocking scan
847
- scanAndCache(resolvedPath, { force: true }).catch(e => console.error('[Web] Knowledge scan error:', e));
860
+ try {
861
+ const graph = await getGraph(projectSlug);
862
+ if (!graph) {
863
+ res.writeHead(404, { 'Content-Type': 'application/json' });
864
+ res.end(JSON.stringify({ error: 'Graph not found. Run a scan first.' }));
865
+ }
866
+ else {
867
+ const health = getProjectHealth(graph);
868
+ res.writeHead(200, { 'Content-Type': 'application/json' });
869
+ res.end(JSON.stringify({
870
+ slug: projectSlug,
871
+ scannedAt: graph.scannedAt,
872
+ nodeCount: graph.nodeCount,
873
+ edgeCount: graph.edgeCount,
874
+ ...health,
875
+ }));
876
+ }
877
+ }
878
+ catch (e) {
879
+ res.writeHead(500, { 'Content-Type': 'application/json' });
880
+ res.end(JSON.stringify({ error: safeErrorMessage(e) }));
881
+ }
848
882
  }
883
+ // ---- Knowledge Graph: list all ----
849
884
  }
850
- catch {
851
- res.writeHead(400, { 'Content-Type': 'application/json' });
852
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
885
+ else if (url === '/api/knowledge' && req.method === 'GET') {
886
+ try {
887
+ const slugs = await listGraphs();
888
+ const result = [];
889
+ for (const slug of slugs) {
890
+ const graph = await getGraph(slug);
891
+ if (graph) {
892
+ result.push({
893
+ slug,
894
+ nodeCount: graph.nodeCount,
895
+ edgeCount: graph.edgeCount,
896
+ scannedAt: graph.scannedAt,
897
+ summary: graph.buildSummary(),
898
+ });
899
+ }
900
+ }
901
+ res.writeHead(200, { 'Content-Type': 'application/json' });
902
+ res.end(JSON.stringify(result));
903
+ }
904
+ catch (e) {
905
+ res.writeHead(500, { 'Content-Type': 'application/json' });
906
+ res.end(JSON.stringify({ error: safeErrorMessage(e) }));
907
+ }
908
+ // ---- Knowledge Graph: trigger scan ----
853
909
  }
854
- // ---- Pipeline history (time-ordered) ----
855
- }
856
- else if (url === '/api/pipeline/history' && req.method === 'GET') {
857
- const limitParam = (req.url?.split('?')[1] || '').match(/limit=(\d+)/);
858
- const limit = limitParam ? Math.min(Number(limitParam[1]), 100) : 50;
859
- const history = runnerRef?.getPipelineHistory(limit) ?? [];
860
- res.writeHead(200, { 'Content-Type': 'application/json' });
861
- res.end(JSON.stringify(history));
862
- // ---- Monitors: list ----
863
- }
864
- else if (url === '/api/monitors' && req.method === 'GET') {
865
- res.writeHead(200, { 'Content-Type': 'application/json' });
866
- res.end(JSON.stringify(getActiveMonitors()));
867
- // ---- Monitors: register ----
868
- }
869
- else if (url === '/api/monitors' && req.method === 'POST') {
870
- const body = await readBody(req);
871
- try {
872
- const config = JSON.parse(body);
873
- if (!config.id ||
874
- !config.name ||
875
- !Array.isArray(config.checkCommand) ||
876
- config.checkCommand.length === 0 ||
877
- !config.completionCheck) {
910
+ else if (url === '/api/knowledge/scan' && req.method === 'POST') {
911
+ const body = await readBody(req);
912
+ try {
913
+ const { projectPath } = JSON.parse(body);
914
+ if (!projectPath) {
915
+ res.writeHead(400, { 'Content-Type': 'application/json' });
916
+ res.end(JSON.stringify({ error: 'Missing projectPath' }));
917
+ }
918
+ else {
919
+ const resolvedPath = projectPath.replace('~', homedir());
920
+ res.writeHead(202, { 'Content-Type': 'application/json' });
921
+ res.end(JSON.stringify({ ok: true, slug: toProjectSlug(resolvedPath) }));
922
+ // Non-blocking scan
923
+ scanAndCache(resolvedPath, { force: true }).catch(e => console.error('[Web] Knowledge scan error:', e));
924
+ }
925
+ }
926
+ catch {
878
927
  res.writeHead(400, { 'Content-Type': 'application/json' });
879
- res.end(JSON.stringify({
880
- error: 'Missing or invalid required fields: id, name, checkCommand (string[]), completionCheck',
881
- }));
882
- return;
928
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
883
929
  }
884
- const monitor = registerMonitor(config);
885
- res.writeHead(201, { 'Content-Type': 'application/json' });
886
- res.end(JSON.stringify(monitor));
930
+ // ---- Pipeline history (time-ordered) ----
887
931
  }
888
- catch (e) {
889
- res.writeHead(400, { 'Content-Type': 'application/json' });
890
- res.end(JSON.stringify({ error: safeErrorMessage(e) }));
891
- }
892
- // ---- Monitors: delete ----
893
- }
894
- else if (url.startsWith('/api/monitors/') && req.method === 'DELETE') {
895
- const monitorId = url.replace('/api/monitors/', '');
896
- const deleted = unregisterMonitor(monitorId);
897
- res.writeHead(deleted ? 200 : 404, { 'Content-Type': 'application/json' });
898
- res.end(JSON.stringify({ ok: deleted }));
899
- // ---- Processes: list ----
900
- }
901
- else if (url === '/api/processes' && req.method === 'GET') {
902
- // Spawned CLI subprocesses (PID-tracked) + in-process pipeline tasks. Native
903
- // adapters (codex-responses/openrouter/local) run the worker/reviewer in-process
904
- // with no child PID, so without the pipeline entries the panel looks empty even
905
- // while tasks are actively running.
906
- const subprocs = getAllProcesses().map((p) => ({ ...p, kind: 'subprocess', id: String(p.pid) }));
907
- const pipelines = (runnerRef?.getRunningPipelines() ?? []).map((t) => ({
908
- kind: 'pipeline',
909
- id: t.id,
910
- pid: null,
911
- taskId: t.issue ?? t.title,
912
- project: t.project,
913
- stage: t.stage ?? 'running',
914
- projectPath: t.projectPath,
915
- spawnedAt: t.startedAt,
916
- lastActivityAt: t.startedAt,
917
- }));
918
- res.writeHead(200, { 'Content-Type': 'application/json' });
919
- res.end(JSON.stringify([...pipelines, ...subprocs]));
920
- // ---- Processes: kill (PID) or cancel (pipeline task id) ----
921
- }
922
- else if (url.startsWith('/api/processes/') && req.method === 'DELETE') {
923
- const idStr = decodeURIComponent(url.replace('/api/processes/', ''));
924
- const pid = parseInt(idStr, 10);
925
- if (!isNaN(pid) && String(pid) === idStr) {
926
- const killed = await killProcess(pid);
927
- res.writeHead(killed ? 200 : 404, { 'Content-Type': 'application/json' });
928
- res.end(JSON.stringify({ ok: killed }));
932
+ else if (url === '/api/pipeline/history' && req.method === 'GET') {
933
+ const limitParam = (req.url?.split('?')[1] || '').match(/limit=(\d+)/);
934
+ const limit = limitParam ? Math.min(Number(limitParam[1]), 100) : 50;
935
+ const history = runnerRef?.getPipelineHistory(limit) ?? [];
936
+ res.writeHead(200, { 'Content-Type': 'application/json' });
937
+ res.end(JSON.stringify(history));
938
+ // ---- Monitors: list ----
929
939
  }
930
- else {
931
- // Non-numeric id → in-process pipeline task: abort it (and its adapter call).
932
- const cancelled = runnerRef?.cancelTask(idStr) ?? false;
933
- res.writeHead(cancelled ? 200 : 404, { 'Content-Type': 'application/json' });
934
- res.end(JSON.stringify({ ok: cancelled }));
940
+ else if (url === '/api/monitors' && req.method === 'GET') {
941
+ res.writeHead(200, { 'Content-Type': 'application/json' });
942
+ res.end(JSON.stringify(getActiveMonitors()));
943
+ // ---- Monitors: register ----
935
944
  }
936
- // ---- Scan Paths: list ----
937
- }
938
- else if (url === '/api/scan-paths' && req.method === 'GET') {
939
- // removedConfigPaths에 있는 경로는 UI에 표시하지 않음
940
- const allConfigPaths = runnerRef?.getAllowedProjects() ?? [];
941
- const configPaths = allConfigPaths.filter(p => !removedConfigPaths.has(p));
942
- res.writeHead(200, { 'Content-Type': 'application/json' });
943
- res.end(JSON.stringify({
944
- configPaths,
945
- customPaths: Array.from(customBasePaths),
946
- }));
947
- // ---- Scan Paths: add ----
948
- }
949
- else if (url === '/api/scan-paths' && req.method === 'POST') {
950
- const body = await readBody(req);
951
- try {
952
- const { path: newPath } = JSON.parse(body);
953
- if (typeof newPath !== 'string' || !newPath.trim()) {
954
- res.writeHead(400, { 'Content-Type': 'application/json' });
955
- res.end(JSON.stringify({ error: 'Missing path' }));
945
+ else if (url === '/api/monitors' && req.method === 'POST') {
946
+ const body = await readBody(req);
947
+ try {
948
+ const config = JSON.parse(body);
949
+ if (!config.id ||
950
+ !config.name ||
951
+ !Array.isArray(config.checkCommand) ||
952
+ config.checkCommand.length === 0 ||
953
+ !config.completionCheck) {
954
+ res.writeHead(400, { 'Content-Type': 'application/json' });
955
+ res.end(JSON.stringify({
956
+ error: 'Missing or invalid required fields: id, name, checkCommand (string[]), completionCheck',
957
+ }));
958
+ return;
959
+ }
960
+ const monitor = registerMonitor(config);
961
+ res.writeHead(201, { 'Content-Type': 'application/json' });
962
+ res.end(JSON.stringify(monitor));
956
963
  }
957
- else {
958
- customBasePaths.add(newPath.trim());
959
- invalidateProjectCache();
960
- // Update runner's allowedProjects with merged list
961
- const configPaths = runnerRef?.getAllowedProjects() ?? [];
962
- const merged = [...new Set([...configPaths, ...customBasePaths])];
963
- runnerRef?.updateAllowedProjects(merged);
964
- saveReposConfig();
965
- res.writeHead(200, { 'Content-Type': 'application/json' });
966
- res.end(JSON.stringify({ ok: true }));
964
+ catch (e) {
965
+ res.writeHead(400, { 'Content-Type': 'application/json' });
966
+ res.end(JSON.stringify({ error: safeErrorMessage(e) }));
967
967
  }
968
+ // ---- Monitors: delete ----
968
969
  }
969
- catch {
970
- res.writeHead(400, { 'Content-Type': 'application/json' });
971
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
970
+ else if (url.startsWith('/api/monitors/') && req.method === 'DELETE') {
971
+ const monitorId = url.replace('/api/monitors/', '');
972
+ const deleted = unregisterMonitor(monitorId);
973
+ res.writeHead(deleted ? 200 : 404, { 'Content-Type': 'application/json' });
974
+ res.end(JSON.stringify({ ok: deleted }));
975
+ // ---- Processes: list ----
972
976
  }
973
- // ---- Scan Paths: remove ----
974
- }
975
- else if (url.startsWith('/api/scan-paths/') && req.method === 'DELETE') {
976
- const encodedPath = url.replace('/api/scan-paths/', '');
977
- const decodedPath = decodeURIComponent(encodedPath);
978
- // customPaths에서 제거
979
- customBasePaths.delete(decodedPath);
980
- // configPaths에서도 제거: removedConfigPaths에 기록하고 runner에서 즉시 반영
981
- const allConfigPaths = runnerRef?.getAllowedProjects() ?? [];
982
- if (allConfigPaths.includes(decodedPath)) {
983
- removedConfigPaths.add(decodedPath);
984
- runnerRef?.updateAllowedProjects(allConfigPaths.filter(p => p !== decodedPath));
977
+ else if (url === '/api/processes' && req.method === 'GET') {
978
+ // Spawned CLI subprocesses (PID-tracked) + in-process pipeline tasks. Native
979
+ // adapters (codex-responses/openrouter/local) run the worker/reviewer in-process
980
+ // with no child PID, so without the pipeline entries the panel looks empty even
981
+ // while tasks are actively running.
982
+ const subprocs = getAllProcesses().map((p) => ({ ...p, kind: 'subprocess', id: String(p.pid) }));
983
+ const pipelines = (runnerRef?.getRunningPipelines() ?? []).map((t) => ({
984
+ kind: 'pipeline',
985
+ id: t.id,
986
+ pid: null,
987
+ taskId: t.issue ?? t.title,
988
+ project: t.project,
989
+ stage: t.stage ?? 'running',
990
+ projectPath: t.projectPath,
991
+ spawnedAt: t.startedAt,
992
+ lastActivityAt: t.startedAt,
993
+ }));
994
+ res.writeHead(200, { 'Content-Type': 'application/json' });
995
+ res.end(JSON.stringify([...pipelines, ...subprocs]));
996
+ // ---- Processes: kill (PID) or cancel (pipeline task id) ----
985
997
  }
986
- invalidateProjectCache();
987
- saveReposConfig();
988
- res.writeHead(200, { 'Content-Type': 'application/json' });
989
- res.end(JSON.stringify({ ok: true }));
990
- // ---- Filesystem browse (folder picker) ----
991
- // GET /api/fs/list?path=<absolute or ~/...>
992
- // Returns: { path, parent, entries: [{name, isDir}] } — dotfiles excluded, dirs first.
993
- }
994
- else if (url.startsWith('/api/fs/list') && req.method === 'GET') {
995
- try {
996
- const qs = url.split('?')[1] ?? '';
997
- const params = new URLSearchParams(qs);
998
- const requested = params.get('path')?.trim();
999
- const startPath = requested && requested.length > 0
1000
- ? requested
1001
- : homedir();
1002
- const expanded = startPath.startsWith('~')
1003
- ? join(homedir(), startPath.slice(1))
1004
- : startPath;
1005
- const absolute = resolvePath(expanded);
1006
- const st = await stat(absolute);
1007
- if (!st.isDirectory()) {
1008
- res.writeHead(400, { 'Content-Type': 'application/json' });
1009
- res.end(JSON.stringify({ error: 'Not a directory', path: absolute }));
1010
- return;
998
+ else if (url.startsWith('/api/processes/') && req.method === 'DELETE') {
999
+ const idStr = decodeURIComponent(url.replace('/api/processes/', ''));
1000
+ const pid = parseInt(idStr, 10);
1001
+ if (!isNaN(pid) && String(pid) === idStr) {
1002
+ const killed = await killProcess(pid);
1003
+ res.writeHead(killed ? 200 : 404, { 'Content-Type': 'application/json' });
1004
+ res.end(JSON.stringify({ ok: killed }));
1011
1005
  }
1012
- const raw = await readdir(absolute, { withFileTypes: true });
1013
- const entries = raw
1014
- .filter((d) => !d.name.startsWith('.'))
1015
- .map((d) => ({ name: d.name, isDir: d.isDirectory() }))
1016
- .sort((a, b) => {
1017
- if (a.isDir !== b.isDir)
1018
- return a.isDir ? -1 : 1;
1019
- return a.name.localeCompare(b.name);
1020
- });
1021
- const parent = dirname(absolute);
1006
+ else {
1007
+ // Non-numeric id → in-process pipeline task: abort it (and its adapter call).
1008
+ const cancelled = runnerRef?.cancelTask(idStr) ?? false;
1009
+ res.writeHead(cancelled ? 200 : 404, { 'Content-Type': 'application/json' });
1010
+ res.end(JSON.stringify({ ok: cancelled }));
1011
+ }
1012
+ // ---- Scan Paths: list ----
1013
+ }
1014
+ else if (url === '/api/scan-paths' && req.method === 'GET') {
1015
+ // removedConfigPaths에 있는 경로는 UI에 표시하지 않음
1016
+ const allConfigPaths = runnerRef?.getAllowedProjects() ?? [];
1017
+ const configPaths = allConfigPaths.filter(p => !removedConfigPaths.has(p));
1022
1018
  res.writeHead(200, { 'Content-Type': 'application/json' });
1023
1019
  res.end(JSON.stringify({
1024
- path: absolute,
1025
- parent: parent === absolute ? null : parent,
1026
- name: basename(absolute) || absolute,
1027
- entries,
1020
+ configPaths,
1021
+ customPaths: Array.from(customBasePaths),
1028
1022
  }));
1023
+ // ---- Scan Paths: add ----
1029
1024
  }
1030
- catch (e) {
1031
- res.writeHead(500, { 'Content-Type': 'application/json' });
1032
- res.end(JSON.stringify({ error: safeErrorMessage(e) }));
1033
- }
1034
- // ---- Discord history (legacy) ----
1035
- }
1036
- else if (url === '/api/history') {
1037
- const history = await getChatHistory();
1038
- res.writeHead(200, { 'Content-Type': 'application/json' });
1039
- res.end(JSON.stringify(history));
1040
- // ---- Exec: submit task ----
1041
- }
1042
- else if (url === '/api/exec' && req.method === 'POST') {
1043
- const body = await readBody(req);
1044
- try {
1045
- const { prompt, projectPath, pipeline, workerOnly, model } = JSON.parse(body);
1046
- if (!prompt?.trim()) {
1025
+ else if (url === '/api/scan-paths' && req.method === 'POST') {
1026
+ const body = await readBody(req);
1027
+ try {
1028
+ const { path: newPath } = JSON.parse(body);
1029
+ if (typeof newPath !== 'string' || !newPath.trim()) {
1030
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1031
+ res.end(JSON.stringify({ error: 'Missing path' }));
1032
+ }
1033
+ else {
1034
+ customBasePaths.add(newPath.trim());
1035
+ invalidateProjectCache();
1036
+ // Update runner's allowedProjects with merged list
1037
+ const configPaths = runnerRef?.getAllowedProjects() ?? [];
1038
+ const merged = [...new Set([...configPaths, ...customBasePaths])];
1039
+ runnerRef?.updateAllowedProjects(merged);
1040
+ saveReposConfig();
1041
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1042
+ res.end(JSON.stringify({ ok: true }));
1043
+ }
1044
+ }
1045
+ catch {
1047
1046
  res.writeHead(400, { 'Content-Type': 'application/json' });
1048
- res.end(JSON.stringify({ error: 'Missing prompt' }));
1049
- return;
1047
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
1050
1048
  }
1051
- const taskId = startExecTask(prompt, { projectPath, pipeline, workerOnly, model });
1052
- res.writeHead(202, { 'Content-Type': 'application/json' });
1053
- res.end(JSON.stringify({ taskId, status: 'queued' }));
1049
+ // ---- Scan Paths: remove ----
1054
1050
  }
1055
- catch {
1056
- res.writeHead(400, { 'Content-Type': 'application/json' });
1057
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
1051
+ else if (url.startsWith('/api/scan-paths/') && req.method === 'DELETE') {
1052
+ const encodedPath = url.replace('/api/scan-paths/', '');
1053
+ const decodedPath = decodeURIComponent(encodedPath);
1054
+ // customPaths에서 제거
1055
+ customBasePaths.delete(decodedPath);
1056
+ // configPaths에서도 제거: removedConfigPaths에 기록하고 runner에서 즉시 반영
1057
+ const allConfigPaths = runnerRef?.getAllowedProjects() ?? [];
1058
+ if (allConfigPaths.includes(decodedPath)) {
1059
+ removedConfigPaths.add(decodedPath);
1060
+ runnerRef?.updateAllowedProjects(allConfigPaths.filter(p => p !== decodedPath));
1061
+ }
1062
+ invalidateProjectCache();
1063
+ saveReposConfig();
1064
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1065
+ res.end(JSON.stringify({ ok: true }));
1066
+ // ---- Filesystem browse (folder picker) ----
1067
+ // GET /api/fs/list?path=<absolute or ~/...>
1068
+ // Returns: { path, parent, entries: [{name, isDir}] } — dotfiles excluded, dirs first.
1058
1069
  }
1059
- // ---- Plan dispatch: TUI /plan cockpit daemon loop ----
1060
- }
1061
- else if (url === '/api/plan/dispatch' && req.method === 'POST') {
1062
- const body = await readBody(req);
1063
- try {
1064
- const { goal, projectPath, subTasks } = JSON.parse(body);
1065
- if (!goal?.trim()) {
1070
+ else if (url.startsWith('/api/fs/list') && req.method === 'GET') {
1071
+ try {
1072
+ const requested = requestUrl.searchParams.get('path')?.trim();
1073
+ const startPath = requested && requested.length > 0
1074
+ ? requested
1075
+ : homedir();
1076
+ const expanded = startPath.startsWith('~')
1077
+ ? join(homedir(), startPath.slice(1))
1078
+ : startPath;
1079
+ const absolute = resolvePath(expanded);
1080
+ const st = await stat(absolute);
1081
+ if (!st.isDirectory()) {
1082
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1083
+ res.end(JSON.stringify({ error: 'Not a directory', path: absolute }));
1084
+ return;
1085
+ }
1086
+ const raw = await readdir(absolute, { withFileTypes: true });
1087
+ const entries = raw
1088
+ .filter((d) => !d.name.startsWith('.'))
1089
+ .map((d) => ({ name: d.name, isDir: d.isDirectory() }))
1090
+ .sort((a, b) => {
1091
+ if (a.isDir !== b.isDir)
1092
+ return a.isDir ? -1 : 1;
1093
+ return a.name.localeCompare(b.name);
1094
+ });
1095
+ const parent = dirname(absolute);
1096
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1097
+ res.end(JSON.stringify({
1098
+ path: absolute,
1099
+ parent: parent === absolute ? null : parent,
1100
+ name: basename(absolute) || absolute,
1101
+ entries,
1102
+ }));
1103
+ }
1104
+ catch (e) {
1105
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1106
+ res.end(JSON.stringify({ error: safeErrorMessage(e) }));
1107
+ }
1108
+ // ---- Discord history (legacy) ----
1109
+ }
1110
+ else if (url === '/api/history') {
1111
+ const history = await getChatHistory();
1112
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1113
+ res.end(JSON.stringify(history));
1114
+ // ---- Exec: submit task ----
1115
+ }
1116
+ else if (url === '/api/exec' && req.method === 'POST') {
1117
+ const body = await readBody(req);
1118
+ try {
1119
+ const { prompt, projectPath, pipeline, workerOnly, model } = JSON.parse(body);
1120
+ if (!prompt?.trim()) {
1121
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1122
+ res.end(JSON.stringify({ error: 'Missing prompt' }));
1123
+ return;
1124
+ }
1125
+ const taskId = startExecTask(prompt, { projectPath, pipeline, workerOnly, model });
1126
+ res.writeHead(202, { 'Content-Type': 'application/json' });
1127
+ res.end(JSON.stringify({ taskId, status: 'queued' }));
1128
+ }
1129
+ catch {
1066
1130
  res.writeHead(400, { 'Content-Type': 'application/json' });
1067
- res.end(JSON.stringify({ error: 'Missing goal' }));
1068
- return;
1131
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
1069
1132
  }
1070
- const resolvedPath = projectPath ?? process.cwd();
1071
- const tasks = Array.isArray(subTasks) ? subTasks : [];
1072
- const triggerHeartbeat = () => {
1073
- runnerRef?.heartbeat().catch((e) => console.error('[Web] plan heartbeat error:', e));
1074
- };
1075
- // Path A a task source is registered (Linear OR local SQLite): create a
1076
- // parent issue + dependency-wired sub-issues (reusing the autonomous
1077
- // engine, which routes through the same source), then heartbeat.
1078
- const source = getTaskSource();
1079
- if (source) {
1080
- const parent = await source.createTask(goal, `Planned via the \`/plan\` cockpit.\n\n${tasks.length} sub-task(s) dispatched.`);
1081
- if ('error' in parent) {
1082
- res.writeHead(502, { 'Content-Type': 'application/json' });
1083
- res.end(JSON.stringify({ error: `Task source: ${parent.error}` }));
1133
+ // ---- Plan dispatch: TUI /plan cockpit → daemon loop ----
1134
+ }
1135
+ else if (url === '/api/plan/dispatch' && req.method === 'POST') {
1136
+ const body = await readBody(req);
1137
+ try {
1138
+ const { goal, projectPath, subTasks } = JSON.parse(body);
1139
+ if (!goal?.trim()) {
1140
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1141
+ res.end(JSON.stringify({ error: 'Missing goal' }));
1084
1142
  return;
1085
1143
  }
1086
- if (tasks.length === 0) {
1087
- // Planner saw no decomposition run the goal itself as one task.
1088
- await source.updateState(parent.id, 'Todo').catch(() => { });
1089
- triggerHeartbeat();
1144
+ const resolvedPath = projectPath ?? process.cwd();
1145
+ const tasks = Array.isArray(subTasks) ? subTasks : [];
1146
+ const triggerHeartbeat = () => {
1147
+ runnerRef?.heartbeat().catch((e) => console.error('[Web] plan heartbeat error:', e));
1148
+ };
1149
+ // Path A — a task source is registered (Linear OR local SQLite): create a
1150
+ // parent issue + dependency-wired sub-issues (reusing the autonomous
1151
+ // engine, which routes through the same source), then heartbeat.
1152
+ const source = getTaskSource();
1153
+ if (source) {
1154
+ const parent = await source.createTask(goal, `Planned via the \`/plan\` cockpit.\n\n${tasks.length} sub-task(s) dispatched.`);
1155
+ if ('error' in parent) {
1156
+ res.writeHead(502, { 'Content-Type': 'application/json' });
1157
+ res.end(JSON.stringify({ error: `Task source: ${parent.error}` }));
1158
+ return;
1159
+ }
1160
+ if (tasks.length === 0) {
1161
+ // Planner saw no decomposition — run the goal itself as one task.
1162
+ await source.updateState(parent.id, 'Todo').catch(() => { });
1163
+ triggerHeartbeat();
1164
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1165
+ res.end(JSON.stringify({
1166
+ mode: source.kind,
1167
+ parentIssue: { id: parent.id, identifier: parent.identifier },
1168
+ subIssues: [],
1169
+ }));
1170
+ return;
1171
+ }
1172
+ const totalMinutes = tasks.reduce((sum, t) => sum + (t.estimatedMinutes || 0), 0);
1173
+ await createSubIssuesWithDependencies(parent.id, { title: goal }, tasks, totalMinutes, { reportToDiscord: () => { }, scheduleNextHeartbeat: triggerHeartbeat }, parent.id, 20);
1090
1174
  res.writeHead(200, { 'Content-Type': 'application/json' });
1091
1175
  res.end(JSON.stringify({
1092
1176
  mode: source.kind,
1093
1177
  parentIssue: { id: parent.id, identifier: parent.identifier },
1094
- subIssues: [],
1095
1178
  }));
1096
1179
  return;
1097
1180
  }
1098
- const totalMinutes = tasks.reduce((sum, t) => sum + (t.estimatedMinutes || 0), 0);
1099
- await createSubIssuesWithDependencies(parent.id, { title: goal }, tasks, totalMinutes, { reportToDiscord: () => { }, scheduleNextHeartbeat: triggerHeartbeat }, parent.id, 20);
1181
+ // Path B (fallback) no Linear: run each sub-task via the exec pipeline.
1182
+ const items = tasks.length > 0
1183
+ ? tasks
1184
+ : [{ title: goal, description: goal, estimatedMinutes: 0, priority: 3 }];
1185
+ const taskIds = items.map((st) => startExecTask(`${st.title}\n\n${st.description ?? ''}`.trim(), {
1186
+ projectPath: resolvedPath,
1187
+ pipeline: true,
1188
+ }));
1189
+ res.writeHead(202, { 'Content-Type': 'application/json' });
1190
+ res.end(JSON.stringify({ mode: 'exec', taskIds }));
1191
+ }
1192
+ catch (err) {
1193
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1194
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Invalid JSON' }));
1195
+ }
1196
+ // ---- Exec: task status ----
1197
+ }
1198
+ else if (url.startsWith('/api/exec/') && req.method === 'GET') {
1199
+ const taskId = url.replace('/api/exec/', '');
1200
+ const entry = execTasks.get(taskId);
1201
+ if (!entry) {
1202
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1203
+ res.end(JSON.stringify({ error: 'Task not found' }));
1204
+ }
1205
+ else {
1100
1206
  res.writeHead(200, { 'Content-Type': 'application/json' });
1101
1207
  res.end(JSON.stringify({
1102
- mode: source.kind,
1103
- parentIssue: { id: parent.id, identifier: parent.identifier },
1208
+ taskId: entry.taskId,
1209
+ status: entry.status,
1210
+ currentStage: entry.currentStage,
1211
+ result: entry.result,
1212
+ error: entry.error,
1104
1213
  }));
1105
- return;
1106
1214
  }
1107
- // Path B (fallback) — no Linear: run each sub-task via the exec pipeline.
1108
- const items = tasks.length > 0
1109
- ? tasks
1110
- : [{ title: goal, description: goal, estimatedMinutes: 0, priority: 3 }];
1111
- const taskIds = items.map((st) => startExecTask(`${st.title}\n\n${st.description ?? ''}`.trim(), {
1112
- projectPath: resolvedPath,
1113
- pipeline: true,
1114
- }));
1115
- res.writeHead(202, { 'Content-Type': 'application/json' });
1116
- res.end(JSON.stringify({ mode: 'exec', taskIds }));
1117
- }
1118
- catch (err) {
1119
- res.writeHead(400, { 'Content-Type': 'application/json' });
1120
- res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Invalid JSON' }));
1121
- }
1122
- // ---- Exec: task status ----
1123
- }
1124
- else if (url.startsWith('/api/exec/') && req.method === 'GET') {
1125
- const taskId = url.replace('/api/exec/', '');
1126
- const entry = execTasks.get(taskId);
1127
- if (!entry) {
1128
- res.writeHead(404, { 'Content-Type': 'application/json' });
1129
- res.end(JSON.stringify({ error: 'Task not found' }));
1130
1215
  }
1131
1216
  else {
1132
- res.writeHead(200, { 'Content-Type': 'application/json' });
1133
- res.end(JSON.stringify({
1134
- taskId: entry.taskId,
1135
- status: entry.status,
1136
- currentStage: entry.currentStage,
1137
- result: entry.result,
1138
- error: entry.error,
1139
- }));
1217
+ res.writeHead(404);
1218
+ res.end('Not Found');
1140
1219
  }
1141
1220
  }
1142
- else {
1143
- res.writeHead(404);
1144
- res.end('Not Found');
1221
+ catch (err) {
1222
+ const statusCode = err instanceof HttpError ? err.statusCode : 500;
1223
+ writeJson(res, statusCode, {
1224
+ error: err instanceof HttpError ? err.message : safeErrorMessage(err),
1225
+ });
1145
1226
  }
1146
1227
  });
1147
1228
  server.on('error', (err) => {