@repo-prism/dispatch-hub 1.1.16 → 1.8.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 (72) hide show
  1. package/README.md +2 -1
  2. package/dist/api-errors.d.ts +26 -0
  3. package/dist/api-errors.d.ts.map +1 -0
  4. package/dist/api-errors.js +61 -0
  5. package/dist/api-errors.js.map +1 -0
  6. package/dist/auth.d.ts +8 -0
  7. package/dist/auth.d.ts.map +1 -1
  8. package/dist/auth.js +22 -1
  9. package/dist/auth.js.map +1 -1
  10. package/dist/bin.js +0 -0
  11. package/dist/dashboard/app.css +1 -1
  12. package/dist/dashboard/app.js +199 -111
  13. package/dist/dashboard/app.js.map +636 -6
  14. package/dist/dashboard/hub.css +17449 -275
  15. package/dist/dashboard/index.html +4 -3
  16. package/dist/dashboard/prism-mark.png +0 -0
  17. package/dist/host-telemetry.d.ts +9 -0
  18. package/dist/host-telemetry.d.ts.map +1 -0
  19. package/dist/host-telemetry.js +30 -0
  20. package/dist/host-telemetry.js.map +1 -0
  21. package/dist/hub-record.d.ts.map +1 -1
  22. package/dist/hub-record.js +36 -1
  23. package/dist/hub-record.js.map +1 -1
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/intelligence.d.ts +37 -0
  29. package/dist/intelligence.d.ts.map +1 -0
  30. package/dist/intelligence.js +102 -0
  31. package/dist/intelligence.js.map +1 -0
  32. package/dist/notes.d.ts +32 -0
  33. package/dist/notes.d.ts.map +1 -0
  34. package/dist/notes.js +117 -0
  35. package/dist/notes.js.map +1 -0
  36. package/dist/notify.d.ts +6 -0
  37. package/dist/notify.d.ts.map +1 -1
  38. package/dist/notify.js +66 -12
  39. package/dist/notify.js.map +1 -1
  40. package/dist/paths.d.ts +25 -1
  41. package/dist/paths.d.ts.map +1 -1
  42. package/dist/paths.js +30 -2
  43. package/dist/paths.js.map +1 -1
  44. package/dist/pick-folder.d.ts +9 -0
  45. package/dist/pick-folder.d.ts.map +1 -0
  46. package/dist/pick-folder.js +49 -0
  47. package/dist/pick-folder.js.map +1 -0
  48. package/dist/registry.d.ts +7 -0
  49. package/dist/registry.d.ts.map +1 -1
  50. package/dist/registry.js +45 -6
  51. package/dist/registry.js.map +1 -1
  52. package/dist/server.d.ts +18 -1
  53. package/dist/server.d.ts.map +1 -1
  54. package/dist/server.js +421 -29
  55. package/dist/server.js.map +1 -1
  56. package/dist/snapshot.d.ts +14 -2
  57. package/dist/snapshot.d.ts.map +1 -1
  58. package/dist/snapshot.js +44 -9
  59. package/dist/snapshot.js.map +1 -1
  60. package/dist/statusline.d.ts +2 -0
  61. package/dist/statusline.d.ts.map +1 -1
  62. package/dist/statusline.js +23 -5
  63. package/dist/statusline.js.map +1 -1
  64. package/dist/types.d.ts +66 -3
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js +5 -0
  67. package/dist/types.js.map +1 -1
  68. package/dist/watch.d.ts +22 -5
  69. package/dist/watch.d.ts.map +1 -1
  70. package/dist/watch.js +59 -13
  71. package/dist/watch.js.map +1 -1
  72. package/package.json +7 -3
package/dist/server.js CHANGED
@@ -3,15 +3,22 @@ import { access } from "node:fs/promises";
3
3
  import { createReadStream } from "node:fs";
