@worca/app 1.0.0-rc.1 → 1.1.1

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 (138) hide show
  1. package/README.md +22 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +319 -45
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +189 -21
  32. package/src/core/ask/catalog.mjs +111 -0
  33. package/src/core/ask/comment-deps.mjs +55 -0
  34. package/src/core/ask/events.mjs +506 -0
  35. package/src/core/ask/follow.mjs +107 -0
  36. package/src/core/ask/git-allowlist.mjs +226 -0
  37. package/src/core/ask/limits.mjs +54 -0
  38. package/src/core/ask/mcp-stdio.mjs +135 -0
  39. package/src/core/ask/models.mjs +125 -0
  40. package/src/core/ask/prompt.mjs +261 -0
  41. package/src/core/ask/proposal.mjs +170 -0
  42. package/src/core/ask/redact.mjs +30 -0
  43. package/src/core/ask/spawn.mjs +153 -0
  44. package/src/core/ask/store.mjs +360 -0
  45. package/src/core/ask/tool-deps.mjs +63 -0
  46. package/src/core/ask/tools.mjs +848 -0
  47. package/src/core/ask/turn.mjs +416 -0
  48. package/src/core/ask/worktree-deps.mjs +27 -0
  49. package/src/core/ask/worktrees.mjs +285 -0
  50. package/src/core/chat/command-router.mjs +20 -3
  51. package/src/core/claude-runner.mjs +434 -57
  52. package/src/core/config.mjs +264 -41
  53. package/src/core/cost-budget.mjs +29 -2
  54. package/src/core/db.mjs +684 -47
  55. package/src/core/diff-anchor.mjs +213 -0
  56. package/src/core/diff-comments.mjs +273 -0
  57. package/src/core/engine-select.mjs +32 -0
  58. package/src/core/git-info.mjs +49 -10
  59. package/src/core/graph/builtin-workflows.mjs +51 -0
  60. package/src/core/graph/executor.mjs +894 -0
  61. package/src/core/graph/registry-ports.mjs +12 -0
  62. package/src/core/graph/scheduler.mjs +1065 -0
  63. package/src/core/graph/seed-templates.mjs +318 -0
  64. package/src/core/model-env.mjs +112 -8
  65. package/src/core/model-test.mjs +79 -0
  66. package/src/core/orchestrator.mjs +902 -4098
  67. package/src/core/overview-agent.mjs +15 -3
  68. package/src/core/phases.mjs +208 -537
  69. package/src/core/pipeline-delete.mjs +13 -2
  70. package/src/core/plugin-api.mjs +8 -3
  71. package/src/core/plugin-config.mjs +178 -28
  72. package/src/core/plugin-inventory.mjs +6 -2
  73. package/src/core/plugin-manifest.mjs +199 -11
  74. package/src/core/plugin-models.mjs +1 -0
  75. package/src/core/plugin-repo.mjs +16 -4
  76. package/src/core/plugin-shim-child.mjs +9 -3
  77. package/src/core/plugin-shim.mjs +77 -14
  78. package/src/core/plugin-store.mjs +236 -29
  79. package/src/core/plugin-workflows.mjs +90 -41
  80. package/src/core/preflight.mjs +135 -3
  81. package/src/core/projects.mjs +7 -5
  82. package/src/core/protocol.mjs +8 -35
  83. package/src/core/recoverable-error.mjs +1 -1
  84. package/src/core/run-harness.mjs +3585 -0
  85. package/src/core/run-manifest.mjs +5 -1
  86. package/src/core/settings.mjs +109 -13
  87. package/src/core/skills.mjs +10 -3
  88. package/src/core/source-bindings.mjs +175 -0
  89. package/src/core/sources.mjs +87 -25
  90. package/src/core/stats.mjs +25 -6
  91. package/src/core/title.mjs +51 -4
  92. package/src/core/workflows.mjs +358 -259
  93. package/src/core/workspace-scan.mjs +4 -0
  94. package/src/core/worktree.mjs +98 -7
  95. package/src/shared/graph/agent-meta.mjs +278 -0
  96. package/src/shared/graph/constants.mjs +105 -0
  97. package/src/shared/graph/geometry.mjs +157 -0
  98. package/src/shared/graph/layout.mjs +134 -0
  99. package/src/shared/graph/loops.mjs +130 -0
  100. package/src/shared/graph/manifest.mjs +257 -0
  101. package/src/shared/graph/ports.mjs +153 -0
  102. package/src/shared/graph/route.mjs +397 -0
  103. package/src/shared/graph/template.mjs +165 -0
  104. package/src/shared/graph/thumbnail.mjs +67 -0
  105. package/src/shared/graph/validate.mjs +491 -0
  106. package/src/shared/graph/verdict.mjs +41 -0
  107. package/ui/public/app.js +4008 -1670
  108. package/ui/public/ask-markdown.mjs +145 -0
  109. package/ui/public/ask-model.mjs +264 -0
  110. package/ui/public/ask-panel.mjs +1880 -0
  111. package/ui/public/chat-settings-view.mjs +6 -2
  112. package/ui/public/diff-view.mjs +66 -11
  113. package/ui/public/file-tree.mjs +305 -0
  114. package/ui/public/graph/composer.mjs +889 -0
  115. package/ui/public/graph/inspector.mjs +183 -0
  116. package/ui/public/graph/model.mjs +37 -0
  117. package/ui/public/graph/palette.mjs +144 -0
  118. package/ui/public/graph/run-decor.mjs +410 -0
  119. package/ui/public/graph/run-hosts.mjs +201 -0
  120. package/ui/public/graph/save-dialog.mjs +56 -0
  121. package/ui/public/graph/view.mjs +858 -0
  122. package/ui/public/guardrails-view.mjs +4 -2
  123. package/ui/public/hljs-loader.mjs +180 -0
  124. package/ui/public/index.html +269 -265
  125. package/ui/public/log-filter.mjs +22 -4
  126. package/ui/public/log-line.mjs +45 -19
  127. package/ui/public/models-view.mjs +171 -9
  128. package/ui/public/plugins-view.mjs +106 -4
  129. package/ui/public/source-pane.mjs +190 -8
  130. package/ui/public/stats-view.mjs +81 -1
  131. package/ui/public/style.css +1459 -229
  132. package/ui/public/syntax-highlight.mjs +270 -0
  133. package/ui/public/thinking-orb.mjs +110 -0
  134. package/ui/server.mjs +1667 -98
  135. package/src/core/channels.mjs +0 -302
  136. package/src/core/runners.mjs +0 -167
  137. package/src/core/workflow-validator.mjs +0 -185
  138. package/ui/public/composer-core.mjs +0 -211
package/ui/server.mjs CHANGED
@@ -13,26 +13,66 @@ import os from 'node:os';
13
13
  import fs from 'node:fs';
14
14
  import fsp from 'node:fs/promises';
15
15
  import process from 'node:process';
16
+ import { createRequire } from 'node:module';
16
17
  import { fileURLToPath, pathToFileURL } from 'node:url';
17
18
  import { randomUUID, timingSafeEqual } from 'node:crypto';
18
19
 
19
20
  import { preflightNode } from '../src/core/preflight-node.mjs';
20
- import { createOrchestrator } from '../src/core/orchestrator.mjs';
21
+ import { createOrchestratorFor } from '../src/core/engine-select.mjs';
21
22
  import {
22
23
  listPipelines, readPipeline, listAllPipelines, readPipelineByKey,
23
24
  enrichPipelinesPr, reconcileStaleRunning, readPipelineForResume, persistPrState,
24
25
  readRunLogText, readRunArtifactText, countPipelines, runRootSweepLookups, legacySweepLookups, slugify,
25
- listArtifacts,
26
+ listArtifacts, lookupPipelineRow, findPipelineRowById, resolveIndexedArtifact, resolveIndexedArtifactForRow,
27
+ readPromptFile,
26
28
  } from '../src/core/artifacts.mjs';
27
29
  import { DIFF_PATCH_FILE } from '../src/core/results.mjs';
30
+ import { protectedSectionKeys } from '../src/core/diff-anchor.mjs';
31
+ import {
32
+ addDiffComment, listDiffComments, getDiffComment, setDiffCommentResolved,
33
+ deleteDiffComment, unresolvedCounts, onDiffCommentsChanged, stampSentRunId,
34
+ peekPendingCardComments, clearPendingCardComments, DiffCommentError, DC_ID_RE,
35
+ } from '../src/core/diff-comments.mjs';
28
36
  import { listProjects, addProject, removeProject, normalizeProjectPath, countProjects, worcaHome } from '../src/core/projects.mjs';
29
37
  import {
30
38
  getWorcaRoot, setWorcaRoot, setProjectsRoot, defaultRoot,
31
39
  rawProjectsRoot, defaultProjectsRoot, runRootMode,
32
40
  pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod,
33
41
  setPipelineCostLimitUsd, setTotalCostLimitUsd, setCostLimitResetPeriod, assertCostLimitInputs,
42
+ askMaxTurns, askMaxBudgetUsd, setAskMaxTurns, setAskMaxBudgetUsd, assertAskLimitInputs,
34
43
  chatPrefs, setChatPrefs,
35
44
  } from '../src/core/settings.mjs';
45
+ import {
46
+ ASK_ID_RE, createThread as askCreateThread, getThread as askGetThread,
47
+ listThreads as askListThreads, updateThread as askUpdateThread,
48
+ deleteThread as askDeleteThread, sweepEmptyThreads, sweepStreamingMessages,
49
+ appendMessage as askAppendMessage, getMessage as askGetMessage,
50
+ listMessages as askListMessages, setMessageBlocks as askSetMessageBlocks,
51
+ findCard as askFindCard, updateCardBlock as askUpdateCardBlock,
52
+ addAttachment as askAddAttachment, listAttachments as askListAttachments,
53
+ readAttachmentText as askReadAttachmentText, threadAttachmentBytes as askThreadAttachmentBytes,
54
+ linkRun as askLinkRun, updateRunLink as askUpdateRunLink, listRunLinks as askListRunLinks,
55
+ findRunLinksByPipeline as askFindRunLinksByPipeline,
56
+ setThreadTitle as askSetThreadTitle, finishMessage as askFinishMessage,
57
+ } from '../src/core/ask/store.mjs';
58
+ import { sanitizeTitle as askSanitizeTitle } from '../src/core/title.mjs';
59
+ import { ASK_LIMITS } from '../src/core/ask/limits.mjs';
60
+ import { askCatalog, validateModelEffort } from '../src/core/ask/models.mjs';
61
+ import { buildCatalog as askBuildCatalog } from '../src/core/ask/catalog.mjs';
62
+ import {
63
+ buildSystemPrompt as askBuildSystemPrompt, buildContextHeader as askBuildContextHeader,
64
+ buildTurnPrompt as askBuildTurnPrompt, buildRestoredPrompt as askBuildRestoredPrompt,
65
+ selectInlineAttachments as askSelectInlineAttachments, validateClientContext,
66
+ } from '../src/core/ask/prompt.mjs';
67
+ import {
68
+ listAskWorktrees as askListWorktrees,
69
+ removeAskWorktree as askRemoveWorktree,
70
+ removeThreadWorktrees as askRemoveThreadWorktrees,
71
+ sweepAskWorktrees,
72
+ } from '../src/core/ask/worktrees.mjs';
73
+ import { createAskTurn } from '../src/core/ask/turn.mjs';
74
+ import { attachRunFollower } from '../src/core/ask/follow.mjs';
75
+ import { mockEnabled, MOCK_WRITER_ROLES } from '../src/core/claude-runner.mjs';
36
76
  import { budgetStatus, readCostCapOverride, setCostCapOverride } from '../src/core/cost-budget.mjs';
37
77
  import { getStats } from '../src/core/stats.mjs';
38
78
  import { pickFolderNative } from '../src/core/folder-dialog.mjs';
@@ -40,22 +80,25 @@ import { listFolders } from '../src/core/fs-browse.mjs';
40
80
  import {
41
81
  readConfig, setStep, addCustomModel, removeCustomModel, listModels,
42
82
  PREDEFINED_MODELS, agentSteps, EFFORTS,
43
- readRunConfig, setNodeModel, setFeedbackCycles, setActiveWorkflow, resetWorkflowConfig,
83
+ readRunConfig, setNodeModel, setFeedbackCycles, setWireCycles, setActiveWorkflow, resetWorkflowConfig,
44
84
  globalModelRefs, removeGlobalModelAndRefs, promoteCustomModel, costUnreliableModelIds,
45
85
  } from '../src/core/config.mjs';
46
86
  import { listGlobalModels, addGlobalModel, updateGlobalModel } from '../src/core/settings.mjs';
47
- import { modelEnvRef } from '../src/core/model-env.mjs';
87
+ import { modelEnvRef, SUBAGENT_MODEL_VALUES, subagentModelIssue } from '../src/core/model-env.mjs';
48
88
  import { listPluginModels, modelSecretsSchema, pluginModelSecretStatus } from '../src/core/plugin-models.mjs';
89
+ import { testModel } from '../src/core/model-test.mjs';
49
90
  import { validateGuardrails } from '../src/core/guardrails.mjs';
50
91
  import {
51
92
  listBuiltinGuardrailSets, listGuardrailSets, readGuardrailSet,
52
93
  writeGuardrailSet, deleteGuardrailSet, isBuiltinGuardrailSetId,
53
94
  } from '../src/core/guardrail-store.mjs';
54
95
  import {
55
- DEFAULT_WORKFLOW, listWorkflows, readWorkflow, writeWorkflow, deleteWorkflow,
56
- setWorkflowNodeDefaults, workflowNodeDefaults,
96
+ GRAPH_DEFAULT_WORKFLOW, listWorkflows, deleteWorkflow, isSafeWorkflowId,
97
+ setWorkflowNodeDefaults, workflowNodeDefaults, assertRunnableWorkflow, writeGraphWorkflow,
57
98
  } from '../src/core/workflows.mjs';
58
- import { validateWorkflow } from '../src/core/workflow-validator.mjs';
99
+ import { registryPortsFn } from '../src/core/graph/registry-ports.mjs';
100
+ import { sweepV1Runs, V1_RUN_RETIRED } from '../src/core/db.mjs';
101
+ import { validateGraph, AGENT_TUNABLES } from '../src/shared/graph/validate.mjs';
59
102
  import { loadAgentRegistry } from '../src/core/agent-registry.mjs';
