@worca/app 1.0.0 → 1.2.0-rc.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 (143) hide show
  1. package/README.md +30 -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 +386 -56
  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 +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
package/ui/server.mjs CHANGED
@@ -13,26 +13,72 @@ 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,
44
+ debugSpawnEnabled as storedDebugSpawnEnabled, effectiveDebugSpawn, setDebugSpawnEnabled, assertDebugSpawnInput, SETTINGS_POST_KEYS,
35
45
  } from '../src/core/settings.mjs';
46
+ import {
47
+ ASK_ID_RE, createThread as askCreateThread, getThread as askGetThread,
48
+ listThreads as askListThreads, updateThread as askUpdateThread,
49
+ deleteThread as askDeleteThread, sweepEmptyThreads, sweepStreamingMessages,
50
+ countThreads as askCountThreads, listThreadIds as askListThreadIds,
51
+ countWorktrees as askCountWorktrees, countAttachments as askCountAttachments,
52
+ appendMessage as askAppendMessage, getMessage as askGetMessage,
53
+ listMessages as askListMessages, setMessageBlocks as askSetMessageBlocks,
54
+ findCard as askFindCard, updateCardBlock as askUpdateCardBlock,
55
+ addAttachment as askAddAttachment, listAttachments as askListAttachments,
56
+ getAttachment as askGetAttachment, attachmentPath as askAttachmentPath, threadAttachmentBytes as askThreadAttachmentBytes,
57
+ linkRun as askLinkRun, updateRunLink as askUpdateRunLink, listRunLinks as askListRunLinks,
58
+ findRunLinksByPipeline as askFindRunLinksByPipeline,
59
+ finishMessage as askFinishMessage,
60
+ } from '../src/core/ask/store.mjs';
61
+ import { sanitizeTitle as askSanitizeTitle } from '../src/core/title.mjs';
62
+ import { ASK_LIMITS } from '../src/core/ask/limits.mjs';
63
+ import { askCatalog, validateModelEffort } from '../src/core/ask/models.mjs';
64
+ import { buildCatalog as askBuildCatalog } from '../src/core/ask/catalog.mjs';
65
+ import {
66
+ buildSystemPrompt as askBuildSystemPrompt, buildContextHeader as askBuildContextHeader,
67
+ buildTurnPrompt as askBuildTurnPrompt, buildRestoredPrompt as askBuildRestoredPrompt,
68
+ selectInlineAttachments as askSelectInlineAttachments, validateClientContext,
69
+ } from '../src/core/ask/prompt.mjs';
70
+ import {
71
+ classifyExtension as askClassifyExtension, sniffMime as askSniffMime,
72
+ } from '../src/core/ask/attachment-kind.mjs';
73
+ import {
74
+ listAskWorktrees as askListWorktrees,
75
+ removeAskWorktree as askRemoveWorktree,
76
+ removeThreadWorktrees as askRemoveThreadWorktrees,
77
+ sweepAskWorktrees,
78
+ } from '../src/core/ask/worktrees.mjs';
79
+ import { createAskTurn } from '../src/core/ask/turn.mjs';
80
+ import { attachRunFollower } from '../src/core/ask/follow.mjs';
81
+ import { mockEnabled, MOCK_WRITER_ROLES } from '../src/core/claude-runner.mjs';
36
82
  import { budgetStatus, readCostCapOverride, setCostCapOverride } from '../src/core/cost-budget.mjs';
37
83
  import { getStats } from '../src/core/stats.mjs';
38
84
  import { pickFolderNative } from '../src/core/folder-dialog.mjs';
@@ -40,22 +86,25 @@ import { listFolders } from '../src/core/fs-browse.mjs';
40
86
  import {
41
87
  readConfig, setStep, addCustomModel, removeCustomModel, listModels,
42
88
  PREDEFINED_MODELS, agentSteps, EFFORTS,
43
- readRunConfig, setNodeModel, setFeedbackCycles, setActiveWorkflow, resetWorkflowConfig,
89
+ readRunConfig, setNodeModel, setFeedbackCycles, setWireCycles, setActiveWorkflow, resetWorkflowConfig,
44
90
  globalModelRefs, removeGlobalModelAndRefs, promoteCustomModel, costUnreliableModelIds,
45
91
  } from '../src/core/config.mjs';
46
92
  import { listGlobalModels, addGlobalModel, updateGlobalModel } from '../src/core/settings.mjs';
47
- import { modelEnvRef } from '../src/core/model-env.mjs';
93
+ import { modelEnvRef, maskModelEnvValue, SUBAGENT_MODEL_VALUES, subagentModelIssue } from '../src/core/model-env.mjs';
48
94
  import { listPluginModels, modelSecretsSchema, pluginModelSecretStatus } from '../src/core/plugin-models.mjs';
95
+ import { testModel } from '../src/core/model-test.mjs';
49
96
  import { validateGuardrails } from '../src/core/guardrails.mjs';
50
97
  import {
51
98
  listBuiltinGuardrailSets, listGuardrailSets, readGuardrailSet,
52
99
  writeGuardrailSet, deleteGuardrailSet, isBuiltinGuardrailSetId,
53
100
  } from '../src/core/guardrail-store.mjs';
54
101
  import {
55
- DEFAULT_WORKFLOW, listWorkflows, readWorkflow, writeWorkflow, deleteWorkflow,
56
- setWorkflowNodeDefaults, workflowNodeDefaults,
102
+ GRAPH_DEFAULT_WORKFLOW, listWorkflows, deleteWorkflow, isSafeWorkflowId,
103
+ setWorkflowNodeDefaults, workflowNodeDefaults, assertRunnableWorkflow, writeGraphWorkflow,
57
104
  } from '../src/core/workflows.mjs';
58
- import { validateWorkflow } from '../src/core/workflow-validator.mjs';
105
+ import { registryPortsFn } from '../src/core/graph/registry-ports.mjs';
106
+ import { sweepV1Runs, V1_RUN_RETIRED } from '../src/core/db.mjs';
107
+ import { validateGraph, AGENT_TUNABLES } from '../src/shared/graph/validate.mjs';
59
108
  import { loadAgentRegistry } from '../src/core/agent-registry.mjs';