4
4
  import { dirname, extname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { createClaudeWorkerPort, createCursorWorkerPort, createDispatchRuntime, readRunLog, } from "@repo-prism/dispatch";
7
- import { originAllowed, tokenFromRequest, tokensMatch } from "./auth.js";
6
+ import { createClaudeWorkerPort, createCursorWorkerPort, createDispatchRuntime, DispatchConfigSchema, discoverHostConnectors, vendorCoverage, drainWorkspace, loadConfig, listWorkerModels, requestedWorkerModel, resolveWorkerBackend, getJob, upsertJob, readRunLog, saveConfig, } from "@repo-prism/dispatch";
7
+ import { parseHostRequest } from "@repo-prism/host-session/protocol";
8
+ import { originAllowed, tokenFromRequest, tokensMatch, hubCookieHeader, } from "./auth.js";
8
9
  import { createIdleTimer, IDLE_MS } from "./idle.js";
9
- import { newHubToken, packageVersion, writeHubRecord } from "./hub-record.js";
10
+ import { createIntelligencePlane, } from "./intelligence.js";
11
+ import { newHubToken, packageVersion, readHubRecord, writeHubRecord, } from "./hub-record.js";
10
12
  import { formatJobFinishedNotice } from "./notice.js";
13
+ import { listJobNotes, readJobNote } from "./notes.js";
11
14
  import { createOsNotifier } from "./notify.js";
15
+ import { readHostTelemetry } from "./host-telemetry.js";
12
16
  import { dashboardUrl, hubPort } from "./paths.js";
13
- import { dropMissingWorkspaces, registerWorkspace } from "./registry.js";
17
+ import { HUB_ERROR, publicCaughtError } from "./api-errors.js";
18
+ import { pickLocalFolder } from "./pick-folder.js";
19
+ import { dropMissingWorkspaces, registerWorkspace, unregisterWorkspace, workspaceLabel, } from "./registry.js";
14
20
  import { collectJobs, isInFlight, pathExists, watchWorkspaces, } from "./watch.js";
21
+ import { toSnapshot } from "./snapshot.js";
15
22
  const MIME = {
16
23
  ".html": "text/html; charset=utf-8",
17
24
  ".js": "text/javascript; charset=utf-8",
@@ -22,25 +29,74 @@ const MIME = {
22
29
  function defaultAssetsDir() {
23
30
  return join(dirname(fileURLToPath(import.meta.url)), "dashboard");
24
31
  }
25
- async function defaultControl(workspacePath, jobId, action) {
32
+ async function defaultStartJob(workspacePath, args) {
26
33
  const runtime = createDispatchRuntime({
27
34
  workspaceRoot: workspacePath,
28
35
  worker: createCursorWorkerPort(),
29
36
  claudeWorker: createClaudeWorkerPort(),
37
+ getClientName: () => String(args.hostClient ?? "console"),
38
+ });
39
+ return runtime.handle("start_job", {
40
+ ...args,
41
+ hostClient: args.hostClient ?? "console",
42
+ });
43
+ }
44
+ async function defaultControl(workspacePath, jobId, action, extra) {
45
+ const runtime = createDispatchRuntime({
46
+ workspaceRoot: workspacePath,
47
+ worker: createCursorWorkerPort(),
48
+ claudeWorker: createClaudeWorkerPort(),
49
+ getClientName: () => "console",
50
+ ...(action === "reverify" ? { deferVerify: true } : {}),
51
+ });
52
+ return runtime.handle("job_control", {
53
+ jobId,
54
+ action,
55
+ ...(extra?.path ? { path: extra.path } : {}),
56
+ ...(extra?.context ? { context: extra.context } : {}),
30
57
  });
31
- return runtime.handle("job_control", { jobId, action });
58
+ }
59
+ /**
60
+ * Advance one workspace's job queue (ADR-0047).
61
+ *
62
+ * The hub is the only always-on process on the machine, which makes it the
63
+ * right owner of the drain: a job queued by an MCP server that has since
64
+ * exited still starts, and a job parked behind the cap starts as soon as a
65
+ * slot frees.
66
+ */
67
+ async function defaultDrain(workspacePath) {
68
+ const cursor = createCursorWorkerPort();
69
+ const claude = createClaudeWorkerPort();
70
+ const runtime = createDispatchRuntime({
71
+ workspaceRoot: workspacePath,
72
+ worker: cursor,
73
+ claudeWorker: claude,
74
+ });
75
+ await drainWorkspace(runtime.drainDeps());
32
76
  }
33
77
  export async function startHub(options = {}) {
34
78
  const env = options.env ?? process.env;
35
79
  const notify = options.notify ?? createOsNotifier();
36
80
  const assetsDir = options.assetsDir ?? defaultAssetsDir();
37
81
  const control = options.control ?? defaultControl;
82
+ const startJob = options.startJob ?? defaultStartJob;
38
83
  const version = options.version ?? packageVersion();
39
- const token = newHubToken();
84
+ const intelligence = options.intelligence ?? createIntelligencePlane();
85
+ const pickFolder = options.pickFolder ?? pickLocalFolder;
86
+ const listModels = options.listWorkerModels ??
87
+ ((input) => listWorkerModels({ backend: input.backend }));
88
+ const previous = await readHubRecord(env);
89
+ const token = typeof previous?.token === "string" && previous.token.length >= 16
90
+ ? previous.token
91
+ : newHubToken();
40
92
  const wantedPort = hubPort(env);
41
93
  let workspaces = await dropMissingWorkspaces(pathExists, env);
42
94
  const sse = new Set();
43
95
  let jobs = [];
96
+ // Mirrored from the watcher so every payload can state when it was read and
97
+ // which workspaces failed, rather than presenting a partial list as whole.
98
+ let asOf = new Date().toISOString();
99
+ let workspaceErrors = [];
44
100
  let server;
45
101
  let closed = false;
46
102
  let liveRecord = {
@@ -51,8 +107,11 @@ export async function startHub(options = {}) {
51
107
  startedAt: new Date().toISOString(),
52
108
  };
53
109
  const broadcast = (event) => {
54
- if (event.type === "snapshot")
110
+ if (event.type === "snapshot") {
55
111
  jobs = [...event.jobs];
112
+ asOf = event.asOf;
113
+ workspaceErrors = [...event.errors];
114
+ }
56
115
  if (event.type === "job.updated") {
57
116
  jobs = [
58
117
  ...jobs.filter((row) => !(row.id === event.job.id &&
@@ -60,6 +119,10 @@ export async function startHub(options = {}) {
60
119
  event.job,
61
120
  ];
62
121
  }
122
+ if (event.type === "job.removed") {
123
+ jobs = jobs.filter((row) => !(row.id === event.job.id &&
124
+ row.workspacePath === event.job.workspacePath));
125
+ }
63
126
  const payload = `data: ${JSON.stringify(event)}\n\n`;
64
127
  for (const client of sse) {
65
128
  try {
@@ -77,8 +140,12 @@ export async function startHub(options = {}) {
77
140
  }
78
141
  idle.touch();
79
142
  };
143
+ // The hub tick is the queue's safety net (ADR-0047). `start_job` kicks its
144
+ // own drain, but that kick dies with the MCP process; this catches anything
145
+ // left `queued`, and re-checks jobs parked behind the concurrency cap.
80
146
  const watcher = watchWorkspaces(() => workspaces, onEvent, {
81
147
  pollMs: options.pollMs,
148
+ drain: options.drain ?? defaultDrain,
82
149
  });
83
150
  const idle = createIdleTimer({
84
151
  idleMs: options.idleMs ?? IDLE_MS,
@@ -93,6 +160,7 @@ export async function startHub(options = {}) {
93
160
  closed = true;
94
161
  idle.stop();
95
162
  watcher.close();
163
+ intelligence.close();
96
164
  for (const client of sse) {
97
165
  try {
98
166
  client.res.end();
@@ -107,7 +175,18 @@ export async function startHub(options = {}) {
107
175
  resolve();
108
176
  return;
109
177
  }
110
- server.close(() => resolve());
178
+ const timer = setTimeout(resolve, 1_000);
179
+ timer.unref();
180
+ server.close(() => {
181
+ clearTimeout(timer);
182
+ resolve();
183
+ });
184
+ try {
185
+ server.closeAllConnections();
186
+ }
187
+ catch {
188
+ /* bun / older node */
189
+ }
111
190
  });
112
191
  };
113
192
  server = createServer((req, res) => {
@@ -133,13 +212,21 @@ export async function startHub(options = {}) {
133
212
  }
134
213
  liveRecord = { ...liveRecord, port: listen.port };
135
214
  await writeHubRecord(liveRecord, env);
136
- jobs = await collectJobs(workspaces);
137
- broadcast({ type: "snapshot", jobs });
215
+ void listModels({ backend: "cursor" }).catch(() => {
216
+ /* spawn still lists if this misses */
217
+ });
218
+ const initial = await collectJobs(workspaces);
219
+ broadcast({
220
+ type: "snapshot",
221
+ jobs: initial.jobs,
222
+ asOf: new Date().toISOString(),
223
+ errors: initial.errors,
224
+ });
138
225
  async function handleRequest(req, res) {
139
226
  const host = req.headers.host ?? `127.0.0.1:${liveRecord.port}`;
140
227
  const url = new URL(req.url ?? "/", `http://${host}`);
141
228
  if (!originAllowed(req.headers.origin)) {
142
- json(res, 403, { error: "origin not allowed" });
229
+ json(res, 403, { error: HUB_ERROR.origin });
143
230
  return;
144
231
  }
145
232
  if (url.pathname === "/api/healthz") {
@@ -149,16 +236,157 @@ export async function startHub(options = {}) {
149
236
  pid: process.pid,
150
237
  version,
151
238
  workspaces: workspaces.length,
239
+ // Whether the Intelligence plane has actually loaded Core, and on
240
+ // what. A reader can tell an idle Console from a busy one.
241
+ intelligence: {
242
+ loaded: intelligence.loaded(),
243
+ workspace: intelligence.openWorkspace() ?? null,
244
+ },
152
245
  });
153
246
  return;
154
247
  }
155
248
  const isApi = url.pathname.startsWith("/api/");
156
249
  if (isApi && !tokensMatch(liveRecord.token, tokenFromRequest(req, url))) {
157
- json(res, 401, { error: "unauthorized" });
250
+ json(res, 401, { error: HUB_ERROR.unauthorized });
251
+ return;
252
+ }
253
+ if (req.method === "GET" && url.pathname === "/api/telemetry/host") {
254
+ json(res, 200, await readHostTelemetry(workspaces[0]?.path ?? process.cwd()));
255
+ return;
256
+ }
257
+ if (req.method === "GET" && url.pathname === "/api/worker-models") {
258
+ const requested = url.searchParams.get("backend")?.trim();
259
+ const workspace = url.searchParams.get("workspace")?.trim() || workspaces[0]?.path;
260
+ let backend;
261
+ if (requested === "cursor" || requested === "claude") {
262
+ backend = requested;
263
+ }
264
+ else {
265
+ const config = workspace
266
+ ? await loadConfig(workspace).catch(() => undefined)
267
+ : undefined;
268
+ backend = resolveWorkerBackend({ config, env });
269
+ }
270
+ json(res, 200, {
271
+ backend,
272
+ models: await listModels({ backend }),
273
+ });
274
+ return;
275
+ }
276
+ if (req.method === "POST" && url.pathname === "/api/jobs") {
277
+ const body = await readBody(req);
278
+ const workspace = String(body.workspace ?? "").trim() || workspaces[0]?.path || "";
279
+ const title = String(body.title ?? "").trim();
280
+ if (!workspace || !title) {
281
+ json(res, 400, { error: HUB_ERROR.titleRequired });
282
+ return;
283
+ }
284
+ try {
285
+ const workerModel = requestedWorkerModel(body.workerModel ?? body.model);
286
+ const origin = body.origin === "retry" ||
287
+ body.origin === "reverify" ||
288
+ body.origin === "finding" ||
289
+ body.origin === "instruct"
290
+ ? body.origin
291
+ : undefined;
292
+ const parentJobId = String(body.parentJobId ?? "").trim();
293
+ const result = await startJob(workspace, {
294
+ title,
295
+ prd: String(body.prd ?? ""),
296
+ playbook: String(body.playbook ?? "console"),
297
+ ...(body.placement === "worktree" || body.placement === "checkout"
298
+ ? { placement: body.placement }
299
+ : {}),
300
+ ...(body.workerBackend === "cursor" || body.workerBackend === "claude"
301
+ ? { workerBackend: body.workerBackend }
302
+ : {}),
303
+ ...(workerModel ? { workerModel } : {}),
304
+ ...(parentJobId ? { parentJobId } : {}),
305
+ ...(origin ? { origin } : {}),
306
+ hostClient: "console",
307
+ });
308
+ await watcher.refresh({ drain: true });
309
+ json(res, 200, result ?? { ok: true });
310
+ }
311
+ catch (cause) {
312
+ json(res, 500, { error: publicCaughtError(cause) });
313
+ }
314
+ return;
315
+ }
316
+ const jobPatch = /^\/api\/jobs\/([^/]+)$/.exec(url.pathname);
317
+ if (req.method === "PATCH" && jobPatch) {
318
+ const jobId = decodeURIComponent(jobPatch[1] ?? "");
319
+ const body = await readBody(req);
320
+ const workspace = String(body.workspace ?? "").trim() ||
321
+ jobs.find((job) => job.id === jobId)?.workspacePath ||
322
+ "";
323
+ if (!workspace || !jobId) {
324
+ json(res, 400, { error: HUB_ERROR.workspaceJob });
325
+ return;
326
+ }
327
+ if (!("prd" in body)) {
328
+ json(res, 400, { error: HUB_ERROR.prdRequired });
329
+ return;
330
+ }
331
+ try {
332
+ const current = await getJob(workspace, jobId);
333
+ if (!current) {
334
+ json(res, 404, { error: HUB_ERROR.jobMissing });
335
+ return;
336
+ }
337
+ const next = await upsertJob(workspace, {
338
+ ...current,
339
+ prd: String(body.prd ?? ""),
340
+ });
341
+ await watcher.refresh({ drain: false });
342
+ json(res, 200, {
343
+ job: toSnapshot(next, workspace),
344
+ message: "Saved the brief.",
345
+ });
346
+ }
347
+ catch (cause) {
348
+ json(res, 500, { error: publicCaughtError(cause) });
349
+ }
158
350
  return;
159
351
  }
160
352
  if (req.method === "GET" && url.pathname === "/api/jobs") {
161
- json(res, 200, { jobs });
353
+ json(res, 200, {
354
+ jobs: [...watcher.jobs()],
355
+ asOf: watcher.asOf(),
356
+ errors: [...watcher.errors()],
357
+ });
358
+ return;
359
+ }
360
+ // The Repos plane needs to tell "no repositories registered" apart from
361
+ // "registered, but no jobs" — two very different things the old board
362
+ // rendered as one empty sentence (ADR-0048).
363
+ if (req.method === "GET" && url.pathname === "/api/repos") {
364
+ json(res, 200, {
365
+ repos: workspaces.map((entry) => ({
366
+ path: entry.path,
367
+ label: entry.label,
368
+ lastSeenAt: entry.lastSeenAt,
369
+ jobCount: jobs.filter((job) => job.workspacePath === entry.path)
370
+ .length,
371
+ error: workspaceErrors.find((row) => row.workspacePath === entry.path)
372
+ ?.detail,
373
+ })),
374
+ asOf,
375
+ });
376
+ return;
377
+ }
378
+ // What the user's agent window already has connected (ADR-0049). Served
379
+ // from here rather than read directly by the IDE, so the extension keeps
380
+ // its one dependency on Dispatch — HTTP — instead of importing it.
381
+ if (req.method === "GET" && url.pathname === "/api/connectors") {
382
+ const workspace = url.searchParams.get("workspace")?.trim() || workspaces[0]?.path;
383
+ const discovery = await discoverHostConnectors(workspace ? { workspaceRoot: workspace } : {});
384
+ json(res, 200, {
385
+ connectors: discovery.connectors,
386
+ unreadable: discovery.unreadable,
387
+ vendors: vendorCoverage(discovery.connectors),
388
+ asOf: new Date().toISOString(),
389
+ });
162
390
  return;
163
391
  }
164
392
  // Per-job console for the board's expander (M-066 P-P6).
@@ -169,15 +397,55 @@ export async function startHub(options = {}) {
169
397
  jobs.find((job) => job.id === jobId)?.workspacePath ??
170
398
  "";
171
399
  if (!workspace) {
172
- json(res, 400, { error: "workspace required" });
400
+ json(res, 400, { error: HUB_ERROR.workspaceRequired });
173
401
  return;
174
402
  }
403
+ const since = url.searchParams.get("since")?.trim();
175
404
  const page = await readRunLog(workspace, jobId, {
176
405
  limit: Number(url.searchParams.get("limit") ?? "200") || 200,
406
+ ...(since ? { since } : {}),
177
407
  });
178
408
  json(res, 200, page);
179
409
  return;
180
410
  }
411
+ const notesMatch = /^\/api\/jobs\/([^/]+)\/notes$/.exec(url.pathname);
412
+ if (req.method === "GET" && notesMatch) {
413
+ const jobId = decodeURIComponent(notesMatch[1] ?? "");
414
+ const job = jobs.find((row) => row.id === jobId);
415
+ const workspace = url.searchParams.get("workspace") ?? job?.workspacePath ?? "";
416
+ if (!workspace) {
417
+ json(res, 400, { error: HUB_ERROR.workspaceRequired });
418
+ return;
419
+ }
420
+ const rel = url.searchParams.get("path")?.trim();
421
+ try {
422
+ if (rel) {
423
+ const file = await readJobNote({
424
+ workspace,
425
+ rel,
426
+ ...(job?.worktreePath ? { worktreePath: job.worktreePath } : {}),
427
+ });
428
+ if (!file) {
429
+ json(res, 404, { error: HUB_ERROR.noteMissing });
430
+ return;
431
+ }
432
+ json(res, 200, file);
433
+ return;
434
+ }
435
+ const listed = await listJobNotes({
436
+ workspace,
437
+ jobId,
438
+ ...(job?.worktreePath ? { worktreePath: job.worktreePath } : {}),
439
+ ...(job?.resultSummary ? { summary: job.resultSummary } : {}),
440
+ ...(job?.notes ? { stored: job.notes } : {}),
441
+ });
442
+ json(res, 200, { notes: listed });
443
+ }
444
+ catch {
445
+ json(res, 500, { error: HUB_ERROR.noteRead });
446
+ }
447
+ return;
448
+ }
181
449
  if (req.method === "GET" && url.pathname === "/api/events") {
182
450
  res.writeHead(200, {
183
451
  "Content-Type": "text/event-stream",
@@ -187,7 +455,12 @@ export async function startHub(options = {}) {
187
455
  const client = { res };
188
456
  sse.add(client);
189
457
  idle.touch();
190
- res.write(`data: ${JSON.stringify({ type: "snapshot", jobs })}\n\n`);
458
+ res.write(`data: ${JSON.stringify({
459
+ type: "snapshot",
460
+ jobs: [...watcher.jobs()],
461
+ asOf: watcher.asOf(),
462
+ errors: [...watcher.errors()],
463
+ })}\n\n`);
191
464
  req.on("close", () => {
192
465
  sse.delete(client);
193
466
  });
@@ -197,7 +470,7 @@ export async function startHub(options = {}) {
197
470
  const body = await readBody(req);
198
471
  const path = String(body.path ?? "").trim();
199
472
  if (!path) {
200
- json(res, 400, { error: "path required" });
473
+ json(res, 400, { error: HUB_ERROR.pathRequired });
201
474
  return;
202
475
  }
203
476
  workspaces = await registerWorkspace(path, env);
@@ -205,6 +478,74 @@ export async function startHub(options = {}) {
205
478
  json(res, 200, { workspaces });
206
479
  return;
207
480
  }
481
+ if (req.method === "POST" && url.pathname === "/api/workspaces/remove") {
482
+ const body = await readBody(req);
483
+ const path = String(body.path ?? "").trim();
484
+ if (!path) {
485
+ json(res, 400, { error: HUB_ERROR.pathRequired });
486
+ return;
487
+ }
488
+ const before = workspaces.length;
489
+ workspaces = await unregisterWorkspace(path, env);
490
+ if (workspaces.length === before) {
491
+ json(res, 404, { error: "That repository is not registered." });
492
+ return;
493
+ }
494
+ void watcher.refresh();
495
+ json(res, 200, { workspaces });
496
+ return;
497
+ }
498
+ if (req.method === "POST" && url.pathname === "/api/workspaces/pick") {
499
+ const picked = await pickFolder();
500
+ if (!picked) {
501
+ json(res, 200, { cancelled: true });
502
+ return;
503
+ }
504
+ workspaces = await registerWorkspace(picked, env);
505
+ void watcher.refresh();
506
+ json(res, 200, {
507
+ path: picked,
508
+ label: workspaceLabel(picked),
509
+ workspaces,
510
+ });
511
+ return;
512
+ }
513
+ // The Intelligence plane (ADR-0048). Same `HostRequest`/`HostResponse`
514
+ // contract the IDE webview already speaks, so `@repo-prism/app-shell`
515
+ // mounts against it unchanged — but behind this Console's token and origin
516
+ // allowlist, rather than the retired bridge's `Access-Control-Allow-Origin: *`.
517
+ if (req.method === "POST" && url.pathname === "/api/host") {
518
+ const body = await readBody(req);
519
+ const id = typeof body.id === "string" ? body.id : "?";
520
+ // Validated with the same guard the webview host uses, so an unknown
521
+ // method is a 400 here rather than an unhandled cast three layers down.
522
+ const parsed = parseHostRequest(body);
523
+ if (!parsed.ok) {
524
+ json(res, 400, { id, ok: false, error: parsed.reason });
525
+ return;
526
+ }
527
+ const workspace = (typeof body.workspace === "string" ? body.workspace.trim() : "") ||
528
+ url.searchParams.get("workspace") ||
529
+ workspaces[0]?.path ||
530
+ "";
531
+ if (!workspace) {
532
+ json(res, 200, {
533
+ id,
534
+ ok: false,
535
+ error: "No repository registered with Prism yet. Open a repo in your editor, or run a Prism command in it.",
536
+ });
537
+ return;
538
+ }
539
+ idle.touch();
540
+ try {
541
+ const answer = await intelligence.handle(workspace, parsed.value);
542
+ json(res, 200, answer);
543
+ }
544
+ catch (cause) {
545
+ json(res, 200, { id, ok: false, error: publicCaughtError(cause) });
546
+ }
547
+ return;
548
+ }
208
549
  const controlMatch = /^\/api\/jobs\/([^/]+)\/control$/.exec(url.pathname);
209
550
  if (req.method === "POST" && controlMatch) {
210
551
  const jobId = decodeURIComponent(controlMatch[1] ?? "");
@@ -213,36 +554,87 @@ export async function startHub(options = {}) {
213
554
  const workspace = String(body.workspace ?? "").trim() ||
214
555
  jobs.find((job) => job.id === jobId)?.workspacePath;
215
556
  if (!workspace || !action) {
216
- json(res, 400, { error: "workspace and action required" });
557
+ json(res, 400, { error: HUB_ERROR.actionRequired });
217
558
  return;
218
559
  }
219
560
  try {
220
- const result = await control(workspace, jobId, action);
221
- void watcher.refresh();
561
+ const extra = {
562
+ ...(typeof body.path === "string" ? { path: body.path } : {}),
563
+ ...(typeof body.context === "string"
564
+ ? { context: body.context }
565
+ : {}),
566
+ };
567
+ const result = await control(workspace, jobId, action, extra);
568
+ await watcher.refresh({ drain: false });
222
569
  json(res, 200, result ?? { ok: true });
223
570
  }
224
571
  catch (cause) {
225
- const message = cause instanceof Error ? cause.message : String(cause);
226
- json(res, 500, { error: message });
572
+ json(res, 500, { error: publicCaughtError(cause) });
573
+ }
574
+ return;
575
+ }
576
+ if (req.method === "GET" && url.pathname === "/api/settings") {
577
+ const workspace = url.searchParams.get("workspace")?.trim() || workspaces[0]?.path;
578
+ if (!workspace) {
579
+ json(res, 400, { error: HUB_ERROR.workspaceRequired });
580
+ return;
227
581
  }
582
+ const config = await loadConfig(workspace);
583
+ const entry = workspaces.find((row) => row.path === workspace);
584
+ json(res, 200, {
585
+ workspace,
586
+ label: entry?.label ?? workspace,
587
+ config,
588
+ });
589
+ return;
590
+ }
591
+ if (req.method === "POST" && url.pathname === "/api/settings") {
592
+ const body = await readBody(req);
593
+ const workspace = String(body.workspace ?? "").trim() || workspaces[0]?.path;
594
+ if (!workspace) {
595
+ json(res, 400, { error: HUB_ERROR.workspaceRequired });
596
+ return;
597
+ }
598
+ const parsed = DispatchConfigSchema.partial().safeParse(body);
599
+ if (!parsed.success) {
600
+ json(res, 400, { error: publicCaughtError(parsed.error) });
601
+ return;
602
+ }
603
+ const patch = {};
604
+ for (const [key, value] of Object.entries(parsed.data)) {
605
+ if (value !== undefined)
606
+ patch[key] = value;
607
+ }
608
+ const config = await saveConfig(workspace, patch);
609
+ json(res, 200, { workspace, config });
228
610
  return;
229
611
  }
230
612
  if (req.method === "GET" &&
231
613
  (url.pathname === "/" || url.pathname === "/index.html")) {
232
- await serveFile(res, join(assetsDir, "index.html"), "text/html; charset=utf-8");
614
+ const headers = {
615
+ "Content-Type": "text/html; charset=utf-8",
616
+ "Cache-Control": "no-store",
617
+ };
618
+ if (tokensMatch(liveRecord.token, tokenFromRequest(req, url))) {
619
+ headers["Set-Cookie"] = hubCookieHeader(liveRecord.token);
620
+ }
621
+ await serveFile(res, join(assetsDir, "index.html"), headers);
233
622
  return;
234
623
  }
235
624
  if (req.method === "GET" && url.pathname.startsWith("/assets/")) {
236
625
  const relative = url.pathname.slice("/assets/".length);
237
626
  if (relative.includes("..")) {
238
- json(res, 400, { error: "bad path" });
627
+ json(res, 400, { error: HUB_ERROR.badPath });
239
628
  return;
240
629
  }
241
630
  const file = join(assetsDir, relative);
242
- await serveFile(res, file, MIME[extname(file)] ?? "application/octet-stream");
631
+ await serveFile(res, file, {
632
+ "Content-Type": MIME[extname(file)] ?? "application/octet-stream",
633
+ "Cache-Control": "no-store",
634
+ });
243
635
  return;
244
636
  }
245
- json(res, 404, { error: "not found" });
637
+ json(res, 404, { error: HUB_ERROR.notFound });
246
638
  }
247
639
  return {
248
640
  record: liveRecord,
@@ -268,15 +660,15 @@ async function readBody(req) {
268
660
  return {};
269
661
  }
270
662
  }
271
- async function serveFile(res, path, mime) {
663
+ async function serveFile(res, path, headers) {
272
664
  try {
273
665
  await access(path);
274
666
  }
275
667
  catch {
276
- json(res, 404, { error: "not found" });
668
+ json(res, 404, { error: HUB_ERROR.notFound });
277
669
  return;
278
670
  }
279
- res.writeHead(200, { "Content-Type": mime });
671
+ res.writeHead(200, headers);
280
672
  createReadStream(path).pipe(res);
281
673
  }
282
674
  //# sourceMappingURL=server.js.map