60
103
  import {
61
104
  listLocalBranches, currentBranch, isValidSourceRef, sweepRunRoots, sweepLegacyWorktreesAll,
@@ -72,7 +115,6 @@ import { projectKey } from '../src/core/store.mjs';
72
115
  import { createWorkspaceScan } from '../src/core/workspace-scan.mjs';
73
116
  import { createAgentGen } from '../src/core/agent-gen.mjs';
74
117
  import { listAgents, readAgent, createAgent, updateAgent, deleteAgent, AGENT_KEY_RE } from '../src/core/agent-store.mjs';
75
- import { CHANNEL_IDS } from '../src/core/channels.mjs';
76
118
  import {
77
119
  listInstalledPlugins, installPlugin, updatePlugin, uninstallPlugin,
78
120
  setPluginEnabled, doctorPlugin,
@@ -83,7 +125,14 @@ import {
83
125
  addMarketplace, listMarketplaces, syncMarketplace, refreshAllMarketplaces,
84
126
  removeMarketplace, readMarketplaces, seedBuiltinMarketplace,
85
127
  } from '../src/core/marketplaces.mjs';
86
- import { redactedConfig, writePluginConfig, readPluginConfig } from '../src/core/plugin-config.mjs';
128
+ import {
129
+ redactedConfig, writePluginConfig, readPluginConfig, listProfiles, listProfileIds,
130
+ createProfile, deleteProfile, isValidProfileId, DEFAULT_PROFILE,
131
+ } from '../src/core/plugin-config.mjs';
132
+ import {
133
+ setBinding, clearBinding, listBindingsForScope,
134
+ clearBindingsForProfile, resolveProfile,
135
+ } from '../src/core/source-bindings.mjs';
87
136
  import { createChannelHost } from '../src/core/chat/channel-host.mjs';
88
137
  import { createCommandRouter } from '../src/core/chat/command-router.mjs';
89
138
  import { createChatContext } from '../src/core/chat/chat-context.mjs';
@@ -94,6 +143,7 @@ import { readPluginsLock, pluginCurrentDir } from '../src/core/plugins-lock.mjs'
94
143
  import { normalizeManifest, PLUGIN_NAME_RE as MANIFEST_PLUGIN_NAME_RE } from '../src/core/plugin-manifest.mjs';
95
144
  import { listTaskSources, retryWriteback } from '../src/core/sources.mjs';
96
145
  import { callSource, PluginOpError } from '../src/core/plugin-shim.mjs';
146
+ import { HLJS_GRAMMAR_IDS } from './public/hljs-loader.mjs';
97
147
 
98
148
  // ── node:sqlite runtime guard + warning filter ──────────────────────────────────
99
149
  // Drop ONLY the one-time ExperimentalWarning emitted by node:sqlite (the module is
@@ -117,6 +167,45 @@ const PROJECT_ROOT = path.resolve(__dirname, '..');
117
167
  const PUBLIC_DIR = path.join(__dirname, 'public');
118
168
  const AGENTS_DIR = path.join(PROJECT_ROOT, 'agents');
119
169
  const SKILLS_DIR = path.join(PROJECT_ROOT, 'skills');
170
+ const require = createRequire(import.meta.url);
171
+ const HLJS_LANGUAGE_FILE_RE = /^[a-z0-9][a-z0-9-]{0,63}\.min\.js$/;
172
+ // Primaries plus the sub-language grammars their instances register
173
+ // (hljs-loader.mjs); a shipped but unmapped grammar stays a plain 404.
174
+ const HLJS_LANGUAGE_FILES = new Set(
175
+ HLJS_GRAMMAR_IDS.map((id) => `${id}.min.js`),
176
+ );
177
+
178
+ function resolveHljsAssets(resolve = require.resolve, warn = (msg) => console.warn(msg)) {
179
+ try {
180
+ const core = resolve('@highlightjs/cdn-assets/es/core.min.js');
181
+ return { core, languages: path.join(path.dirname(core), 'languages') };
182
+ } catch (err) {
183
+ warn(`[worca-ui] syntax-highlighter assets unavailable: ${err?.message || err}`);
184
+ return null;
185
+ }
186
+ }
187
+
188
+ const HLJS_ASSETS = resolveHljsAssets();
189
+
190
+ // Ask Worca §10.7: the chat's markdown pipeline is served from node_modules the
191
+ // same way the hljs assets are, but resolved with import.meta.resolve — the CJS
192
+ // require.resolve lands on marked's CJS build, and dompurify/package.json is not
193
+ // exported. Each package degrades independently: a missing one just leaves its
194
+ // route unregistered and the existing /vendor no-store 404 answers.
195
+ function resolveEsmAsset(spec, resolve = (s) => import.meta.resolve(s), warn = (msg) => console.warn(msg)) {
196
+ try {
197
+ return fileURLToPath(resolve(spec));
198
+ } catch (err) {
199
+ warn(`[worca-ui] ask markdown asset unavailable (${spec}): ${err?.message || err}`);
200
+ return null;
201
+ }
202
+ }
203
+
204
+ const ASK_VENDOR_ASSETS = {
205
+ marked: resolveEsmAsset('marked'),
206
+ dompurify: resolveEsmAsset('dompurify'),
207
+ };
208
+
120
209
  const PORT = Number(process.env.PORT) || 4317;
121
210
  // Bind to loopback by default (S1). Power users who knowingly want LAN exposure
122
211
  // can set WORCA_HOST=0.0.0.0, but the localhost-only Host/Origin guard still
@@ -161,7 +250,9 @@ function liveRunIds() {
161
250
  // `stepgraphify` (§7.3) was emitted by the orchestrator and handled by the client
162
251
  // but missing here, so the graphify badge only appeared after a reload (via the
163
252
  // persisted column) and never live. It rides the same pass-through as `stepskills`.
164
- const EVENT_NAMES = ['phase', 'log', 'question', 'artifact', 'state', 'done', 'error', 'subagent', 'stepskills', 'stepgraphify', 'title'];
253
+ // `exec` and `token` are the graph engine's (§5.7). `phase` stays for the v1
254
+ // engine AND for the v2 shim until the graph cut-over retires it.
255
+ const EVENT_NAMES = ['exec', 'token', 'log', 'question', 'artifact', 'state', 'done', 'error', 'subagent', 'stepskills', 'stepgraphify', 'title'];
165
256
  // The scan-* WS family (Workspaces M5, §5.4). A NEW family in the SAME runs Map;
166
257
  // the 7-event run plumbing above is untouched. createWorkspaceScan emits many
167
258
  // scan-progress then exactly one terminal scan-done OR scan-error.
@@ -182,6 +273,20 @@ const wss = new WebSocketServer({ server, path: '/ws' });
182
273
  /** All currently connected sockets. */
183
274
  const sockets = new Set();
184
275
 
276
+ // server.close() only calls back once every connection is gone, and Node's
277
+ // closeAllConnections() skips UPGRADED sockets — a WebSocket whose close
278
+ // handshake has not completed (a client that vanished, or a test tearing down
279
+ // right after ws.close()) keeps the callback from ever firing; under load that
280
+ // is a hang. Terminate the lingering clients first so close() is deterministic
281
+ // on every OS; the per-socket 'close' handlers below drop them from `sockets`.
282
+ {
283
+ const httpClose = server.close.bind(server);
284
+ server.close = (cb) => {
285
+ for (const ws of sockets) { try { ws.terminate(); } catch { /* already gone */ } }
286
+ return httpClose(cb);
287
+ };
288
+ }
289
+
185
290
  wss.on('connection', (ws, req) => {
186
291
  // S1: WS upgrades bypass the express middleware chain, so re-apply the
187
292
  // loopback guard here (same DNS-rebinding protection as the HTTP routes).
@@ -197,24 +302,31 @@ wss.on('connection', (ws, req) => {
197
302
  let requestedRunId = null;
198
303
  let requestedScanId = null;
199
304
  let requestedGenId = null;
305
+ let requestedThreadId = null;
200
306
  try {
201
307
  const u = new URL(req.url, 'http://localhost');
202
308
  requestedRunId = u.searchParams.get('runId');
203
309
  requestedScanId = u.searchParams.get('scanId');
204
310
  requestedGenId = u.searchParams.get('genId');
311
+ requestedThreadId = u.searchParams.get('threadId');
205
312
  } catch {
206
313
  requestedRunId = null;
207
314
  requestedScanId = null;
208
315
  requestedGenId = null;
316
+ requestedThreadId = null;
209
317
  }
210
318
  const id = requestedRunId || requestedScanId || requestedGenId;
211
319
 
212
- send(ws, { type: 'hello', runs: summarizeRuns() });
320
+ send(ws, { type: 'hello', runs: summarizeRuns(), ask: askHello() });
213
321
 
214
322
  if (id && runs.has(id)) {
215
323
  replayEntry(ws, runs.get(id));
216
324
  }
217
325
 
326
+ if (requestedThreadId && askJobs.has(requestedThreadId)) {
327
+ replayAskJob(ws, askJobs.get(requestedThreadId));
328
+ }
329
+
218
330
  ws.on('close', () => sockets.delete(ws));
219
331
  ws.on('error', () => sockets.delete(ws));
220
332
  ws.on('message', (data) => {
@@ -231,6 +343,10 @@ wss.on('connection', (ws, req) => {
231
343
  if (subId && runs.has(subId)) {
232
344
  replayEntry(ws, runs.get(subId));
233
345
  }
346
+ const askThreadId = msg && msg.type === 'subscribe' && typeof msg.threadId === 'string' ? msg.threadId : null;
347
+ if (askThreadId && askJobs.has(askThreadId)) {
348
+ replayAskJob(ws, askJobs.get(askThreadId));
349
+ }
234
350
  });
235
351
  });
236
352
 
@@ -302,6 +418,32 @@ function emitChanged(type, action) {
302
418
  broadcast({ type, action: action || null });
303
419
  }
304
420
 
421
+ // Every comment mutation in THIS process (the REST routes below) pokes the open
422
+ // Diff tabs. A poke carries ids only — no payload, so it is idempotent and has no
423
+ // ordering concerns; the client refetches and repaints its CARDS, never the diff.
424
+ // MCP-side mutations happen in the stdio CHILD process and cannot reach this
425
+ // listener; they arrive through the turn's comment hook instead.
426
+ onDiffCommentsChanged(({ storeKey, pipelineId }) => {
427
+ broadcast({ type: 'diff-comments-changed', storeKey, pipelineId });
428
+ });
429
+
430
+ /** Resolve an 8-hex pipeline id to its History store key and poke the open Diff
431
+ * tabs. Used for MCP-side writes, which happen in the stdio CHILD process and
432
+ * cannot reach the listener above. The frame is byte-identical to the REST one,
433
+ * so the client has ONE code path. findPipelineRowById is key-agnostic and
434
+ * includes archived rows. Exported through `_testing` — the wiring at the ask
435
+ * turn is a one-liner precisely so this function is the whole testable surface. */
436
+ function emitDiffCommentsChanged(runId) {
437
+ try {
438
+ const row = findPipelineRowById(runId);
439
+ if (!row) return false;
440
+ const storeKey = (row.target === 'workspace' || row.workspace_key)
441
+ ? `workspaces/${row.workspace_key}` : row.project_key;
442
+ broadcast({ type: 'diff-comments-changed', storeKey, pipelineId: row.id });
443
+ return true;
444
+ } catch { return false; } // a poke is best effort
445
+ }
446
+
305
447
  // Append a tagged event to an entry's ring buffer (runId LAST so the runs-Map key
306
448
  // always wins over any id the orchestrator stamped). Shared by the live wire
307
449
  // (record) and out-of-band resolutions (resolvePending) so both honor MAX_BUFFER.
@@ -431,7 +573,7 @@ function wireRun(entry) {
431
573
  entry.status = 'error';
432
574
  resolvePending(entry, { reason: 'error' });
433
575
  }
434
- if (name === 'phase') {
576
+ if (name === 'exec') {
435
577
  entry.status = 'running';
436
578
  }
437
579
  if (name === 'state' && payload && typeof payload === 'object') {
@@ -576,7 +718,6 @@ app.post('/api/ingress/teams/:plugin/:channelId/:token',
576
718
  // ---------------------------------------------------------------------------
577
719
  // Express middleware + static
578
720
  // ---------------------------------------------------------------------------
579
- app.use(express.json({ limit: '8mb' }));
580
721
 
581
722
  // S1: worca-cc's UI/API has no auth and runs agents with permissionMode
582
723
  // 'acceptEdits' — it is a single-user *localhost* tool. The server binds to
@@ -584,13 +725,89 @@ app.use(express.json({ limit: '8mb' }));
584
725
  // suspenders: reject any request whose Host (or browser Origin) is not a
585
726
  // loopback name, so a malicious page resolving a name to 127.0.0.1 still can't
586
727
  // drive the API. Override WORCA_HOST only if you understand the exposure.
728
+ //
729
+ // FIRST, ahead of the body parser (MIN-108): a refused request must be refused
730
+ // before a single byte of its body is parsed or buffered, and a malformed body
731
+ // from a non-loopback Host used to answer 400 (with a stack) where a valid one
732
+ // answered 403. The ingress webhook above is deliberately mounted EARLIER and
733
+ // stays exempt — it carries its own token check and 256 KB cap.
587
734
  app.use((req, res, next) => {
588
735
  if (!isLocalRequest(req)) {
589
- return res.status(403).json({ error: 'forbidden: worca-cc is a localhost-only tool' });
736
+ return res.status(403).json({ error: 'forbidden: worca is a localhost-only tool' });
590
737
  }
591
738
  next();
592
739
  });
593
740
 
741
+ app.use(express.json({ limit: '8mb' }));
742
+
743
+ if (HLJS_ASSETS) {
744
+ const sendHljsModule = (file) => (_req, res, next) => {
745
+ res.type('text/javascript');
746
+ res.set('X-Content-Type-Options', 'nosniff');
747
+ res.sendFile(file, (err) => {
748
+ if (!err) return;
749
+ if (res.headersSent) return next(err);
750
+ next();
751
+ });
752
+ };
753
+ app.get('/vendor/hljs/core.min.js', sendHljsModule(HLJS_ASSETS.core));
754
+ app.get('/vendor/hljs/languages/:file', (req, res, next) => {
755
+ const file = String(req.params.file || '');
756
+ if (!HLJS_LANGUAGE_FILE_RE.test(file) || !HLJS_LANGUAGE_FILES.has(file)) return next();
757
+ const candidate = path.join(HLJS_ASSETS.languages, file);
758
+ try {
759
+ if (!fs.statSync(candidate).isFile()) return next();
760
+ } catch {
761
+ return next();
762
+ }
763
+ return sendHljsModule(candidate)(req, res, next);
764
+ });
765
+ }
766
+
767
+ // Ask Worca §10.7 vendor routes. sendHljsModule's shape, reused verbatim: the
768
+ // sendFile error path falls through to the /vendor no-store handlers below.
769
+ const sendEsmModule = (file) => (_req, res, next) => {
770
+ res.type('text/javascript');
771
+ res.set('X-Content-Type-Options', 'nosniff');
772
+ res.sendFile(file, (err) => {
773
+ if (!err) return;
774
+ if (res.headersSent) return next(err);
775
+ next();
776
+ });
777
+ };
778
+ if (ASK_VENDOR_ASSETS.marked) {
779
+ app.get('/vendor/marked/marked.esm.js', sendEsmModule(ASK_VENDOR_ASSETS.marked));
780
+ }
781
+ if (ASK_VENDOR_ASSETS.dompurify) {
782
+ app.get('/vendor/dompurify/purify.es.mjs', sendEsmModule(ASK_VENDOR_ASSETS.dompurify));
783
+ }
784
+
785
+ app.use('/vendor', (err, _req, res, next) => {
786
+ if (res.headersSent) return next(err);
787
+ res.set('Cache-Control', 'no-store');
788
+ const status = err?.status === 400 ? 400 : 404;
789
+ res.status(status).type('text/plain').send(status === 400 ? 'Bad request' : 'Not found');
790
+ });
791
+ app.use('/vendor', (_req, res) => {
792
+ res.set('Cache-Control', 'no-store');
793
+ res.status(404).type('text/plain').send('Not found');
794
+ });
795
+
796
+ // src/shared/** is the ONE source of the graph model for server + browser
797
+ // (no build step). ui modules import it by relative path that walks above
798
+ // ui/public; the browser clamps that URL at '/', so it must be served here at
799
+ // exactly the repo-relative path. The 404 tail keeps a typo'd path from
800
+ // falling through to the SPA index.html (which Chrome reports as a MIME error).
801
+ const SHARED_DIR = path.join(PROJECT_ROOT, 'src', 'shared');
802
+ app.use('/src/shared', express.static(SHARED_DIR, {
803
+ index: false,
804
+ setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
805
+ }));
806
+ app.use('/src/shared', (_req, res) => {
807
+ res.set('Cache-Control', 'no-store');
808
+ res.status(404).type('text/plain').send('Not found');
809
+ });
810
+
594
811
  app.use(express.static(PUBLIC_DIR, { extensions: ['html'] }));
595
812
 
596
813
  const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
@@ -694,6 +911,9 @@ function normalizeRunSource(raw) {
694
911
  return { ok: false, error: `source.${k} is required for type "plugin"` };
695
912
  }
696
913
  }
914
+ if (raw.profile !== undefined && !isValidProfileId(raw.profile)) {
915
+ return { ok: false, error: 'source.profile is not a valid profile id' };
916
+ }
697
917
  return {
698
918
  ok: true,
699
919
  source: {
@@ -702,12 +922,34 @@ function normalizeRunSource(raw) {
702
922
  sourceId: raw.sourceId.trim(),
703
923
  taskId: raw.taskId.trim(),
704
924
  inputs: raw.inputs && typeof raw.inputs === 'object' && !Array.isArray(raw.inputs) ? raw.inputs : undefined,
925
+ // Which configuration of the source the task came from. Absent is legal
926
+ // (single-profile sources); an id that is not path-safe is not.
927
+ profile: typeof raw.profile === 'string' && raw.profile ? raw.profile : undefined,
705
928
  },
706
929
  };
707
930
  }
708
931
  return { ok: false, error: `unknown source.type "${type}"` };
709
932
  }
710
933
 
934
+ /**
935
+ * A markdown source that NAMES a promptFile must name one we can read. Resolution
936
+ * happens inside the orchestrator, which this route launches fire-and-forget AFTER
937
+ * it has already answered — so without this submit-time check a bad path surfaces
938
+ * as an anonymous mid-run error event on a pipeline the client was told started.
939
+ * Resolved against the same base the orchestrator uses (the project, or a
940
+ * workspace's primary member).
941
+ * @returns {Promise<string|null>} the error message, or null when there is nothing wrong
942
+ */
943
+ async function promptFileProblem(source, projectDir) {
944
+ if (!source || source.type !== 'markdown' || !source.promptFile) return null;
945
+ try {
946
+ await readPromptFile(projectDir, source.promptFile);
947
+ return null;
948
+ } catch (err) {
949
+ return err && err.message ? err.message : String(err);
950
+ }
951
+ }
952
+
711
953
  // Fallback run title when the client sends none. The legacy path is unchanged
712
954
  // (first 80 chars of the prompt — effectivePrompt is guaranteed set there); a
713
955
  // plugin source starts as "<plugin>: <taskId>" until the orchestrator resolves
@@ -719,6 +961,67 @@ function fallbackRunTitle(effectivePrompt, source) {
719
961
  return String(text).slice(0, 80);
720
962
  }
721
963
 
964
+ // Wire an Ask Worca follower for a card-linked run: the orchestrator's
965
+ // state/question/error/done events become thread notices, ask_run_links patches
966
+ // and ask-run-status frames. Used by POST /api/run at launch AND by resumeRun
967
+ // (a resumed pipeline is a NEW orchestrator; the paused lineage's follower
968
+ // detached on done{paused}, so the link must be re-followed — review of PR #376).
969
+ function attachAskFollower(orch, { threadId, runId, cardId }) {
970
+ const follower = attachRunFollower(orch, {
971
+ threadId,
972
+ runId,
973
+ cardId,
974
+ post: ({ text, href }) => {
975
+ try {
976
+ const m = askAppendMessage(threadId, {
977
+ role: 'system', text, blocks: [{ kind: 'notice', text, href }],
978
+ });
979
+ broadcast({ type: 'ask-message', threadId, message: m });
980
+ } catch { /* thread deleted mid-run */ }
981
+ },
982
+ updateStatus: (patch) => {
983
+ try {
984
+ const linkPatch = {};
985
+ if (patch.pipelineId) linkPatch.pipelineId = patch.pipelineId;
986
+ if (patch.status) linkPatch.status = patch.status;
987
+ if (patch.phase !== undefined) linkPatch.phase = patch.phase;
988
+ const row = Object.keys(linkPatch).length
989
+ ? askUpdateRunLink(threadId, runId, linkPatch) : null;
990
+ // The 8-hex History id lands on the FIRST state event (follow.mjs guards
991
+ // "first truthy sight only"), which is the first moment a
992
+ // "sent to #<runId>" marker could point anywhere real. Never the
993
+ // runs-Map UUID, never at launch, and never a resolve.
994
+ if (linkPatch.pipelineId && row && row.commentIds.length) {
995
+ try { stampSentRunId(row.commentIds, linkPatch.pipelineId); } catch { /* best effort */ }
996
+ }
997
+ if (patch.cardFailed) {
998
+ flipCard(threadId, cardId, { state: 'failed', error: patch.cardFailed });
999
+ }
1000
+ broadcast({
1001
+ type: 'ask-run-status', threadId, runId,
1002
+ pipelineId: (row && row.pipelineId) || patch.pipelineId || null,
1003
+ cardId,
1004
+ status: patch.status || (row && row.status) || null,
1005
+ phase: patch.phase !== undefined ? patch.phase : ((row && row.phase) || null),
1006
+ });
1007
+ } catch { /* thread deleted mid-run */ }
1008
+ },
1009
+ onDetached: () => {
1010
+ const set = askFollowers.get(threadId);
1011
+ if (set) {
1012
+ set.delete(follower);
1013
+ if (!set.size) askFollowers.delete(threadId);
1014
+ }
1015
+ },
1016
+ });
1017
+ let set = askFollowers.get(threadId);
1018
+ if (!set) {
1019
+ set = new Set();
1020
+ askFollowers.set(threadId, set);
1021
+ }
1022
+ set.add(follower);
1023
+ }
1024
+
722
1025
  // ---------------------------------------------------------------------------
723
1026
  // POST /api/run -> start a new orchestration run
724
1027
  // body (single-project): { projectDir, prompt?, promptMarkdown?, title?, mock? }
@@ -739,6 +1042,28 @@ app.post('/api/run', async (req, res) => {
739
1042
  return badRequest(res, 'workspaceId or projectDir is required');
740
1043
  }
741
1044
 
1045
+ // Ask Worca card link (§8.1): both or neither; the thread must exist and
1046
+ // the card must still be `proposed` BEFORE any run state is created.
1047
+ const hasAskThread = body.askThreadId !== undefined && body.askThreadId !== null;
1048
+ const hasAskCard = body.askCardId !== undefined && body.askCardId !== null;
1049
+ let askLink = null;
1050
+ if (hasAskThread || hasAskCard) {
1051
+ if (!hasAskThread || !hasAskCard) {
1052
+ return badRequest(res, 'askThreadId and askCardId must be provided together');
1053
+ }
1054
+ if (typeof body.askThreadId !== 'string' || !ASK_ID_RE.test(body.askThreadId)
1055
+ || typeof body.askCardId !== 'string' || !ASK_ID_RE.test(body.askCardId)) {
1056
+ return badRequest(res, 'invalid askThreadId or askCardId');
1057
+ }
1058
+ if (!askGetThread(body.askThreadId)) return badRequest(res, 'unknown askThreadId');
1059
+ const found = askFindCard(body.askThreadId, body.askCardId);
1060
+ if (!found) return badRequest(res, 'unknown askCardId');
1061
+ if (found.block.state !== 'proposed') {
1062
+ return res.status(409).json({ error: `card is ${found.block.state}` });
1063
+ }
1064
+ askLink = { threadId: body.askThreadId, cardId: body.askCardId };
1065
+ }
1066
+
742
1067
  // ── Shared resolution (factored BEFORE the target branch, §2.6) ──────────
743
1068
  // NEW (plugins §7.3): body.source is the task-source descriptor; shape-check
744
1069
  // only and pass through — the orchestrator resolves it exactly once. Absent
@@ -747,6 +1072,29 @@ app.post('/api/run', async (req, res) => {
747
1072
  if (sourceCheck && !sourceCheck.ok) return badRequest(res, sourceCheck.error);
748
1073
  const source = sourceCheck ? sourceCheck.source : null;
749
1074
 
1075
+ // A multiProfile source without a profile would run against the (empty)
1076
+ // default bucket and die mid-pipeline with a confusing connector error —
1077
+ // reject it here, at submit, where the client can still fix it. The same
1078
+ // goes for a profile that is no longer IN the roster (deleted in another
1079
+ // tab after the client resolved it) and for a profile supplied to a source
1080
+ // that does not use them (it would read a phantom bucket instead of the
1081
+ // real config). A broken or uninstalled plugin is left for
1082
+ // resolveTaskInput to report.
1083
+ if (source && source.type === 'plugin') {
1084
+ const m = readInstalledManifest(source.plugin);
1085
+ const ts = m && (m.taskSources || []).find((s) => s.id === source.sourceId);
1086
+ if (ts && ts.multiProfile) {
1087
+ if (!source.profile) {
1088
+ return badRequest(res, `source.profile is required — task source "${source.sourceId}" has per-profile configuration`);
1089
+ }
1090
+ if (!listProfileIds(source.plugin).includes(source.profile)) {
1091
+ return badRequest(res, `plugin "${source.plugin}" has no profile "${source.profile}" — it may have been deleted; re-select one`);
1092
+ }
1093
+ } else if (ts && source.profile) {
1094
+ return badRequest(res, `task source "${source.sourceId}" does not use profiles — omit source.profile`);
1095
+ }
1096
+ }
1097
+
750
1098
  // prompt OR promptMarkdown. promptMarkdown is treated as the prompt text.
751
1099
  const prompt = typeof body.prompt === 'string' && body.prompt.trim() ? body.prompt : undefined;
752
1100
  const promptMarkdown =
@@ -770,7 +1118,16 @@ app.post('/api/run', async (req, res) => {
770
1118
  // so the client gets a clean 400 instead of a mid-run error event.
771
1119
  const workflowId =
772
1120
  typeof body.workflowId === 'string' && body.workflowId.trim() ? body.workflowId.trim() : 'wf_default';
773
- if (!(await readWorkflow(workflowId))) return badRequest(res, `unknown workflowId "${workflowId}"`);
1121
+ // ONE gate for every run entry point, ONE status: unknown (today's text) and
1122
+ // archived (the upgrade explanation the UI shows verbatim) answer 400 through
1123
+ // badRequest. A graph row runs on the graph engine — createOrchestratorFor
1124
+ // routes it off the row's version.
1125
+ let workflowRow;
1126
+ try {
1127
+ workflowRow = await assertRunnableWorkflow(workflowId);
1128
+ } catch (err) {
1129
+ return badRequest(res, err && err.message ? err.message : String(err));
1130
+ }
774
1131
 
775
1132
  // Optional guardrailsId selects the named guardrail set that IS this run's
776
1133
  // policy (applied uniformly to every member — guardrails are per-run only).
@@ -858,7 +1215,10 @@ app.post('/api/run', async (req, res) => {
858
1215
  return badRequest(res, `unknown or invalid sourceBranch: ${badOverride}`);
859
1216
  }
860
1217
 
861
- orch = createOrchestrator({
1218
+ const wsFileProblem = await promptFileProblem(effectiveSource, projects[0].projectDir);
1219
+ if (wsFileProblem) return badRequest(res, wsFileProblem);
1220
+
1221
+ orch = await createOrchestratorFor({
862
1222
  workspace: {
863
1223
  id: ws.id,
864
1224
  key: ws.id, // ws.id === workspaceKey(ws); routes artifacts to its store
@@ -872,6 +1232,7 @@ app.post('/api/run', async (req, res) => {
872
1232
  extras,
873
1233
  agentsDir: AGENTS_DIR,
874
1234
  workflowId,
1235
+ template: workflowRow,
875
1236
  guardrailsId,
876
1237
  branch,
877
1238
  claude: { permissionMode: 'acceptEdits', mock },
@@ -911,7 +1272,10 @@ app.post('/api/run', async (req, res) => {
911
1272
  return badRequest(res, `unknown or invalid sourceBranch: ${branch.source}`);
912
1273
  }
913
1274
 
914
- orch = createOrchestrator({
1275
+ const fileProblem = await promptFileProblem(effectiveSource, projectDir);
1276
+ if (fileProblem) return badRequest(res, fileProblem);
1277
+
1278
+ orch = await createOrchestratorFor({
915
1279
  projectDir,
916
1280
  prompt: effectivePrompt,
917
1281
  ...(effectiveSource ? { source: effectiveSource } : {}),
@@ -919,6 +1283,7 @@ app.post('/api/run', async (req, res) => {
919
1283
  extras,
920
1284
  agentsDir: AGENTS_DIR,
921
1285
  workflowId,
1286
+ template: workflowRow,
922
1287
  guardrailsId,
923
1288
  branch,
924
1289
  claude: { permissionMode: 'acceptEdits', mock },
@@ -939,6 +1304,48 @@ app.post('/api/run', async (req, res) => {
939
1304
 
940
1305
  runs.set(runId, entry);
941
1306
  wireRun(entry);
1307
+ if (askLink) {
1308
+ // Card-state TOCTOU: awaits (source-ref check, budget) sit between Hunk
1309
+ // B's `proposed` check and here — a concurrent Start may have flipped
1310
+ // the card already. That is a LOST RACE, not a detail to log: the loser
1311
+ // must not launch a second pipeline for the same card (review of PR #376).
1312
+ // Withdraw the run entry (nothing has run or been announced yet) and 409.
1313
+ const still = askFindCard(askLink.threadId, askLink.cardId);
1314
+ if (!still || still.block.state !== 'proposed') {
1315
+ runs.delete(runId);
1316
+ return res.status(409).json({ error: `card is no longer proposed (${still ? still.block.state : 'gone'})` });
1317
+ }
1318
+ try {
1319
+ askLinkRun(askLink.threadId, { runId, cardId: askLink.cardId, status: entry.status });
1320
+ flipCard(askLink.threadId, askLink.cardId, { state: 'started', runId });
1321
+ // The card's pending comment ids move onto the link row, keyed by the minted
1322
+ // UUID exactly as pipeline_id is before it exists. Consumed one-shot: a card
1323
+ // launches at most once. Own try/catch — comment bookkeeping must never
1324
+ // abort the card flip or the run.
1325
+ try {
1326
+ // Read, WRITE, then consume — not consume-then-write. A combined take()
1327
+ // deletes the rows it returns, so if askUpdateRunLink throws in between (its
1328
+ // catch here only logs) the ids are gone and the sent_run_id stamp is lost
1329
+ // with no way to recover them. peek/commit keeps the delete on the success
1330
+ // path only; a second launch of the same card cannot happen anyway (the
1331
+ // card must be in state 'proposed' above).
1332
+ const pendingComments = peekPendingCardComments(askLink.cardId);
1333
+ if (pendingComments.length) {
1334
+ askUpdateRunLink(askLink.threadId, runId, { commentIds: pendingComments });
1335
+ clearPendingCardComments(askLink.cardId);
1336
+ }
1337
+ } catch (e) { console.error('[diff-comments] pending-card handoff failed:', e && e.message ? e.message : e); }
1338
+ const startedMsg = askAppendMessage(askLink.threadId, {
1339
+ role: 'system',
1340
+ text: `Run started — "${title}"`,
1341
+ blocks: [{ kind: 'notice', text: `Run started — "${title}"`, href: `#running/${runId}` }],
1342
+ });
1343
+ broadcast({ type: 'ask-message', threadId: askLink.threadId, message: startedMsg });
1344
+ attachAskFollower(orch, { threadId: askLink.threadId, runId, cardId: askLink.cardId });
1345
+ } catch (err) {
1346
+ console.error(`[worca-ui] ask run link failed: ${err && err.message ? err.message : err}`);
1347
+ }
1348
+ }
942
1349
  announceRun(entry);
943
1350
 
944
1351
  // Fire-and-forget; all progress is surfaced through events.
@@ -1169,10 +1576,18 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
1169
1576
  if (!saved) throw new ResumeError(404, { error: 'pipeline not found' });
1170
1577
  if (saved.row.status !== 'paused' && saved.row.status !== 'interrupted') throw new ResumeError(400, { error: `pipeline is "${saved.row.status}", not resumable` });
1171
1578
  if (!saved.resumePoint) throw new ResumeError(400, { error: 'pipeline has no resume point' });
1579
+ if (saved.resumePoint.version !== 2) {
1580
+ throw new ResumeError(409, { code: 'ENGINE_RETIRED', error: V1_RUN_RETIRED });
1581
+ }
1172
1582
 
1173
1583
  if (saved.row.archived_at) {
1174
1584
  throw new ResumeError(409, { error: 'pipeline is archived' });
1175
1585
  }
1586
+ // No graph re-validation on RESUME, on purpose: _restoreFromResumePoint never
1587
+ // reads the workflow row — the frozen manifest supplies topology and port
1588
+ // identity (resolvedFromManifest: snapshot wins), so a template that drifted
1589
+ // while the run sat paused cannot strand it. A vanished agent KEY is the one
1590
+ // resume-time hazard, and _preflightAgentKeys already refuses it (§9.4).
1176
1591
  const budget = budgetStatus();
1177
1592
  if (budget.blocked) {
1178
1593
  throw new ResumeError(403, { error: 'total cost limit reached', budget });
@@ -1226,7 +1641,7 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
1226
1641
 
1227
1642
  const effMock = mock || isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK);
1228
1643
  const runId = randomUUID();
1229
- const orch = createOrchestrator({
1644
+ const orch = await createOrchestratorFor({
1230
1645
  projectDir,
1231
1646
  ...(workspace ? { workspace } : {}),
1232
1647
  agentsDir: AGENTS_DIR,
@@ -1255,6 +1670,22 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
1255
1670
  wireRun(entry);
1256
1671
  announceRun(entry);
1257
1672
 
1673
+ // A card-linked run keeps reporting to its chat across the resume: the link
1674
+ // row moves to the new runId and a fresh follower takes over (the old one
1675
+ // detached on done{paused}). Best-effort per row — chat bookkeeping must never
1676
+ // block a resume.
1677
+ for (const link of askFindRunLinksByPipeline(pipelineId)) {
1678
+ try {
1679
+ if (!askUpdateRunLink(link.threadId, link.runId, { runId, status: 'running' })) continue;
1680
+ const text = `Run resumed — "${saved.row.title || 'run'}"`;
1681
+ const m = askAppendMessage(link.threadId, { role: 'system', text, blocks: [{ kind: 'notice', text, href: `#running/${runId}` }] });
1682
+ broadcast({ type: 'ask-message', threadId: link.threadId, message: m });
1683
+ attachAskFollower(orch, { threadId: link.threadId, runId, cardId: link.cardId });
1684
+ } catch (err) {
1685
+ console.error(`[worca-ui] ask follower re-attach failed: ${err && err.message ? err.message : err}`);
1686
+ }
1687
+ }
1688
+
1258
1689
  // Evict the superseded paused/interrupted lineage for this pipeline. The old
1259
1690
  // entry is inert (paused), but summarizeRuns() broadcasts EVERY Map entry on
1260
1691
  // each hello — leaving it resurfaces the now-resumed (and possibly already
@@ -1362,6 +1793,22 @@ app.get('/api/runs/:id', async (req, res) => {
1362
1793
  }
1363
1794
  });
1364
1795
 
1796
+ // GET /api/runs/:id/artifact?rel= -> the same payload, resolved by the pipeline
1797
+ // id ALONE (findPipelineRowById): the Running page knows the run's pipelineId
1798
+ // but no store key until History has been visited. Placed beside /api/runs/:id
1799
+ // (`:id` matches one path segment, so the two never shadow each other).
1800
+ app.get('/api/runs/:id/artifact', async (req, res) => {
1801
+ try {
1802
+ const row = findPipelineRowById(req.params.id);
1803
+ if (!row) return res.status(404).json({ error: 'pipeline not found' });
1804
+ const hit = await resolveIndexedArtifactForRow(row, req.query.rel);
1805
+ if (!hit) return res.status(404).json({ error: 'artifact not found' });
1806
+ res.json(hit);
1807
+ } catch (err) {
1808
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1809
+ }
1810
+ });
1811
+
1365
1812
  // Shared query-scope resolver for the retained-work routes (recovery-patch GET +
1366
1813
  // discard POST). Returns null after writing the error response itself. The older
1367
1814
  // DELETE /api/runs/:id route keeps its inline copy DELIBERATELY (it shadows the
@@ -1538,10 +1985,176 @@ app.get('/api/history/:key/:id/log', async (req, res) => {
1538
1985
  }
1539
1986
  });
1540
1987
 
1988
+ // ---------------------------------------------------------------------------
1989
+ // Internal, line-anchored diff comments. Bound to BOTH route families below: the
1990
+ // /api/history/:key/:id key regex forbids a slash, so a workspace run (store key
1991
+ // "workspaces/<id>") can only be reached through /api/workspaces/:id/runs/:runId —
1992
+ // the same split the /diff and /log routes already carry. One handler set, two
1993
+ // registrations: the two can never diverge.
1994
+ //
1995
+ // Traversal posture matches the /diff route below: the run dir comes from a DB row
1996
+ // via readRunArtifactText, and the relPath is the CONSTANT DIFF_PATCH_FILE. No
1997
+ // route here ever passes user input as a path.
1998
+ // ---------------------------------------------------------------------------
1999
+
2000
+ // The history key regex is an inline literal on every route in this family; this
2001
+ // block keeps that convention rather than introducing a shared constant the rest
2002
+ // of the file does not use.
2003
+ const commentsHistoryKey = (res, key) => {
2004
+ if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(key)) {
2005
+ res.status(404).json({ error: 'pipeline not found' });
2006
+ return null;
2007
+ }
2008
+ return key;
2009
+ };
2010
+ const commentsWorkspaceKey = (res, id) => {
2011
+ if (!WORKSPACE_KEY_RE.test(id)) { res.status(404).json({ error: 'pipeline not found' }); return null; }
2012
+ return `workspaces/${id}`;
2013
+ };
2014
+ const commentIdParam = (res, value) => {
2015
+ if (typeof value !== 'string' || !DC_ID_RE.test(value)) {
2016
+ res.status(400).json({ error: 'invalid comment id' });
2017
+ return null;
2018
+ }
2019
+ return value;
2020
+ };
2021
+ const commentsFail = (res, err) => res.status(500).json({ error: err && err.message ? err.message : String(err) });
2022
+
2023
+ /** The run row for a store key + id, or null after answering 404. */
2024
+ function commentRun(res, storeKey, id) {
2025
+ const row = lookupPipelineRow(storeKey, id);
2026
+ if (!row) { res.status(404).json({ error: 'pipeline not found' }); return null; }
2027
+ return row;
2028
+ }
2029
+
2030
+ async function commentsList(res, storeKey, id) {
2031
+ try {
2032
+ const row = commentRun(res, storeKey, id);
2033
+ if (!row) return;
2034
+ // The UI needs to know whether the '+' affordance may appear at all; a run
2035
+ // whose patch is gone (archived, or never captured) can only read and delete.
2036
+ const patchText = await readRunArtifactText(storeKey, row.id, DIFF_PATCH_FILE);
2037
+ res.json({
2038
+ comments: listDiffComments(storeKey, row.id),
2039
+ patchAvailable: !!patchText,
2040
+ // Section keys the protected-path floor will refuse whatever the line, so the
2041
+ // browser can drop the '+' up front instead of surfacing a 400 on submit. The
2042
+ // preset itself never leaves the server.
2043
+ protectedPaths: protectedSectionKeys(patchText),
2044
+ });
2045
+ } catch (err) { commentsFail(res, err); }
2046
+ }
2047
+
2048
+ async function commentsCreate(req, res, storeKey, id) {
2049
+ try {
2050
+ const row = commentRun(res, storeKey, id);
2051
+ if (!row) return;
2052
+ const body = req.body || {};
2053
+ const patchText = await readRunArtifactText(storeKey, row.id, DIFF_PATCH_FILE);
2054
+ // `!patchText` covers BOTH null (absent/unreadable) and '' (present but empty):
2055
+ // addDiffComment refuses the empty string too, and it must surface as 409, not
2056
+ // as the 400 an anchor failure would get.
2057
+ if (!patchText) {
2058
+ // 409, not 400: the request is well-formed, the RUN is no longer commentable.
2059
+ return res.status(409).json({ error: 'this run has no stored diff — comments cannot be created on it' });
2060
+ }
2061
+ const comment = addDiffComment({
2062
+ storeKey, pipelineId: row.id, patchText,
2063
+ project: body.project ?? null, path: body.path, side: body.side, line: body.line,
2064
+ body: body.body, author: 'user',
2065
+ });
2066
+ res.status(201).json({ comment });
2067
+ } catch (err) {
2068
+ if (err instanceof DiffCommentError) return badRequest(res, err.message);
2069
+ commentsFail(res, err);
2070
+ }
2071
+ }
2072
+
2073
+ /** A comment reached through a run URL must BELONG to that run — never id alone. */
2074
+ function commentOfRun(res, storeKey, id, cid) {
2075
+ const row = commentRun(res, storeKey, id);
2076
+ if (!row) return null;
2077
+ const comment = getDiffComment(cid);
2078
+ if (!comment || comment.storeKey !== storeKey || comment.pipelineId !== row.id) {
2079
+ res.status(404).json({ error: 'comment not found' });
2080
+ return null;
2081
+ }
2082
+ return comment;
2083
+ }
2084
+
2085
+ function commentsPatch(req, res, storeKey, id, cid) {
2086
+ try {
2087
+ // Existence BEFORE shape: an unknown run must 404 on every verb, including a
2088
+ // PATCH whose body happens to be malformed.
2089
+ if (!commentOfRun(res, storeKey, id, cid)) return;
2090
+ const raw = (req.body || {}).resolved;
2091
+ if (typeof raw !== 'boolean') return badRequest(res, 'resolved must be a boolean');
2092
+ res.json({ comment: setDiffCommentResolved(cid, raw) });
2093
+ } catch (err) { commentsFail(res, err); }
2094
+ }
2095
+
2096
+ function commentsDelete(res, storeKey, id, cid) {
2097
+ try {
2098
+ if (!commentOfRun(res, storeKey, id, cid)) return;
2099
+ deleteDiffComment(cid);
2100
+ res.json({ ok: true });
2101
+ } catch (err) { commentsFail(res, err); }
2102
+ }
2103
+
2104
+ app.get('/api/history/:key/:id/comments', async (req, res) => {
2105
+ const key = commentsHistoryKey(res, req.params.key); if (!key) return;
2106
+ await commentsList(res, key, req.params.id);
2107
+ });
2108
+ app.post('/api/history/:key/:id/comments', async (req, res) => {
2109
+ const key = commentsHistoryKey(res, req.params.key); if (!key) return;
2110
+ await commentsCreate(req, res, key, req.params.id);
2111
+ });
2112
+ app.patch('/api/history/:key/:id/comments/:cid', (req, res) => {
2113
+ const key = commentsHistoryKey(res, req.params.key); if (!key) return;
2114
+ const cid = commentIdParam(res, req.params.cid); if (!cid) return;
2115
+ commentsPatch(req, res, key, req.params.id, cid);
2116
+ });
2117
+ app.delete('/api/history/:key/:id/comments/:cid', (req, res) => {
2118
+ const key = commentsHistoryKey(res, req.params.key); if (!key) return;
2119
+ const cid = commentIdParam(res, req.params.cid); if (!cid) return;
2120
+ commentsDelete(res, key, req.params.id, cid);
2121
+ });
2122
+
2123
+ app.get('/api/workspaces/:id/runs/:runId/comments', async (req, res) => {
2124
+ const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
2125
+ await commentsList(res, key, req.params.runId);
2126
+ });
2127
+ app.post('/api/workspaces/:id/runs/:runId/comments', async (req, res) => {
2128
+ const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
2129
+ await commentsCreate(req, res, key, req.params.runId);
2130
+ });
2131
+ app.patch('/api/workspaces/:id/runs/:runId/comments/:cid', (req, res) => {
2132
+ const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
2133
+ const cid = commentIdParam(res, req.params.cid); if (!cid) return;
2134
+ commentsPatch(req, res, key, req.params.runId, cid);
2135
+ });
2136
+ app.delete('/api/workspaces/:id/runs/:runId/comments/:cid', (req, res) => {
2137
+ const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
2138
+ const cid = commentIdParam(res, req.params.cid); if (!cid) return;
2139
+ commentsDelete(res, key, req.params.runId, cid);
2140
+ });
2141
+
2142
+ // Unresolved counts for every run, for the History list pill. Its own endpoint
2143
+ // rather than a field on /api/history: that response has a localStorage skeleton
2144
+ // cache, so a cached paint would show a stale pill; and diff-comments-changed can
2145
+ // repaint pills from here without forcing a whole History reload.
2146
+ app.get('/api/diff-comments/counts', (_req, res) => {
2147
+ try { res.json({ counts: unresolvedCounts() }); } catch (err) { commentsFail(res, err); }
2148
+ });
2149
+
1541
2150
  // ---------------------------------------------------------------------------
1542
2151
  // GET /api/history/:key/:id/diff -> the run's persisted diff-patch.patch, inline
1543
- // (text/x-diff). Exists only for runs that reached done (results are built on
1544
- // the done path only); everything else 404s and the UI shows its empty state.
2152
+ // (text/x-diff). The route is status-agnostic and always has been: the artifact
2153
+ // exists for every run that reached a checkpoint AND changed something under it
2154
+ // the done path AND the stopped/error paths, which build results too (orchestrator
2155
+ // run() and resume()). A run stopped before its checkpoint has none, nor does one
2156
+ // that changed nothing (_buildResults writes neither artifact for an empty patch),
2157
+ // and neither does an archived one; all of those 404 and the UI shows its empty state.
1545
2158
  // Key validation mirrors the /log route (:1529); the artifact read follows the
1546
2159
  // recovery-patch route's readRunArtifactText pattern (:1408) — the log routes
1547
2160
  // themselves use the specialized readRunLogText. The relPath is the CONSTANT
@@ -1561,6 +2174,23 @@ app.get('/api/history/:key/:id/diff', async (req, res) => {
1561
2174
  }
1562
2175
  });
1563
2176
 
2177
+ // GET /api/history/:key/:id/artifact?rel= -> { rel, text } for ONE artifact the
2178
+ // run indexed (the End card's result chip). `rel` never reaches the FS: it only
2179
+ // selects among the pipeline's own artifacts rows (exact rel_path, else a path
2180
+ // suffix). Same key regex as /diff.
2181
+ app.get('/api/history/:key/:id/artifact', async (req, res) => {
2182
+ if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(req.params.key)) {
2183
+ return res.status(404).json({ error: 'pipeline not found' });
2184
+ }
2185
+ try {
2186
+ const hit = await resolveIndexedArtifact(req.params.key, req.params.id, req.query.rel);
2187
+ if (!hit) return res.status(404).json({ error: 'artifact not found' });
2188
+ res.json(hit);
2189
+ } catch (err) {
2190
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2191
+ }
2192
+ });
2193
+
1564
2194
  // ---------------------------------------------------------------------------
1565
2195
  // DELETE /api/runs/:id?projectKey=... (or ?projectDir=...)
1566
2196
  // ARCHIVE a FINISHED pipeline: reclaims everything on disk — its store folder,
@@ -2062,6 +2692,8 @@ app.get('/api/workspaces/:id/runs/:runId/log', async (req, res) => {
2062
2692
  }
2063
2693
  });
2064
2694
 
2695
+ // NOTE: the /comments twins for workspace runs are registered with their project
2696
+ // siblings up at the diff-comments block — the pair must be read together.
2065
2697
  app.get('/api/workspaces/:id/runs/:runId/diff', async (req, res) => {
2066
2698
  if (!WORKSPACE_KEY_RE.test(req.params.id)) {
2067
2699
  return res.status(404).json({ error: 'pipeline not found' });
@@ -2075,6 +2707,18 @@ app.get('/api/workspaces/:id/runs/:runId/diff', async (req, res) => {
2075
2707
  }
2076
2708
  });
2077
2709
 
2710
+ // The End-card result chip's workspace twin (see the project route above).
2711
+ app.get('/api/workspaces/:id/runs/:runId/artifact', async (req, res) => {
2712
+ if (!WORKSPACE_KEY_RE.test(req.params.id)) return res.status(404).json({ error: 'pipeline not found' });
2713
+ try {
2714
+ const hit = await resolveIndexedArtifact(`workspaces/${req.params.id}`, req.params.runId, req.query.rel);
2715
+ if (!hit) return res.status(404).json({ error: 'artifact not found' });
2716
+ res.json(hit);
2717
+ } catch (err) {
2718
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2719
+ }
2720
+ });
2721
+
2078
2722
  // ---------------------------------------------------------------------------
2079
2723
  // GET /api/settings -> { root, projectsRoot, projectsRootDefault, default }
2080
2724
  // root : the configured Worca CC data-root base, '' when unset
@@ -2106,6 +2750,8 @@ const settingsState = () => ({
2106
2750
  pipelineCostLimitUsd: pipelineCostLimitUsd(),
2107
2751
  totalCostLimitUsd: totalCostLimitUsd(),
2108
2752
  costLimitResetPeriod: costLimitResetPeriod(),
2753
+ askMaxTurns: askMaxTurns(),
2754
+ askMaxBudgetUsd: askMaxBudgetUsd(),
2109
2755
  });
2110
2756
 
2111
2757
  app.get('/api/settings', (_req, res) => {
@@ -2120,6 +2766,7 @@ app.post('/api/settings', async (req, res) => {
2120
2766
  const body = req.body || {};
2121
2767
  const has = (k) => Object.prototype.hasOwnProperty.call(body, k);
2122
2768
  const hasBudgetKey = has('pipelineCostLimitUsd') || has('totalCostLimitUsd') || has('costLimitResetPeriod');
2769
+ const hasAskKey = has('askMaxTurns') || has('askMaxBudgetUsd');
2123
2770
  // Normalize the budget keys first, then validate them as a SET before ANY write.
2124
2771
  // Each setter persists on its own, so a two-key POST whose second key is invalid
2125
2772
  // used to answer 400 with the first key already on disk, no budget-changed
@@ -2131,8 +2778,15 @@ app.post('/api/settings', async (req, res) => {
2131
2778
  if (has('costLimitResetPeriod')) {
2132
2779
  budget.costLimitResetPeriod = typeof body.costLimitResetPeriod === 'string' ? body.costLimitResetPeriod : '';
2133
2780
  }
2781
+ // Ask Worca per-turn guards (ask-worca-design.md §6.9): same set-validation
2782
+ // discipline. `null` is a VALUE for askMaxBudgetUsd (no cap) and must survive
2783
+ // normalisation; only undefined becomes a clear.
2784
+ const ask = {};
2785
+ if (has('askMaxTurns')) ask.askMaxTurns = body.askMaxTurns ?? '';
2786
+ if (has('askMaxBudgetUsd')) ask.askMaxBudgetUsd = body.askMaxBudgetUsd === undefined ? '' : body.askMaxBudgetUsd;
2134
2787
  try {
2135
2788
  assertCostLimitInputs(budget);
2789
+ assertAskLimitInputs(ask);
2136
2790
  if (has('chat')) await setChatPrefs(body.chat);
2137
2791
  if (has('projectsRoot')) {
2138
2792
  await setProjectsRoot(typeof body.projectsRoot === 'string' ? body.projectsRoot : '');
@@ -2140,9 +2794,11 @@ app.post('/api/settings', async (req, res) => {
2140
2794
  if (has('pipelineCostLimitUsd')) await setPipelineCostLimitUsd(budget.pipelineCostLimitUsd);
2141
2795
  if (has('totalCostLimitUsd')) await setTotalCostLimitUsd(budget.totalCostLimitUsd);
2142
2796
  if (has('costLimitResetPeriod')) await setCostLimitResetPeriod(budget.costLimitResetPeriod);
2143
- // Legacy contract: a POST that names no known key clears root. Budget keys
2144
- // must not trip it — a budget-only save would otherwise wipe the root.
2145
- if (has('root') || !(has('projectsRoot') || hasBudgetKey || has('chat'))) {
2797
+ if (has('askMaxTurns')) await setAskMaxTurns(ask.askMaxTurns);
2798
+ if (has('askMaxBudgetUsd')) await setAskMaxBudgetUsd(ask.askMaxBudgetUsd);
2799
+ // Legacy contract: a POST that names no known key clears root. Budget and ask
2800
+ // keys must not trip it — a budget-only or ask-only save would otherwise wipe the root.
2801
+ if (has('root') || !(has('projectsRoot') || hasBudgetKey || hasAskKey || has('chat'))) {
2146
2802
  await setWorcaRoot(typeof body.root === 'string' ? body.root : '');
2147
2803
  }
2148
2804
  if (hasBudgetKey) emitChanged('budget-changed');
@@ -2167,6 +2823,7 @@ app.get('/api/config', async (req, res) => {
2167
2823
  return res.json({
2168
2824
  config: { steps: {}, customModels: [] },
2169
2825
  models: await listModels(''), steps: agentSteps(), efforts: EFFORTS,
2826
+ subagentModels: SUBAGENT_MODEL_VALUES,
2170
2827
  });
2171
2828
  }
2172
2829
  const projectDir = resolveProjectDir(raw);
@@ -2185,6 +2842,10 @@ app.get('/api/config', async (req, res) => {
2185
2842
  ]);
2186
2843
  res.json({
2187
2844
  config, models, steps: agentSteps(), efforts: EFFORTS,
2845
+ // The sub-agent model policy vocabulary is a FIXED alias enum (the CLI's Task
2846
+ // tool refuses catalog ids), so it ships beside `efforts` rather than being
2847
+ // derived from `models`.
2848
+ subagentModels: SUBAGENT_MODEL_VALUES,
2188
2849
  });
2189
2850
  } catch (err) {
2190
2851
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
@@ -2198,6 +2859,7 @@ app.post('/api/config', async (req, res) => {
2198
2859
  try {
2199
2860
  await setStep(projectDir, body.step, {
2200
2861
  model: body.model, effort: body.effort, fanOut: body.fanOut, askQuestions: body.askQuestions,
2862
+ subagentModel: body.subagentModel,
2201
2863
  });
2202
2864
  // Respond with the FULL run-config (mirrors PATCH): setStep's return value is
2203
2865
  // the legacy {steps, customModels} view only, and clients assign the response
@@ -2219,31 +2881,55 @@ app.post('/api/config', async (req, res) => {
2219
2881
  // validates model/effort against the effective catalog exactly like setStep
2220
2882
  // (configurable-models-design.md §4.5) -> 400; setFeedbackCycles still COERCES
2221
2883
  // maxCycles to >= 1 (it never throws).
2222
- // body: { projectDir, workflowId, nodes?:{[id]:{model,effort}}, feedbacks?:{[id]:{maxCycles}}, activeWorkflowId? }
2884
+ // body: { projectDir, workflowId, nodes?:{[id]:{model,effort}}, feedbacks?:{[id]:{maxCycles}}, wires?:{[wireId]:{maxCycles}}, activeWorkflowId? }
2223
2885
  // ---------------------------------------------------------------------------
2224
2886
  app.patch('/api/config', async (req, res) => {
2225
2887
  const body = req.body || {};
2226
2888
  const projectDir = resolveProjectDir(body.projectDir);
2227
2889
  if (!projectDir) return badRequest(res, 'projectDir is required');
2228
2890
  const workflowId = typeof body.workflowId === 'string' ? body.workflowId.trim() : '';
2891
+ // MAJ-1: every arm below keys a normalized table by workflowId, and
2892
+ // readWorkflowsMap rebuilds a map from those keys — an id like '__proto__'
2893
+ // used to be persisted unchecked and then broke readRunConfig for the whole
2894
+ // project. isSafeWorkflowId is the store's own id rule, so the API can never
2895
+ // write an id the store would refuse. (The recovery route DELETE
2896
+ // /api/config/workflow stays deliberately ungated: an already-poisoned row
2897
+ // must still be clearable.)
2898
+ const workflowIdError = (what) => {
2899
+ if (!workflowId) return `workflowId is required to set ${what} config`;
2900
+ if (!isSafeWorkflowId(workflowId)) return 'invalid workflowId';
2901
+ return null;
2902
+ };
2229
2903
  try {
2230
2904
  if (body.nodes && typeof body.nodes === 'object') {
2231
- if (!workflowId) return badRequest(res, 'workflowId is required to set node config');
2905
+ const bad = workflowIdError('node');
2906
+ if (bad) return badRequest(res, bad);
2232
2907
  for (const [nodeId, sel] of Object.entries(body.nodes)) {
2233
2908
  await setNodeModel(projectDir, workflowId, nodeId, {
2234
2909
  model: sel && sel.model, effort: sel && sel.effort,
2235
2910
  fanOut: sel && sel.fanOut, askQuestions: sel && sel.askQuestions,
2911
+ subagentModel: sel && sel.subagentModel,
2236
2912
  });
2237
2913
  }
2238
2914
  }
2239
2915
  if (body.feedbacks && typeof body.feedbacks === 'object') {
2240
- if (!workflowId) return badRequest(res, 'workflowId is required to set feedback config');
2916
+ const bad = workflowIdError('feedback');
2917
+ if (bad) return badRequest(res, bad);
2241
2918
  for (const [fbId, sel] of Object.entries(body.feedbacks)) {
2242
2919
  await setFeedbackCycles(projectDir, workflowId, fbId, sel && sel.maxCycles);
2243
2920
  }
2244
2921
  }
2922
+ if (body.wires && typeof body.wires === 'object') {
2923
+ const bad = workflowIdError('wire');
2924
+ if (bad) return badRequest(res, bad);
2925
+ for (const [wireId, sel] of Object.entries(body.wires)) {
2926
+ await setWireCycles(projectDir, workflowId, wireId, sel && sel.maxCycles);
2927
+ }
2928
+ }
2245
2929
  if (typeof body.activeWorkflowId === 'string' && body.activeWorkflowId.trim()) {
2246
- await setActiveWorkflow(projectDir, body.activeWorkflowId.trim());
2930
+ const active = body.activeWorkflowId.trim();
2931
+ if (!isSafeWorkflowId(active)) return badRequest(res, 'invalid workflowId');
2932
+ await setActiveWorkflow(projectDir, active);
2247
2933
  }
2248
2934
  const config = await readRunConfig(projectDir);
2249
2935
  res.json({ config });
@@ -2330,6 +3016,7 @@ const pluginModelsPayload = () => {
2330
3016
  k, typeof v === 'string' ? maskEnvValue(v) : `(secret: ${v.secret})`,
2331
3017
  ])),
2332
3018
  secrets: status.filter((s) => m.secrets.includes(s.key)),
3019
+ ...(m.cost ? { cost: m.cost } : {}), // manifest-pinned pricing — config, never a credential
2333
3020
  ...(flagged.has(m.id.toLowerCase()) ? { costUnreliable: true } : {}),
2334
3021
  };
2335
3022
  });
@@ -2342,7 +3029,7 @@ app.get('/api/models', (req, res) => {
2342
3029
  app.post('/api/models', async (req, res) => {
2343
3030
  const b = req.body || {};
2344
3031
  try {
2345
- const model = await addGlobalModel({ id: b.id, label: b.label, efforts: b.efforts, env: b.env });
3032
+ const model = await addGlobalModel({ id: b.id, label: b.label, efforts: b.efforts, env: b.env, cost: b.cost });
2346
3033
  res.json({ model: maskedGlobalModel(model), models: maskedGlobalModels() });
2347
3034
  } catch (err) {
2348
3035
  // addGlobalModel throws only on validation (empty/dup id, unknown effort,
@@ -2425,6 +3112,10 @@ app.post('/api/models/export-plugin', async (req, res) => {
2425
3112
  ...(entry.label !== entry.id ? { label: entry.label } : {}),
2426
3113
  ...(entry.efforts.length && entry.efforts.length !== EFFORTS.length ? { efforts: entry.efforts } : {}),
2427
3114
  ...(Object.keys(env).length ? { env } : {}),
3115
+ // Pricing travels with the model. It is configuration, not a credential —
3116
+ // and a shared on-prem model is precisely one the CLI would otherwise
3117
+ // price by NAME on every machine that installs the plugin.
3118
+ ...(entry.cost ? { cost: entry.cost } : {}),
2428
3119
  });
2429
3120
  }
2430
3121
 
@@ -2495,7 +3186,7 @@ app.patch('/api/models/:id', async (req, res) => {
2495
3186
  env = Object.fromEntries(Object.entries(env).filter(([, v]) => !isMaskedEcho(v)));
2496
3187
  }
2497
3188
  try {
2498
- const model = await updateGlobalModel(req.params.id, { label: b.label, efforts: b.efforts, env });
3189
+ const model = await updateGlobalModel(req.params.id, { label: b.label, efforts: b.efforts, env, cost: b.cost });
2499
3190
  res.json({ model: maskedGlobalModel(model), models: maskedGlobalModels() });
2500
3191
  } catch (err) {
2501
3192
  // updateGlobalModel throws only on validation (unknown id, unknown effort,
@@ -2534,6 +3225,35 @@ app.delete('/api/models/:id', async (req, res) => {
2534
3225
  }
2535
3226
  });
2536
3227
 
3228
+ // Live connectivity check for a catalog model — the Models-view Test button.
3229
+ // Explicit user action only (one real, tiny API call against wherever the
3230
+ // model routes). Caller mistakes get an HTTP status; the test OUTCOME rides a
3231
+ // 200 envelope, same convention as POST /api/chat/test. Ids resolve global
3232
+ // first, then plugin — resolveModelEnv's precedence.
3233
+ const modelTestsInFlight = new Set();
3234
+ app.post('/api/models/:id/test', async (req, res) => {
3235
+ const id = String(req.params.id);
3236
+ const lc = id.toLowerCase();
3237
+ const global = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
3238
+ const plugin = global ? null : listPluginModels().find((m) => m.id.toLowerCase() === lc);
3239
+ if (!global && !plugin) return res.status(404).json({ error: `unknown model id ${JSON.stringify(id)}` });
3240
+ if (plugin && plugin.secrets.length) {
3241
+ // Don't burn a spawn guaranteed to fail — resolveModelEnv drops unset secrets.
3242
+ const unset = pluginModelSecretStatus(plugin.plugin)
3243
+ .filter((s) => plugin.secrets.includes(s.key) && !s.set).map((s) => s.key);
3244
+ if (unset.length) {
3245
+ return badRequest(res, `secret ${unset.join(', ')} is not set — configure it in the plugin's settings`);
3246
+ }
3247
+ }
3248
+ if (modelTestsInFlight.has(lc)) return badRequest(res, 'test already running for this model');
3249
+ modelTestsInFlight.add(lc);
3250
+ try {
3251
+ res.json(await testModel(id));
3252
+ } finally {
3253
+ modelTestsInFlight.delete(lc);
3254
+ }
3255
+ });
3256
+
2537
3257
  // ---------------------------------------------------------------------------
2538
3258
  // Workflow templates (global store at ~/.worca-cc/workflows). Topology only;
2539
3259
  // model/effort/cycles live in per-project run-config. CRUD mirrors the
@@ -2550,6 +3270,10 @@ function nodeDefaultsError(raw, models, where) {
2550
3270
  const effort = typeof raw.effort === 'string' ? raw.effort.trim() : '';
2551
3271
  const entry = model ? models.find((m) => m.id === model) : null;
2552
3272
  if (model && !entry) return `unknown model "${model}"`;
3273
+ // subagentModel is a fixed alias enum, NOT a catalog id: validated via the
3274
+ // shared helper so a typo is a 400 with the same message every writer uses.
3275
+ const subIssue = subagentModelIssue(raw.subagentModel);
3276
+ if (subIssue) return subIssue;
2553
3277
  if (!effort) return '';
2554
3278
  if (!EFFORTS.includes(effort)) return `unknown effort "${effort}"`;
2555
3279
  if (!entry) return 'select a model before choosing an effort';
@@ -2557,11 +3281,15 @@ function nodeDefaultsError(raw, models, where) {
2557
3281
  return '';
2558
3282
  }
2559
3283
 
2560
- app.get('/api/workflows', async (_req, res) => {
3284
+ app.get('/api/workflows', async (req, res) => {
2561
3285
  try {
2562
- // The built-in default is never persisted to the user store; callers
2563
- // prepend it (CONTRACT: GET -> { workflows: [DEFAULT_WORKFLOW, ...listWorkflows()] }).
2564
- res.json({ workflows: [DEFAULT_WORKFLOW, ...(await listWorkflows())] }); // CONV-1: await
3286
+ if (isTruthy(req.query.archived)) {
3287
+ const all = await listWorkflows({ includeArchived: true });
3288
+ return res.json({ workflows: all.filter((w) => w.archivedAt) });
3289
+ }
3290
+ // CONTRACT: [ GRAPH_DEFAULT_WORKFLOW, ...listWorkflows() ]. The built-in is
3291
+ // never a persisted row (listWorkflows filters its id), so it cannot appear twice.
3292
+ res.json({ workflows: [GRAPH_DEFAULT_WORKFLOW, ...(await listWorkflows())] });
2565
3293
  } catch (err) {
2566
3294
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
2567
3295
  }
@@ -2569,51 +3297,80 @@ app.get('/api/workflows', async (_req, res) => {
2569
3297
 
2570
3298
  app.get('/api/workflows/:id', async (req, res) => {
2571
3299
  try {
2572
- const tpl = await readWorkflow(req.params.id); // CONV-1: await; returns DEFAULT_WORKFLOW for "wf_default"
2573
- if (!tpl) return res.status(404).json({ error: 'workflow not found' });
2574
- res.json(tpl);
3300
+ // ONE gate, ONE message: an archived id explains itself instead of reading
3301
+ // as a plain 404 (assertRunnableWorkflow owns both texts). checkGraph:false
3302
+ // this is a READ (the Composer's Open): a template stranded by an agent-port
3303
+ // edit must still load, or the user could never repair it. The RUN path keeps
3304
+ // the graph check.
3305
+ res.json(await assertRunnableWorkflow(req.params.id, { checkGraph: false }));
2575
3306
  } catch (err) {
3307
+ if (err && (err.code === 'NOT_FOUND' || err.code === 'ARCHIVED')) {
3308
+ return res.status(404).json({ error: err.message });
3309
+ }
2576
3310
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
2577
3311
  }
2578
3312
  });
2579
3313
 
2580
3314
  app.post('/api/workflows', async (req, res) => {
2581
3315
  const body = req.body || {};
2582
- // Build the candidate template from the editor payload (topology only).
2583
- const tpl = {
3316
+ // The v1 pipeline format is RETIRED: only graphs are accepted (spec §10.2).
3317
+ // The whole v1 arm (its steps-borne node defaults, validateWorkflow and
3318
+ // writeWorkflow) died with it — nothing reaches the v1 store through the API.
3319
+ if (body.version !== 2) {
3320
+ return badRequest(res, 'v1 pipeline templates are no longer accepted — save a graph (version 2)');
3321
+ }
3322
+ // ── v2 graph save ──────────────────────────────────────────────────────────
3323
+ // The 422 body is the SHARED validator's issue list, by construction: the
3324
+ // composer renders exactly what it would have computed locally, so the server
3325
+ // and the client can never disagree about why a graph is illegal.
3326
+ const graph = {
3327
+ id: typeof body.id === 'string' ? body.id : undefined,
2584
3328
  name: typeof body.name === 'string' ? body.name.trim() : '',
2585
- domain: typeof body.domain === 'string' ? body.domain : undefined, // writeWorkflow normDomain → 'general' if absent/blank/malformed
2586
- steps: Array.isArray(body.steps) ? body.steps : [],
2587
- feedbacks: Array.isArray(body.feedbacks) ? body.feedbacks : [],
3329
+ domain: typeof body.domain === 'string' ? body.domain : undefined,
3330
+ nodes: Array.isArray(body.nodes) ? body.nodes : [],
3331
+ wires: Array.isArray(body.wires) ? body.wires : [],
3332
+ ...(body.canvas && typeof body.canvas === 'object' ? { canvas: body.canvas } : {}),
2588
3333
  };
2589
- if (!tpl.name) return badRequest(res, 'name is required');
3334
+ if (!graph.name) return badRequest(res, 'name is required');
2590
3335
  try {
2591
- // Per-node defaults ride along in steps (§4.4); hold them to the same catalog
2592
- // rules as PATCH .../defaults so an imported template cannot smuggle in a
2593
- // model id that no per-project override would be allowed to name.
3336
+ // Catalog validation FIRST: a v2 node's `config` IS its defaults block (§4),
3337
+ // so a value the per-project override could not name must not ride in
3338
+ // through a template save. nodeDefaultsError checks the tunables (model +
3339
+ // effort against the catalog, subagentModel against the alias enum), so
3340
+ // only AGENT_TUNABLES are handed to it — topology keys (awaitAll, arity,
3341
+ // planStoreSeed) never are.
2594
3342
  const models = await listModels('');
2595
- for (const group of tpl.steps) {
2596
- for (const node of Array.isArray(group) ? group : []) {
2597
- if (!node || typeof node !== 'object' || node.defaults === undefined) continue;
2598
- const err = nodeDefaultsError(node.defaults, models, `node "${node.id}"`);
2599
- if (err) return badRequest(res, err);
2600
- }
3343
+ for (const n of graph.nodes) {
3344
+ if (!n || n.kind !== 'agent' || !n.config || typeof n.config !== 'object') continue;
3345
+ const picked = Object.fromEntries(
3346
+ AGENT_TUNABLES.filter((k) => k in n.config).map((k) => [k, n.config[k]]));
3347
+ const bad = nodeDefaultsError(picked, models, `node "${n.id}"`);
3348
+ if (bad) return badRequest(res, bad);
2601
3349
  }
2602
- const registry = loadAgentRegistry(AGENTS_DIR);
2603
- const { ok, errors, warnings } = validateWorkflow(tpl, registry);
2604
- if (!ok) return res.status(400).json({ error: 'invalid workflow', errors, warnings });
2605
- // writeWorkflow stamps id/createdAt/updatedAt and writes atomically (temp+rename).
2606
- const workflow = await writeWorkflow(tpl); // CONV-1: await
2607
- res.status(201).json({ workflow, warnings });
3350
+ const portsFn = registryPortsFn(loadAgentRegistry(AGENTS_DIR));
3351
+ const { errors, warnings } = validateGraph({ ...graph, version: 2 }, portsFn);
3352
+ if (errors.length) return res.status(422).json({ error: 'invalid graph', errors, warnings });
3353
+ // rejectCollision (MAJ-5): the body carried no id, so wf_<slug(name)> is a
3354
+ // GUESS it must never silently replace a pipeline the user can see.
3355
+ const workflow = await writeGraphWorkflow(graph, { rejectCollision: true });
3356
+ return res.status(201).json({ workflow, warnings });
2608
3357
  } catch (err) {
2609
- res.status(500).json({ error: err && err.message ? err.message : String(err) });
3358
+ // C-3: a name that slugs onto the reserved wf_default is a caller error, not
3359
+ // a server fault — 422, the same code the validator's refusal uses. MAJ-5: a
3360
+ // minted id already in use is a 409 carrying that id, so the dialog can offer
3361
+ // rename/overwrite. Both bodies carry NO issues/errors array on purpose:
3362
+ // app.js's saveWorkflow maps `error` straight into the save dialog's message
3363
+ // line, verbatim.
3364
+ if (err && err.code === 'RESERVED_NAME') return res.status(422).json({ error: err.message });
3365
+ if (err && err.code === 'ID_TAKEN') return res.status(409).json({ error: err.message, id: err.id });
3366
+ return res.status(500).json({ error: err && err.message ? err.message : String(err) });
2610
3367
  }
2611
3368
  });
2612
3369
 
2613
3370
  // ---------------------------------------------------------------------------
2614
3371
  // PATCH /api/workflows/:id/defaults -> set the template's per-node defaults
2615
3372
  // (newpipeline-ux-design.md §4.4). body: { defaults: { [nodeId]: {model?, effort?,
2616
- // fanOut?, askQuestions?} | null } }; null (or an empty block) clears a node, an
3373
+ // fanOut?, askQuestions?, subagentModel?} | null } }; null (or an empty block) clears a node, an
2617
3374
  // absent node keeps what it has. Model/effort validate against the PROJECT-LESS
2618
3375
  // catalog (predefined ⊕ global ⊕ plugin) — defaults are global, so a legacy
2619
3376
  // per-project custom model is deliberately not a valid default.
@@ -2750,32 +3507,583 @@ app.delete('/api/guardrails/:id', async (req, res) => {
2750
3507
  }
2751
3508
  });
2752
3509
 
3510
+ // ---------------------------------------------------------------------------
3511
+ // Ask Worca (ask-worca-design.md §8). askJobs is SEPARATE from the runs Map —
3512
+ // the client's Running badge counts runs entries, and a thread id is the
3513
+ // subscription key (§8.3). No store/home access at import time (the chatCtx
3514
+ // rule): the Maps are bare and every store call lives inside a handler or
3515
+ // bootMaintenance.
3516
+ // ---------------------------------------------------------------------------
3517
+ const askJobs = new Map(); // threadId -> {turn, messageId, userMessageId, events, seq, status, startedAt, graceTimer}
3518
+ // Threads whose DELETE is past its first await (worktree removal spawns git):
3519
+ // POST /messages refuses them so no turn can start against rows that are
3520
+ // about to cascade (review of PR #376 — a turn started in that window outlived
3521
+ // the delete as a live job holding a global slot).
3522
+ const askDeleting = new Set();
3523
+ const askFollowers = new Map(); // threadId -> Set<{detach}>
3524
+ const ASK_JOB_MAX_BUFFER = 5000; // same arithmetic as MAX_BUFFER: deltas dominate; eviction ⇒ client seq-gap re-sync
3525
+
3526
+ function askInFlight(threadId) {
3527
+ const job = askJobs.get(threadId);
3528
+ return job && job.status === 'running' ? job : null;
3529
+ }
3530
+
3531
+ function askRunningCount() {
3532
+ let n = 0;
3533
+ for (const job of askJobs.values()) if (job.status === 'running') n += 1;
3534
+ return n;
3535
+ }
3536
+
3537
+ /** hello payload: running turns only (§8.2). A job whose slot was just
3538
+ * reserved (messageId still null — the message route's atomic reservation,
3539
+ * Task 6) is skipped: it becomes visible once its assistant row exists. */
3540
+ function askHello() {
3541
+ const out = [];
3542
+ for (const [threadId, job] of askJobs.entries()) {
3543
+ if (job.status === 'running' && job.messageId) out.push({ threadId, messageId: job.messageId });
3544
+ }
3545
+ return out;
3546
+ }
3547
+
3548
+ /** Replay a job's stamped ring buffer to one socket. No state snapshot — the
3549
+ * REST thread GET is the snapshot; the client dedupes by seq (§6.6). */
3550
+ function replayAskJob(ws, job) {
3551
+ for (const ev of job.events) send(ws, ev);
3552
+ }
3553
+
3554
+ /** The stamping closure (§17: reducer frames are BARE; the server stamps).
3555
+ * Shared by the turn's own ask-start/ask-done/ask-error and every reducer
3556
+ * frame, so ALL job frames are buffered, replayed and seq-ordered alike. */
3557
+ function stampAskFrames(threadId, job) {
3558
+ return (bare) => {
3559
+ const frame = { ...bare, threadId, messageId: job.messageId, seq: ++job.seq };
3560
+ job.events.push(frame);
3561
+ if (job.events.length > ASK_JOB_MAX_BUFFER) job.events.splice(0, job.events.length - ASK_JOB_MAX_BUFFER);
3562
+ broadcast(frame);
3563
+ };
3564
+ }
3565
+
3566
+ /** 400 on shape (spec §8.1 — a DELIBERATE divergence from the house 404-on-
3567
+ * malformed-param style), null-return contract like badRequest. */
3568
+ function askIdParam(res, value, kind) {
3569
+ if (typeof value !== 'string' || !ASK_ID_RE.test(value)) {
3570
+ res.status(400).json({ error: `invalid ${kind} id` });
3571
+ return null;
3572
+ }
3573
+ return value;
3574
+ }
3575
+
3576
+ app.get('/api/ask/threads', (req, res) => {
3577
+ try {
3578
+ const raw = Number.parseInt(String(req.query.limit ?? ''), 10);
3579
+ const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 200) : 50;
3580
+ const threads = askListThreads({ limit }).map((t) => ({ ...t, inFlight: !!askInFlight(t.id) }));
3581
+ res.json({ threads });
3582
+ } catch (err) {
3583
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3584
+ }
3585
+ });
3586
+
3587
+ app.post('/api/ask/threads', (req, res) => {
3588
+ try {
3589
+ const body = req.body || {};
3590
+ let title = null;
3591
+ if (body.title !== undefined && body.title !== null && body.title !== '') {
3592
+ if (typeof body.title !== 'string' || body.title.length > 120) {
3593
+ return badRequest(res, 'title must be a string of at most 120 characters');
3594
+ }
3595
+ title = body.title.trim() || null;
3596
+ }
3597
+ const thread = askCreateThread();
3598
+ if (title) askUpdateThread(thread.id, { title });
3599
+ res.status(201).json({ thread: askGetThread(thread.id) });
3600
+ } catch (err) {
3601
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3602
+ }
3603
+ });
3604
+
3605
+ app.get('/api/ask/threads/:id', (req, res) => {
3606
+ const id = askIdParam(res, req.params.id, 'thread');
3607
+ if (!id) return;
3608
+ try {
3609
+ const thread = askGetThread(id);
3610
+ if (!thread) return res.status(404).json({ error: 'thread not found' });
3611
+ const job = askInFlight(id);
3612
+ res.json({
3613
+ thread,
3614
+ messages: askListMessages(id),
3615
+ attachments: askListAttachments(id),
3616
+ runLinks: askListRunLinks(id),
3617
+ // P4 §10: the SAME narrow envelope the list_worktrees MCP tool returns —
3618
+ // never the full row (threadId/projectDir/updatedAt stay server-side).
3619
+ worktrees: askListWorktrees(id).map((w) => ({
3620
+ worktreeId: w.worktreeId, projectKey: w.projectKey, ref: w.ref,
3621
+ commit: w.commit, path: w.path, createdAt: w.createdAt,
3622
+ })),
3623
+ inFlight: job && job.messageId ? { messageId: job.messageId } : null, // null while the slot is only reserved
3624
+ });
3625
+ } catch (err) {
3626
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3627
+ }
3628
+ });
3629
+
3630
+ app.patch('/api/ask/threads/:id', (req, res) => {
3631
+ const id = askIdParam(res, req.params.id, 'thread');
3632
+ if (!id) return;
3633
+ try {
3634
+ const raw = (req.body || {}).title;
3635
+ if (typeof raw !== 'string' || !raw.trim() || raw.length > 120) {
3636
+ return badRequest(res, 'title must be a non-empty string of at most 120 characters');
3637
+ }
3638
+ const thread = askUpdateThread(id, { title: raw.trim() });
3639
+ if (!thread) return res.status(404).json({ error: 'thread not found' });
3640
+ res.json({ thread });
3641
+ } catch (err) {
3642
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3643
+ }
3644
+ });
3645
+
3646
+ // §7.5 order: abort the in-flight turn -> detach followers -> remove the chat's
3647
+ // worktrees git-properly -> delete the row (tx + cascades) + rm -rf inside
3648
+ // deleteThread -> drop the job entry.
3649
+ app.delete('/api/ask/threads/:id', async (req, res) => {
3650
+ const id = askIdParam(res, req.params.id, 'thread');
3651
+ if (!id) return;
3652
+ try {
3653
+ if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
3654
+ askDeleting.add(id);
3655
+ const stopJob = () => {
3656
+ const job = askJobs.get(id);
3657
+ if (job && job.turn && typeof job.turn.stop === 'function') {
3658
+ try { job.turn.stop(); } catch { /* best-effort */ }
3659
+ }
3660
+ return job;
3661
+ };
3662
+ stopJob();
3663
+ const followers = askFollowers.get(id);
3664
+ if (followers) {
3665
+ for (const f of [...followers]) {
3666
+ try { f.detach(); } catch { /* best-effort */ }
3667
+ }
3668
+ askFollowers.delete(id);
3669
+ }
3670
+ // P4 §5: git-proper removal of every worktree BEFORE the row cascade — the
3671
+ // rmSync inside askDeleteThread alone would leave stale `git worktree`
3672
+ // registrations in the source repos. Never throws (best-effort per row).
3673
+ await askRemoveThreadWorktrees(id);
3674
+ // Re-read the job AFTER the await: askDeleting blocks new turns, but a turn
3675
+ // that was already mid-start is stopped here rather than left running.
3676
+ const job = stopJob();
3677
+ askDeleteThread(id);
3678
+ if (job) {
3679
+ if (job.graceTimer) clearTimeout(job.graceTimer);
3680
+ askJobs.delete(id);
3681
+ }
3682
+ res.json({ ok: true });
3683
+ } catch (err) {
3684
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3685
+ } finally {
3686
+ askDeleting.delete(id);
3687
+ }
3688
+ });
3689
+
3690
+ // P4 §10: manual worktree delete from the panel. Allowed while a turn is in
3691
+ // flight — the model's next operation on it gets a clean tool error.
3692
+ app.delete('/api/ask/threads/:id/worktrees/:wtId', async (req, res) => {
3693
+ const id = askIdParam(res, req.params.id, 'thread');
3694
+ if (!id) return;
3695
+ const wtId = askIdParam(res, req.params.wtId, 'worktree');
3696
+ if (!wtId) return;
3697
+ try {
3698
+ if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
3699
+ const out = await askRemoveWorktree({ threadId: id, wtId });
3700
+ res.json(out);
3701
+ } catch (err) {
3702
+ if (err && err.name === 'AskWorktreeError') return res.status(404).json({ error: err.message });
3703
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3704
+ }
3705
+ });
3706
+
3707
+ app.get('/api/ask/threads/:id/attachments/:attId', (req, res) => {
3708
+ const id = askIdParam(res, req.params.id, 'thread');
3709
+ if (!id) return;
3710
+ const attId = askIdParam(res, req.params.attId, 'attachment');
3711
+ if (!attId) return;
3712
+ try {
3713
+ if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
3714
+ const att = askReadAttachmentText(id, attId);
3715
+ if (!att) return res.status(404).json({ error: 'attachment not found' });
3716
+ res.set('X-Content-Type-Options', 'nosniff');
3717
+ res.set('Content-Disposition', 'inline');
3718
+ res.type('text/plain; charset=utf-8').send(att.text);
3719
+ } catch (err) {
3720
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3721
+ }
3722
+ });
3723
+
3724
+ // D8/§8.1: the chat model catalog. Fresh per request (the /api/config
3725
+ // precedent) — a cache would go stale against global-model edits.
3726
+ app.get('/api/ask/models', async (_req, res) => {
3727
+ try {
3728
+ res.json(await askCatalog());
3729
+ } catch (err) {
3730
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3731
+ }
3732
+ });
3733
+
3734
+ /** lookupPipelineRow/findPipelineRowById return the RAW `SELECT * FROM pipelines`
3735
+ * row: snake_case columns, and `branch` is a JSON DOCUMENT
3736
+ * ({source, feature, worktreeDir, …}), not a branch name. Reading
3737
+ * row.startedAt/row.branch directly loses the date and pastes a JSON blob into
3738
+ * the [worca context] line (dry-run-verified). */
3739
+ function askRunFromPipelineRow(row) {
3740
+ let branchObj = null;
3741
+ if (typeof row.branch === 'string') {
3742
+ try { branchObj = JSON.parse(row.branch); } catch { branchObj = null; }
3743
+ } else if (row.branch && typeof row.branch === 'object') {
3744
+ branchObj = row.branch;
3745
+ }
3746
+ const branch = branchObj && typeof branchObj.feature === 'string'
3747
+ ? branchObj.feature
3748
+ : (typeof branchObj === 'string' ? branchObj : null);
3749
+ return {
3750
+ id: row.id,
3751
+ title: row.title || '',
3752
+ status: row.status || '',
3753
+ startedAt: row.started_at || row.updated_at || '',
3754
+ branch,
3755
+ };
3756
+ }
3757
+
3758
+ /** Resolve the VALIDATED client context into the server-side shape
3759
+ * buildContextHeader consumes (§6.5: server-resolved rows only — never
3760
+ * client-supplied titles or paths). Every lookup is individually guarded:
3761
+ * a vanished row degrades to an absent header line, never a 500. */
3762
+ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], currentMessageId = null) {
3763
+ const out = { now: new Date().toISOString() };
3764
+ if (ctx.view) out.view = ctx.view;
3765
+ if (ctx.diffPath) out.diffPath = ctx.diffPath; // client-supplied, already length-checked by validateClientContext
3766
+ try {
3767
+ if (ctx.projectKey || ctx.projectDir) {
3768
+ const projects = await listProjects();
3769
+ const p = projects.find((x) =>
3770
+ (ctx.projectKey && x.key === ctx.projectKey) || (ctx.projectDir && x.path === ctx.projectDir));
3771
+ if (p) out.project = { name: p.name, key: p.key };
3772
+ }
3773
+ } catch { /* absent line */ }
3774
+ try {
3775
+ if (ctx.workspaceId) {
3776
+ const ws = await readWorkspace(ctx.workspaceId);
3777
+ if (ws) {
3778
+ // readWorkspace returns {id, name, projectPaths, projectKeys, …} — there
3779
+ // is NO per-member name object (the {projectName} shape is a local
3780
+ // /api/run construction, ui/server.mjs:894). Member display names are
3781
+ // the path basenames, same as that precedent.
3782
+ out.workspace = {
3783
+ name: ws.name, id: ws.id,
3784
+ members: (ws.projectPaths || []).map((p) => path.basename(p)).filter(Boolean),
3785
+ };
3786
+ }
3787
+ }
3788
+ } catch { /* absent line */ }
3789
+ try {
3790
+ if (ctx.pipelineId) {
3791
+ const key = ctx.workspaceId ? `workspaces/${ctx.workspaceId}` : out.project?.key;
3792
+ const row = (key ? lookupPipelineRow(key, ctx.pipelineId) : null) || findPipelineRowById(ctx.pipelineId);
3793
+ if (row) out.run = askRunFromPipelineRow(row);
3794
+ } else if (ctx.runId && runs.has(ctx.runId)) {
3795
+ const entry = runs.get(ctx.runId);
3796
+ out.run = {
3797
+ id: entry.pipelineId || ctx.runId.slice(0, 8), title: entry.title || '',
3798
+ status: entry.status || '', startedAt: entry.startedAt || '', branch: null,
3799
+ };
3800
+ }
3801
+ } catch { /* absent line */ }
3802
+ // (the ctx.runId branch reads the LIVE runs-Map entry, which really is
3803
+ // camelCase — only the DB pipeline row needs askRunFromPipelineRow)
3804
+ try {
3805
+ const links = askListRunLinks(threadId).slice(0, ASK_LIMITS.headerRuns).map((l) => {
3806
+ const live = runs.get(l.runId);
3807
+ return {
3808
+ id: l.pipelineId || l.runId.slice(0, 8),
3809
+ title: (live && live.title) || '', status: l.status || (live && live.status) || '',
3810
+ phase: l.phase || '',
3811
+ };
3812
+ });
3813
+ if (links.length) out.linkedRuns = links;
3814
+ const cards = [];
3815
+ for (const m of askListMessages(threadId)) {
3816
+ if (!Array.isArray(m.blocks)) continue;
3817
+ for (const b of m.blocks) {
3818
+ if (b && b.kind === 'card') {
3819
+ cards.push({
3820
+ id: b.id, state: b.state, workflowId: b.card && b.card.workflowId,
3821
+ targetName: (b.card && (b.card.projectName || b.card.workspaceName)) || '',
3822
+ });
3823
+ }
3824
+ }
3825
+ }
3826
+ if (cards.length) out.cards = cards.slice(-ASK_LIMITS.headerCards);
3827
+ // §6.5: the CURRENT message's non-inlined files, then EARLIER attachments
3828
+ // newest first — inlined current files must not be double-listed, so the
3829
+ // earlier set excludes the whole current message, not just `listed` ids.
3830
+ const earlier = askListAttachments(threadId)
3831
+ .filter((a) => !currentMessageId || a.messageId !== currentMessageId)
3832
+ .slice(-ASK_LIMITS.headerAttachments)
3833
+ .reverse()
3834
+ .map((a) => ({ id: a.id, name: a.name, bytes: a.bytes }));
3835
+ const atts = [...listedAttachments.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes })), ...earlier];
3836
+ if (atts.length) out.attachments = atts.slice(0, ASK_LIMITS.headerAttachments);
3837
+ } catch { /* absent lines */ }
3838
+ return out;
3839
+ }
3840
+
3841
+ /** R-F: whenever mock mode is on, EVERY ask spawn carries markers. The card is
3842
+ * the mock propose_run INPUT, derived from page context so a seeded project/
3843
+ * workspace validates and an empty context exercises the rejection notice. */
3844
+ function mockAskCard(ctx = {}, text = '') {
3845
+ const target = ctx.workspaceId
3846
+ ? { workspaceId: ctx.workspaceId }
3847
+ : { projectKey: ctx.projectKey || 'mock-project-00000000' };
3848
+ return { ...target, workflowId: 'wf_default', guardrailsId: 'normal', brief: text.slice(0, 200) || 'Mock run' };
3849
+ }
3850
+
3851
+ app.post('/api/ask/threads/:id/messages', async (req, res) => {
3852
+ const id = askIdParam(res, req.params.id, 'thread');
3853
+ if (!id) return;
3854
+ try {
3855
+ const thread = askGetThread(id);
3856
+ if (!thread) return res.status(404).json({ error: 'thread not found' });
3857
+ if (askDeleting.has(id)) return res.status(409).json({ error: 'thread is being deleted' });
3858
+ if (askInFlight(id)) return res.status(409).json({ error: 'turn in flight' });
3859
+ // Budget gate (F6), same figure /api/run enforces: Ask spend is folded into the
3860
+ // total window (cost-budget.mjs totalWindowSpendUsd), so chat must stop at
3861
+ // the cap it helps fill instead of spending past it while pipelines are 403'd
3862
+ // (review of PR #376).
3863
+ const budget = budgetStatus();
3864
+ if (budget.blocked) return res.status(403).json({ error: 'total cost limit reached', budget });
3865
+ if (askRunningCount() >= ASK_LIMITS.turnsGlobal) {
3866
+ return res.status(429).json({ error: `at most ${ASK_LIMITS.turnsGlobal} turns may run at once` });
3867
+ }
3868
+ const body = req.body || {};
3869
+ const text = typeof body.text === 'string' ? body.text : '';
3870
+ if (!text.trim()) return badRequest(res, 'text is required');
3871
+ const mv = await validateModelEffort(body.model, body.effort);
3872
+ if (!mv.ok) return badRequest(res, mv.error);
3873
+ const cv = validateClientContext(body.context);
3874
+ if (!cv.ok) return badRequest(res, cv.error);
3875
+
3876
+ // §7.3 — validate EVERY attachment before ANY write (all-or-nothing).
3877
+ const files = [];
3878
+ if (body.attachments !== undefined) {
3879
+ if (!Array.isArray(body.attachments)) return badRequest(res, 'attachments must be an array');
3880
+ if (body.attachments.length > ASK_LIMITS.attachment.maxFiles) {
3881
+ return badRequest(res, `at most ${ASK_LIMITS.attachment.maxFiles} attachments per message`);
3882
+ }
3883
+ const dec = new TextDecoder('utf-8', { fatal: true });
3884
+ for (const a of body.attachments) {
3885
+ const name = a && typeof a.name === 'string' ? a.name : '';
3886
+ const dot = name.lastIndexOf('.');
3887
+ const ext = dot === -1 ? '' : name.slice(dot).toLowerCase();
3888
+ if (!ASK_LIMITS.attachment.extensions.includes(ext)) {
3889
+ return badRequest(res, `attachment type not allowed: ${name || '(unnamed)'}`);
3890
+ }
3891
+ const raw = typeof a.dataBase64 === 'string' ? a.dataBase64 : '';
3892
+ const buf = raw ? Buffer.from(raw, 'base64') : Buffer.alloc(0);
3893
+ if (!buf.length) return badRequest(res, `attachment is empty or not valid base64: ${name}`);
3894
+ if (buf.length > ASK_LIMITS.attachment.maxBytesPerFile) {
3895
+ return res.status(413).json({ error: `attachment over ${ASK_LIMITS.attachment.maxBytesPerFile} bytes: ${name}` });
3896
+ }
3897
+ let bodyText;
3898
+ try { bodyText = dec.decode(buf); } catch { return badRequest(res, `attachment is not valid UTF-8: ${name}`); }
3899
+ if (bodyText.includes('\u0000')) return badRequest(res, `attachment contains NUL bytes: ${name}`);
3900
+ files.push({ name, text: bodyText, bytes: buf.length });
3901
+ }
3902
+ const total = askThreadAttachmentBytes(id) + files.reduce((s, f) => s + f.bytes, 0);
3903
+ if (total > ASK_LIMITS.attachment.maxBytesPerThread) {
3904
+ return res.status(413).json({ error: 'attachment budget for this thread exceeded' });
3905
+ }
3906
+ }
3907
+
3908
+ // §6.2.2 ATOMIC re-check + slot reservation. Today every await between the
3909
+ // top 409/429 pair and here resolves in microtasks (validateModelEffort ->
3910
+ // composeCatalog; askBuildCatalog -> three synchronous better-sqlite3
3911
+ // reads), so the route is macrotask-atomic and two POSTs cannot interleave
3912
+ // (empirically instrumented). The reservation is what keeps that true if
3913
+ // any of those readers ever becomes genuinely async: it is synchronous —
3914
+ // check-and-set cannot interleave — and runs BEFORE the first write, so a
3915
+ // loser leaves no rows.
3916
+ if (askDeleting.has(id)) return res.status(409).json({ error: 'thread is being deleted' });
3917
+ if (askInFlight(id)) return res.status(409).json({ error: 'turn in flight' });
3918
+ if (askRunningCount() >= ASK_LIMITS.turnsGlobal) {
3919
+ return res.status(429).json({ error: `at most ${ASK_LIMITS.turnsGlobal} turns may run at once` });
3920
+ }
3921
+ const prev = askJobs.get(id);
3922
+ if (prev && prev.graceTimer) clearTimeout(prev.graceTimer); // atomic replace of a grace entry (§8.3)
3923
+ const job = {
3924
+ turn: null, messageId: null, userMessageId: null, // ids filled once the rows exist;
3925
+ events: [], seq: 0, status: 'running', // askHello()/GET inFlight skip a null messageId
3926
+ startedAt: new Date().toISOString(), graceTimer: null,
3927
+ };
3928
+ askJobs.set(id, job);
3929
+
3930
+ let asstMsg = null;
3931
+ let turn;
3932
+ try {
3933
+ // Writes. Store the LAST context + model/effort on the thread (§6.5 tail, D8).
3934
+ askUpdateThread(id, { context: cv.context, model: mv.model, effort: mv.effort });
3935
+ let deterministicTitle = thread.title;
3936
+ let titleWasAuto = false;
3937
+ if (thread.title == null) {
3938
+ // §7.4 — no frame for the deterministic title. titleWasAuto gates the
3939
+ // D13 background replacement: a title given at THREAD CREATION is the
3940
+ // user's, and the haiku call must never fire for it (§17 Q&A 1).
3941
+ deterministicTitle = askSanitizeTitle(text.slice(0, 80)) || 'New chat';
3942
+ askSetThreadTitle(id, deterministicTitle);
3943
+ titleWasAuto = true;
3944
+ }
3945
+ const userMsg = askAppendMessage(id, { role: 'user', text });
3946
+ job.userMessageId = userMsg.id;
3947
+ const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, text: f.text }));
3948
+ if (attRows.length) {
3949
+ askSetMessageBlocks(userMsg.id, attRows.map((a) => ({ kind: 'attachment', id: a.id, name: a.name, bytes: a.bytes })));
3950
+ }
3951
+ broadcast({ type: 'ask-message', threadId: id, message: askGetMessage(userMsg.id) }); // echo for other tabs
3952
+ asstMsg = askAppendMessage(id, { role: 'assistant', text: '', status: 'streaming', model: mv.model, effort: mv.effort });
3953
+ job.messageId = asstMsg.id;
3954
+
3955
+ // Prompt assembly (§6.5) — the route owns it; the turn only spawns.
3956
+ const catalog = await askBuildCatalog();
3957
+ const systemPrompt = askBuildSystemPrompt(catalog);
3958
+ const withText = attRows.map((a, i) => ({ id: a.id, name: a.name, bytes: a.bytes, text: files[i].text }));
3959
+ const { inline, listed } = askSelectInlineAttachments(withText);
3960
+ const headerCtx = await resolveAskContext(id, cv.context, listed, userMsg.id);
3961
+ const header = askBuildContextHeader(headerCtx);
3962
+ const prompt = askBuildTurnPrompt(header, text, inline);
3963
+ const prior = askListMessages(id).filter((m) => m.seq < userMsg.seq);
3964
+ const restoredPrompt = askBuildRestoredPrompt(prior, prompt);
3965
+ const attachmentNames = {};
3966
+ for (const a of askListAttachments(id)) attachmentNames[a.id] = a.name;
3967
+
3968
+ turn = createAskTurn({
3969
+ threadId: id, assistantMessageId: asstMsg.id, userMessageId: userMsg.id,
3970
+ prompt, systemPrompt, restoredPrompt,
3971
+ model: mv.model, effort: mv.effort,
3972
+ resumeSessionId: thread.sessionId || null,
3973
+ firstTurn: userMsg.seq === 1 && titleWasAuto, // D13 guard: never replace a user-authored title
3974
+ firstText: text,
3975
+ deterministicTitle,
3976
+ mock: mockEnabled({}) ? { card: mockAskCard(cv.context, text) } : null, // R-F
3977
+ attachmentNames,
3978
+ deps: {
3979
+ onFrame: stampAskFrames(id, job),
3980
+ onOutOfTurn: (f) => broadcast({ ...f, threadId: id }),
3981
+ onCommentMutation: ({ runId }) => { emitDiffCommentsChanged(runId); },
3982
+ },
3983
+ });
3984
+ job.turn = turn;
3985
+ } catch (err) {
3986
+ // A write/assembly failure must release the reserved slot and never leave
3987
+ // a `streaming` row for the boot sweep to find.
3988
+ if (askJobs.get(id) === job) askJobs.delete(id);
3989
+ if (asstMsg) {
3990
+ try {
3991
+ askFinishMessage(asstMsg.id, {
3992
+ text: '', blocks: [{ kind: 'notice', text: 'failed to start the turn' }],
3993
+ status: 'error', reason: null, usage: null, costUsd: null, durationMs: null,
3994
+ });
3995
+ } catch { /* thread gone */ }
3996
+ }
3997
+ return res.status(500).json({ error: err && err.message ? err.message : String(err) });
3998
+ }
3999
+ const settleJob = (status) => {
4000
+ if (askJobs.get(id) !== job) return;
4001
+ job.status = status;
4002
+ job.graceTimer = setTimeout(() => {
4003
+ if (askJobs.get(id) === job) askJobs.delete(id);
4004
+ }, ASK_LIMITS.jobGraceMs);
4005
+ job.graceTimer.unref?.();
4006
+ };
4007
+ turn.on('done', () => settleJob('done'));
4008
+ turn.on('error', () => settleJob('error'));
4009
+ // Fire-and-forget with a backstop (startAgentGen shape) — run() never throws.
4010
+ Promise.resolve()
4011
+ .then(() => turn.run())
4012
+ .catch((err) => {
4013
+ console.error(`[worca-ui] ask turn crashed: ${err && err.message ? err.message : err}`);
4014
+ settleJob('error');
4015
+ });
4016
+ res.status(202).json({ userMessageId: job.userMessageId, assistantMessageId: job.messageId });
4017
+ } catch (err) {
4018
+ // Only pre-reservation throws land here (`job` is block-scoped to the outer
4019
+ // try and every post-reservation failure returned from the inner catch), so
4020
+ // there is no slot to release.
4021
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
4022
+ }
4023
+ });
4024
+
4025
+ // Idempotent stop (the /api/agents/generate/stop family): always {ok:true}
4026
+ // after the shape check; the costUsd:null rule lives in the turn (R-C).
4027
+ app.post('/api/ask/threads/:id/stop', (req, res) => {
4028
+ const id = askIdParam(res, req.params.id, 'thread');
4029
+ if (!id) return;
4030
+ const job = askInFlight(id);
4031
+ if (job && job.turn && typeof job.turn.stop === 'function') {
4032
+ try { job.turn.stop(); } catch { /* best-effort */ }
4033
+ }
4034
+ res.json({ ok: true });
4035
+ });
4036
+
4037
+ /** R-B dual update. Flip in the STORE and, when the owning thread's turn is
4038
+ * still streaming, in the LIVE reducer (updateBlock re-emits the stamped
4039
+ * ask-card job frame) — otherwise finishMessage at turn end reverts the flip
4040
+ * with the reducer's stale copy. When no live reducer held the card (turn
4041
+ * over, or the card sits on an earlier message), re-broadcast the whole
4042
+ * message so tabs upsert the flipped block by message.id (§6.6 out-of-turn). */
4043
+ function flipCard(threadId, cardId, patch) {
4044
+ const block = askUpdateCardBlock(threadId, cardId, patch);
4045
+ if (!block) return null;
4046
+ const job = askInFlight(threadId);
4047
+ const live = job && job.turn && job.turn.reducer ? job.turn.reducer.updateBlock(cardId, patch) : null;
4048
+ if (!live) {
4049
+ const found = askFindCard(threadId, cardId);
4050
+ if (found) broadcast({ type: 'ask-message', threadId, message: found.message });
4051
+ }
4052
+ return block;
4053
+ }
4054
+
4055
+ // D14 dismiss ("Not now" keeps a stub — the client renders state:'dismissed').
4056
+ app.post('/api/ask/threads/:id/cards/:cardId', (req, res) => {
4057
+ const id = askIdParam(res, req.params.id, 'thread');
4058
+ if (!id) return;
4059
+ const cardId = askIdParam(res, req.params.cardId, 'card');
4060
+ if (!cardId) return;
4061
+ try {
4062
+ if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
4063
+ if ((req.body || {}).state !== 'dismissed') return badRequest(res, 'state must be "dismissed"');
4064
+ const found = askFindCard(id, cardId);
4065
+ if (!found) return res.status(404).json({ error: 'card not found' });
4066
+ if (found.block.state !== 'proposed') {
4067
+ return res.status(409).json({ error: `card is ${found.block.state}` });
4068
+ }
4069
+ const block = flipCard(id, cardId, { state: 'dismissed' });
4070
+ // Dismiss is terminal: the card's parked comment ids can never reach a run,
4071
+ // so drop them here exactly as the launch path does at its own success point
4072
+ // (:1155). Own try/catch — comment bookkeeping must never fail the dismiss.
4073
+ try { clearPendingCardComments(cardId); }
4074
+ catch (e) { console.error('[diff-comments] dismiss cleanup failed:', e && e.message ? e.message : e); }
4075
+ res.json({ block });
4076
+ } catch (err) {
4077
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
4078
+ }
4079
+ });
4080
+
2753
4081
  // ---------------------------------------------------------------------------
2754
4082
  // /api/agents* -> agent registry + user-agent CRUD, delegated to
2755
4083
  // src/core/agent-store.mjs (layered builtin + ~/.worca-cc/agents user pairs).
2756
4084
  // GET returns palette render order (.order ascending) with origin stamped; the
2757
4085
  // client builds draggable pills (colored dot + displayName + icon) from this.
2758
4086
  // ---------------------------------------------------------------------------
2759
- // Channel vocabulary for the UI editor/wizard: built-in CHANNEL_IDS first, then
2760
- // every CUSTOM id any registry agent references (produces/consumes/
2761
- // optionalConsumes/channelDefs[].id), appended sorted + deduped. Channels are an
2762
- // open vocabulary — a closed list would silently strip custom ids on edit.
2763
- function collectChannelIds(agents) {
2764
- const customs = new Set();
2765
- for (const a of Array.isArray(agents) ? agents : []) {
2766
- if (!a) continue;
2767
- const ids = [
2768
- ...(Array.isArray(a.produces) ? a.produces : []),
2769
- ...(Array.isArray(a.consumes) ? a.consumes : []),
2770
- ...(Array.isArray(a.optionalConsumes) ? a.optionalConsumes : []),
2771
- ...(Array.isArray(a.channelDefs) ? a.channelDefs.map((d) => d && d.id) : []),
2772
- ];
2773
- for (const id of ids) {
2774
- if (typeof id === 'string' && id && !CHANNEL_IDS.includes(id)) customs.add(id);
2775
- }
2776
- }
2777
- return [...CHANNEL_IDS, ...[...customs].sort()];
2778
- }
2779
4087
 
2780
4088
  app.get('/api/agents', async (req, res) => {
2781
4089
  try {
@@ -2783,7 +4091,11 @@ app.get('/api/agents', async (req, res) => {
2783
4091
  // §6.6: workspace-only agents stay out of the Composer palette by default;
2784
4092
  // the Agents management view passes ?all=1 to see them too.
2785
4093
  const agents = isTruthy(req.query.all) ? all : all.filter((m) => m.scope !== 'workspace-only');
2786
- res.json({ agents, channels: collectChannelIds(all) });
4094
+ // mockWriterRoles drives ONE select in the agent form. It is a CLOSED list
4095
+ // (the mock switch in claude-runner.mjs), unlike the open channel vocabulary
4096
+ // it replaces in Task 12: an unknown mockRole is dropped by the registry
4097
+ // with a warning, never rejected.
4098
+ res.json({ agents, mockWriterRoles: [...MOCK_WRITER_ROLES] });
2787
4099
  } catch (err) {
2788
4100
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
2789
4101
  }
@@ -2809,9 +4121,6 @@ function agentErrorStatus(code) {
2809
4121
  function startAgentGen(input) {
2810
4122
  const orch = createAgentGen({
2811
4123
  ...input,
2812
- // Same open vocabulary as GET /api/agents (callers pass the registry union);
2813
- // built-ins-only fallback keeps direct/_testing callers working.
2814
- channels: Array.isArray(input.channels) && input.channels.length ? input.channels : CHANNEL_IDS,
2815
4124
  claude: { permissionMode: 'acceptEdits', mock: isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK) },
2816
4125
  });
2817
4126
  // The engine mints its own genId (agen_<uuid>) and tags every emitted event
@@ -2862,7 +4171,7 @@ app.post('/api/agents/generate', async (req, res) => {
2862
4171
  const genId = startAgentGen({
2863
4172
  name, purpose: String(body.purpose || ''), details: String(body.details || ''),
2864
4173
  expectedBefore: pick(body.expectedBefore), expectedAfter: pick(body.expectedAfter),
2865
- userMarkdown, channels: collectChannelIds(allAgents),
4174
+ userMarkdown,
2866
4175
  });
2867
4176
  res.json({ genId });
2868
4177
  } catch (err) {
@@ -3081,7 +4390,7 @@ app.post('/api/plugins/install', async (req, res) => {
3081
4390
  repoUrl: body.repoUrl.trim(), subdir, name: body.name.trim(), sha: body.sha.trim(), marketplace,
3082
4391
  });
3083
4392
  reloadChatWorkers(body.name.trim());
3084
- res.json(out); // { ok: true, inventory }
4393
+ res.json(out); // { ok: true, inventory, warnings, ignored }
3085
4394
  } catch (err) {
3086
4395
  sendPluginError(res, err);
3087
4396
  }
@@ -3128,6 +4437,8 @@ app.delete('/api/plugins/:name', async (req, res) => {
3128
4437
  if (!name) return;
3129
4438
  const purge = isTruthy(req.query.purge) || !!(req.body && req.body.purge === true);
3130
4439
  try {
4440
+ // uninstallPlugin also drops the plugin's source bindings (core-side, so
4441
+ // the CLI's `worca plugin remove` clears them identically).
3131
4442
  await uninstallPlugin(name, { purge });
3132
4443
  reloadChatWorkers(name);
3133
4444
  res.json({ ok: true, purged: purge });
@@ -3165,17 +4476,38 @@ app.post('/api/plugins/:name/doctor', async (req, res) => {
3165
4476
  // GET /api/plugins/:name/config -> per-source schema + redacted values. Secrets
3166
4477
  // NEVER travel to the browser: redactedConfig replaces a stored secret with
3167
4478
  // { set: true } (§7.6).
4479
+ // ?profile=<id> selects which configuration to echo (multi-profile sources);
4480
+ // absent = the default bucket, which is all a single-profile source ever uses.
3168
4481
  app.get('/api/plugins/:name/config', (req, res) => {
3169
4482
  const name = requirePlugin(req, res);
3170
4483
  if (!name) return;
3171
4484
  const manifest = readInstalledManifest(name);
3172
4485
  if (!manifest) return res.status(409).json({ error: 'plugin manifest unreadable — run doctor' });
4486
+ const wanted = typeof req.query.profile === 'string' && req.query.profile ? req.query.profile : null;
4487
+ if (wanted && !isValidProfileId(wanted)) return badRequest(res, 'invalid profile id');
3173
4488
  try {
3174
- const sources = (manifest.taskSources || []).map((s) => ({
3175
- id: s.id,
3176
- schema: s.configSchema,
3177
- values: redactedConfig(name, s.configSchema),
3178
- }));
4489
+ const profiles = listProfiles(name);
4490
+ const sources = (manifest.taskSources || []).map((s) => {
4491
+ // For a multi-profile source, "which profile" is a real choice: echo the
4492
+ // requested one, else the first in the roster. A source with no profiles
4493
+ // yet has nothing to show — the UI's move is "create one", not a form.
4494
+ // A requested profile that is not in the roster is a caller error (a
4495
+ // typo'd URL) — echoing an empty form for it would let a Save quietly
4496
+ // create the typo as a real profile. Checked inside the map so the guard
4497
+ // only fires for sources that use profiles at all.
4498
+ if (s.multiProfile && wanted && !profiles.some((p) => p.id === wanted)) {
4499
+ throw Object.assign(new Error(`plugin "${name}" has no profile "${wanted}"`), { code: 'BAD_REQUEST' });
4500
+ }
4501
+ const profile = s.multiProfile ? (wanted || profiles[0]?.id || null) : null;
4502
+ return {
4503
+ id: s.id,
4504
+ schema: s.configSchema,
4505
+ multiProfile: s.multiProfile === true,
4506
+ profile,
4507
+ profiles: s.multiProfile ? profiles : [],
4508
+ values: s.multiProfile && !profile ? {} : redactedConfig(name, s.configSchema, profile),
4509
+ };
4510
+ });
3179
4511
  const channels = (manifest.chatChannels || []).map((c) => ({
3180
4512
  id: c.id,
3181
4513
  displayName: c.displayName,
@@ -3196,7 +4528,78 @@ app.get('/api/plugins/:name/config', (req, res) => {
3196
4528
  }
3197
4529
  });
3198
4530
 
3199
- // PUT /api/plugins/:name/config { sourceId | channelId, values } ->
4531
+ // POST /api/plugins/:name/profiles { sourceId, id, label } — create (or relabel)
4532
+ // a profile of a multi-profile source. Creating a profile is deliberately
4533
+ // separate from saving into it: the roster entry must exist BEFORE the config
4534
+ // form has anything to write to.
4535
+ app.post('/api/plugins/:name/profiles', (req, res) => {
4536
+ const name = requirePlugin(req, res);
4537
+ if (!name) return;
4538
+ const body = req.body || {};
4539
+ const manifest = readInstalledManifest(name);
4540
+ if (!manifest) return res.status(409).json({ error: 'plugin manifest unreadable — run doctor' });
4541
+ const source = (manifest.taskSources || []).find((s) => s.id === body.sourceId);
4542
+ if (!source) return badRequest(res, 'sourceId does not match a task source of this plugin');
4543
+ if (!source.multiProfile) return badRequest(res, `task source "${source.id}" does not support profiles`);
4544
+ if (!isValidProfileId(body.id)) {
4545
+ return badRequest(res, 'profile id must be lowercase letters, digits and dashes');
4546
+ }
4547
+ // "default" is the implicit bucket every profile-less read/write shares
4548
+ // (chat channels, model secrets, migrated legacy config). Enrolled in the
4549
+ // roster it would become deletable like any member — and deleting it wipes
4550
+ // that shared bucket. createProfile throws too; 400 with the reason here.
4551
+ if (body.id === DEFAULT_PROFILE) {
4552
+ return badRequest(res, `profile id "${DEFAULT_PROFILE}" is reserved — pick another name`);
4553
+ }
4554
+ try {
4555
+ res.json({ ok: true, profile: createProfile(name, body.id, body.label) });
4556
+ } catch (err) {
4557
+ sendPluginError(res, err);
4558
+ }
4559
+ });
4560
+
4561
+ // DELETE /api/plugins/:name/profiles/:id?sourceId=… — drop a profile, its stored
4562
+ // config/secrets/state, and every project binding that named it (a binding
4563
+ // pointing at a deleted profile would otherwise resolve to nothing at run time).
4564
+ // Binding cleanup is PLUGIN-wide, not per-source: deleteProfile removes the
4565
+ // profile's buckets for the whole plugin, so a sibling source's binding naming
4566
+ // it would dangle just the same. sourceId is still required — it authorizes the
4567
+ // call against a source that actually uses profiles.
4568
+ app.delete('/api/plugins/:name/profiles/:id', (req, res) => {
4569
+ const name = requirePlugin(req, res);
4570
+ if (!name) return;
4571
+ const manifest = readInstalledManifest(name);
4572
+ if (!manifest) return res.status(409).json({ error: 'plugin manifest unreadable — run doctor' });
4573
+ const sourceId = typeof req.query.sourceId === 'string' ? req.query.sourceId : '';
4574
+ const sources = manifest.taskSources || [];
4575
+ const source = sources.find((s) => s.id === sourceId) || (sources.length === 1 ? sources[0] : null);
4576
+ if (!source) return badRequest(res, 'sourceId does not match a task source of this plugin');
4577
+ // Mirror the POST guard: a single-profile source only has the implicit
4578
+ // 'default' bucket, and deleting THAT would wipe its entire config/secrets/
4579
+ // state. Same for ids not in the roster — deleteProfile would still drop
4580
+ // whatever buckets happen to share the id (e.g. migrated legacy data under
4581
+ // 'default'), so only roster members are deletable.
4582
+ if (!source.multiProfile) return badRequest(res, `task source "${source.id}" does not support profiles`);
4583
+ if (!isValidProfileId(req.params.id)) return badRequest(res, 'invalid profile id');
4584
+ // Reserved even if a pre-reservation roster enrolled it: deleting "default"
4585
+ // would strip the shared bucket (chat-channel config, model secrets,
4586
+ // migrated legacy data) out of all three files.
4587
+ if (req.params.id === DEFAULT_PROFILE) {
4588
+ return badRequest(res, `profile id "${DEFAULT_PROFILE}" is reserved — it cannot be deleted`);
4589
+ }
4590
+ if (!listProfiles(name).some((p) => p.id === req.params.id)) {
4591
+ return badRequest(res, `plugin "${name}" has no profile "${req.params.id}"`);
4592
+ }
4593
+ try {
4594
+ deleteProfile(name, req.params.id);
4595
+ const unbound = clearBindingsForProfile(name, req.params.id);
4596
+ res.json({ ok: true, unbound });
4597
+ } catch (err) {
4598
+ sendPluginError(res, err);
4599
+ }
4600
+ });
4601
+
4602
+ // PUT /api/plugins/:name/config { sourceId | channelId, values, profile? } ->
3200
4603
  // writePluginConfig routes secret:true keys to data/secrets.json (0600,
3201
4604
  // atomic). Request values are NEVER logged and NEVER echoed back (the response
3202
4605
  // is a bare receipt). A channelId save also hot-restarts the channel worker.
@@ -3222,6 +4625,8 @@ app.put('/api/plugins/:name/config', (req, res) => {
3222
4625
  }
3223
4626
  }
3224
4627
  let schema;
4628
+ let source = null;
4629
+ let profile = null;
3225
4630
  if (typeof body.channelId === 'string' && body.channelId) {
3226
4631
  const channel = (manifest.chatChannels || []).find((c) => c.id === body.channelId);
3227
4632
  if (!channel) return badRequest(res, 'channelId does not match a chat channel of this plugin');
@@ -3231,12 +4636,22 @@ app.put('/api/plugins/:name/config', (req, res) => {
3231
4636
  const sourceId = typeof body.sourceId === 'string' && body.sourceId
3232
4637
  ? body.sourceId
3233
4638
  : (sources.length === 1 ? sources[0].id : '');
3234
- const source = sources.find((s) => s.id === sourceId);
4639
+ source = sources.find((s) => s.id === sourceId);
3235
4640
  if (!source) return badRequest(res, 'sourceId does not match a task source of this plugin');
4641
+ profile = typeof body.profile === 'string' && body.profile ? body.profile : null;
4642
+ if (profile && !isValidProfileId(profile)) return badRequest(res, 'invalid profile id');
4643
+ if (source.multiProfile && !profile) return badRequest(res, 'profile is required for this task source');
4644
+ // Saves go only into EXISTING roster members — mirroring the GET guard,
4645
+ // whose whole point is that a Save must not quietly mint a typo'd (or
4646
+ // just-deleted) id as a real profile with secrets stored under it.
4647
+ // Creation stays solely on POST /profiles.
4648
+ if (source.multiProfile && !listProfiles(name).some((p) => p.id === profile)) {
4649
+ return badRequest(res, `plugin "${name}" has no profile "${profile}" — create it first`);
4650
+ }
3236
4651
  schema = source.configSchema;
3237
4652
  }
3238
4653
  try {
3239
- writePluginConfig(name, schema, body.values);
4654
+ writePluginConfig(name, schema, body.values, profile);
3240
4655
  reloadChatWorkers(name);
3241
4656
  res.json({ ok: true });
3242
4657
  } catch (err) {
@@ -3261,7 +4676,84 @@ app.get('/api/plugins/:name/model-env', (req, res) => {
3261
4676
  if (typeof v === 'string') env[k] = v;
3262
4677
  else secretKeys.push(k);
3263
4678
  }
3264
- res.json({ id: model.id, label: model.label, efforts: model.efforts, env, secretKeys });
4679
+ // `cost` rides along so "Edit a copy" starts from the plugin's pricing — a
4680
+ // copy that silently dropped it would repay the CLI's by-name figure.
4681
+ res.json({
4682
+ id: model.id, label: model.label, efforts: model.efforts, env, secretKeys,
4683
+ ...(model.cost ? { cost: model.cost } : {}),
4684
+ });
4685
+ });
4686
+
4687
+ // ---------------------------------------------------------------------------
4688
+ // /api/source-bindings -> which PROFILE of a task source a project/workspace
4689
+ // pulls from. Set once per project; every run then resolves it silently, which
4690
+ // is the point — a per-run dropdown is how you start a pipeline against the
4691
+ // wrong tracker without noticing (see src/core/source-bindings.mjs).
4692
+ // ---------------------------------------------------------------------------
4693
+
4694
+ /** Shared scope parsing for the two binding routes. Accepts a project by key or
4695
+ * by path (the New Pipeline form knows the path, the Projects view the key). */
4696
+ function bindingScope(q = {}) {
4697
+ const workspaceId = typeof q.workspaceId === 'string' && q.workspaceId.trim() ? q.workspaceId.trim() : '';
4698
+ if (workspaceId) return { scopeType: 'workspace', scopeKey: workspaceId };
4699
+ const key = typeof q.projectKey === 'string' && q.projectKey.trim() ? q.projectKey.trim() : '';
4700
+ if (key) return { scopeType: 'project', scopeKey: key };
4701
+ const dir = typeof q.projectDir === 'string' && q.projectDir.trim() ? q.projectDir.trim() : '';
4702
+ if (dir) return { scopeType: 'project', scopeKey: projectKey(path.resolve(dir)) };
4703
+ return null;
4704
+ }
4705
+
4706
+ // GET /api/source-bindings?projectDir=…|projectKey=…|workspaceId=…
4707
+ // [&plugin=&sourceId=] -> { bindings: [...] } or, when a source is named,
4708
+ // the RESOLVED profile for it: { profile, via, candidates? }.
4709
+ app.get('/api/source-bindings', async (req, res) => {
4710
+ const scope = bindingScope(req.query);
4711
+ if (!scope) return badRequest(res, 'projectDir, projectKey or workspaceId is required');
4712
+ const plugin = typeof req.query.plugin === 'string' ? req.query.plugin.trim() : '';
4713
+ const sourceId = typeof req.query.sourceId === 'string' ? req.query.sourceId.trim() : '';
4714
+ try {
4715
+ if (!plugin || !sourceId) return res.json({ ...scope, bindings: listBindingsForScope(scope.scopeType, scope.scopeKey) });
4716
+ // A workspace with no binding of its own inherits from its members when they
4717
+ // agree, so the member keys have to be resolved before asking.
4718
+ let memberKeys;
4719
+ if (scope.scopeType === 'workspace') {
4720
+ const ws = await readWorkspace(scope.scopeKey);
4721
+ memberKeys = ws ? ws.projectKeys : [];
4722
+ }
4723
+ res.json({
4724
+ ...scope,
4725
+ ...resolveProfile({ ...scope, plugin, sourceId, memberKeys, available: listProfileIds(plugin) }),
4726
+ });
4727
+ } catch (err) {
4728
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
4729
+ }
4730
+ });
4731
+
4732
+ // PUT /api/source-bindings { projectDir|projectKey|workspaceId, plugin,
4733
+ // sourceId, profile } — profile:null clears the binding.
4734
+ app.put('/api/source-bindings', (req, res) => {
4735
+ const body = req.body || {};
4736
+ const scope = bindingScope(body);
4737
+ if (!scope) return badRequest(res, 'projectDir, projectKey or workspaceId is required');
4738
+ const plugin = typeof body.plugin === 'string' ? body.plugin.trim() : '';
4739
+ const sourceId = typeof body.sourceId === 'string' ? body.sourceId.trim() : '';
4740
+ if (!plugin || !sourceId) return badRequest(res, 'plugin and sourceId are required');
4741
+ const ref = { ...scope, plugin, sourceId };
4742
+ try {
4743
+ if (body.profile === null || body.profile === '') {
4744
+ clearBinding(ref);
4745
+ return res.json({ ok: true, ...scope, profile: null });
4746
+ }
4747
+ if (!isValidProfileId(body.profile)) return badRequest(res, 'invalid profile id');
4748
+ // Binding to a profile that does not exist would resolve to nothing at run
4749
+ // time — reject it here, where the user can still see why.
4750
+ if (!listProfileIds(plugin).includes(body.profile)) {
4751
+ return badRequest(res, `plugin "${plugin}" has no profile "${body.profile}"`);
4752
+ }
4753
+ res.json({ ok: true, ...scope, profile: setBinding(ref, body.profile) });
4754
+ } catch (err) {
4755
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
4756
+ }
3265
4757
  });
3266
4758
 
3267
4759
  // ---------------------------------------------------------------------------
@@ -3303,8 +4795,21 @@ app.post('/api/sources/call', async (req, res) => {
3303
4795
  if (input && typeof input.optionsFrom === 'string' && input.optionsFrom) allowed.add(input.optionsFrom);
3304
4796
  }
3305
4797
  if (!allowed.has(op)) return badRequest(res, `op "${op}" is not allowed for this source`);
4798
+ const profile = typeof body.profile === 'string' && body.profile ? body.profile : null;
4799
+ if (profile && !isValidProfileId(profile)) return badRequest(res, 'invalid profile id');
4800
+ if (source.multiProfile && !profile) return badRequest(res, 'profile is required for this task source');
4801
+ // Same submit-time guards as /api/run: a deleted profile must 400 here, not
4802
+ // fail deep in the connector against an empty bucket, and a profile on a
4803
+ // single-profile source would read (and persist state into) a phantom
4804
+ // bucket instead of the real config.
4805
+ if (source.multiProfile && !listProfileIds(plugin).includes(profile)) {
4806
+ return badRequest(res, `plugin "${plugin}" has no profile "${profile}"`);
4807
+ }
4808
+ if (!source.multiProfile && profile) {
4809
+ return badRequest(res, `task source "${sourceId}" does not use profiles — omit profile`);
4810
+ }
3306
4811
  try {
3307
- const result = await callSource({ plugin, sourceId, op, args });
4812
+ const result = await callSource({ plugin, sourceId, op, args, profile });
3308
4813
  res.json({ ok: true, result });
3309
4814
  } catch (err) {
3310
4815
  if (err instanceof PluginOpError) {
@@ -3475,11 +4980,32 @@ app.post('/api/chat/test', async (req, res) => {
3475
4980
  app.use((req, res, next) => {
3476
4981
  if (req.method !== 'GET') return next();
3477
4982
  if (req.path.startsWith('/api/') || req.path.startsWith('/ws')) return next();
4983
+ if (req.path === '/vendor' || req.path.startsWith('/vendor/')) return next();
3478
4984
  res.sendFile(path.join(PUBLIC_DIR, 'index.html'), (err) => {
3479
4985
  if (err) next();
3480
4986
  });
3481
4987
  });
3482
4988
 
4989
+ // ---------------------------------------------------------------------------
4990
+ // The LAST middleware, and the only GLOBAL error handler (`/vendor` keeps its
4991
+ // own path-scoped one): every failure answers { error } as JSON (MIN-108).
4992
+ // Without it express's default handler renders an HTML page carrying the thrown
4993
+ // stack — absolute node_modules paths included — for the two failures that happen
4994
+ // BEFORE any route runs: a malformed JSON body and a body past the cap. The
4995
+ // four-argument signature is what makes express treat this as an error handler,
4996
+ // so `next` stays even though only the headers-sent path uses it.
4997
+ // ---------------------------------------------------------------------------
4998
+ app.use((err, _req, res, next) => {
4999
+ if (res.headersSent) return next(err); // let express abort the stream
5000
+ if (err && err.type === 'entity.parse.failed') return res.status(400).json({ error: 'malformed JSON body' });
5001
+ if (err && err.type === 'entity.too.large') return res.status(413).json({ error: 'request body too large' });
5002
+ // Anything else: honour a body-parser 4xx (charset.unsupported / encoding.unsupported
5003
+ // are 415, request.aborted is 400) — a client error logged as a 500 misleads. Ours or
5004
+ // not, it is one line, no stack, never HTML.
5005
+ const status = Number.isInteger(err?.status) && err.status >= 400 && err.status < 500 ? err.status : 500;
5006
+ return res.status(status).json({ error: err && err.message ? err.message : 'internal error' });
5007
+ });
5008
+
3483
5009
  /**
3484
5010
  * Boot maintenance, in the PINNED order (§8.12):
3485
5011
  * 1. reconcileStaleRunning — stamps every stale `running` row -> `interrupted`,
@@ -3503,12 +5029,12 @@ app.use((req, res, next) => {
3503
5029
  * everything up to the first `await` — including the reconcile — still runs before
3504
5030
  * `server.listen`, exactly as it did when this was an inline block.
3505
5031
  *
3506
- * @param {{log?: (scope:'run-root'|'legacy', level:string, msg:string) => void}} [args]
5032
+ * @param {{log?: (scope:'run-root'|'legacy'|'ask-worktrees', level:string, msg:string) => void}} [args]
3507
5033
  * optional sink for the per-candidate lines both sweeps emit; omitted, each
3508
5034
  * sweep keeps its own console default.
3509
5035
  */
3510
5036
  export async function bootMaintenance({ log } = {}) {
3511
- const summary = { reconciled: 0, runRoots: null, legacy: null };
5037
+ const summary = { reconciled: 0, sweptV1: 0, runRoots: null, legacy: null, ask: null, askWorktrees: null };
3512
5038
  const sink = (scope) => (typeof log === 'function' ? (level, msg) => log(scope, level, msg) : undefined);
3513
5039
 
3514
5040
  // Runs left 'running' by a previous process that died before writing a terminal
@@ -3521,6 +5047,16 @@ export async function bootMaintenance({ log } = {}) {
3521
5047
  console.error(`[worca-ui] stale-run reconcile failed: ${err && err.message ? err.message : err}`);
3522
5048
  }
3523
5049
 
5050
+ // A DB stamped past 24 by a divergent ladder can still hold v1 resume points
5051
+ // (crash-reconciled runs keep theirs). One idempotent sweep per boot.
5052
+ try {
5053
+ const swept = sweepV1Runs();
5054
+ summary.sweptV1 = swept.length;
5055
+ if (swept.length) console.log(`[worca-ui] retired ${swept.length} run(s) paused on the v1 engine`);
5056
+ } catch (err) {
5057
+ console.error(`[worca-ui] v1-run sweep failed: ${err && err.message ? err.message : err}`);
5058
+ }
5059
+
3524
5060
  try {
3525
5061
  const r = await sweepRunRoots({
3526
5062
  worcaHome: worcaHome(), ...runRootSweepLookups(), log: sink('run-root'),
@@ -3562,6 +5098,34 @@ export async function bootMaintenance({ log } = {}) {
3562
5098
  } catch (err) {
3563
5099
  console.error(`[worca-ui] legacy worktree sweep failed: ${err && err.message ? err.message : err} — nothing was removed`);
3564
5100
  }
5101
+
5102
+ // Ask Worca (§6.2): mark turns orphaned by a restart, sweep stale empty threads.
5103
+ try {
5104
+ const interrupted = sweepStreamingMessages();
5105
+ const emptyThreads = sweepEmptyThreads();
5106
+ summary.ask = { interrupted, emptyThreads };
5107
+ if (interrupted || emptyThreads) {
5108
+ console.log(`[worca-ui] ask sweep: ${interrupted} interrupted turn(s), ${emptyThreads} empty thread(s)`);
5109
+ }
5110
+ } catch (err) {
5111
+ summary.ask = { interrupted: 0, emptyThreads: 0 };
5112
+ console.error(`[worca-ui] ask sweep failed: ${err && err.message ? err.message : err}`);
5113
+ }
5114
+
5115
+ // Ask worktrees (P4 §5): reconcile ask_worktrees rows vs on-disk checkouts
5116
+ // both ways. Three-state inside the sweep: a DB failure aborts with nothing
5117
+ // removed. `sink('ask-worktrees')` is undefined on a log-less boot, which is
5118
+ // exactly the sweep's own default — never call sink(...) directly.
5119
+ try {
5120
+ const r = await sweepAskWorktrees({ log: sink('ask-worktrees') });
5121
+ summary.askWorktrees = r;
5122
+ if (r.removedDirs || r.prunedRows) {
5123
+ console.log(`[worca-ui] ask-worktree sweep: removed ${r.removedDirs} orphan dir(s), dropped ${r.prunedRows} stale row(s)`);
5124
+ }
5125
+ if (r.failed) console.error(`[worca-ui] ask-worktree sweep: ${r.failed} candidate(s) skipped`);
5126
+ } catch (err) {
5127
+ console.error(`[worca-ui] ask-worktree sweep failed: ${err && err.message ? err.message : err}`);
5128
+ }
3565
5129
  return summary;
3566
5130
  }
3567
5131
 
@@ -3606,4 +5170,9 @@ if (isMain) {
3606
5170
  }
3607
5171
 
3608
5172
  export { app, server, runs };
3609
- export const _testing = { wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen, chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork, chatNotifier, resumeRun };
5173
+ export const _testing = {
5174
+ wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen,
5175
+ chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
5176
+ chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, resolveAskContext, flipCard,
5177
+ emitDiffCommentsChanged,
5178
+ };