60
109
  import {
61
110
  listLocalBranches, currentBranch, isValidSourceRef, sweepRunRoots, sweepLegacyWorktreesAll,
@@ -72,7 +121,6 @@ import { projectKey } from '../src/core/store.mjs';
72
121
  import { createWorkspaceScan } from '../src/core/workspace-scan.mjs';
73
122
  import { createAgentGen } from '../src/core/agent-gen.mjs';
74
123
  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
124
  import {
77
125
  listInstalledPlugins, installPlugin, updatePlugin, uninstallPlugin,
78
126
  setPluginEnabled, doctorPlugin,
@@ -83,7 +131,14 @@ import {
83
131
  addMarketplace, listMarketplaces, syncMarketplace, refreshAllMarketplaces,
84
132
  removeMarketplace, readMarketplaces, seedBuiltinMarketplace,
85
133
  } from '../src/core/marketplaces.mjs';
86
- import { redactedConfig, writePluginConfig, readPluginConfig } from '../src/core/plugin-config.mjs';
134
+ import {
135
+ redactedConfig, writePluginConfig, readPluginConfig, listProfiles, listProfileIds,
136
+ createProfile, deleteProfile, isValidProfileId, DEFAULT_PROFILE,
137
+ } from '../src/core/plugin-config.mjs';
138
+ import {
139
+ setBinding, clearBinding, listBindingsForScope,
140
+ clearBindingsForProfile, resolveProfile,
141
+ } from '../src/core/source-bindings.mjs';
87
142
  import { createChannelHost } from '../src/core/chat/channel-host.mjs';
88
143
  import { createCommandRouter } from '../src/core/chat/command-router.mjs';
89
144
  import { createChatContext } from '../src/core/chat/chat-context.mjs';
@@ -94,6 +149,7 @@ import { readPluginsLock, pluginCurrentDir } from '../src/core/plugins-lock.mjs'
94
149
  import { normalizeManifest, PLUGIN_NAME_RE as MANIFEST_PLUGIN_NAME_RE } from '../src/core/plugin-manifest.mjs';
95
150
  import { listTaskSources, retryWriteback } from '../src/core/sources.mjs';
96
151
  import { callSource, PluginOpError } from '../src/core/plugin-shim.mjs';
152
+ import { HLJS_GRAMMAR_IDS } from './public/hljs-loader.mjs';
97
153
 
98
154
  // ── node:sqlite runtime guard + warning filter ──────────────────────────────────
99
155
  // Drop ONLY the one-time ExperimentalWarning emitted by node:sqlite (the module is
@@ -117,6 +173,45 @@ const PROJECT_ROOT = path.resolve(__dirname, '..');
117
173
  const PUBLIC_DIR = path.join(__dirname, 'public');
118
174
  const AGENTS_DIR = path.join(PROJECT_ROOT, 'agents');
119
175
  const SKILLS_DIR = path.join(PROJECT_ROOT, 'skills');
176
+ const require = createRequire(import.meta.url);
177
+ const HLJS_LANGUAGE_FILE_RE = /^[a-z0-9][a-z0-9-]{0,63}\.min\.js$/;
178
+ // Primaries plus the sub-language grammars their instances register
179
+ // (hljs-loader.mjs); a shipped but unmapped grammar stays a plain 404.
180
+ const HLJS_LANGUAGE_FILES = new Set(
181
+ HLJS_GRAMMAR_IDS.map((id) => `${id}.min.js`),
182
+ );
183
+
184
+ function resolveHljsAssets(resolve = require.resolve, warn = (msg) => console.warn(msg)) {
185
+ try {
186
+ const core = resolve('@highlightjs/cdn-assets/es/core.min.js');
187
+ return { core, languages: path.join(path.dirname(core), 'languages') };
188
+ } catch (err) {
189
+ warn(`[worca-ui] syntax-highlighter assets unavailable: ${err?.message || err}`);
190
+ return null;
191
+ }
192
+ }
193
+
194
+ const HLJS_ASSETS = resolveHljsAssets();
195
+
196
+ // Ask Worca §10.7: the chat's markdown pipeline is served from node_modules the
197
+ // same way the hljs assets are, but resolved with import.meta.resolve — the CJS
198
+ // require.resolve lands on marked's CJS build, and dompurify/package.json is not
199
+ // exported. Each package degrades independently: a missing one just leaves its
200
+ // route unregistered and the existing /vendor no-store 404 answers.
201
+ function resolveEsmAsset(spec, resolve = (s) => import.meta.resolve(s), warn = (msg) => console.warn(msg)) {
202
+ try {
203
+ return fileURLToPath(resolve(spec));
204
+ } catch (err) {
205
+ warn(`[worca-ui] ask markdown asset unavailable (${spec}): ${err?.message || err}`);
206
+ return null;
207
+ }
208
+ }
209
+
210
+ const ASK_VENDOR_ASSETS = {
211
+ marked: resolveEsmAsset('marked'),
212
+ dompurify: resolveEsmAsset('dompurify'),
213
+ };
214
+
120
215
  const PORT = Number(process.env.PORT) || 4317;
121
216
  // Bind to loopback by default (S1). Power users who knowingly want LAN exposure
122
217
  // can set WORCA_HOST=0.0.0.0, but the localhost-only Host/Origin guard still
@@ -161,7 +256,9 @@ function liveRunIds() {
161
256
  // `stepgraphify` (§7.3) was emitted by the orchestrator and handled by the client
162
257
  // but missing here, so the graphify badge only appeared after a reload (via the
163
258
  // 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'];
259
+ // `exec` and `token` are the graph engine's (§5.7). `phase` stays for the v1
260
+ // engine AND for the v2 shim until the graph cut-over retires it.
261
+ const EVENT_NAMES = ['exec', 'token', 'log', 'question', 'artifact', 'state', 'done', 'error', 'subagent', 'stepskills', 'stepgraphify', 'title'];
165
262
  // The scan-* WS family (Workspaces M5, §5.4). A NEW family in the SAME runs Map;
166
263
  // the 7-event run plumbing above is untouched. createWorkspaceScan emits many
167
264
  // scan-progress then exactly one terminal scan-done OR scan-error.
@@ -182,6 +279,20 @@ const wss = new WebSocketServer({ server, path: '/ws' });
182
279
  /** All currently connected sockets. */
183
280
  const sockets = new Set();
184
281
 
282
+ // server.close() only calls back once every connection is gone, and Node's
283
+ // closeAllConnections() skips UPGRADED sockets — a WebSocket whose close
284
+ // handshake has not completed (a client that vanished, or a test tearing down
285
+ // right after ws.close()) keeps the callback from ever firing; under load that
286
+ // is a hang. Terminate the lingering clients first so close() is deterministic
287
+ // on every OS; the per-socket 'close' handlers below drop them from `sockets`.
288
+ {
289
+ const httpClose = server.close.bind(server);
290
+ server.close = (cb) => {
291
+ for (const ws of sockets) { try { ws.terminate(); } catch { /* already gone */ } }
292
+ return httpClose(cb);
293
+ };
294
+ }
295
+
185
296
  wss.on('connection', (ws, req) => {
186
297
  // S1: WS upgrades bypass the express middleware chain, so re-apply the
187
298
  // loopback guard here (same DNS-rebinding protection as the HTTP routes).
@@ -197,24 +308,31 @@ wss.on('connection', (ws, req) => {
197
308
  let requestedRunId = null;
198
309
  let requestedScanId = null;
199
310
  let requestedGenId = null;
311
+ let requestedThreadId = null;
200
312
  try {
201
313
  const u = new URL(req.url, 'http://localhost');
202
314
  requestedRunId = u.searchParams.get('runId');
203
315
  requestedScanId = u.searchParams.get('scanId');
204
316
  requestedGenId = u.searchParams.get('genId');
317
+ requestedThreadId = u.searchParams.get('threadId');
205
318
  } catch {
206
319
  requestedRunId = null;
207
320
  requestedScanId = null;
208
321
  requestedGenId = null;
322
+ requestedThreadId = null;
209
323
  }
210
324
  const id = requestedRunId || requestedScanId || requestedGenId;
211
325
 
212
- send(ws, { type: 'hello', runs: summarizeRuns() });
326
+ send(ws, { type: 'hello', runs: summarizeRuns(), ask: askHello() });
213
327
 
214
328
  if (id && runs.has(id)) {
215
329
  replayEntry(ws, runs.get(id));
216
330
  }
217
331
 
332
+ if (requestedThreadId && askJobs.has(requestedThreadId)) {
333
+ replayAskJob(ws, askJobs.get(requestedThreadId));
334
+ }
335
+
218
336
  ws.on('close', () => sockets.delete(ws));
219
337
  ws.on('error', () => sockets.delete(ws));
220
338
  ws.on('message', (data) => {
@@ -231,6 +349,10 @@ wss.on('connection', (ws, req) => {
231
349
  if (subId && runs.has(subId)) {
232
350
  replayEntry(ws, runs.get(subId));
233
351
  }
352
+ const askThreadId = msg && msg.type === 'subscribe' && typeof msg.threadId === 'string' ? msg.threadId : null;
353
+ if (askThreadId && askJobs.has(askThreadId)) {
354
+ replayAskJob(ws, askJobs.get(askThreadId));
355
+ }
234
356
  });
235
357
  });
236
358
 
@@ -302,6 +424,32 @@ function emitChanged(type, action) {
302
424
  broadcast({ type, action: action || null });
303
425
  }
304
426
 
427
+ // Every comment mutation in THIS process (the REST routes below) pokes the open
428
+ // Diff tabs. A poke carries ids only — no payload, so it is idempotent and has no
429
+ // ordering concerns; the client refetches and repaints its CARDS, never the diff.
430
+ // MCP-side mutations happen in the stdio CHILD process and cannot reach this
431
+ // listener; they arrive through the turn's comment hook instead.
432
+ onDiffCommentsChanged(({ storeKey, pipelineId }) => {
433
+ broadcast({ type: 'diff-comments-changed', storeKey, pipelineId });
434
+ });
435
+
436
+ /** Resolve an 8-hex pipeline id to its History store key and poke the open Diff
437
+ * tabs. Used for MCP-side writes, which happen in the stdio CHILD process and
438
+ * cannot reach the listener above. The frame is byte-identical to the REST one,
439
+ * so the client has ONE code path. findPipelineRowById is key-agnostic and
440
+ * includes archived rows. Exported through `_testing` — the wiring at the ask
441
+ * turn is a one-liner precisely so this function is the whole testable surface. */
442
+ function emitDiffCommentsChanged(runId) {
443
+ try {
444
+ const row = findPipelineRowById(runId);
445
+ if (!row) return false;
446
+ const storeKey = (row.target === 'workspace' || row.workspace_key)
447
+ ? `workspaces/${row.workspace_key}` : row.project_key;
448
+ broadcast({ type: 'diff-comments-changed', storeKey, pipelineId: row.id });
449
+ return true;
450
+ } catch { return false; } // a poke is best effort
451
+ }
452
+
305
453
  // Append a tagged event to an entry's ring buffer (runId LAST so the runs-Map key
306
454
  // always wins over any id the orchestrator stamped). Shared by the live wire
307
455
  // (record) and out-of-band resolutions (resolvePending) so both honor MAX_BUFFER.
@@ -342,6 +490,9 @@ function summarizeRuns() {
342
490
  // 'cost_pipeline'/'cost_total') instead of showing a plain "Paused" card
343
491
  // until the next event.
344
492
  pauseReason: r.pauseReason || null,
493
+ // The clipped failure message behind reason 'error', or null — so a
494
+ // reload/reconnect restores the "Paused · error" detail, not a bare card.
495
+ pauseDetail: r.pauseDetail || null,
345
496
  startedAt: r.startedAt,
346
497
  pendingQuestion: r.pendingQuestion || null,
347
498
  // kind discriminator so the client routes runs vs scans vs agent generations
@@ -422,16 +573,23 @@ function wireRun(entry) {
422
573
  // Remember the pause reason for summarizeRuns (hello). Reset on every
423
574
  // done so a later reasonless finish cannot leave a stale cost banner.
424
575
  entry.pauseReason = (payload && payload.reason) || null;
576
+ // ...and WHAT went wrong for an error-pause, reset alongside it.
577
+ entry.pauseDetail = (payload && payload.detail) || null;
425
578
  resolvePending(entry, { reason: entry.status });
426
579
  if (payload?.reason === 'cost_pipeline' || payload?.reason === 'cost_total') {
427
580
  emitChanged('budget-changed');
428
581
  }
429
582
  }
430
583
  if (name === 'error') {
431
- entry.status = 'error';
432
- resolvePending(entry, { reason: 'error' });
584
+ // The launch-error channel (a failure BEFORE the pipeline row exists). A
585
+ // converted in-run failure pauses and emits no 'error'; never let a stray
586
+ // one demote a parked run.
587
+ if (entry.status !== 'paused' && entry.status !== 'pausing') {
588
+ entry.status = 'error';
589
+ resolvePending(entry, { reason: 'error' });
590
+ }
433
591
  }
434
- if (name === 'phase') {
592
+ if (name === 'exec') {
435
593
  entry.status = 'running';
436
594
  }
437
595
  if (name === 'state' && payload && typeof payload === 'object') {
@@ -576,7 +734,6 @@ app.post('/api/ingress/teams/:plugin/:channelId/:token',
576
734
  // ---------------------------------------------------------------------------
577
735
  // Express middleware + static
578
736
  // ---------------------------------------------------------------------------
579
- app.use(express.json({ limit: '8mb' }));
580
737
 
581
738
  // S1: worca-cc's UI/API has no auth and runs agents with permissionMode
582
739
  // 'acceptEdits' — it is a single-user *localhost* tool. The server binds to
@@ -584,13 +741,98 @@ app.use(express.json({ limit: '8mb' }));
584
741
  // suspenders: reject any request whose Host (or browser Origin) is not a
585
742
  // loopback name, so a malicious page resolving a name to 127.0.0.1 still can't
586
743
  // drive the API. Override WORCA_HOST only if you understand the exposure.
744
+ //
745
+ // FIRST, ahead of the body parser (MIN-108): a refused request must be refused
746
+ // before a single byte of its body is parsed or buffered, and a malformed body
747
+ // from a non-loopback Host used to answer 400 (with a stack) where a valid one
748
+ // answered 403. The ingress webhook above is deliberately mounted EARLIER and
749
+ // stays exempt — it carries its own token check and 256 KB cap.
587
750
  app.use((req, res, next) => {
588
751
  if (!isLocalRequest(req)) {
589
- return res.status(403).json({ error: 'forbidden: worca-cc is a localhost-only tool' });
752
+ return res.status(403).json({ error: 'forbidden: worca is a localhost-only tool' });
590
753
  }
591
754
  next();
592
755
  });
593
756
 
757
+ // Ask attachments ride base64 inside the message JSON (§7.3), and a binary
758
+ // attachment (#398) may legitimately be 5 MB — several of them blow the app-wide
759
+ // 8mb cap below. Registered BEFORE the global parser on the ONE route that
760
+ // carries uploads (a body parsed here is skipped there): every other ask route
761
+ // reads a string field or nothing and keeps the 8mb window. 64mb covers
762
+ // maxFiles × maxBytesPerBinaryFile at base64's 4/3 inflation, so every
763
+ // over-budget upload still reaches the route's OWN clear 400/413, not a raw
764
+ // parser error.
765
+ app.post('/api/ask/threads/:id/messages', express.json({ limit: '64mb' }));
766
+ app.use(express.json({ limit: '8mb' }));
767
+
768
+ if (HLJS_ASSETS) {
769
+ const sendHljsModule = (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
+ app.get('/vendor/hljs/core.min.js', sendHljsModule(HLJS_ASSETS.core));
779
+ app.get('/vendor/hljs/languages/:file', (req, res, next) => {
780
+ const file = String(req.params.file || '');
781
+ if (!HLJS_LANGUAGE_FILE_RE.test(file) || !HLJS_LANGUAGE_FILES.has(file)) return next();
782
+ const candidate = path.join(HLJS_ASSETS.languages, file);
783
+ try {
784
+ if (!fs.statSync(candidate).isFile()) return next();
785
+ } catch {
786
+ return next();
787
+ }
788
+ return sendHljsModule(candidate)(req, res, next);
789
+ });
790
+ }
791
+
792
+ // Ask Worca §10.7 vendor routes. sendHljsModule's shape, reused verbatim: the
793
+ // sendFile error path falls through to the /vendor no-store handlers below.
794
+ const sendEsmModule = (file) => (_req, res, next) => {
795
+ res.type('text/javascript');
796
+ res.set('X-Content-Type-Options', 'nosniff');
797
+ res.sendFile(file, (err) => {
798
+ if (!err) return;
799
+ if (res.headersSent) return next(err);
800
+ next();
801
+ });
802
+ };
803
+ if (ASK_VENDOR_ASSETS.marked) {
804
+ app.get('/vendor/marked/marked.esm.js', sendEsmModule(ASK_VENDOR_ASSETS.marked));
805
+ }
806
+ if (ASK_VENDOR_ASSETS.dompurify) {
807
+ app.get('/vendor/dompurify/purify.es.mjs', sendEsmModule(ASK_VENDOR_ASSETS.dompurify));
808
+ }
809
+
810
+ app.use('/vendor', (err, _req, res, next) => {
811
+ if (res.headersSent) return next(err);
812
+ res.set('Cache-Control', 'no-store');
813
+ const status = err?.status === 400 ? 400 : 404;
814
+ res.status(status).type('text/plain').send(status === 400 ? 'Bad request' : 'Not found');
815
+ });
816
+ app.use('/vendor', (_req, res) => {
817
+ res.set('Cache-Control', 'no-store');
818
+ res.status(404).type('text/plain').send('Not found');
819
+ });
820
+
821
+ // src/shared/** is the ONE source of the graph model for server + browser
822
+ // (no build step). ui modules import it by relative path that walks above
823
+ // ui/public; the browser clamps that URL at '/', so it must be served here at
824
+ // exactly the repo-relative path. The 404 tail keeps a typo'd path from
825
+ // falling through to the SPA index.html (which Chrome reports as a MIME error).
826
+ const SHARED_DIR = path.join(PROJECT_ROOT, 'src', 'shared');
827
+ app.use('/src/shared', express.static(SHARED_DIR, {
828
+ index: false,
829
+ setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
830
+ }));
831
+ app.use('/src/shared', (_req, res) => {
832
+ res.set('Cache-Control', 'no-store');
833
+ res.status(404).type('text/plain').send('Not found');
834
+ });
835
+
594
836
  app.use(express.static(PUBLIC_DIR, { extensions: ['html'] }));
595
837
 
596
838
  const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
@@ -694,6 +936,9 @@ function normalizeRunSource(raw) {
694
936
  return { ok: false, error: `source.${k} is required for type "plugin"` };
695
937
  }
696
938
  }
939
+ if (raw.profile !== undefined && !isValidProfileId(raw.profile)) {
940
+ return { ok: false, error: 'source.profile is not a valid profile id' };
941
+ }
697
942
  return {
698
943
  ok: true,
699
944
  source: {
@@ -702,12 +947,34 @@ function normalizeRunSource(raw) {
702
947
  sourceId: raw.sourceId.trim(),
703
948
  taskId: raw.taskId.trim(),
704
949
  inputs: raw.inputs && typeof raw.inputs === 'object' && !Array.isArray(raw.inputs) ? raw.inputs : undefined,
950
+ // Which configuration of the source the task came from. Absent is legal
951
+ // (single-profile sources); an id that is not path-safe is not.
952
+ profile: typeof raw.profile === 'string' && raw.profile ? raw.profile : undefined,
705
953
  },
706
954
  };
707
955
  }
708
956
  return { ok: false, error: `unknown source.type "${type}"` };
709
957
  }
710
958
 
959
+ /**
960
+ * A markdown source that NAMES a promptFile must name one we can read. Resolution
961
+ * happens inside the orchestrator, which this route launches fire-and-forget AFTER
962
+ * it has already answered — so without this submit-time check a bad path surfaces
963
+ * as an anonymous mid-run error event on a pipeline the client was told started.
964
+ * Resolved against the same base the orchestrator uses (the project, or a
965
+ * workspace's primary member).
966
+ * @returns {Promise<string|null>} the error message, or null when there is nothing wrong
967
+ */
968
+ async function promptFileProblem(source, projectDir) {
969
+ if (!source || source.type !== 'markdown' || !source.promptFile) return null;
970
+ try {
971
+ await readPromptFile(projectDir, source.promptFile);
972
+ return null;
973
+ } catch (err) {
974
+ return err && err.message ? err.message : String(err);
975
+ }
976
+ }
977
+
711
978
  // Fallback run title when the client sends none. The legacy path is unchanged
712
979
  // (first 80 chars of the prompt — effectivePrompt is guaranteed set there); a
713
980
  // plugin source starts as "<plugin>: <taskId>" until the orchestrator resolves
@@ -719,6 +986,67 @@ function fallbackRunTitle(effectivePrompt, source) {
719
986
  return String(text).slice(0, 80);
720
987
  }
721
988
 
989
+ // Wire an Ask Worca follower for a card-linked run: the orchestrator's
990
+ // state/question/error/done events become thread notices, ask_run_links patches
991
+ // and ask-run-status frames. Used by POST /api/run at launch AND by resumeRun
992
+ // (a resumed pipeline is a NEW orchestrator; the paused lineage's follower
993
+ // detached on done{paused}, so the link must be re-followed — review of PR #376).
994
+ function attachAskFollower(orch, { threadId, runId, cardId }) {
995
+ const follower = attachRunFollower(orch, {
996
+ threadId,
997
+ runId,
998
+ cardId,
999
+ post: ({ text, href }) => {
1000
+ try {
1001
+ const m = askAppendMessage(threadId, {
1002
+ role: 'system', text, blocks: [{ kind: 'notice', text, href }],
1003
+ });
1004
+ broadcast({ type: 'ask-message', threadId, message: m });
1005
+ } catch { /* thread deleted mid-run */ }
1006
+ },
1007
+ updateStatus: (patch) => {
1008
+ try {
1009
+ const linkPatch = {};
1010
+ if (patch.pipelineId) linkPatch.pipelineId = patch.pipelineId;
1011
+ if (patch.status) linkPatch.status = patch.status;
1012
+ if (patch.phase !== undefined) linkPatch.phase = patch.phase;
1013
+ const row = Object.keys(linkPatch).length
1014
+ ? askUpdateRunLink(threadId, runId, linkPatch) : null;
1015
+ // The 8-hex History id lands on the FIRST state event (follow.mjs guards
1016
+ // "first truthy sight only"), which is the first moment a
1017
+ // "sent to #<runId>" marker could point anywhere real. Never the
1018
+ // runs-Map UUID, never at launch, and never a resolve.
1019
+ if (linkPatch.pipelineId && row && row.commentIds.length) {
1020
+ try { stampSentRunId(row.commentIds, linkPatch.pipelineId); } catch { /* best effort */ }
1021
+ }
1022
+ if (patch.cardFailed) {
1023
+ flipCard(threadId, cardId, { state: 'failed', error: patch.cardFailed });
1024
+ }
1025
+ broadcast({
1026
+ type: 'ask-run-status', threadId, runId,
1027
+ pipelineId: (row && row.pipelineId) || patch.pipelineId || null,
1028
+ cardId,
1029
+ status: patch.status || (row && row.status) || null,
1030
+ phase: patch.phase !== undefined ? patch.phase : ((row && row.phase) || null),
1031
+ });
1032
+ } catch { /* thread deleted mid-run */ }
1033
+ },
1034
+ onDetached: () => {
1035
+ const set = askFollowers.get(threadId);
1036
+ if (set) {
1037
+ set.delete(follower);
1038
+ if (!set.size) askFollowers.delete(threadId);
1039
+ }
1040
+ },
1041
+ });
1042
+ let set = askFollowers.get(threadId);
1043
+ if (!set) {
1044
+ set = new Set();
1045
+ askFollowers.set(threadId, set);
1046
+ }
1047
+ set.add(follower);
1048
+ }
1049
+
722
1050
  // ---------------------------------------------------------------------------
723
1051
  // POST /api/run -> start a new orchestration run
724
1052
  // body (single-project): { projectDir, prompt?, promptMarkdown?, title?, mock? }
@@ -739,6 +1067,28 @@ app.post('/api/run', async (req, res) => {
739
1067
  return badRequest(res, 'workspaceId or projectDir is required');
740
1068
  }
741
1069
 
1070
+ // Ask Worca card link (§8.1): both or neither; the thread must exist and
1071
+ // the card must still be `proposed` BEFORE any run state is created.
1072
+ const hasAskThread = body.askThreadId !== undefined && body.askThreadId !== null;
1073
+ const hasAskCard = body.askCardId !== undefined && body.askCardId !== null;
1074
+ let askLink = null;
1075
+ if (hasAskThread || hasAskCard) {
1076
+ if (!hasAskThread || !hasAskCard) {
1077
+ return badRequest(res, 'askThreadId and askCardId must be provided together');
1078
+ }
1079
+ if (typeof body.askThreadId !== 'string' || !ASK_ID_RE.test(body.askThreadId)
1080
+ || typeof body.askCardId !== 'string' || !ASK_ID_RE.test(body.askCardId)) {
1081
+ return badRequest(res, 'invalid askThreadId or askCardId');
1082
+ }
1083
+ if (!askGetThread(body.askThreadId)) return badRequest(res, 'unknown askThreadId');
1084
+ const found = askFindCard(body.askThreadId, body.askCardId);
1085
+ if (!found) return badRequest(res, 'unknown askCardId');
1086
+ if (found.block.state !== 'proposed') {
1087
+ return res.status(409).json({ error: `card is ${found.block.state}` });
1088
+ }
1089
+ askLink = { threadId: body.askThreadId, cardId: body.askCardId };
1090
+ }
1091
+
742
1092
  // ── Shared resolution (factored BEFORE the target branch, §2.6) ──────────
743
1093
  // NEW (plugins §7.3): body.source is the task-source descriptor; shape-check
744
1094
  // only and pass through — the orchestrator resolves it exactly once. Absent
@@ -747,6 +1097,29 @@ app.post('/api/run', async (req, res) => {
747
1097
  if (sourceCheck && !sourceCheck.ok) return badRequest(res, sourceCheck.error);
748
1098
  const source = sourceCheck ? sourceCheck.source : null;
749
1099
 
1100
+ // A multiProfile source without a profile would run against the (empty)
1101
+ // default bucket and die mid-pipeline with a confusing connector error —
1102
+ // reject it here, at submit, where the client can still fix it. The same
1103
+ // goes for a profile that is no longer IN the roster (deleted in another
1104
+ // tab after the client resolved it) and for a profile supplied to a source
1105
+ // that does not use them (it would read a phantom bucket instead of the
1106
+ // real config). A broken or uninstalled plugin is left for
1107
+ // resolveTaskInput to report.
1108
+ if (source && source.type === 'plugin') {
1109
+ const m = readInstalledManifest(source.plugin);
1110
+ const ts = m && (m.taskSources || []).find((s) => s.id === source.sourceId);
1111
+ if (ts && ts.multiProfile) {
1112
+ if (!source.profile) {
1113
+ return badRequest(res, `source.profile is required — task source "${source.sourceId}" has per-profile configuration`);
1114
+ }
1115
+ if (!listProfileIds(source.plugin).includes(source.profile)) {
1116
+ return badRequest(res, `plugin "${source.plugin}" has no profile "${source.profile}" — it may have been deleted; re-select one`);
1117
+ }
1118
+ } else if (ts && source.profile) {
1119
+ return badRequest(res, `task source "${source.sourceId}" does not use profiles — omit source.profile`);
1120
+ }
1121
+ }
1122
+
750
1123
  // prompt OR promptMarkdown. promptMarkdown is treated as the prompt text.
751
1124
  const prompt = typeof body.prompt === 'string' && body.prompt.trim() ? body.prompt : undefined;
752
1125
  const promptMarkdown =
@@ -770,7 +1143,16 @@ app.post('/api/run', async (req, res) => {
770
1143
  // so the client gets a clean 400 instead of a mid-run error event.
771
1144
  const workflowId =
772
1145
  typeof body.workflowId === 'string' && body.workflowId.trim() ? body.workflowId.trim() : 'wf_default';
773
- if (!(await readWorkflow(workflowId))) return badRequest(res, `unknown workflowId "${workflowId}"`);
1146
+ // ONE gate for every run entry point, ONE status: unknown (today's text) and
1147
+ // archived (the upgrade explanation the UI shows verbatim) answer 400 through
1148
+ // badRequest. A graph row runs on the graph engine — createOrchestratorFor
1149
+ // routes it off the row's version.
1150
+ let workflowRow;
1151
+ try {
1152
+ workflowRow = await assertRunnableWorkflow(workflowId);
1153
+ } catch (err) {
1154
+ return badRequest(res, err && err.message ? err.message : String(err));
1155
+ }
774
1156
 
775
1157
  // Optional guardrailsId selects the named guardrail set that IS this run's
776
1158
  // policy (applied uniformly to every member — guardrails are per-run only).
@@ -858,7 +1240,10 @@ app.post('/api/run', async (req, res) => {
858
1240
  return badRequest(res, `unknown or invalid sourceBranch: ${badOverride}`);
859
1241
  }
860
1242
 
861
- orch = createOrchestrator({
1243
+ const wsFileProblem = await promptFileProblem(effectiveSource, projects[0].projectDir);
1244
+ if (wsFileProblem) return badRequest(res, wsFileProblem);
1245
+
1246
+ orch = await createOrchestratorFor({
862
1247
  workspace: {
863
1248
  id: ws.id,
864
1249
  key: ws.id, // ws.id === workspaceKey(ws); routes artifacts to its store
@@ -872,6 +1257,7 @@ app.post('/api/run', async (req, res) => {
872
1257
  extras,
873
1258
  agentsDir: AGENTS_DIR,
874
1259
  workflowId,
1260
+ template: workflowRow,
875
1261
  guardrailsId,
876
1262
  branch,
877
1263
  claude: { permissionMode: 'acceptEdits', mock },
@@ -911,7 +1297,10 @@ app.post('/api/run', async (req, res) => {
911
1297
  return badRequest(res, `unknown or invalid sourceBranch: ${branch.source}`);
912
1298
  }
913
1299
 
914
- orch = createOrchestrator({
1300
+ const fileProblem = await promptFileProblem(effectiveSource, projectDir);
1301
+ if (fileProblem) return badRequest(res, fileProblem);
1302
+
1303
+ orch = await createOrchestratorFor({
915
1304
  projectDir,
916
1305
  prompt: effectivePrompt,
917
1306
  ...(effectiveSource ? { source: effectiveSource } : {}),
@@ -919,6 +1308,7 @@ app.post('/api/run', async (req, res) => {
919
1308
  extras,
920
1309
  agentsDir: AGENTS_DIR,
921
1310
  workflowId,
1311
+ template: workflowRow,
922
1312
  guardrailsId,
923
1313
  branch,
924
1314
  claude: { permissionMode: 'acceptEdits', mock },
@@ -939,6 +1329,48 @@ app.post('/api/run', async (req, res) => {
939
1329
 
940
1330
  runs.set(runId, entry);
941
1331
  wireRun(entry);
1332
+ if (askLink) {
1333
+ // Card-state TOCTOU: awaits (source-ref check, budget) sit between Hunk
1334
+ // B's `proposed` check and here — a concurrent Start may have flipped
1335
+ // the card already. That is a LOST RACE, not a detail to log: the loser
1336
+ // must not launch a second pipeline for the same card (review of PR #376).
1337
+ // Withdraw the run entry (nothing has run or been announced yet) and 409.
1338
+ const still = askFindCard(askLink.threadId, askLink.cardId);
1339
+ if (!still || still.block.state !== 'proposed') {
1340
+ runs.delete(runId);
1341
+ return res.status(409).json({ error: `card is no longer proposed (${still ? still.block.state : 'gone'})` });
1342
+ }
1343
+ try {
1344
+ askLinkRun(askLink.threadId, { runId, cardId: askLink.cardId, status: entry.status });
1345
+ flipCard(askLink.threadId, askLink.cardId, { state: 'started', runId });
1346
+ // The card's pending comment ids move onto the link row, keyed by the minted
1347
+ // UUID exactly as pipeline_id is before it exists. Consumed one-shot: a card
1348
+ // launches at most once. Own try/catch — comment bookkeeping must never
1349
+ // abort the card flip or the run.
1350
+ try {
1351
+ // Read, WRITE, then consume — not consume-then-write. A combined take()
1352
+ // deletes the rows it returns, so if askUpdateRunLink throws in between (its
1353
+ // catch here only logs) the ids are gone and the sent_run_id stamp is lost
1354
+ // with no way to recover them. peek/commit keeps the delete on the success
1355
+ // path only; a second launch of the same card cannot happen anyway (the
1356
+ // card must be in state 'proposed' above).
1357
+ const pendingComments = peekPendingCardComments(askLink.cardId);
1358
+ if (pendingComments.length) {
1359
+ askUpdateRunLink(askLink.threadId, runId, { commentIds: pendingComments });
1360
+ clearPendingCardComments(askLink.cardId);
1361
+ }
1362
+ } catch (e) { console.error('[diff-comments] pending-card handoff failed:', e && e.message ? e.message : e); }
1363
+ const startedMsg = askAppendMessage(askLink.threadId, {
1364
+ role: 'system',
1365
+ text: `Run started — "${title}"`,
1366
+ blocks: [{ kind: 'notice', text: `Run started — "${title}"`, href: `#running/${runId}` }],
1367
+ });
1368
+ broadcast({ type: 'ask-message', threadId: askLink.threadId, message: startedMsg });
1369
+ attachAskFollower(orch, { threadId: askLink.threadId, runId, cardId: askLink.cardId });
1370
+ } catch (err) {
1371
+ console.error(`[worca-ui] ask run link failed: ${err && err.message ? err.message : err}`);
1372
+ }
1373
+ }
942
1374
  announceRun(entry);
943
1375
 
944
1376
  // Fire-and-forget; all progress is surfaced through events.
@@ -1169,10 +1601,18 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
1169
1601
  if (!saved) throw new ResumeError(404, { error: 'pipeline not found' });
1170
1602
  if (saved.row.status !== 'paused' && saved.row.status !== 'interrupted') throw new ResumeError(400, { error: `pipeline is "${saved.row.status}", not resumable` });
1171
1603
  if (!saved.resumePoint) throw new ResumeError(400, { error: 'pipeline has no resume point' });
1604
+ if (saved.resumePoint.version !== 2) {
1605
+ throw new ResumeError(409, { code: 'ENGINE_RETIRED', error: V1_RUN_RETIRED });
1606
+ }
1172
1607
 
1173
1608
  if (saved.row.archived_at) {
1174
1609
  throw new ResumeError(409, { error: 'pipeline is archived' });
1175
1610
  }
1611
+ // No graph re-validation on RESUME, on purpose: _restoreFromResumePoint never
1612
+ // reads the workflow row — the frozen manifest supplies topology and port
1613
+ // identity (resolvedFromManifest: snapshot wins), so a template that drifted
1614
+ // while the run sat paused cannot strand it. A vanished agent KEY is the one
1615
+ // resume-time hazard, and _preflightAgentKeys already refuses it (§9.4).
1176
1616
  const budget = budgetStatus();
1177
1617
  if (budget.blocked) {
1178
1618
  throw new ResumeError(403, { error: 'total cost limit reached', budget });
@@ -1226,7 +1666,7 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
1226
1666
 
1227
1667
  const effMock = mock || isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK);
1228
1668
  const runId = randomUUID();
1229
- const orch = createOrchestrator({
1669
+ const orch = await createOrchestratorFor({
1230
1670
  projectDir,
1231
1671
  ...(workspace ? { workspace } : {}),
1232
1672
  agentsDir: AGENTS_DIR,
@@ -1255,6 +1695,22 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
1255
1695
  wireRun(entry);
1256
1696
  announceRun(entry);
1257
1697
 
1698
+ // A card-linked run keeps reporting to its chat across the resume: the link
1699
+ // row moves to the new runId and a fresh follower takes over (the old one
1700
+ // detached on done{paused}). Best-effort per row — chat bookkeeping must never
1701
+ // block a resume.
1702
+ for (const link of askFindRunLinksByPipeline(pipelineId)) {
1703
+ try {
1704
+ if (!askUpdateRunLink(link.threadId, link.runId, { runId, status: 'running' })) continue;
1705
+ const text = `Run resumed — "${saved.row.title || 'run'}"`;
1706
+ const m = askAppendMessage(link.threadId, { role: 'system', text, blocks: [{ kind: 'notice', text, href: `#running/${runId}` }] });
1707
+ broadcast({ type: 'ask-message', threadId: link.threadId, message: m });
1708
+ attachAskFollower(orch, { threadId: link.threadId, runId, cardId: link.cardId });
1709
+ } catch (err) {
1710
+ console.error(`[worca-ui] ask follower re-attach failed: ${err && err.message ? err.message : err}`);
1711
+ }
1712
+ }
1713
+
1258
1714
  // Evict the superseded paused/interrupted lineage for this pipeline. The old
1259
1715
  // entry is inert (paused), but summarizeRuns() broadcasts EVERY Map entry on
1260
1716
  // each hello — leaving it resurfaces the now-resumed (and possibly already
@@ -1362,6 +1818,22 @@ app.get('/api/runs/:id', async (req, res) => {
1362
1818
  }
1363
1819
  });
1364
1820
 
1821
+ // GET /api/runs/:id/artifact?rel= -> the same payload, resolved by the pipeline
1822
+ // id ALONE (findPipelineRowById): the Running page knows the run's pipelineId
1823
+ // but no store key until History has been visited. Placed beside /api/runs/:id
1824
+ // (`:id` matches one path segment, so the two never shadow each other).
1825
+ app.get('/api/runs/:id/artifact', async (req, res) => {
1826
+ try {
1827
+ const row = findPipelineRowById(req.params.id);
1828
+ if (!row) return res.status(404).json({ error: 'pipeline not found' });
1829
+ const hit = await resolveIndexedArtifactForRow(row, req.query.rel);
1830
+ if (!hit) return res.status(404).json({ error: 'artifact not found' });
1831
+ res.json(hit);
1832
+ } catch (err) {
1833
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
1834
+ }
1835
+ });
1836
+
1365
1837
  // Shared query-scope resolver for the retained-work routes (recovery-patch GET +
1366
1838
  // discard POST). Returns null after writing the error response itself. The older
1367
1839
  // DELETE /api/runs/:id route keeps its inline copy DELIBERATELY (it shadows the
@@ -1538,10 +2010,176 @@ app.get('/api/history/:key/:id/log', async (req, res) => {
1538
2010
  }
1539
2011
  });
1540
2012
 
2013
+ // ---------------------------------------------------------------------------
2014
+ // Internal, line-anchored diff comments. Bound to BOTH route families below: the
2015
+ // /api/history/:key/:id key regex forbids a slash, so a workspace run (store key
2016
+ // "workspaces/<id>") can only be reached through /api/workspaces/:id/runs/:runId —
2017
+ // the same split the /diff and /log routes already carry. One handler set, two
2018
+ // registrations: the two can never diverge.
2019
+ //
2020
+ // Traversal posture matches the /diff route below: the run dir comes from a DB row
2021
+ // via readRunArtifactText, and the relPath is the CONSTANT DIFF_PATCH_FILE. No
2022
+ // route here ever passes user input as a path.
2023
+ // ---------------------------------------------------------------------------
2024
+
2025
+ // The history key regex is an inline literal on every route in this family; this
2026
+ // block keeps that convention rather than introducing a shared constant the rest
2027
+ // of the file does not use.
2028
+ const commentsHistoryKey = (res, key) => {
2029
+ if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(key)) {
2030
+ res.status(404).json({ error: 'pipeline not found' });
2031
+ return null;
2032
+ }
2033
+ return key;
2034
+ };
2035
+ const commentsWorkspaceKey = (res, id) => {
2036
+ if (!WORKSPACE_KEY_RE.test(id)) { res.status(404).json({ error: 'pipeline not found' }); return null; }
2037
+ return `workspaces/${id}`;
2038
+ };
2039
+ const commentIdParam = (res, value) => {
2040
+ if (typeof value !== 'string' || !DC_ID_RE.test(value)) {
2041
+ res.status(400).json({ error: 'invalid comment id' });
2042
+ return null;
2043
+ }
2044
+ return value;
2045
+ };
2046
+ const commentsFail = (res, err) => res.status(500).json({ error: err && err.message ? err.message : String(err) });
2047
+
2048
+ /** The run row for a store key + id, or null after answering 404. */
2049
+ function commentRun(res, storeKey, id) {
2050
+ const row = lookupPipelineRow(storeKey, id);
2051
+ if (!row) { res.status(404).json({ error: 'pipeline not found' }); return null; }
2052
+ return row;
2053
+ }
2054
+
2055
+ async function commentsList(res, storeKey, id) {
2056
+ try {
2057
+ const row = commentRun(res, storeKey, id);
2058
+ if (!row) return;
2059
+ // The UI needs to know whether the '+' affordance may appear at all; a run
2060
+ // whose patch is gone (archived, or never captured) can only read and delete.
2061
+ const patchText = await readRunArtifactText(storeKey, row.id, DIFF_PATCH_FILE);
2062
+ res.json({
2063
+ comments: listDiffComments(storeKey, row.id),
2064
+ patchAvailable: !!patchText,
2065
+ // Section keys the protected-path floor will refuse whatever the line, so the
2066
+ // browser can drop the '+' up front instead of surfacing a 400 on submit. The
2067
+ // preset itself never leaves the server.
2068
+ protectedPaths: protectedSectionKeys(patchText),
2069
+ });
2070
+ } catch (err) { commentsFail(res, err); }
2071
+ }
2072
+
2073
+ async function commentsCreate(req, res, storeKey, id) {
2074
+ try {
2075
+ const row = commentRun(res, storeKey, id);
2076
+ if (!row) return;
2077
+ const body = req.body || {};
2078
+ const patchText = await readRunArtifactText(storeKey, row.id, DIFF_PATCH_FILE);
2079
+ // `!patchText` covers BOTH null (absent/unreadable) and '' (present but empty):
2080
+ // addDiffComment refuses the empty string too, and it must surface as 409, not
2081
+ // as the 400 an anchor failure would get.
2082
+ if (!patchText) {
2083
+ // 409, not 400: the request is well-formed, the RUN is no longer commentable.
2084
+ return res.status(409).json({ error: 'this run has no stored diff — comments cannot be created on it' });
2085
+ }
2086
+ const comment = addDiffComment({
2087
+ storeKey, pipelineId: row.id, patchText,
2088
+ project: body.project ?? null, path: body.path, side: body.side, line: body.line,
2089
+ body: body.body, author: 'user',
2090
+ });
2091
+ res.status(201).json({ comment });
2092
+ } catch (err) {
2093
+ if (err instanceof DiffCommentError) return badRequest(res, err.message);
2094
+ commentsFail(res, err);
2095
+ }
2096
+ }
2097
+
2098
+ /** A comment reached through a run URL must BELONG to that run — never id alone. */
2099
+ function commentOfRun(res, storeKey, id, cid) {
2100
+ const row = commentRun(res, storeKey, id);
2101
+ if (!row) return null;
2102
+ const comment = getDiffComment(cid);
2103
+ if (!comment || comment.storeKey !== storeKey || comment.pipelineId !== row.id) {
2104
+ res.status(404).json({ error: 'comment not found' });
2105
+ return null;
2106
+ }
2107
+ return comment;
2108
+ }
2109
+
2110
+ function commentsPatch(req, res, storeKey, id, cid) {
2111
+ try {
2112
+ // Existence BEFORE shape: an unknown run must 404 on every verb, including a
2113
+ // PATCH whose body happens to be malformed.
2114
+ if (!commentOfRun(res, storeKey, id, cid)) return;
2115
+ const raw = (req.body || {}).resolved;
2116
+ if (typeof raw !== 'boolean') return badRequest(res, 'resolved must be a boolean');
2117
+ res.json({ comment: setDiffCommentResolved(cid, raw) });
2118
+ } catch (err) { commentsFail(res, err); }
2119
+ }
2120
+
2121
+ function commentsDelete(res, storeKey, id, cid) {
2122
+ try {
2123
+ if (!commentOfRun(res, storeKey, id, cid)) return;
2124
+ deleteDiffComment(cid);
2125
+ res.json({ ok: true });
2126
+ } catch (err) { commentsFail(res, err); }
2127
+ }
2128
+
2129
+ app.get('/api/history/:key/:id/comments', async (req, res) => {
2130
+ const key = commentsHistoryKey(res, req.params.key); if (!key) return;
2131
+ await commentsList(res, key, req.params.id);
2132
+ });
2133
+ app.post('/api/history/:key/:id/comments', async (req, res) => {
2134
+ const key = commentsHistoryKey(res, req.params.key); if (!key) return;
2135
+ await commentsCreate(req, res, key, req.params.id);
2136
+ });
2137
+ app.patch('/api/history/:key/:id/comments/:cid', (req, res) => {
2138
+ const key = commentsHistoryKey(res, req.params.key); if (!key) return;
2139
+ const cid = commentIdParam(res, req.params.cid); if (!cid) return;
2140
+ commentsPatch(req, res, key, req.params.id, cid);
2141
+ });
2142
+ app.delete('/api/history/:key/:id/comments/:cid', (req, res) => {
2143
+ const key = commentsHistoryKey(res, req.params.key); if (!key) return;
2144
+ const cid = commentIdParam(res, req.params.cid); if (!cid) return;
2145
+ commentsDelete(res, key, req.params.id, cid);
2146
+ });
2147
+
2148
+ app.get('/api/workspaces/:id/runs/:runId/comments', async (req, res) => {
2149
+ const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
2150
+ await commentsList(res, key, req.params.runId);
2151
+ });
2152
+ app.post('/api/workspaces/:id/runs/:runId/comments', async (req, res) => {
2153
+ const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
2154
+ await commentsCreate(req, res, key, req.params.runId);
2155
+ });
2156
+ app.patch('/api/workspaces/:id/runs/:runId/comments/:cid', (req, res) => {
2157
+ const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
2158
+ const cid = commentIdParam(res, req.params.cid); if (!cid) return;
2159
+ commentsPatch(req, res, key, req.params.runId, cid);
2160
+ });
2161
+ app.delete('/api/workspaces/:id/runs/:runId/comments/:cid', (req, res) => {
2162
+ const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
2163
+ const cid = commentIdParam(res, req.params.cid); if (!cid) return;
2164
+ commentsDelete(res, key, req.params.runId, cid);
2165
+ });
2166
+
2167
+ // Unresolved counts for every run, for the History list pill. Its own endpoint
2168
+ // rather than a field on /api/history: that response has a localStorage skeleton
2169
+ // cache, so a cached paint would show a stale pill; and diff-comments-changed can
2170
+ // repaint pills from here without forcing a whole History reload.
2171
+ app.get('/api/diff-comments/counts', (_req, res) => {
2172
+ try { res.json({ counts: unresolvedCounts() }); } catch (err) { commentsFail(res, err); }
2173
+ });
2174
+
1541
2175
  // ---------------------------------------------------------------------------
1542
2176
  // 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.
2177
+ // (text/x-diff). The route is status-agnostic and always has been: the artifact
2178
+ // exists for every run that reached a checkpoint AND changed something under it
2179
+ // the done path AND the stopped/error paths, which build results too (orchestrator
2180
+ // run() and resume()). A run stopped before its checkpoint has none, nor does one
2181
+ // that changed nothing (_buildResults writes neither artifact for an empty patch),
2182
+ // and neither does an archived one; all of those 404 and the UI shows its empty state.
1545
2183
  // Key validation mirrors the /log route (:1529); the artifact read follows the
1546
2184
  // recovery-patch route's readRunArtifactText pattern (:1408) — the log routes
1547
2185
  // themselves use the specialized readRunLogText. The relPath is the CONSTANT
@@ -1561,6 +2199,23 @@ app.get('/api/history/:key/:id/diff', async (req, res) => {
1561
2199
  }
1562
2200
  });
1563
2201
 
2202
+ // GET /api/history/:key/:id/artifact?rel= -> { rel, text } for ONE artifact the
2203
+ // run indexed (the End card's result chip). `rel` never reaches the FS: it only
2204
+ // selects among the pipeline's own artifacts rows (exact rel_path, else a path
2205
+ // suffix). Same key regex as /diff.
2206
+ app.get('/api/history/:key/:id/artifact', async (req, res) => {
2207
+ if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(req.params.key)) {
2208
+ return res.status(404).json({ error: 'pipeline not found' });
2209
+ }
2210
+ try {
2211
+ const hit = await resolveIndexedArtifact(req.params.key, req.params.id, req.query.rel);
2212
+ if (!hit) return res.status(404).json({ error: 'artifact not found' });
2213
+ res.json(hit);
2214
+ } catch (err) {
2215
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2216
+ }
2217
+ });
2218
+
1564
2219
  // ---------------------------------------------------------------------------
1565
2220
  // DELETE /api/runs/:id?projectKey=... (or ?projectDir=...)
1566
2221
  // ARCHIVE a FINISHED pipeline: reclaims everything on disk — its store folder,
@@ -2062,6 +2717,8 @@ app.get('/api/workspaces/:id/runs/:runId/log', async (req, res) => {
2062
2717
  }
2063
2718
  });
2064
2719
 
2720
+ // NOTE: the /comments twins for workspace runs are registered with their project
2721
+ // siblings up at the diff-comments block — the pair must be read together.
2065
2722
  app.get('/api/workspaces/:id/runs/:runId/diff', async (req, res) => {
2066
2723
  if (!WORKSPACE_KEY_RE.test(req.params.id)) {
2067
2724
  return res.status(404).json({ error: 'pipeline not found' });
@@ -2075,6 +2732,18 @@ app.get('/api/workspaces/:id/runs/:runId/diff', async (req, res) => {
2075
2732
  }
2076
2733
  });
2077
2734
 
2735
+ // The End-card result chip's workspace twin (see the project route above).
2736
+ app.get('/api/workspaces/:id/runs/:runId/artifact', async (req, res) => {
2737
+ if (!WORKSPACE_KEY_RE.test(req.params.id)) return res.status(404).json({ error: 'pipeline not found' });
2738
+ try {
2739
+ const hit = await resolveIndexedArtifact(`workspaces/${req.params.id}`, req.params.runId, req.query.rel);
2740
+ if (!hit) return res.status(404).json({ error: 'artifact not found' });
2741
+ res.json(hit);
2742
+ } catch (err) {
2743
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2744
+ }
2745
+ });
2746
+
2078
2747
  // ---------------------------------------------------------------------------
2079
2748
  // GET /api/settings -> { root, projectsRoot, projectsRootDefault, default }
2080
2749
  // root : the configured Worca CC data-root base, '' when unset
@@ -2106,6 +2775,10 @@ const settingsState = () => ({
2106
2775
  pipelineCostLimitUsd: pipelineCostLimitUsd(),
2107
2776
  totalCostLimitUsd: totalCostLimitUsd(),
2108
2777
  costLimitResetPeriod: costLimitResetPeriod(),
2778
+ askMaxTurns: askMaxTurns(),
2779
+ askMaxBudgetUsd: askMaxBudgetUsd(),
2780
+ debugSpawnEnabled: storedDebugSpawnEnabled(), // what is STORED (the checkbox)
2781
+ debugSpawnEffective: effectiveDebugSpawn(), // what the next spawn will DO, and why
2109
2782
  });
2110
2783
 
2111
2784
  app.get('/api/settings', (_req, res) => {
@@ -2120,6 +2793,8 @@ app.post('/api/settings', async (req, res) => {
2120
2793
  const body = req.body || {};
2121
2794
  const has = (k) => Object.prototype.hasOwnProperty.call(body, k);
2122
2795
  const hasBudgetKey = has('pipelineCostLimitUsd') || has('totalCostLimitUsd') || has('costLimitResetPeriod');
2796
+ const hasAskKey = has('askMaxTurns') || has('askMaxBudgetUsd');
2797
+ const hasDebugSpawnKey = has('debugSpawnEnabled');
2123
2798
  // Normalize the budget keys first, then validate them as a SET before ANY write.
2124
2799
  // Each setter persists on its own, so a two-key POST whose second key is invalid
2125
2800
  // used to answer 400 with the first key already on disk, no budget-changed
@@ -2131,8 +2806,24 @@ app.post('/api/settings', async (req, res) => {
2131
2806
  if (has('costLimitResetPeriod')) {
2132
2807
  budget.costLimitResetPeriod = typeof body.costLimitResetPeriod === 'string' ? body.costLimitResetPeriod : '';
2133
2808
  }
2809
+ // Ask Worca per-turn guards (ask-worca-design.md §6.9): same set-validation
2810
+ // discipline. `null` is a VALUE for askMaxBudgetUsd (no cap) and must survive
2811
+ // normalisation; only undefined becomes a clear.
2812
+ const ask = {};
2813
+ if (has('askMaxTurns')) ask.askMaxTurns = body.askMaxTurns ?? '';
2814
+ if (has('askMaxBudgetUsd')) ask.askMaxBudgetUsd = body.askMaxBudgetUsd === undefined ? '' : body.askMaxBudgetUsd;
2134
2815
  try {
2135
2816
  assertCostLimitInputs(budget);
2817
+ assertAskLimitInputs(ask);
2818
+ if (hasDebugSpawnKey) assertDebugSpawnInput(body.debugSpawnEnabled);
2819
+ // Root first: it is the one key whose setter can still fail AFTER the asserts
2820
+ // above (an unusable path), so every other key's write must come after it or
2821
+ // a mixed POST would answer 400 with those keys already applied on disk.
2822
+ // Legacy contract: a POST that names NO known key clears root; the known
2823
+ // keys live beside their setters (SETTINGS_POST_KEYS), not in a list here.
2824
+ if (has('root') || !SETTINGS_POST_KEYS.some(has)) {
2825
+ await setWorcaRoot(typeof body.root === 'string' ? body.root : '');
2826
+ }
2136
2827
  if (has('chat')) await setChatPrefs(body.chat);
2137
2828
  if (has('projectsRoot')) {
2138
2829
  await setProjectsRoot(typeof body.projectsRoot === 'string' ? body.projectsRoot : '');
@@ -2140,12 +2831,13 @@ app.post('/api/settings', async (req, res) => {
2140
2831
  if (has('pipelineCostLimitUsd')) await setPipelineCostLimitUsd(budget.pipelineCostLimitUsd);
2141
2832
  if (has('totalCostLimitUsd')) await setTotalCostLimitUsd(budget.totalCostLimitUsd);
2142
2833
  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'))) {
2146
- await setWorcaRoot(typeof body.root === 'string' ? body.root : '');
2147
- }
2834
+ if (has('askMaxTurns')) await setAskMaxTurns(ask.askMaxTurns);
2835
+ if (has('askMaxBudgetUsd')) await setAskMaxBudgetUsd(ask.askMaxBudgetUsd);
2836
+ if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
2148
2837
  if (hasBudgetKey) emitChanged('budget-changed');
2838
+ // Other open tabs repaint their Settings cards (a stale tab could otherwise
2839
+ // "save" its old checkbox state over this one with no feedback to either).
2840
+ if (hasAskKey || hasDebugSpawnKey) emitChanged('settings-changed');
2149
2841
  res.json({ ...settingsState(), chat: chatPrefs() });
2150
2842
  } catch (err) {
2151
2843
  // The setters throw only on an unusable path -> client error (400).
@@ -2167,6 +2859,7 @@ app.get('/api/config', async (req, res) => {
2167
2859
  return res.json({
2168
2860
  config: { steps: {}, customModels: [] },
2169
2861
  models: await listModels(''), steps: agentSteps(), efforts: EFFORTS,
2862
+ subagentModels: SUBAGENT_MODEL_VALUES,
2170
2863
  });
2171
2864
  }
2172
2865
  const projectDir = resolveProjectDir(raw);
@@ -2185,6 +2878,10 @@ app.get('/api/config', async (req, res) => {
2185
2878
  ]);
2186
2879
  res.json({
2187
2880
  config, models, steps: agentSteps(), efforts: EFFORTS,
2881
+ // The sub-agent model policy vocabulary is a FIXED alias enum (the CLI's Task
2882
+ // tool refuses catalog ids), so it ships beside `efforts` rather than being
2883
+ // derived from `models`.
2884
+ subagentModels: SUBAGENT_MODEL_VALUES,
2188
2885
  });
2189
2886
  } catch (err) {
2190
2887
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
@@ -2198,6 +2895,7 @@ app.post('/api/config', async (req, res) => {
2198
2895
  try {
2199
2896
  await setStep(projectDir, body.step, {
2200
2897
  model: body.model, effort: body.effort, fanOut: body.fanOut, askQuestions: body.askQuestions,
2898
+ subagentModel: body.subagentModel,
2201
2899
  });
2202
2900
  // Respond with the FULL run-config (mirrors PATCH): setStep's return value is
2203
2901
  // the legacy {steps, customModels} view only, and clients assign the response
@@ -2219,31 +2917,55 @@ app.post('/api/config', async (req, res) => {
2219
2917
  // validates model/effort against the effective catalog exactly like setStep
2220
2918
  // (configurable-models-design.md §4.5) -> 400; setFeedbackCycles still COERCES
2221
2919
  // maxCycles to >= 1 (it never throws).
2222
- // body: { projectDir, workflowId, nodes?:{[id]:{model,effort}}, feedbacks?:{[id]:{maxCycles}}, activeWorkflowId? }
2920
+ // body: { projectDir, workflowId, nodes?:{[id]:{model,effort}}, feedbacks?:{[id]:{maxCycles}}, wires?:{[wireId]:{maxCycles}}, activeWorkflowId? }
2223
2921
  // ---------------------------------------------------------------------------
2224
2922
  app.patch('/api/config', async (req, res) => {
2225
2923
  const body = req.body || {};
2226
2924
  const projectDir = resolveProjectDir(body.projectDir);
2227
2925
  if (!projectDir) return badRequest(res, 'projectDir is required');
2228
2926
  const workflowId = typeof body.workflowId === 'string' ? body.workflowId.trim() : '';
2927
+ // MAJ-1: every arm below keys a normalized table by workflowId, and
2928
+ // readWorkflowsMap rebuilds a map from those keys — an id like '__proto__'
2929
+ // used to be persisted unchecked and then broke readRunConfig for the whole
2930
+ // project. isSafeWorkflowId is the store's own id rule, so the API can never
2931
+ // write an id the store would refuse. (The recovery route DELETE
2932
+ // /api/config/workflow stays deliberately ungated: an already-poisoned row
2933
+ // must still be clearable.)
2934
+ const workflowIdError = (what) => {
2935
+ if (!workflowId) return `workflowId is required to set ${what} config`;
2936
+ if (!isSafeWorkflowId(workflowId)) return 'invalid workflowId';
2937
+ return null;
2938
+ };
2229
2939
  try {
2230
2940
  if (body.nodes && typeof body.nodes === 'object') {
2231
- if (!workflowId) return badRequest(res, 'workflowId is required to set node config');
2941
+ const bad = workflowIdError('node');
2942
+ if (bad) return badRequest(res, bad);
2232
2943
  for (const [nodeId, sel] of Object.entries(body.nodes)) {
2233
2944
  await setNodeModel(projectDir, workflowId, nodeId, {
2234
2945
  model: sel && sel.model, effort: sel && sel.effort,
2235
2946
  fanOut: sel && sel.fanOut, askQuestions: sel && sel.askQuestions,
2947
+ subagentModel: sel && sel.subagentModel,
2236
2948
  });
2237
2949
  }
2238
2950
  }
2239
2951
  if (body.feedbacks && typeof body.feedbacks === 'object') {
2240
- if (!workflowId) return badRequest(res, 'workflowId is required to set feedback config');
2952
+ const bad = workflowIdError('feedback');
2953
+ if (bad) return badRequest(res, bad);
2241
2954
  for (const [fbId, sel] of Object.entries(body.feedbacks)) {
2242
2955
  await setFeedbackCycles(projectDir, workflowId, fbId, sel && sel.maxCycles);
2243
2956
  }
2244
2957
  }
2958
+ if (body.wires && typeof body.wires === 'object') {
2959
+ const bad = workflowIdError('wire');
2960
+ if (bad) return badRequest(res, bad);
2961
+ for (const [wireId, sel] of Object.entries(body.wires)) {
2962
+ await setWireCycles(projectDir, workflowId, wireId, sel && sel.maxCycles);
2963
+ }
2964
+ }
2245
2965
  if (typeof body.activeWorkflowId === 'string' && body.activeWorkflowId.trim()) {
2246
- await setActiveWorkflow(projectDir, body.activeWorkflowId.trim());
2966
+ const active = body.activeWorkflowId.trim();
2967
+ if (!isSafeWorkflowId(active)) return badRequest(res, 'invalid workflowId');
2968
+ await setActiveWorkflow(projectDir, active);
2247
2969
  }
2248
2970
  const config = await readRunConfig(projectDir);
2249
2971
  res.json({ config });
@@ -2301,8 +3023,7 @@ app.delete('/api/config/models', async (req, res) => {
2301
3023
  // back means "keep" and is dropped from the write.
2302
3024
  // ---------------------------------------------------------------------------
2303
3025
 
2304
- const maskEnvValue = (v) =>
2305
- (modelEnvRef(v) ? v : (v.length > 8 ? `••••••${v.slice(-4)}` : '••••••'));
3026
+ const maskEnvValue = (v) => (modelEnvRef(v) ? v : maskModelEnvValue(v));
2306
3027
  const maskedGlobalModel = (m) => (m.env
2307
3028
  ? { ...m, env: Object.fromEntries(Object.entries(m.env).map(([k, v]) => [k, maskEnvValue(v)])) }
2308
3029
  : m);
@@ -2330,6 +3051,7 @@ const pluginModelsPayload = () => {
2330
3051
  k, typeof v === 'string' ? maskEnvValue(v) : `(secret: ${v.secret})`,
2331
3052
  ])),
2332
3053
  secrets: status.filter((s) => m.secrets.includes(s.key)),
3054
+ ...(m.cost ? { cost: m.cost } : {}), // manifest-pinned pricing — config, never a credential
2333
3055
  ...(flagged.has(m.id.toLowerCase()) ? { costUnreliable: true } : {}),
2334
3056
  };
2335
3057
  });
@@ -2342,7 +3064,7 @@ app.get('/api/models', (req, res) => {
2342
3064
  app.post('/api/models', async (req, res) => {
2343
3065
  const b = req.body || {};
2344
3066
  try {
2345
- const model = await addGlobalModel({ id: b.id, label: b.label, efforts: b.efforts, env: b.env });
3067
+ const model = await addGlobalModel({ id: b.id, label: b.label, efforts: b.efforts, env: b.env, cost: b.cost });
2346
3068
  res.json({ model: maskedGlobalModel(model), models: maskedGlobalModels() });
2347
3069
  } catch (err) {
2348
3070
  // addGlobalModel throws only on validation (empty/dup id, unknown effort,
@@ -2425,6 +3147,10 @@ app.post('/api/models/export-plugin', async (req, res) => {
2425
3147
  ...(entry.label !== entry.id ? { label: entry.label } : {}),
2426
3148
  ...(entry.efforts.length && entry.efforts.length !== EFFORTS.length ? { efforts: entry.efforts } : {}),
2427
3149
  ...(Object.keys(env).length ? { env } : {}),
3150
+ // Pricing travels with the model. It is configuration, not a credential —
3151
+ // and a shared on-prem model is precisely one the CLI would otherwise
3152
+ // price by NAME on every machine that installs the plugin.
3153
+ ...(entry.cost ? { cost: entry.cost } : {}),
2428
3154
  });
2429
3155
  }
2430
3156
 
@@ -2495,7 +3221,7 @@ app.patch('/api/models/:id', async (req, res) => {
2495
3221
  env = Object.fromEntries(Object.entries(env).filter(([, v]) => !isMaskedEcho(v)));
2496
3222
  }
2497
3223
  try {
2498
- const model = await updateGlobalModel(req.params.id, { label: b.label, efforts: b.efforts, env });
3224
+ const model = await updateGlobalModel(req.params.id, { label: b.label, efforts: b.efforts, env, cost: b.cost });
2499
3225
  res.json({ model: maskedGlobalModel(model), models: maskedGlobalModels() });
2500
3226
  } catch (err) {
2501
3227
  // updateGlobalModel throws only on validation (unknown id, unknown effort,
@@ -2534,6 +3260,35 @@ app.delete('/api/models/:id', async (req, res) => {
2534
3260
  }
2535
3261
  });
2536
3262
 
3263
+ // Live connectivity check for a catalog model — the Models-view Test button.
3264
+ // Explicit user action only (one real, tiny API call against wherever the
3265
+ // model routes). Caller mistakes get an HTTP status; the test OUTCOME rides a
3266
+ // 200 envelope, same convention as POST /api/chat/test. Ids resolve global
3267
+ // first, then plugin — resolveModelEnv's precedence.
3268
+ const modelTestsInFlight = new Set();
3269
+ app.post('/api/models/:id/test', async (req, res) => {
3270
+ const id = String(req.params.id);
3271
+ const lc = id.toLowerCase();
3272
+ const global = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
3273
+ const plugin = global ? null : listPluginModels().find((m) => m.id.toLowerCase() === lc);
3274
+ if (!global && !plugin) return res.status(404).json({ error: `unknown model id ${JSON.stringify(id)}` });
3275
+ if (plugin && plugin.secrets.length) {
3276
+ // Don't burn a spawn guaranteed to fail — resolveModelEnv drops unset secrets.
3277
+ const unset = pluginModelSecretStatus(plugin.plugin)
3278
+ .filter((s) => plugin.secrets.includes(s.key) && !s.set).map((s) => s.key);
3279
+ if (unset.length) {
3280
+ return badRequest(res, `secret ${unset.join(', ')} is not set — configure it in the plugin's settings`);
3281
+ }
3282
+ }
3283
+ if (modelTestsInFlight.has(lc)) return badRequest(res, 'test already running for this model');
3284
+ modelTestsInFlight.add(lc);
3285
+ try {
3286
+ res.json(await testModel(id));
3287
+ } finally {
3288
+ modelTestsInFlight.delete(lc);
3289
+ }
3290
+ });
3291
+
2537
3292
  // ---------------------------------------------------------------------------
2538
3293
  // Workflow templates (global store at ~/.worca-cc/workflows). Topology only;
2539
3294
  // model/effort/cycles live in per-project run-config. CRUD mirrors the
@@ -2550,6 +3305,10 @@ function nodeDefaultsError(raw, models, where) {
2550
3305
  const effort = typeof raw.effort === 'string' ? raw.effort.trim() : '';
2551
3306
  const entry = model ? models.find((m) => m.id === model) : null;
2552
3307
  if (model && !entry) return `unknown model "${model}"`;
3308
+ // subagentModel is a fixed alias enum, NOT a catalog id: validated via the
3309
+ // shared helper so a typo is a 400 with the same message every writer uses.
3310
+ const subIssue = subagentModelIssue(raw.subagentModel);
3311
+ if (subIssue) return subIssue;
2553
3312
  if (!effort) return '';
2554
3313
  if (!EFFORTS.includes(effort)) return `unknown effort "${effort}"`;
2555
3314
  if (!entry) return 'select a model before choosing an effort';
@@ -2557,11 +3316,15 @@ function nodeDefaultsError(raw, models, where) {
2557
3316
  return '';
2558
3317
  }
2559
3318
 
2560
- app.get('/api/workflows', async (_req, res) => {
3319
+ app.get('/api/workflows', async (req, res) => {
2561
3320
  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
3321
+ if (isTruthy(req.query.archived)) {
3322
+ const all = await listWorkflows({ includeArchived: true });
3323
+ return res.json({ workflows: all.filter((w) => w.archivedAt) });
3324
+ }
3325
+ // CONTRACT: [ GRAPH_DEFAULT_WORKFLOW, ...listWorkflows() ]. The built-in is
3326
+ // never a persisted row (listWorkflows filters its id), so it cannot appear twice.
3327
+ res.json({ workflows: [GRAPH_DEFAULT_WORKFLOW, ...(await listWorkflows())] });
2565
3328
  } catch (err) {
2566
3329
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
2567
3330
  }
@@ -2569,51 +3332,80 @@ app.get('/api/workflows', async (_req, res) => {
2569
3332
 
2570
3333
  app.get('/api/workflows/:id', async (req, res) => {
2571
3334
  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);
3335
+ // ONE gate, ONE message: an archived id explains itself instead of reading
3336
+ // as a plain 404 (assertRunnableWorkflow owns both texts). checkGraph:false
3337
+ // this is a READ (the Composer's Open): a template stranded by an agent-port
3338
+ // edit must still load, or the user could never repair it. The RUN path keeps
3339
+ // the graph check.
3340
+ res.json(await assertRunnableWorkflow(req.params.id, { checkGraph: false }));
2575
3341
  } catch (err) {
3342
+ if (err && (err.code === 'NOT_FOUND' || err.code === 'ARCHIVED')) {
3343
+ return res.status(404).json({ error: err.message });
3344
+ }
2576
3345
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
2577
3346
  }
2578
3347
  });
2579
3348
 
2580
3349
  app.post('/api/workflows', async (req, res) => {
2581
3350
  const body = req.body || {};
2582
- // Build the candidate template from the editor payload (topology only).
2583
- const tpl = {
3351
+ // The v1 pipeline format is RETIRED: only graphs are accepted (spec §10.2).
3352
+ // The whole v1 arm (its steps-borne node defaults, validateWorkflow and
3353
+ // writeWorkflow) died with it — nothing reaches the v1 store through the API.
3354
+ if (body.version !== 2) {
3355
+ return badRequest(res, 'v1 pipeline templates are no longer accepted — save a graph (version 2)');
3356
+ }
3357
+ // ── v2 graph save ──────────────────────────────────────────────────────────
3358
+ // The 422 body is the SHARED validator's issue list, by construction: the
3359
+ // composer renders exactly what it would have computed locally, so the server
3360
+ // and the client can never disagree about why a graph is illegal.
3361
+ const graph = {
3362
+ id: typeof body.id === 'string' ? body.id : undefined,
2584
3363
  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 : [],
3364
+ domain: typeof body.domain === 'string' ? body.domain : undefined,
3365
+ nodes: Array.isArray(body.nodes) ? body.nodes : [],
3366
+ wires: Array.isArray(body.wires) ? body.wires : [],
3367
+ ...(body.canvas && typeof body.canvas === 'object' ? { canvas: body.canvas } : {}),
2588
3368
  };
2589
- if (!tpl.name) return badRequest(res, 'name is required');
3369
+ if (!graph.name) return badRequest(res, 'name is required');
2590
3370
  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.
3371
+ // Catalog validation FIRST: a v2 node's `config` IS its defaults block (§4),
3372
+ // so a value the per-project override could not name must not ride in
3373
+ // through a template save. nodeDefaultsError checks the tunables (model +
3374
+ // effort against the catalog, subagentModel against the alias enum), so
3375
+ // only AGENT_TUNABLES are handed to it — topology keys (awaitAll, arity,
3376
+ // planStoreSeed) never are.
2594
3377
  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
- }
3378
+ for (const n of graph.nodes) {
3379
+ if (!n || n.kind !== 'agent' || !n.config || typeof n.config !== 'object') continue;
3380
+ const picked = Object.fromEntries(
3381
+ AGENT_TUNABLES.filter((k) => k in n.config).map((k) => [k, n.config[k]]));
3382
+ const bad = nodeDefaultsError(picked, models, `node "${n.id}"`);
3383
+ if (bad) return badRequest(res, bad);
2601
3384
  }
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 });
3385
+ const portsFn = registryPortsFn(loadAgentRegistry(AGENTS_DIR));
3386
+ const { errors, warnings } = validateGraph({ ...graph, version: 2 }, portsFn);
3387
+ if (errors.length) return res.status(422).json({ error: 'invalid graph', errors, warnings });
3388
+ // rejectCollision (MAJ-5): the body carried no id, so wf_<slug(name)> is a
3389
+ // GUESS it must never silently replace a pipeline the user can see.
3390
+ const workflow = await writeGraphWorkflow(graph, { rejectCollision: true });
3391
+ return res.status(201).json({ workflow, warnings });
2608
3392
  } catch (err) {
2609
- res.status(500).json({ error: err && err.message ? err.message : String(err) });
3393
+ // C-3: a name that slugs onto the reserved wf_default is a caller error, not
3394
+ // a server fault — 422, the same code the validator's refusal uses. MAJ-5: a
3395
+ // minted id already in use is a 409 carrying that id, so the dialog can offer
3396
+ // rename/overwrite. Both bodies carry NO issues/errors array on purpose:
3397
+ // app.js's saveWorkflow maps `error` straight into the save dialog's message
3398
+ // line, verbatim.
3399
+ if (err && err.code === 'RESERVED_NAME') return res.status(422).json({ error: err.message });
3400
+ if (err && err.code === 'ID_TAKEN') return res.status(409).json({ error: err.message, id: err.id });
3401
+ return res.status(500).json({ error: err && err.message ? err.message : String(err) });
2610
3402
  }
2611
3403
  });
2612
3404
 
2613
3405
  // ---------------------------------------------------------------------------
2614
3406
  // PATCH /api/workflows/:id/defaults -> set the template's per-node defaults
2615
3407
  // (newpipeline-ux-design.md §4.4). body: { defaults: { [nodeId]: {model?, effort?,
2616
- // fanOut?, askQuestions?} | null } }; null (or an empty block) clears a node, an
3408
+ // fanOut?, askQuestions?, subagentModel?} | null } }; null (or an empty block) clears a node, an
2617
3409
  // absent node keeps what it has. Model/effort validate against the PROJECT-LESS
2618
3410
  // catalog (predefined ⊕ global ⊕ plugin) — defaults are global, so a legacy
2619
3411
  // per-project custom model is deliberately not a valid default.
@@ -2750,32 +3542,769 @@ app.delete('/api/guardrails/:id', async (req, res) => {
2750
3542
  }
2751
3543
  });
2752
3544
 
3545
+ // ---------------------------------------------------------------------------
3546
+ // Ask Worca (ask-worca-design.md §8). askJobs is SEPARATE from the runs Map —
3547
+ // the client's Running badge counts runs entries, and a thread id is the
3548
+ // subscription key (§8.3). No store/home access at import time (the chatCtx
3549
+ // rule): the Maps are bare and every store call lives inside a handler or
3550
+ // bootMaintenance.
3551
+ // ---------------------------------------------------------------------------
3552
+ const askJobs = new Map(); // threadId -> {turn, messageId, userMessageId, events, seq, status, startedAt, graceTimer}
3553
+ // Threads whose DELETE is past its first await (worktree removal spawns git):
3554
+ // POST /messages refuses them so no turn can start against rows that are
3555
+ // about to cascade (review of PR #376 — a turn started in that window outlived
3556
+ // the delete as a live job holding a global slot).
3557
+ const askDeleting = new Set();
3558
+ const askFollowers = new Map(); // threadId -> Set<{detach}>
3559
+ const ASK_JOB_MAX_BUFFER = 5000; // same arithmetic as MAX_BUFFER: deltas dominate; eviction ⇒ client seq-gap re-sync
3560
+
3561
+ function askInFlight(threadId) {
3562
+ const job = askJobs.get(threadId);
3563
+ return job && job.status === 'running' ? job : null;
3564
+ }
3565
+
3566
+ function askRunningCount() {
3567
+ let n = 0;
3568
+ for (const job of askJobs.values()) if (job.status === 'running') n += 1;
3569
+ return n;
3570
+ }
3571
+
3572
+ /** hello payload: running turns only (§8.2). A job whose slot was just
3573
+ * reserved (messageId still null — the message route's atomic reservation,
3574
+ * Task 6) is skipped: it becomes visible once its assistant row exists. */
3575
+ function askHello() {
3576
+ const out = [];
3577
+ for (const [threadId, job] of askJobs.entries()) {
3578
+ if (job.status === 'running' && job.messageId) out.push({ threadId, messageId: job.messageId });
3579
+ }
3580
+ return out;
3581
+ }
3582
+
3583
+ /** Replay a job's stamped ring buffer to one socket. No state snapshot — the
3584
+ * REST thread GET is the snapshot; the client dedupes by seq (§6.6). */
3585
+ function replayAskJob(ws, job) {
3586
+ for (const ev of job.events) send(ws, ev);
3587
+ }
3588
+
3589
+ /** The stamping closure (§17: reducer frames are BARE; the server stamps).
3590
+ * Shared by the turn's own ask-start/ask-done/ask-error and every reducer
3591
+ * frame, so ALL job frames are buffered, replayed and seq-ordered alike. */
3592
+ function stampAskFrames(threadId, job) {
3593
+ return (bare) => {
3594
+ const frame = { ...bare, threadId, messageId: job.messageId, seq: ++job.seq };
3595
+ job.events.push(frame);
3596
+ if (job.events.length > ASK_JOB_MAX_BUFFER) job.events.splice(0, job.events.length - ASK_JOB_MAX_BUFFER);
3597
+ broadcast(frame);
3598
+ };
3599
+ }
3600
+
3601
+ /** The narrow worktree envelope the snapshot GET and the `ask-worktrees` frame
3602
+ * share (P4 §10): never the full row — threadId/projectDir/updatedAt stay
3603
+ * server-side. Mirrors the list_worktrees MCP tool (src/core/ask/tools.mjs). */
3604
+ function askWorktreesEnvelope(threadId) {
3605
+ return askListWorktrees(threadId).map((w) => ({
3606
+ worktreeId: w.worktreeId, projectKey: w.projectKey, ref: w.ref,
3607
+ commit: w.commit, path: w.path, createdAt: w.createdAt,
3608
+ }));
3609
+ }
3610
+
3611
+ /** Broadcast the thread's CURRENT worktrees as an out-of-turn frame (seq-less,
3612
+ * threadId-tagged, like ask-title). Fed by the turn's onWorktreeMutation hook —
3613
+ * the MCP child opened/removed/navigated a checkout this process never saw —
3614
+ * and by the manual DELETE route, so every tab's count and popover follow
3615
+ * without a snapshot GET. Best effort; false when the thread is gone. */
3616
+ function emitAskWorktrees(threadId) {
3617
+ try {
3618
+ if (!askGetThread(threadId)) return false;
3619
+ broadcast({ type: 'ask-worktrees', threadId, worktrees: askWorktreesEnvelope(threadId) });
3620
+ return true;
3621
+ } catch { return false; } // a poke is best effort
3622
+ }
3623
+
3624
+ /** 400 on shape (spec §8.1 — a DELIBERATE divergence from the house 404-on-
3625
+ * malformed-param style), null-return contract like badRequest. */
3626
+ function askIdParam(res, value, kind) {
3627
+ if (typeof value !== 'string' || !ASK_ID_RE.test(value)) {
3628
+ res.status(400).json({ error: `invalid ${kind} id` });
3629
+ return null;
3630
+ }
3631
+ return value;
3632
+ }
3633
+
3634
+ app.get('/api/ask/threads', (req, res) => {
3635
+ try {
3636
+ const raw = Number.parseInt(String(req.query.limit ?? ''), 10);
3637
+ const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 200) : 50;
3638
+ const threads = askListThreads({ limit }).map((t) => ({ ...t, inFlight: !!askInFlight(t.id) }));
3639
+ // total = EVERY saved chat (the History popover's meter), not the capped page above.
3640
+ res.json({ threads, total: askCountThreads() });
3641
+ } catch (err) {
3642
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3643
+ }
3644
+ });
3645
+
3646
+ // Settings → "Delete all chat history": the counts the confirm dialog quotes,
3647
+ // read fresh right before it opens.
3648
+ app.get('/api/ask/history', (req, res) => {
3649
+ try {
3650
+ res.json({
3651
+ threads: askCountThreads(),
3652
+ worktrees: askCountWorktrees(),
3653
+ attachments: askCountAttachments(),
3654
+ inFlight: askRunningCount(),
3655
+ });
3656
+ } catch (err) {
3657
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3658
+ }
3659
+ });
3660
+
3661
+ // Bulk delete: every thread through deleteAskThreadFully, SEQUENTIALLY (each
3662
+ // worktree removal spawns git — never in parallel), best-effort per thread. The
3663
+ // ids come from listThreadIds (no cap), never from the LIMIT-ed listThreads.
3664
+ // One JSON at the end; then a seq-less out-of-turn frame so every open tab
3665
+ // drops its now-dead st.threadId (the panel would otherwise keep it until the
3666
+ // next 404).
3667
+ app.delete('/api/ask/threads', async (req, res) => {
3668
+ const removed = { threads: 0, worktrees: 0 };
3669
+ const failed = [];
3670
+ try {
3671
+ for (const id of askListThreadIds()) {
3672
+ try {
3673
+ const r = await deleteAskThreadFully(id);
3674
+ if (r.deleted) {
3675
+ removed.threads += 1;
3676
+ removed.worktrees += r.worktrees;
3677
+ } else failed.push(id);
3678
+ } catch {
3679
+ failed.push(id);
3680
+ }
3681
+ }
3682
+ res.json({ ok: true, removed, failed });
3683
+ broadcast({ type: 'ask-history-cleared' });
3684
+ } catch (err) {
3685
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3686
+ }
3687
+ });
3688
+
3689
+ app.post('/api/ask/threads', (req, res) => {
3690
+ try {
3691
+ const body = req.body || {};
3692
+ let title = null;
3693
+ if (body.title !== undefined && body.title !== null && body.title !== '') {
3694
+ if (typeof body.title !== 'string' || body.title.length > 120) {
3695
+ return badRequest(res, 'title must be a string of at most 120 characters');
3696
+ }
3697
+ title = body.title.trim() || null;
3698
+ }
3699
+ const thread = askCreateThread();
3700
+ if (title) askUpdateThread(thread.id, { title });
3701
+ res.status(201).json({ thread: askGetThread(thread.id) });
3702
+ } catch (err) {
3703
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3704
+ }
3705
+ });
3706
+
3707
+ app.get('/api/ask/threads/:id', (req, res) => {
3708
+ const id = askIdParam(res, req.params.id, 'thread');
3709
+ if (!id) return;
3710
+ try {
3711
+ const thread = askGetThread(id);
3712
+ if (!thread) return res.status(404).json({ error: 'thread not found' });
3713
+ const job = askInFlight(id);
3714
+ res.json({
3715
+ thread,
3716
+ messages: askListMessages(id),
3717
+ attachments: askListAttachments(id),
3718
+ runLinks: askListRunLinks(id),
3719
+ // P4 §10: the SAME narrow envelope the list_worktrees MCP tool and the
3720
+ // ask-worktrees frame carry — never the full row.
3721
+ worktrees: askWorktreesEnvelope(id),
3722
+ inFlight: job && job.messageId ? { messageId: job.messageId } : null, // null while the slot is only reserved
3723
+ });
3724
+ } catch (err) {
3725
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3726
+ }
3727
+ });
3728
+
3729
+ app.patch('/api/ask/threads/:id', (req, res) => {
3730
+ const id = askIdParam(res, req.params.id, 'thread');
3731
+ if (!id) return;
3732
+ try {
3733
+ const body = req.body || {};
3734
+ const patch = {};
3735
+ // Title keeps its original contract exactly: a PATCH that names neither field
3736
+ // still earns the title error, so pre-#397 callers see identical behaviour.
3737
+ if (body.title !== undefined || body.scope === undefined) {
3738
+ const raw = body.title;
3739
+ if (typeof raw !== 'string' || !raw.trim() || raw.length > 120) {
3740
+ return badRequest(res, 'title must be a non-empty string of at most 120 characters');
3741
+ }
3742
+ patch.title = raw.trim();
3743
+ }
3744
+ if (body.scope !== undefined) {
3745
+ // #397: the Ask panel's scope selector. Merged per field into the stored
3746
+ // context — the pin replaces only the target keys, so the last page
3747
+ // context (view, run, diff file) survives a selector change.
3748
+ const sv = askValidateScope(body.scope);
3749
+ if (!sv.ok) return badRequest(res, sv.error);
3750
+ const cur = askGetThread(id);
3751
+ if (!cur) return res.status(404).json({ error: 'thread not found' });
3752
+ const base = cur.context && typeof cur.context === 'object' && !Array.isArray(cur.context) ? { ...cur.context } : {};
3753
+ delete base.projectDir;
3754
+ delete base.projectKey;
3755
+ delete base.workspaceId;
3756
+ patch.context = { ...base, ...sv.scope };
3757
+ }
3758
+ const thread = askUpdateThread(id, patch);
3759
+ if (!thread) return res.status(404).json({ error: 'thread not found' });
3760
+ res.json({ thread });
3761
+ } catch (err) {
3762
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3763
+ }
3764
+ });
3765
+
3766
+ // §7.5 order: abort the in-flight turn -> detach followers -> remove the chat's
3767
+ // worktrees git-properly -> delete the row (tx + cascades) + rm -rf inside
3768
+ // deleteThread -> drop the job entry. Shared by the per-thread DELETE and the
3769
+ // bulk DELETE; askDeleting brackets the whole thing per id (POST /messages
3770
+ // refuses the thread while its delete is past the first await).
3771
+ // Returns { deleted, worktrees } — worktrees = rows removeThreadWorktrees removed.
3772
+ async function deleteAskThreadFully(id) {
3773
+ askDeleting.add(id);
3774
+ try {
3775
+ const stopJob = () => {
3776
+ const job = askJobs.get(id);
3777
+ if (job && job.turn && typeof job.turn.stop === 'function') {
3778
+ try { job.turn.stop(); } catch { /* best-effort */ }
3779
+ }
3780
+ return job;
3781
+ };
3782
+ stopJob();
3783
+ const followers = askFollowers.get(id);
3784
+ if (followers) {
3785
+ for (const f of [...followers]) {
3786
+ try { f.detach(); } catch { /* best-effort */ }
3787
+ }
3788
+ askFollowers.delete(id);
3789
+ }
3790
+ // P4 §5: git-proper removal of every worktree BEFORE the row cascade — the
3791
+ // rmSync inside askDeleteThread alone would leave stale `git worktree`
3792
+ // registrations in the source repos. Never throws (best-effort per row).
3793
+ const { removed } = await askRemoveThreadWorktrees(id);
3794
+ // Re-read the job AFTER the await: askDeleting blocks new turns, but a turn
3795
+ // that was already mid-start is stopped here rather than left running.
3796
+ const job = stopJob();
3797
+ const deleted = askDeleteThread(id);
3798
+ if (job) {
3799
+ if (job.graceTimer) clearTimeout(job.graceTimer);
3800
+ askJobs.delete(id);
3801
+ }
3802
+ return { deleted, worktrees: removed };
3803
+ } finally {
3804
+ askDeleting.delete(id);
3805
+ }
3806
+ }
3807
+
3808
+ app.delete('/api/ask/threads/:id', async (req, res) => {
3809
+ const id = askIdParam(res, req.params.id, 'thread');
3810
+ if (!id) return;
3811
+ try {
3812
+ if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
3813
+ await deleteAskThreadFully(id);
3814
+ res.json({ ok: true });
3815
+ } catch (err) {
3816
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3817
+ }
3818
+ });
3819
+
3820
+ // P4 §10: manual worktree delete from the panel. Allowed while a turn is in
3821
+ // flight — the model's next operation on it gets a clean tool error.
3822
+ app.delete('/api/ask/threads/:id/worktrees/:wtId', async (req, res) => {
3823
+ const id = askIdParam(res, req.params.id, 'thread');
3824
+ if (!id) return;
3825
+ const wtId = askIdParam(res, req.params.wtId, 'worktree');
3826
+ if (!wtId) return;
3827
+ try {
3828
+ if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
3829
+ const out = await askRemoveWorktree({ threadId: id, wtId });
3830
+ emitAskWorktrees(id); // every open tab's count/popover follows the delete
3831
+ res.json(out);
3832
+ } catch (err) {
3833
+ if (err && err.name === 'AskWorktreeError') return res.status(404).json({ error: err.message });
3834
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3835
+ }
3836
+ });
3837
+
3838
+ app.get('/api/ask/threads/:id/attachments/:attId', (req, res) => {
3839
+ const id = askIdParam(res, req.params.id, 'thread');
3840
+ if (!id) return;
3841
+ const attId = askIdParam(res, req.params.attId, 'attachment');
3842
+ if (!attId) return;
3843
+ try {
3844
+ if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
3845
+ const att = askGetAttachment(id, attId);
3846
+ const file = att ? askAttachmentPath(id, attId) : null;
3847
+ if (!file) return res.status(404).json({ error: 'attachment not found' });
3848
+ // Text bodies serve as utf-8 text/plain (pre-#398, byte-for-byte: the body
3849
+ // was UTF-8-validated at upload and stored verbatim). Only sniff-verified
3850
+ // allowlisted mimes are ever stored (never scriptable markup like SVG/HTML),
3851
+ // so serving the real mime inline is safe — and it is what lets the
3852
+ // transcript render <img> thumbnails (#398).
3853
+ const type = att.kind === 'text' ? 'text/plain; charset=utf-8' : (att.mime || 'application/octet-stream');
3854
+ // Streamed, not readFileSync + send: a body is immutable under its
3855
+ // store-minted id, so a stat-based ETag/Last-Modified plus a year-long
3856
+ // private immutable cache replaces a 5 MB sync read and sha1 per request —
3857
+ // the transcript re-creates every <img> on each structural render.
3858
+ res.sendFile(path.basename(file), {
3859
+ root: path.dirname(file),
3860
+ dotfiles: 'deny',
3861
+ cacheControl: false,
3862
+ headers: {
3863
+ 'Content-Type': type,
3864
+ 'X-Content-Type-Options': 'nosniff',
3865
+ 'Content-Disposition': 'inline',
3866
+ 'Cache-Control': 'private, max-age=31536000, immutable',
3867
+ },
3868
+ }, (err) => {
3869
+ if (!err || res.headersSent) return;
3870
+ if (err.code === 'ENOENT' || err.status === 404) return res.status(404).json({ error: 'attachment not found' });
3871
+ res.status(500).json({ error: err.message || String(err) });
3872
+ });
3873
+ } catch (err) {
3874
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3875
+ }
3876
+ });
3877
+
3878
+ // D8/§8.1: the chat model catalog. Fresh per request (the /api/config
3879
+ // precedent) — a cache would go stale against global-model edits.
3880
+ app.get('/api/ask/models', async (_req, res) => {
3881
+ try {
3882
+ res.json(await askCatalog());
3883
+ } catch (err) {
3884
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3885
+ }
3886
+ });
3887
+
3888
+ /** lookupPipelineRow/findPipelineRowById return the RAW `SELECT * FROM pipelines`
3889
+ * row: snake_case columns, and `branch` is a JSON DOCUMENT
3890
+ * ({source, feature, worktreeDir, …}), not a branch name. Reading
3891
+ * row.startedAt/row.branch directly loses the date and pastes a JSON blob into
3892
+ * the [worca context] line (dry-run-verified). */
3893
+ function askRunFromPipelineRow(row) {
3894
+ let branchObj = null;
3895
+ if (typeof row.branch === 'string') {
3896
+ try { branchObj = JSON.parse(row.branch); } catch { branchObj = null; }
3897
+ } else if (row.branch && typeof row.branch === 'object') {
3898
+ branchObj = row.branch;
3899
+ }
3900
+ const branch = branchObj && typeof branchObj.feature === 'string'
3901
+ ? branchObj.feature
3902
+ : (typeof branchObj === 'string' ? branchObj : null);
3903
+ return {
3904
+ id: row.id,
3905
+ title: row.title || '',
3906
+ status: row.status || '',
3907
+ startedAt: row.started_at || row.updated_at || '',
3908
+ branch,
3909
+ };
3910
+ }
3911
+
3912
+ /** #397: the user-pinned scope of an ask context — {projectKey} | {workspaceId} | null. */
3913
+ function askPinnedScope(context) {
3914
+ if (!context || typeof context !== 'object' || context.pinned !== true) return null;
3915
+ if (typeof context.projectKey === 'string' && context.projectKey) return { projectKey: context.projectKey };
3916
+ if (typeof context.workspaceId === 'string' && context.workspaceId) return { workspaceId: context.workspaceId };
3917
+ return null;
3918
+ }
3919
+
3920
+ /** #397 per-field merge: the pinned scope replaces the page context's TARGET keys
3921
+ * (projectDir/projectKey/workspaceId); view, run, pipeline and diff-file context
3922
+ * still follow the page. */
3923
+ function askApplyPin(ctx, pin) {
3924
+ const out = { ...ctx, pinned: true };
3925
+ delete out.projectDir;
3926
+ delete out.projectKey;
3927
+ delete out.workspaceId;
3928
+ return { ...out, ...pin };
3929
+ }
3930
+
3931
+ /** #397 selector PATCH body: {pinned:false} | {pinned:true, projectKey|workspaceId}. */
3932
+ function askValidateScope(raw) {
3933
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, error: 'scope must be an object' };
3934
+ if (typeof raw.pinned !== 'boolean') return { ok: false, error: 'scope.pinned must be true or false' };
3935
+ if (!raw.pinned) return { ok: true, scope: { pinned: false } };
3936
+ const cv = validateClientContext({ projectKey: raw.projectKey, workspaceId: raw.workspaceId });
3937
+ if (!cv.ok) return { ok: false, error: cv.error.replace('context.', 'scope.') };
3938
+ const keys = ['projectKey', 'workspaceId'].filter((k) => cv.context[k]);
3939
+ if (keys.length !== 1) return { ok: false, error: 'scope needs exactly one of projectKey / workspaceId' };
3940
+ return { ok: true, scope: { pinned: true, [keys[0]]: cv.context[keys[0]] } };
3941
+ }
3942
+
3943
+ /** Resolve the VALIDATED client context into the server-side shape
3944
+ * buildContextHeader consumes (§6.5: server-resolved rows only — never
3945
+ * client-supplied titles or paths). Every lookup is individually guarded:
3946
+ * a vanished row degrades to an absent header line, never a 500. */
3947
+ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], currentMessageId = null) {
3948
+ const out = { now: new Date().toISOString() };
3949
+ if (ctx.pinned === true) out.pinned = true; // #397: rendered as the [pinned by the user] marker
3950
+ if (ctx.view) out.view = ctx.view;
3951
+ if (ctx.diffPath) out.diffPath = ctx.diffPath; // client-supplied, already length-checked by validateClientContext
3952
+ try {
3953
+ if (ctx.projectKey || ctx.projectDir) {
3954
+ const projects = await listProjects();
3955
+ const p = projects.find((x) =>
3956
+ (ctx.projectKey && x.key === ctx.projectKey) || (ctx.projectDir && x.path === ctx.projectDir));
3957
+ if (p) out.project = { name: p.name, key: p.key };
3958
+ }
3959
+ } catch { /* absent line */ }
3960
+ try {
3961
+ if (ctx.workspaceId) {
3962
+ const ws = await readWorkspace(ctx.workspaceId);
3963
+ if (ws) {
3964
+ // readWorkspace returns {id, name, projectPaths, projectKeys, …} — there
3965
+ // is NO per-member name object (the {projectName} shape is a local
3966
+ // /api/run construction, ui/server.mjs:894). Member display names are
3967
+ // the path basenames, same as that precedent.
3968
+ out.workspace = {
3969
+ name: ws.name, id: ws.id,
3970
+ members: (ws.projectPaths || []).map((p) => path.basename(p)).filter(Boolean),
3971
+ };
3972
+ }
3973
+ }
3974
+ } catch { /* absent line */ }
3975
+ try {
3976
+ if (ctx.pipelineId) {
3977
+ const key = ctx.workspaceId ? `workspaces/${ctx.workspaceId}` : out.project?.key;
3978
+ const row = (key ? lookupPipelineRow(key, ctx.pipelineId) : null) || findPipelineRowById(ctx.pipelineId);
3979
+ if (row) out.run = askRunFromPipelineRow(row);
3980
+ } else if (ctx.runId && runs.has(ctx.runId)) {
3981
+ const entry = runs.get(ctx.runId);
3982
+ out.run = {
3983
+ id: entry.pipelineId || ctx.runId.slice(0, 8), title: entry.title || '',
3984
+ status: entry.status || '', startedAt: entry.startedAt || '', branch: null,
3985
+ };
3986
+ }
3987
+ } catch { /* absent line */ }
3988
+ // (the ctx.runId branch reads the LIVE runs-Map entry, which really is
3989
+ // camelCase — only the DB pipeline row needs askRunFromPipelineRow)
3990
+ try {
3991
+ const links = askListRunLinks(threadId).slice(0, ASK_LIMITS.headerRuns).map((l) => {
3992
+ const live = runs.get(l.runId);
3993
+ return {
3994
+ id: l.pipelineId || l.runId.slice(0, 8),
3995
+ title: (live && live.title) || '', status: l.status || (live && live.status) || '',
3996
+ phase: l.phase || '',
3997
+ };
3998
+ });
3999
+ if (links.length) out.linkedRuns = links;
4000
+ const cards = [];
4001
+ for (const m of askListMessages(threadId)) {
4002
+ if (!Array.isArray(m.blocks)) continue;
4003
+ for (const b of m.blocks) {
4004
+ if (b && b.kind === 'card') {
4005
+ cards.push({
4006
+ id: b.id, state: b.state, workflowId: b.card && b.card.workflowId,
4007
+ targetName: (b.card && (b.card.projectName || b.card.workspaceName)) || '',
4008
+ });
4009
+ }
4010
+ }
4011
+ }
4012
+ if (cards.length) out.cards = cards.slice(-ASK_LIMITS.headerCards);
4013
+ // §6.5: the CURRENT message's non-inlined files, then EARLIER attachments
4014
+ // newest first — inlined current files must not be double-listed, so the
4015
+ // earlier set excludes the whole current message, not just `listed` ids.
4016
+ const earlier = askListAttachments(threadId)
4017
+ .filter((a) => !currentMessageId || a.messageId !== currentMessageId)
4018
+ .slice(-ASK_LIMITS.headerAttachments)
4019
+ .reverse()
4020
+ .map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime }));
4021
+ const atts = [...listedAttachments.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime })), ...earlier];
4022
+ if (atts.length) out.attachments = atts.slice(0, ASK_LIMITS.headerAttachments);
4023
+ } catch { /* absent lines */ }
4024
+ return out;
4025
+ }
4026
+
4027
+ /** R-F: whenever mock mode is on, EVERY ask spawn carries markers. The card is
4028
+ * the mock propose_run INPUT, derived from page context so a seeded project/
4029
+ * workspace validates and an empty context exercises the rejection notice. */
4030
+ function mockAskCard(ctx = {}, text = '') {
4031
+ const target = ctx.workspaceId
4032
+ ? { workspaceId: ctx.workspaceId }
4033
+ : { projectKey: ctx.projectKey || 'mock-project-00000000' };
4034
+ return { ...target, workflowId: 'wf_default', guardrailsId: 'normal', brief: text.slice(0, 200) || 'Mock run' };
4035
+ }
4036
+
4037
+ app.post('/api/ask/threads/:id/messages', async (req, res) => {
4038
+ const id = askIdParam(res, req.params.id, 'thread');
4039
+ if (!id) return;
4040
+ try {
4041
+ const thread = askGetThread(id);
4042
+ if (!thread) return res.status(404).json({ error: 'thread not found' });
4043
+ if (askDeleting.has(id)) return res.status(409).json({ error: 'thread is being deleted' });
4044
+ if (askInFlight(id)) return res.status(409).json({ error: 'turn in flight' });
4045
+ // Budget gate (F6), same figure /api/run enforces: Ask spend is folded into the
4046
+ // total window (cost-budget.mjs totalWindowSpendUsd), so chat must stop at
4047
+ // the cap it helps fill instead of spending past it while pipelines are 403'd
4048
+ // (review of PR #376).
4049
+ const budget = budgetStatus();
4050
+ if (budget.blocked) return res.status(403).json({ error: 'total cost limit reached', budget });
4051
+ if (askRunningCount() >= ASK_LIMITS.turnsGlobal) {
4052
+ return res.status(429).json({ error: `at most ${ASK_LIMITS.turnsGlobal} turns may run at once` });
4053
+ }
4054
+ const body = req.body || {};
4055
+ const text = typeof body.text === 'string' ? body.text : '';
4056
+ if (!text.trim()) return badRequest(res, 'text is required');
4057
+ const mv = await validateModelEffort(body.model, body.effort);
4058
+ if (!mv.ok) return badRequest(res, mv.error);
4059
+ const cv = validateClientContext(body.context);
4060
+ if (!cv.ok) return badRequest(res, cv.error);
4061
+ // #397: explicit pin beats page context, per field. A context carrying its own
4062
+ // `pinned` verdict is authoritative — the selector-aware client already merged
4063
+ // (true) or explicitly chose Auto (false). A context WITHOUT one comes from a
4064
+ // pre-selector tab, and inherits the thread's stored pin so a stale tab can
4065
+ // never silently unpin (or re-scope) the conversation.
4066
+ let ctx = cv.context;
4067
+ if (ctx.pinned === undefined) {
4068
+ const inherited = askPinnedScope(thread.context);
4069
+ if (inherited) ctx = askApplyPin(ctx, inherited);
4070
+ }
4071
+
4072
+ // §7.3 — validate EVERY attachment before ANY write (all-or-nothing).
4073
+ const files = [];
4074
+ if (body.attachments !== undefined) {
4075
+ if (!Array.isArray(body.attachments)) return badRequest(res, 'attachments must be an array');
4076
+ if (body.attachments.length > ASK_LIMITS.attachment.maxFiles) {
4077
+ return badRequest(res, `at most ${ASK_LIMITS.attachment.maxFiles} attachments per message`);
4078
+ }
4079
+ const dec = new TextDecoder('utf-8', { fatal: true });
4080
+ for (const a of body.attachments) {
4081
+ const name = a && typeof a.name === 'string' ? a.name : '';
4082
+ const dot = name.lastIndexOf('.');
4083
+ const ext = dot === -1 ? '' : name.slice(dot).toLowerCase();
4084
+ // #398: the extension CLAIMS a type; text kinds are then proven by UTF-8
4085
+ // decoding (as before), binary kinds by their magic number — a body that
4086
+ // does not match its claim is refused here, before any write.
4087
+ const cls = askClassifyExtension(ext);
4088
+ if (!cls) return badRequest(res, `attachment type not allowed: ${name || '(unnamed)'}`);
4089
+ const raw = typeof a.dataBase64 === 'string' ? a.dataBase64 : '';
4090
+ const buf = raw ? Buffer.from(raw, 'base64') : Buffer.alloc(0);
4091
+ if (!buf.length) return badRequest(res, `attachment is empty or not valid base64: ${name}`);
4092
+ const cap = cls.kind === 'text' ? ASK_LIMITS.attachment.maxBytesPerFile : ASK_LIMITS.attachment.maxBytesPerBinaryFile;
4093
+ if (buf.length > cap) {
4094
+ return res.status(413).json({ error: `attachment over ${cap} bytes: ${name}` });
4095
+ }
4096
+ if (cls.kind !== 'text') {
4097
+ const sniffed = askSniffMime(buf);
4098
+ if (sniffed !== cls.mime) {
4099
+ return badRequest(res, `attachment content does not match its extension: ${name}`);
4100
+ }
4101
+ files.push({ name, kind: cls.kind, mime: cls.mime, data: buf, bytes: buf.length });
4102
+ continue;
4103
+ }
4104
+ let bodyText;
4105
+ try { bodyText = dec.decode(buf); } catch { return badRequest(res, `attachment is not valid UTF-8: ${name}`); }
4106
+ if (bodyText.includes('\u0000')) return badRequest(res, `attachment contains NUL bytes: ${name}`);
4107
+ files.push({ name, kind: 'text', mime: cls.mime, text: bodyText, bytes: buf.length });
4108
+ }
4109
+ const total = askThreadAttachmentBytes(id) + files.reduce((s, f) => s + f.bytes, 0);
4110
+ if (total > ASK_LIMITS.attachment.maxBytesPerThread) {
4111
+ return res.status(413).json({ error: 'attachment budget for this thread exceeded' });
4112
+ }
4113
+ }
4114
+
4115
+ // §6.2.2 ATOMIC re-check + slot reservation. Today every await between the
4116
+ // top 409/429 pair and here resolves in microtasks (validateModelEffort ->
4117
+ // composeCatalog; askBuildCatalog -> three synchronous better-sqlite3
4118
+ // reads), so the route is macrotask-atomic and two POSTs cannot interleave
4119
+ // (empirically instrumented). The reservation is what keeps that true if
4120
+ // any of those readers ever becomes genuinely async: it is synchronous —
4121
+ // check-and-set cannot interleave — and runs BEFORE the first write, so a
4122
+ // loser leaves no rows.
4123
+ if (askDeleting.has(id)) return res.status(409).json({ error: 'thread is being deleted' });
4124
+ if (askInFlight(id)) return res.status(409).json({ error: 'turn in flight' });
4125
+ if (askRunningCount() >= ASK_LIMITS.turnsGlobal) {
4126
+ return res.status(429).json({ error: `at most ${ASK_LIMITS.turnsGlobal} turns may run at once` });
4127
+ }
4128
+ const prev = askJobs.get(id);
4129
+ if (prev && prev.graceTimer) clearTimeout(prev.graceTimer); // atomic replace of a grace entry (§8.3)
4130
+ const job = {
4131
+ turn: null, messageId: null, userMessageId: null, // ids filled once the rows exist;
4132
+ events: [], seq: 0, status: 'running', // askHello()/GET inFlight skip a null messageId
4133
+ startedAt: new Date().toISOString(), graceTimer: null,
4134
+ };
4135
+ askJobs.set(id, job);
4136
+
4137
+ let asstMsg = null;
4138
+ let turn;
4139
+ let echoAttachments = [];
4140
+ try {
4141
+ // Writes. Store the LAST context + model/effort on the thread (§6.5 tail, D8).
4142
+ // `ctx` (pin-merged) rather than cv.context: the stored row is what restores
4143
+ // the selector on reopen and what the MCP child reads for tool defaulting.
4144
+ askUpdateThread(id, { context: ctx, model: mv.model, effort: mv.effort });
4145
+ // §7.4 — NOTHING is stamped on the row before the 202: the thread stays
4146
+ // untitled (the header reads "Ask Worca") until the D13 background title
4147
+ // announces itself. titleWasAuto gates that call: a title given at THREAD
4148
+ // CREATION is the user's, and the haiku call must never fire for it
4149
+ // (§17 Q&A 1). deterministicTitle is only the turn's fallback for an
4150
+ // empty haiku result (turn.mjs _kickoffTitle), never written here.
4151
+ const titleWasAuto = thread.title == null;
4152
+ const deterministicTitle = titleWasAuto ? (askSanitizeTitle(text.slice(0, 80)) || 'New chat') : thread.title;
4153
+ const userMsg = askAppendMessage(id, { role: 'user', text });
4154
+ job.userMessageId = userMsg.id;
4155
+ const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, kind: f.kind, mime: f.mime, text: f.text, data: f.data }));
4156
+ // The decoded binary bodies are on disk now. `files` is captured by this
4157
+ // scope's closures (settleJob, the turn listeners, onOutOfTurn) for the whole
4158
+ // turn plus jobGraceMs, so up to 25 MB of dead Buffers would otherwise stay
4159
+ // reachable per running thread.
4160
+ for (const f of files) f.data = null;
4161
+ echoAttachments = attRows.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime }));
4162
+ if (attRows.length) {
4163
+ // `kind` is the BLOCK kind, so the attachment's own kind rides as attKind
4164
+ // (the UI keys image thumbnails off it, #398).
4165
+ askSetMessageBlocks(userMsg.id, attRows.map((a) => ({ kind: 'attachment', id: a.id, name: a.name, bytes: a.bytes, attKind: a.kind, mime: a.mime })));
4166
+ }
4167
+ broadcast({ type: 'ask-message', threadId: id, message: askGetMessage(userMsg.id) }); // echo for other tabs
4168
+ asstMsg = askAppendMessage(id, { role: 'assistant', text: '', status: 'streaming', model: mv.model, effort: mv.effort });
4169
+ job.messageId = asstMsg.id;
4170
+
4171
+ // Prompt assembly (§6.5) — the route owns it; the turn only spawns.
4172
+ const catalog = await askBuildCatalog();
4173
+ const systemPrompt = askBuildSystemPrompt(catalog);
4174
+ const withText = attRows.map((a, i) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime, text: files[i].text }));
4175
+ const { inline, listed } = askSelectInlineAttachments(withText);
4176
+ const headerCtx = await resolveAskContext(id, ctx, listed, userMsg.id);
4177
+ const header = askBuildContextHeader(headerCtx);
4178
+ const prompt = askBuildTurnPrompt(header, text, inline);
4179
+ const prior = askListMessages(id).filter((m) => m.seq < userMsg.seq);
4180
+ const restoredPrompt = askBuildRestoredPrompt(prior, prompt);
4181
+ const attachmentNames = {};
4182
+ for (const a of askListAttachments(id)) attachmentNames[a.id] = a.name;
4183
+
4184
+ turn = createAskTurn({
4185
+ threadId: id, assistantMessageId: asstMsg.id, userMessageId: userMsg.id,
4186
+ prompt, systemPrompt, restoredPrompt,
4187
+ model: mv.model, effort: mv.effort,
4188
+ resumeSessionId: thread.sessionId || null,
4189
+ firstTurn: userMsg.seq === 1 && titleWasAuto, // D13 guard: never replace a user-authored title
4190
+ firstText: text,
4191
+ deterministicTitle,
4192
+ pinnedScope: askPinnedScope(ctx), // #397: proposal defaulting + mismatch flag
4193
+ mock: mockEnabled({}) ? { card: mockAskCard(ctx, text) } : null, // R-F
4194
+ attachmentNames,
4195
+ deps: {
4196
+ onFrame: stampAskFrames(id, job),
4197
+ onOutOfTurn: (f) => broadcast({ ...f, threadId: id }),
4198
+ onCommentMutation: ({ runId }) => { emitDiffCommentsChanged(runId); },
4199
+ onWorktreeMutation: () => { emitAskWorktrees(id); },
4200
+ },
4201
+ });
4202
+ job.turn = turn;
4203
+ } catch (err) {
4204
+ // A write/assembly failure must release the reserved slot and never leave
4205
+ // a `streaming` row for the boot sweep to find.
4206
+ if (askJobs.get(id) === job) askJobs.delete(id);
4207
+ if (asstMsg) {
4208
+ try {
4209
+ askFinishMessage(asstMsg.id, {
4210
+ text: '', blocks: [{ kind: 'notice', text: 'failed to start the turn' }],
4211
+ status: 'error', reason: null, usage: null, costUsd: null, durationMs: null,
4212
+ });
4213
+ } catch { /* thread gone */ }
4214
+ }
4215
+ return res.status(500).json({ error: err && err.message ? err.message : String(err) });
4216
+ }
4217
+ const settleJob = (status) => {
4218
+ if (askJobs.get(id) !== job) return;
4219
+ job.status = status;
4220
+ job.graceTimer = setTimeout(() => {
4221
+ if (askJobs.get(id) === job) askJobs.delete(id);
4222
+ }, ASK_LIMITS.jobGraceMs);
4223
+ job.graceTimer.unref?.();
4224
+ };
4225
+ turn.on('done', () => settleJob('done'));
4226
+ turn.on('error', () => settleJob('error'));
4227
+ // Fire-and-forget with a backstop (startAgentGen shape) — run() never throws.
4228
+ Promise.resolve()
4229
+ .then(() => turn.run())
4230
+ .catch((err) => {
4231
+ console.error(`[worca-ui] ask turn crashed: ${err && err.message ? err.message : err}`);
4232
+ settleJob('error');
4233
+ });
4234
+ // `attachments` carries the store-minted ids so the sender's own echo can key
4235
+ // image thumbnails and the thread budget off them (the ask-message broadcast
4236
+ // may have raced ahead of this response, or been missed on a brand-new thread).
4237
+ res.status(202).json({ userMessageId: job.userMessageId, assistantMessageId: job.messageId, attachments: echoAttachments });
4238
+ } catch (err) {
4239
+ // Only pre-reservation throws land here (`job` is block-scoped to the outer
4240
+ // try and every post-reservation failure returned from the inner catch), so
4241
+ // there is no slot to release.
4242
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
4243
+ }
4244
+ });
4245
+
4246
+ // Idempotent stop (the /api/agents/generate/stop family): always {ok:true}
4247
+ // after the shape check; the costUsd:null rule lives in the turn (R-C).
4248
+ app.post('/api/ask/threads/:id/stop', (req, res) => {
4249
+ const id = askIdParam(res, req.params.id, 'thread');
4250
+ if (!id) return;
4251
+ const job = askInFlight(id);
4252
+ if (job && job.turn && typeof job.turn.stop === 'function') {
4253
+ try { job.turn.stop(); } catch { /* best-effort */ }
4254
+ }
4255
+ res.json({ ok: true });
4256
+ });
4257
+
4258
+ /** R-B dual update. Flip in the STORE and, when the owning thread's turn is
4259
+ * still streaming, in the LIVE reducer (updateBlock re-emits the stamped
4260
+ * ask-card job frame) — otherwise finishMessage at turn end reverts the flip
4261
+ * with the reducer's stale copy. When no live reducer held the card (turn
4262
+ * over, or the card sits on an earlier message), re-broadcast the whole
4263
+ * message so tabs upsert the flipped block by message.id (§6.6 out-of-turn). */
4264
+ function flipCard(threadId, cardId, patch) {
4265
+ const block = askUpdateCardBlock(threadId, cardId, patch);
4266
+ if (!block) return null;
4267
+ const job = askInFlight(threadId);
4268
+ const live = job && job.turn && job.turn.reducer ? job.turn.reducer.updateBlock(cardId, patch) : null;
4269
+ if (!live) {
4270
+ const found = askFindCard(threadId, cardId);
4271
+ if (found) broadcast({ type: 'ask-message', threadId, message: found.message });
4272
+ }
4273
+ return block;
4274
+ }
4275
+
4276
+ // D14 dismiss ("Not now" keeps a stub — the client renders state:'dismissed').
4277
+ app.post('/api/ask/threads/:id/cards/:cardId', (req, res) => {
4278
+ const id = askIdParam(res, req.params.id, 'thread');
4279
+ if (!id) return;
4280
+ const cardId = askIdParam(res, req.params.cardId, 'card');
4281
+ if (!cardId) return;
4282
+ try {
4283
+ if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
4284
+ if ((req.body || {}).state !== 'dismissed') return badRequest(res, 'state must be "dismissed"');
4285
+ const found = askFindCard(id, cardId);
4286
+ if (!found) return res.status(404).json({ error: 'card not found' });
4287
+ if (found.block.state !== 'proposed') {
4288
+ return res.status(409).json({ error: `card is ${found.block.state}` });
4289
+ }
4290
+ const block = flipCard(id, cardId, { state: 'dismissed' });
4291
+ // Dismiss is terminal: the card's parked comment ids can never reach a run,
4292
+ // so drop them here exactly as the launch path does at its own success point
4293
+ // (:1155). Own try/catch — comment bookkeeping must never fail the dismiss.
4294
+ try { clearPendingCardComments(cardId); }
4295
+ catch (e) { console.error('[diff-comments] dismiss cleanup failed:', e && e.message ? e.message : e); }
4296
+ res.json({ block });
4297
+ } catch (err) {
4298
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
4299
+ }
4300
+ });
4301
+
2753
4302
  // ---------------------------------------------------------------------------
2754
4303
  // /api/agents* -> agent registry + user-agent CRUD, delegated to
2755
4304
  // src/core/agent-store.mjs (layered builtin + ~/.worca-cc/agents user pairs).
2756
4305
  // GET returns palette render order (.order ascending) with origin stamped; the
2757
4306
  // client builds draggable pills (colored dot + displayName + icon) from this.
2758
4307
  // ---------------------------------------------------------------------------
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
4308
 
2780
4309
  app.get('/api/agents', async (req, res) => {
2781
4310
  try {
@@ -2783,7 +4312,11 @@ app.get('/api/agents', async (req, res) => {
2783
4312
  // §6.6: workspace-only agents stay out of the Composer palette by default;
2784
4313
  // the Agents management view passes ?all=1 to see them too.
2785
4314
  const agents = isTruthy(req.query.all) ? all : all.filter((m) => m.scope !== 'workspace-only');
2786
- res.json({ agents, channels: collectChannelIds(all) });
4315
+ // mockWriterRoles drives ONE select in the agent form. It is a CLOSED list
4316
+ // (the mock switch in claude-runner.mjs), unlike the open channel vocabulary
4317
+ // it replaces in Task 12: an unknown mockRole is dropped by the registry
4318
+ // with a warning, never rejected.
4319
+ res.json({ agents, mockWriterRoles: [...MOCK_WRITER_ROLES] });
2787
4320
  } catch (err) {
2788
4321
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
2789
4322
  }
@@ -2809,9 +4342,6 @@ function agentErrorStatus(code) {
2809
4342
  function startAgentGen(input) {
2810
4343
  const orch = createAgentGen({
2811
4344
  ...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
4345
  claude: { permissionMode: 'acceptEdits', mock: isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK) },
2816
4346
  });
2817
4347
  // The engine mints its own genId (agen_<uuid>) and tags every emitted event
@@ -2862,7 +4392,7 @@ app.post('/api/agents/generate', async (req, res) => {
2862
4392
  const genId = startAgentGen({
2863
4393
  name, purpose: String(body.purpose || ''), details: String(body.details || ''),
2864
4394
  expectedBefore: pick(body.expectedBefore), expectedAfter: pick(body.expectedAfter),
2865
- userMarkdown, channels: collectChannelIds(allAgents),
4395
+ userMarkdown,
2866
4396
  });
2867
4397
  res.json({ genId });
2868
4398
  } catch (err) {
@@ -3081,7 +4611,7 @@ app.post('/api/plugins/install', async (req, res) => {
3081
4611
  repoUrl: body.repoUrl.trim(), subdir, name: body.name.trim(), sha: body.sha.trim(), marketplace,
3082
4612
  });
3083
4613
  reloadChatWorkers(body.name.trim());
3084
- res.json(out); // { ok: true, inventory }
4614
+ res.json(out); // { ok: true, inventory, warnings, ignored }
3085
4615
  } catch (err) {
3086
4616
  sendPluginError(res, err);
3087
4617
  }
@@ -3128,6 +4658,8 @@ app.delete('/api/plugins/:name', async (req, res) => {
3128
4658
  if (!name) return;
3129
4659
  const purge = isTruthy(req.query.purge) || !!(req.body && req.body.purge === true);
3130
4660
  try {
4661
+ // uninstallPlugin also drops the plugin's source bindings (core-side, so
4662
+ // the CLI's `worca plugin remove` clears them identically).
3131
4663
  await uninstallPlugin(name, { purge });
3132
4664
  reloadChatWorkers(name);
3133
4665
  res.json({ ok: true, purged: purge });
@@ -3165,17 +4697,38 @@ app.post('/api/plugins/:name/doctor', async (req, res) => {
3165
4697
  // GET /api/plugins/:name/config -> per-source schema + redacted values. Secrets
3166
4698
  // NEVER travel to the browser: redactedConfig replaces a stored secret with
3167
4699
  // { set: true } (§7.6).
4700
+ // ?profile=<id> selects which configuration to echo (multi-profile sources);
4701
+ // absent = the default bucket, which is all a single-profile source ever uses.
3168
4702
  app.get('/api/plugins/:name/config', (req, res) => {
3169
4703
  const name = requirePlugin(req, res);
3170
4704
  if (!name) return;
3171
4705
  const manifest = readInstalledManifest(name);
3172
4706
  if (!manifest) return res.status(409).json({ error: 'plugin manifest unreadable — run doctor' });
4707
+ const wanted = typeof req.query.profile === 'string' && req.query.profile ? req.query.profile : null;
4708
+ if (wanted && !isValidProfileId(wanted)) return badRequest(res, 'invalid profile id');
3173
4709
  try {
3174
- const sources = (manifest.taskSources || []).map((s) => ({
3175
- id: s.id,
3176
- schema: s.configSchema,
3177
- values: redactedConfig(name, s.configSchema),
3178
- }));
4710
+ const profiles = listProfiles(name);
4711
+ const sources = (manifest.taskSources || []).map((s) => {
4712
+ // For a multi-profile source, "which profile" is a real choice: echo the
4713
+ // requested one, else the first in the roster. A source with no profiles
4714
+ // yet has nothing to show — the UI's move is "create one", not a form.
4715
+ // A requested profile that is not in the roster is a caller error (a
4716
+ // typo'd URL) — echoing an empty form for it would let a Save quietly
4717
+ // create the typo as a real profile. Checked inside the map so the guard
4718
+ // only fires for sources that use profiles at all.
4719
+ if (s.multiProfile && wanted && !profiles.some((p) => p.id === wanted)) {
4720
+ throw Object.assign(new Error(`plugin "${name}" has no profile "${wanted}"`), { code: 'BAD_REQUEST' });
4721
+ }
4722
+ const profile = s.multiProfile ? (wanted || profiles[0]?.id || null) : null;
4723
+ return {
4724
+ id: s.id,
4725
+ schema: s.configSchema,
4726
+ multiProfile: s.multiProfile === true,
4727
+ profile,
4728
+ profiles: s.multiProfile ? profiles : [],
4729
+ values: s.multiProfile && !profile ? {} : redactedConfig(name, s.configSchema, profile),
4730
+ };
4731
+ });
3179
4732
  const channels = (manifest.chatChannels || []).map((c) => ({
3180
4733
  id: c.id,
3181
4734
  displayName: c.displayName,
@@ -3196,7 +4749,78 @@ app.get('/api/plugins/:name/config', (req, res) => {
3196
4749
  }
3197
4750
  });
3198
4751
 
3199
- // PUT /api/plugins/:name/config { sourceId | channelId, values } ->
4752
+ // POST /api/plugins/:name/profiles { sourceId, id, label } — create (or relabel)
4753
+ // a profile of a multi-profile source. Creating a profile is deliberately
4754
+ // separate from saving into it: the roster entry must exist BEFORE the config
4755
+ // form has anything to write to.
4756
+ app.post('/api/plugins/:name/profiles', (req, res) => {
4757
+ const name = requirePlugin(req, res);
4758
+ if (!name) return;
4759
+ const body = req.body || {};
4760
+ const manifest = readInstalledManifest(name);
4761
+ if (!manifest) return res.status(409).json({ error: 'plugin manifest unreadable — run doctor' });
4762
+ const source = (manifest.taskSources || []).find((s) => s.id === body.sourceId);
4763
+ if (!source) return badRequest(res, 'sourceId does not match a task source of this plugin');
4764
+ if (!source.multiProfile) return badRequest(res, `task source "${source.id}" does not support profiles`);
4765
+ if (!isValidProfileId(body.id)) {
4766
+ return badRequest(res, 'profile id must be lowercase letters, digits and dashes');
4767
+ }
4768
+ // "default" is the implicit bucket every profile-less read/write shares
4769
+ // (chat channels, model secrets, migrated legacy config). Enrolled in the
4770
+ // roster it would become deletable like any member — and deleting it wipes
4771
+ // that shared bucket. createProfile throws too; 400 with the reason here.
4772
+ if (body.id === DEFAULT_PROFILE) {
4773
+ return badRequest(res, `profile id "${DEFAULT_PROFILE}" is reserved — pick another name`);
4774
+ }
4775
+ try {
4776
+ res.json({ ok: true, profile: createProfile(name, body.id, body.label) });
4777
+ } catch (err) {
4778
+ sendPluginError(res, err);
4779
+ }
4780
+ });
4781
+
4782
+ // DELETE /api/plugins/:name/profiles/:id?sourceId=… — drop a profile, its stored
4783
+ // config/secrets/state, and every project binding that named it (a binding
4784
+ // pointing at a deleted profile would otherwise resolve to nothing at run time).
4785
+ // Binding cleanup is PLUGIN-wide, not per-source: deleteProfile removes the
4786
+ // profile's buckets for the whole plugin, so a sibling source's binding naming
4787
+ // it would dangle just the same. sourceId is still required — it authorizes the
4788
+ // call against a source that actually uses profiles.
4789
+ app.delete('/api/plugins/:name/profiles/:id', (req, res) => {
4790
+ const name = requirePlugin(req, res);
4791
+ if (!name) return;
4792
+ const manifest = readInstalledManifest(name);
4793
+ if (!manifest) return res.status(409).json({ error: 'plugin manifest unreadable — run doctor' });
4794
+ const sourceId = typeof req.query.sourceId === 'string' ? req.query.sourceId : '';
4795
+ const sources = manifest.taskSources || [];
4796
+ const source = sources.find((s) => s.id === sourceId) || (sources.length === 1 ? sources[0] : null);
4797
+ if (!source) return badRequest(res, 'sourceId does not match a task source of this plugin');
4798
+ // Mirror the POST guard: a single-profile source only has the implicit
4799
+ // 'default' bucket, and deleting THAT would wipe its entire config/secrets/
4800
+ // state. Same for ids not in the roster — deleteProfile would still drop
4801
+ // whatever buckets happen to share the id (e.g. migrated legacy data under
4802
+ // 'default'), so only roster members are deletable.
4803
+ if (!source.multiProfile) return badRequest(res, `task source "${source.id}" does not support profiles`);
4804
+ if (!isValidProfileId(req.params.id)) return badRequest(res, 'invalid profile id');
4805
+ // Reserved even if a pre-reservation roster enrolled it: deleting "default"
4806
+ // would strip the shared bucket (chat-channel config, model secrets,
4807
+ // migrated legacy data) out of all three files.
4808
+ if (req.params.id === DEFAULT_PROFILE) {
4809
+ return badRequest(res, `profile id "${DEFAULT_PROFILE}" is reserved — it cannot be deleted`);
4810
+ }
4811
+ if (!listProfiles(name).some((p) => p.id === req.params.id)) {
4812
+ return badRequest(res, `plugin "${name}" has no profile "${req.params.id}"`);
4813
+ }
4814
+ try {
4815
+ deleteProfile(name, req.params.id);
4816
+ const unbound = clearBindingsForProfile(name, req.params.id);
4817
+ res.json({ ok: true, unbound });
4818
+ } catch (err) {
4819
+ sendPluginError(res, err);
4820
+ }
4821
+ });
4822
+
4823
+ // PUT /api/plugins/:name/config { sourceId | channelId, values, profile? } ->
3200
4824
  // writePluginConfig routes secret:true keys to data/secrets.json (0600,
3201
4825
  // atomic). Request values are NEVER logged and NEVER echoed back (the response
3202
4826
  // is a bare receipt). A channelId save also hot-restarts the channel worker.
@@ -3222,6 +4846,8 @@ app.put('/api/plugins/:name/config', (req, res) => {
3222
4846
  }
3223
4847
  }
3224
4848
  let schema;
4849
+ let source = null;
4850
+ let profile = null;
3225
4851
  if (typeof body.channelId === 'string' && body.channelId) {
3226
4852
  const channel = (manifest.chatChannels || []).find((c) => c.id === body.channelId);
3227
4853
  if (!channel) return badRequest(res, 'channelId does not match a chat channel of this plugin');
@@ -3231,12 +4857,22 @@ app.put('/api/plugins/:name/config', (req, res) => {
3231
4857
  const sourceId = typeof body.sourceId === 'string' && body.sourceId
3232
4858
  ? body.sourceId
3233
4859
  : (sources.length === 1 ? sources[0].id : '');
3234
- const source = sources.find((s) => s.id === sourceId);
4860
+ source = sources.find((s) => s.id === sourceId);
3235
4861
  if (!source) return badRequest(res, 'sourceId does not match a task source of this plugin');
4862
+ profile = typeof body.profile === 'string' && body.profile ? body.profile : null;
4863
+ if (profile && !isValidProfileId(profile)) return badRequest(res, 'invalid profile id');
4864
+ if (source.multiProfile && !profile) return badRequest(res, 'profile is required for this task source');
4865
+ // Saves go only into EXISTING roster members — mirroring the GET guard,
4866
+ // whose whole point is that a Save must not quietly mint a typo'd (or
4867
+ // just-deleted) id as a real profile with secrets stored under it.
4868
+ // Creation stays solely on POST /profiles.
4869
+ if (source.multiProfile && !listProfiles(name).some((p) => p.id === profile)) {
4870
+ return badRequest(res, `plugin "${name}" has no profile "${profile}" — create it first`);
4871
+ }
3236
4872
  schema = source.configSchema;
3237
4873
  }
3238
4874
  try {
3239
- writePluginConfig(name, schema, body.values);
4875
+ writePluginConfig(name, schema, body.values, profile);
3240
4876
  reloadChatWorkers(name);
3241
4877
  res.json({ ok: true });
3242
4878
  } catch (err) {
@@ -3261,7 +4897,84 @@ app.get('/api/plugins/:name/model-env', (req, res) => {
3261
4897
  if (typeof v === 'string') env[k] = v;
3262
4898
  else secretKeys.push(k);
3263
4899
  }
3264
- res.json({ id: model.id, label: model.label, efforts: model.efforts, env, secretKeys });
4900
+ // `cost` rides along so "Edit a copy" starts from the plugin's pricing — a
4901
+ // copy that silently dropped it would repay the CLI's by-name figure.
4902
+ res.json({
4903
+ id: model.id, label: model.label, efforts: model.efforts, env, secretKeys,
4904
+ ...(model.cost ? { cost: model.cost } : {}),
4905
+ });
4906
+ });
4907
+
4908
+ // ---------------------------------------------------------------------------
4909
+ // /api/source-bindings -> which PROFILE of a task source a project/workspace
4910
+ // pulls from. Set once per project; every run then resolves it silently, which
4911
+ // is the point — a per-run dropdown is how you start a pipeline against the
4912
+ // wrong tracker without noticing (see src/core/source-bindings.mjs).
4913
+ // ---------------------------------------------------------------------------
4914
+
4915
+ /** Shared scope parsing for the two binding routes. Accepts a project by key or
4916
+ * by path (the New Pipeline form knows the path, the Projects view the key). */
4917
+ function bindingScope(q = {}) {
4918
+ const workspaceId = typeof q.workspaceId === 'string' && q.workspaceId.trim() ? q.workspaceId.trim() : '';
4919
+ if (workspaceId) return { scopeType: 'workspace', scopeKey: workspaceId };
4920
+ const key = typeof q.projectKey === 'string' && q.projectKey.trim() ? q.projectKey.trim() : '';
4921
+ if (key) return { scopeType: 'project', scopeKey: key };
4922
+ const dir = typeof q.projectDir === 'string' && q.projectDir.trim() ? q.projectDir.trim() : '';
4923
+ if (dir) return { scopeType: 'project', scopeKey: projectKey(path.resolve(dir)) };
4924
+ return null;
4925
+ }
4926
+
4927
+ // GET /api/source-bindings?projectDir=…|projectKey=…|workspaceId=…
4928
+ // [&plugin=&sourceId=] -> { bindings: [...] } or, when a source is named,
4929
+ // the RESOLVED profile for it: { profile, via, candidates? }.
4930
+ app.get('/api/source-bindings', async (req, res) => {
4931
+ const scope = bindingScope(req.query);
4932
+ if (!scope) return badRequest(res, 'projectDir, projectKey or workspaceId is required');
4933
+ const plugin = typeof req.query.plugin === 'string' ? req.query.plugin.trim() : '';
4934
+ const sourceId = typeof req.query.sourceId === 'string' ? req.query.sourceId.trim() : '';
4935
+ try {
4936
+ if (!plugin || !sourceId) return res.json({ ...scope, bindings: listBindingsForScope(scope.scopeType, scope.scopeKey) });
4937
+ // A workspace with no binding of its own inherits from its members when they
4938
+ // agree, so the member keys have to be resolved before asking.
4939
+ let memberKeys;
4940
+ if (scope.scopeType === 'workspace') {
4941
+ const ws = await readWorkspace(scope.scopeKey);
4942
+ memberKeys = ws ? ws.projectKeys : [];
4943
+ }
4944
+ res.json({
4945
+ ...scope,
4946
+ ...resolveProfile({ ...scope, plugin, sourceId, memberKeys, available: listProfileIds(plugin) }),
4947
+ });
4948
+ } catch (err) {
4949
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
4950
+ }
4951
+ });
4952
+
4953
+ // PUT /api/source-bindings { projectDir|projectKey|workspaceId, plugin,
4954
+ // sourceId, profile } — profile:null clears the binding.
4955
+ app.put('/api/source-bindings', (req, res) => {
4956
+ const body = req.body || {};
4957
+ const scope = bindingScope(body);
4958
+ if (!scope) return badRequest(res, 'projectDir, projectKey or workspaceId is required');
4959
+ const plugin = typeof body.plugin === 'string' ? body.plugin.trim() : '';
4960
+ const sourceId = typeof body.sourceId === 'string' ? body.sourceId.trim() : '';
4961
+ if (!plugin || !sourceId) return badRequest(res, 'plugin and sourceId are required');
4962
+ const ref = { ...scope, plugin, sourceId };
4963
+ try {
4964
+ if (body.profile === null || body.profile === '') {
4965
+ clearBinding(ref);
4966
+ return res.json({ ok: true, ...scope, profile: null });
4967
+ }
4968
+ if (!isValidProfileId(body.profile)) return badRequest(res, 'invalid profile id');
4969
+ // Binding to a profile that does not exist would resolve to nothing at run
4970
+ // time — reject it here, where the user can still see why.
4971
+ if (!listProfileIds(plugin).includes(body.profile)) {
4972
+ return badRequest(res, `plugin "${plugin}" has no profile "${body.profile}"`);
4973
+ }
4974
+ res.json({ ok: true, ...scope, profile: setBinding(ref, body.profile) });
4975
+ } catch (err) {
4976
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
4977
+ }
3265
4978
  });
3266
4979
 
3267
4980
  // ---------------------------------------------------------------------------
@@ -3303,8 +5016,21 @@ app.post('/api/sources/call', async (req, res) => {
3303
5016
  if (input && typeof input.optionsFrom === 'string' && input.optionsFrom) allowed.add(input.optionsFrom);
3304
5017
  }
3305
5018
  if (!allowed.has(op)) return badRequest(res, `op "${op}" is not allowed for this source`);
5019
+ const profile = typeof body.profile === 'string' && body.profile ? body.profile : null;
5020
+ if (profile && !isValidProfileId(profile)) return badRequest(res, 'invalid profile id');
5021
+ if (source.multiProfile && !profile) return badRequest(res, 'profile is required for this task source');
5022
+ // Same submit-time guards as /api/run: a deleted profile must 400 here, not
5023
+ // fail deep in the connector against an empty bucket, and a profile on a
5024
+ // single-profile source would read (and persist state into) a phantom
5025
+ // bucket instead of the real config.
5026
+ if (source.multiProfile && !listProfileIds(plugin).includes(profile)) {
5027
+ return badRequest(res, `plugin "${plugin}" has no profile "${profile}"`);
5028
+ }
5029
+ if (!source.multiProfile && profile) {
5030
+ return badRequest(res, `task source "${sourceId}" does not use profiles — omit profile`);
5031
+ }
3306
5032
  try {
3307
- const result = await callSource({ plugin, sourceId, op, args });
5033
+ const result = await callSource({ plugin, sourceId, op, args, profile });
3308
5034
  res.json({ ok: true, result });
3309
5035
  } catch (err) {
3310
5036
  if (err instanceof PluginOpError) {
@@ -3475,11 +5201,32 @@ app.post('/api/chat/test', async (req, res) => {
3475
5201
  app.use((req, res, next) => {
3476
5202
  if (req.method !== 'GET') return next();
3477
5203
  if (req.path.startsWith('/api/') || req.path.startsWith('/ws')) return next();
5204
+ if (req.path === '/vendor' || req.path.startsWith('/vendor/')) return next();
3478
5205
  res.sendFile(path.join(PUBLIC_DIR, 'index.html'), (err) => {
3479
5206
  if (err) next();
3480
5207
  });
3481
5208
  });
3482
5209
 
5210
+ // ---------------------------------------------------------------------------
5211
+ // The LAST middleware, and the only GLOBAL error handler (`/vendor` keeps its
5212
+ // own path-scoped one): every failure answers { error } as JSON (MIN-108).
5213
+ // Without it express's default handler renders an HTML page carrying the thrown
5214
+ // stack — absolute node_modules paths included — for the two failures that happen
5215
+ // BEFORE any route runs: a malformed JSON body and a body past the cap. The
5216
+ // four-argument signature is what makes express treat this as an error handler,
5217
+ // so `next` stays even though only the headers-sent path uses it.
5218
+ // ---------------------------------------------------------------------------
5219
+ app.use((err, _req, res, next) => {
5220
+ if (res.headersSent) return next(err); // let express abort the stream
5221
+ if (err && err.type === 'entity.parse.failed') return res.status(400).json({ error: 'malformed JSON body' });
5222
+ if (err && err.type === 'entity.too.large') return res.status(413).json({ error: 'request body too large' });
5223
+ // Anything else: honour a body-parser 4xx (charset.unsupported / encoding.unsupported
5224
+ // are 415, request.aborted is 400) — a client error logged as a 500 misleads. Ours or
5225
+ // not, it is one line, no stack, never HTML.
5226
+ const status = Number.isInteger(err?.status) && err.status >= 400 && err.status < 500 ? err.status : 500;
5227
+ return res.status(status).json({ error: err && err.message ? err.message : 'internal error' });
5228
+ });
5229
+
3483
5230
  /**
3484
5231
  * Boot maintenance, in the PINNED order (§8.12):
3485
5232
  * 1. reconcileStaleRunning — stamps every stale `running` row -> `interrupted`,
@@ -3503,12 +5250,12 @@ app.use((req, res, next) => {
3503
5250
  * everything up to the first `await` — including the reconcile — still runs before
3504
5251
  * `server.listen`, exactly as it did when this was an inline block.
3505
5252
  *
3506
- * @param {{log?: (scope:'run-root'|'legacy', level:string, msg:string) => void}} [args]
5253
+ * @param {{log?: (scope:'run-root'|'legacy'|'ask-worktrees', level:string, msg:string) => void}} [args]
3507
5254
  * optional sink for the per-candidate lines both sweeps emit; omitted, each
3508
5255
  * sweep keeps its own console default.
3509
5256
  */
3510
5257
  export async function bootMaintenance({ log } = {}) {
3511
- const summary = { reconciled: 0, runRoots: null, legacy: null };
5258
+ const summary = { reconciled: 0, sweptV1: 0, runRoots: null, legacy: null, ask: null, askWorktrees: null };
3512
5259
  const sink = (scope) => (typeof log === 'function' ? (level, msg) => log(scope, level, msg) : undefined);
3513
5260
 
3514
5261
  // Runs left 'running' by a previous process that died before writing a terminal
@@ -3521,6 +5268,16 @@ export async function bootMaintenance({ log } = {}) {
3521
5268
  console.error(`[worca-ui] stale-run reconcile failed: ${err && err.message ? err.message : err}`);
3522
5269
  }
3523
5270
 
5271
+ // A DB stamped past 24 by a divergent ladder can still hold v1 resume points
5272
+ // (crash-reconciled runs keep theirs). One idempotent sweep per boot.
5273
+ try {
5274
+ const swept = sweepV1Runs();
5275
+ summary.sweptV1 = swept.length;
5276
+ if (swept.length) console.log(`[worca-ui] retired ${swept.length} run(s) paused on the v1 engine`);
5277
+ } catch (err) {
5278
+ console.error(`[worca-ui] v1-run sweep failed: ${err && err.message ? err.message : err}`);
5279
+ }
5280
+
3524
5281
  try {
3525
5282
  const r = await sweepRunRoots({
3526
5283
  worcaHome: worcaHome(), ...runRootSweepLookups(), log: sink('run-root'),
@@ -3562,6 +5319,34 @@ export async function bootMaintenance({ log } = {}) {
3562
5319
  } catch (err) {
3563
5320
  console.error(`[worca-ui] legacy worktree sweep failed: ${err && err.message ? err.message : err} — nothing was removed`);
3564
5321
  }
5322
+
5323
+ // Ask Worca (§6.2): mark turns orphaned by a restart, sweep stale empty threads.
5324
+ try {
5325
+ const interrupted = sweepStreamingMessages();
5326
+ const emptyThreads = sweepEmptyThreads();
5327
+ summary.ask = { interrupted, emptyThreads };
5328
+ if (interrupted || emptyThreads) {
5329
+ console.log(`[worca-ui] ask sweep: ${interrupted} interrupted turn(s), ${emptyThreads} empty thread(s)`);
5330
+ }
5331
+ } catch (err) {
5332
+ summary.ask = { interrupted: 0, emptyThreads: 0 };
5333
+ console.error(`[worca-ui] ask sweep failed: ${err && err.message ? err.message : err}`);
5334
+ }
5335
+
5336
+ // Ask worktrees (P4 §5): reconcile ask_worktrees rows vs on-disk checkouts
5337
+ // both ways. Three-state inside the sweep: a DB failure aborts with nothing
5338
+ // removed. `sink('ask-worktrees')` is undefined on a log-less boot, which is
5339
+ // exactly the sweep's own default — never call sink(...) directly.
5340
+ try {
5341
+ const r = await sweepAskWorktrees({ log: sink('ask-worktrees') });
5342
+ summary.askWorktrees = r;
5343
+ if (r.removedDirs || r.prunedRows) {
5344
+ console.log(`[worca-ui] ask-worktree sweep: removed ${r.removedDirs} orphan dir(s), dropped ${r.prunedRows} stale row(s)`);
5345
+ }
5346
+ if (r.failed) console.error(`[worca-ui] ask-worktree sweep: ${r.failed} candidate(s) skipped`);
5347
+ } catch (err) {
5348
+ console.error(`[worca-ui] ask-worktree sweep failed: ${err && err.message ? err.message : err}`);
5349
+ }
3565
5350
  return summary;
3566
5351
  }
3567
5352
 
@@ -3606,4 +5391,9 @@ if (isMain) {
3606
5391
  }
3607
5392
 
3608
5393
  export { app, server, runs };
3609
- export const _testing = { wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen, chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork, chatNotifier, resumeRun };
5394
+ export const _testing = {
5395
+ wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen,
5396
+ chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
5397
+ chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
5398
+ emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
5399
+ };