@worca/app 1.1.1 → 1.2.0-rc.2

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.
package/ui/server.mjs CHANGED
@@ -41,19 +41,22 @@ import {
41
41
  setPipelineCostLimitUsd, setTotalCostLimitUsd, setCostLimitResetPeriod, assertCostLimitInputs,
42
42
  askMaxTurns, askMaxBudgetUsd, setAskMaxTurns, setAskMaxBudgetUsd, assertAskLimitInputs,
43
43
  chatPrefs, setChatPrefs,
44
+ debugSpawnEnabled as storedDebugSpawnEnabled, effectiveDebugSpawn, setDebugSpawnEnabled, assertDebugSpawnInput, SETTINGS_POST_KEYS,
44
45
  } from '../src/core/settings.mjs';
45
46
  import {
46
47
  ASK_ID_RE, createThread as askCreateThread, getThread as askGetThread,
47
48
  listThreads as askListThreads, updateThread as askUpdateThread,
48
49
  deleteThread as askDeleteThread, sweepEmptyThreads, sweepStreamingMessages,
50
+ countThreads as askCountThreads, listThreadIds as askListThreadIds,
51
+ countWorktrees as askCountWorktrees, countAttachments as askCountAttachments,
49
52
  appendMessage as askAppendMessage, getMessage as askGetMessage,
50
53
  listMessages as askListMessages, setMessageBlocks as askSetMessageBlocks,
51
54
  findCard as askFindCard, updateCardBlock as askUpdateCardBlock,
52
55
  addAttachment as askAddAttachment, listAttachments as askListAttachments,
53
- readAttachmentText as askReadAttachmentText, threadAttachmentBytes as askThreadAttachmentBytes,
56
+ getAttachment as askGetAttachment, attachmentPath as askAttachmentPath, threadAttachmentBytes as askThreadAttachmentBytes,
54
57
  linkRun as askLinkRun, updateRunLink as askUpdateRunLink, listRunLinks as askListRunLinks,
55
58
  findRunLinksByPipeline as askFindRunLinksByPipeline,
56
- setThreadTitle as askSetThreadTitle, finishMessage as askFinishMessage,
59
+ finishMessage as askFinishMessage,
57
60
  } from '../src/core/ask/store.mjs';
58
61
  import { sanitizeTitle as askSanitizeTitle } from '../src/core/title.mjs';
59
62
  import { ASK_LIMITS } from '../src/core/ask/limits.mjs';
@@ -64,6 +67,9 @@ import {
64
67
  buildTurnPrompt as askBuildTurnPrompt, buildRestoredPrompt as askBuildRestoredPrompt,
65
68
  selectInlineAttachments as askSelectInlineAttachments, validateClientContext,
66
69
  } from '../src/core/ask/prompt.mjs';
70
+ import {
71
+ classifyExtension as askClassifyExtension, sniffMime as askSniffMime,
72
+ } from '../src/core/ask/attachment-kind.mjs';
67
73
  import {
68
74
  listAskWorktrees as askListWorktrees,
69
75
  removeAskWorktree as askRemoveWorktree,
@@ -84,9 +90,12 @@ import {
84
90
  globalModelRefs, removeGlobalModelAndRefs, promoteCustomModel, costUnreliableModelIds,
85
91
  } from '../src/core/config.mjs';
86
92
  import { listGlobalModels, addGlobalModel, updateGlobalModel } from '../src/core/settings.mjs';
87
- import { modelEnvRef, SUBAGENT_MODEL_VALUES, subagentModelIssue } from '../src/core/model-env.mjs';
93
+ import { modelEnvRef, maskModelEnvValue, SUBAGENT_MODEL_VALUES, subagentModelIssue } from '../src/core/model-env.mjs';
88
94
  import { listPluginModels, modelSecretsSchema, pluginModelSecretStatus } from '../src/core/plugin-models.mjs';
89
95
  import { testModel } from '../src/core/model-test.mjs';
96
+ import {
97
+ DEFAULT_UI_PORT, UI_HEALTH_NAME, newUiToken, writeUiInstance, removeUiInstance, uiUrl,
98
+ } from '../src/core/ui-instance.mjs';
90
99
  import { validateGuardrails } from '../src/core/guardrails.mjs';
91
100
  import {
92
101
  listBuiltinGuardrailSets, listGuardrailSets, readGuardrailSet,
@@ -168,6 +177,7 @@ const PUBLIC_DIR = path.join(__dirname, 'public');
168
177
  const AGENTS_DIR = path.join(PROJECT_ROOT, 'agents');
169
178
  const SKILLS_DIR = path.join(PROJECT_ROOT, 'skills');
170
179
  const require = createRequire(import.meta.url);
180
+ const PKG_VERSION = require('../package.json').version;
171
181
  const HLJS_LANGUAGE_FILE_RE = /^[a-z0-9][a-z0-9-]{0,63}\.min\.js$/;
172
182
  // Primaries plus the sub-language grammars their instances register
173
183
  // (hljs-loader.mjs); a shipped but unmapped grammar stays a plain 404.
@@ -206,7 +216,7 @@ const ASK_VENDOR_ASSETS = {
206
216
  dompurify: resolveEsmAsset('dompurify'),
207
217
  };
208
218
 
209
- const PORT = Number(process.env.PORT) || 4317;
219
+ const PORT = Number(process.env.PORT) || DEFAULT_UI_PORT;
210
220
  // Bind to loopback by default (S1). Power users who knowingly want LAN exposure
211
221
  // can set WORCA_HOST=0.0.0.0, but the localhost-only Host/Origin guard still
212
222
  // applies unless they also front it with auth.
@@ -269,6 +279,11 @@ const MAX_BUFFER = 5000;
269
279
  const app = express();
270
280
  const server = http.createServer(app);
271
281
  const wss = new WebSocketServer({ server, path: '/ws' });
282
+ // ws re-emits the http server's 'error' on the WebSocketServer. With no listener
283
+ // here, an EADDRINUSE on listen() became an unhandled 'error' event and a full
284
+ // stack trace; the http server's own handler (isMain below) is the one that
285
+ // reports it, so this side of the pair only has to not throw.
286
+ wss.on('error', () => {});
272
287
 
273
288
  /** All currently connected sockets. */
274
289
  const sockets = new Set();
@@ -484,6 +499,9 @@ function summarizeRuns() {
484
499
  // 'cost_pipeline'/'cost_total') instead of showing a plain "Paused" card
485
500
  // until the next event.
486
501
  pauseReason: r.pauseReason || null,
502
+ // The clipped failure message behind reason 'error', or null — so a
503
+ // reload/reconnect restores the "Paused · error" detail, not a bare card.
504
+ pauseDetail: r.pauseDetail || null,
487
505
  startedAt: r.startedAt,
488
506
  pendingQuestion: r.pendingQuestion || null,
489
507
  // kind discriminator so the client routes runs vs scans vs agent generations
@@ -564,14 +582,21 @@ function wireRun(entry) {
564
582
  // Remember the pause reason for summarizeRuns (hello). Reset on every
565
583
  // done so a later reasonless finish cannot leave a stale cost banner.
566
584
  entry.pauseReason = (payload && payload.reason) || null;
585
+ // ...and WHAT went wrong for an error-pause, reset alongside it.
586
+ entry.pauseDetail = (payload && payload.detail) || null;
567
587
  resolvePending(entry, { reason: entry.status });
568
588
  if (payload?.reason === 'cost_pipeline' || payload?.reason === 'cost_total') {
569
589
  emitChanged('budget-changed');
570
590
  }
571
591
  }
572
592
  if (name === 'error') {
573
- entry.status = 'error';
574
- resolvePending(entry, { reason: 'error' });
593
+ // The launch-error channel (a failure BEFORE the pipeline row exists). A
594
+ // converted in-run failure pauses and emits no 'error'; never let a stray
595
+ // one demote a parked run.
596
+ if (entry.status !== 'paused' && entry.status !== 'pausing') {
597
+ entry.status = 'error';
598
+ resolvePending(entry, { reason: 'error' });
599
+ }
575
600
  }
576
601
  if (name === 'exec') {
577
602
  entry.status = 'running';
@@ -738,6 +763,15 @@ app.use((req, res, next) => {
738
763
  next();
739
764
  });
740
765
 
766
+ // Ask attachments ride base64 inside the message JSON (§7.3), and a binary
767
+ // attachment (#398) may legitimately be 5 MB — several of them blow the app-wide
768
+ // 8mb cap below. Registered BEFORE the global parser on the ONE route that
769
+ // carries uploads (a body parsed here is skipped there): every other ask route
770
+ // reads a string field or nothing and keeps the 8mb window. 64mb covers
771
+ // maxFiles × maxBytesPerBinaryFile at base64's 4/3 inflation, so every
772
+ // over-budget upload still reaches the route's OWN clear 400/413, not a raw
773
+ // parser error.
774
+ app.post('/api/ask/threads/:id/messages', express.json({ limit: '64mb' }));
741
775
  app.use(express.json({ limit: '8mb' }));
742
776
 
743
777
  if (HLJS_ASSETS) {
@@ -2752,6 +2786,51 @@ const settingsState = () => ({
2752
2786
  costLimitResetPeriod: costLimitResetPeriod(),
2753
2787
  askMaxTurns: askMaxTurns(),
2754
2788
  askMaxBudgetUsd: askMaxBudgetUsd(),
2789
+ debugSpawnEnabled: storedDebugSpawnEnabled(), // what is STORED (the checkbox)
2790
+ debugSpawnEffective: effectiveDebugSpawn(), // what the next spawn will DO, and why
2791
+ });
2792
+
2793
+ // ---------------------------------------------------------------------------
2794
+ // Instance lifecycle (`worca ui status|stop|restart`, src/core/ui-instance.mjs)
2795
+ // ---------------------------------------------------------------------------
2796
+ // `uiControl` is set by the boot block below when the server owns a port. Under
2797
+ // test (app imported, no bind) it stays empty: /api/health still answers, and
2798
+ // /api/shutdown refuses with 503 rather than exiting the test runner.
2799
+ const uiControl = { token: null, onShutdown: null, startedAt: null };
2800
+ const startedAtIso = () => uiControl.startedAt || null;
2801
+
2802
+ app.get('/api/health', (req, res) => {
2803
+ const addr = req.socket && req.socket.localPort;
2804
+ res.json({
2805
+ name: UI_HEALTH_NAME,
2806
+ version: PKG_VERSION,
2807
+ pid: process.pid,
2808
+ host: HOST,
2809
+ port: addr || PORT,
2810
+ startedAt: startedAtIso(),
2811
+ });
2812
+ });
2813
+
2814
+ /** Constant-time bearer check; `expected` is the boot-time token from ui.json. */
2815
+ function bearerMatches(header, expected) {
2816
+ if (!expected || typeof header !== 'string') return false;
2817
+ const m = /^Bearer\s+(\S+)$/i.exec(header.trim());
2818
+ if (!m) return false;
2819
+ const a = Buffer.from(m[1]);
2820
+ const b = Buffer.from(expected);
2821
+ return a.length === b.length && timingSafeEqual(a, b);
2822
+ }
2823
+
2824
+ app.post('/api/shutdown', (req, res) => {
2825
+ if (!uiControl.token || typeof uiControl.onShutdown !== 'function') {
2826
+ return res.status(503).json({ error: 'shutdown is only available on a server started with `worca ui`' });
2827
+ }
2828
+ if (!bearerMatches(req.headers.authorization, uiControl.token)) {
2829
+ return res.status(401).json({ error: 'shutdown requires the bearer token from the instance file' });
2830
+ }
2831
+ res.status(202).json({ ok: true, pid: process.pid });
2832
+ // Answer first, exit on the next tick so the 202 actually leaves the socket.
2833
+ setImmediate(() => uiControl.onShutdown('request'));
2755
2834
  });
2756
2835
 
2757
2836
  app.get('/api/settings', (_req, res) => {
@@ -2767,6 +2846,7 @@ app.post('/api/settings', async (req, res) => {
2767
2846
  const has = (k) => Object.prototype.hasOwnProperty.call(body, k);
2768
2847
  const hasBudgetKey = has('pipelineCostLimitUsd') || has('totalCostLimitUsd') || has('costLimitResetPeriod');
2769
2848
  const hasAskKey = has('askMaxTurns') || has('askMaxBudgetUsd');
2849
+ const hasDebugSpawnKey = has('debugSpawnEnabled');
2770
2850
  // Normalize the budget keys first, then validate them as a SET before ANY write.
2771
2851
  // Each setter persists on its own, so a two-key POST whose second key is invalid
2772
2852
  // used to answer 400 with the first key already on disk, no budget-changed
@@ -2787,6 +2867,15 @@ app.post('/api/settings', async (req, res) => {
2787
2867
  try {
2788
2868
  assertCostLimitInputs(budget);
2789
2869
  assertAskLimitInputs(ask);
2870
+ if (hasDebugSpawnKey) assertDebugSpawnInput(body.debugSpawnEnabled);
2871
+ // Root first: it is the one key whose setter can still fail AFTER the asserts
2872
+ // above (an unusable path), so every other key's write must come after it or
2873
+ // a mixed POST would answer 400 with those keys already applied on disk.
2874
+ // Legacy contract: a POST that names NO known key clears root; the known
2875
+ // keys live beside their setters (SETTINGS_POST_KEYS), not in a list here.
2876
+ if (has('root') || !SETTINGS_POST_KEYS.some(has)) {
2877
+ await setWorcaRoot(typeof body.root === 'string' ? body.root : '');
2878
+ }
2790
2879
  if (has('chat')) await setChatPrefs(body.chat);
2791
2880
  if (has('projectsRoot')) {
2792
2881
  await setProjectsRoot(typeof body.projectsRoot === 'string' ? body.projectsRoot : '');
@@ -2796,12 +2885,11 @@ app.post('/api/settings', async (req, res) => {
2796
2885
  if (has('costLimitResetPeriod')) await setCostLimitResetPeriod(budget.costLimitResetPeriod);
2797
2886
  if (has('askMaxTurns')) await setAskMaxTurns(ask.askMaxTurns);
2798
2887
  if (has('askMaxBudgetUsd')) await setAskMaxBudgetUsd(ask.askMaxBudgetUsd);
2799
- // Legacy contract: a POST that names no known key clears root. Budget and ask
2800
- // keys must not trip it — a budget-only or ask-only save would otherwise wipe the root.
2801
- if (has('root') || !(has('projectsRoot') || hasBudgetKey || hasAskKey || has('chat'))) {
2802
- await setWorcaRoot(typeof body.root === 'string' ? body.root : '');
2803
- }
2888
+ if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
2804
2889
  if (hasBudgetKey) emitChanged('budget-changed');
2890
+ // Other open tabs repaint their Settings cards (a stale tab could otherwise
2891
+ // "save" its old checkbox state over this one with no feedback to either).
2892
+ if (hasAskKey || hasDebugSpawnKey) emitChanged('settings-changed');
2805
2893
  res.json({ ...settingsState(), chat: chatPrefs() });
2806
2894
  } catch (err) {
2807
2895
  // The setters throw only on an unusable path -> client error (400).
@@ -2987,8 +3075,7 @@ app.delete('/api/config/models', async (req, res) => {
2987
3075
  // back means "keep" and is dropped from the write.
2988
3076
  // ---------------------------------------------------------------------------
2989
3077
 
2990
- const maskEnvValue = (v) =>
2991
- (modelEnvRef(v) ? v : (v.length > 8 ? `••••••${v.slice(-4)}` : '••••••'));
3078
+ const maskEnvValue = (v) => (modelEnvRef(v) ? v : maskModelEnvValue(v));
2992
3079
  const maskedGlobalModel = (m) => (m.env
2993
3080
  ? { ...m, env: Object.fromEntries(Object.entries(m.env).map(([k, v]) => [k, maskEnvValue(v)])) }
2994
3081
  : m);
@@ -3563,6 +3650,29 @@ function stampAskFrames(threadId, job) {
3563
3650
  };
3564
3651
  }
3565
3652
 
3653
+ /** The narrow worktree envelope the snapshot GET and the `ask-worktrees` frame
3654
+ * share (P4 §10): never the full row — threadId/projectDir/updatedAt stay
3655
+ * server-side. Mirrors the list_worktrees MCP tool (src/core/ask/tools.mjs). */
3656
+ function askWorktreesEnvelope(threadId) {
3657
+ return askListWorktrees(threadId).map((w) => ({
3658
+ worktreeId: w.worktreeId, projectKey: w.projectKey, ref: w.ref,
3659
+ commit: w.commit, path: w.path, createdAt: w.createdAt,
3660
+ }));
3661
+ }
3662
+
3663
+ /** Broadcast the thread's CURRENT worktrees as an out-of-turn frame (seq-less,
3664
+ * threadId-tagged, like ask-title). Fed by the turn's onWorktreeMutation hook —
3665
+ * the MCP child opened/removed/navigated a checkout this process never saw —
3666
+ * and by the manual DELETE route, so every tab's count and popover follow
3667
+ * without a snapshot GET. Best effort; false when the thread is gone. */
3668
+ function emitAskWorktrees(threadId) {
3669
+ try {
3670
+ if (!askGetThread(threadId)) return false;
3671
+ broadcast({ type: 'ask-worktrees', threadId, worktrees: askWorktreesEnvelope(threadId) });
3672
+ return true;
3673
+ } catch { return false; } // a poke is best effort
3674
+ }
3675
+
3566
3676
  /** 400 on shape (spec §8.1 — a DELIBERATE divergence from the house 404-on-
3567
3677
  * malformed-param style), null-return contract like badRequest. */
3568
3678
  function askIdParam(res, value, kind) {
@@ -3578,7 +3688,51 @@ app.get('/api/ask/threads', (req, res) => {
3578
3688
  const raw = Number.parseInt(String(req.query.limit ?? ''), 10);
3579
3689
  const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 200) : 50;
3580
3690
  const threads = askListThreads({ limit }).map((t) => ({ ...t, inFlight: !!askInFlight(t.id) }));
3581
- res.json({ threads });
3691
+ // total = EVERY saved chat (the History popover's meter), not the capped page above.
3692
+ res.json({ threads, total: askCountThreads() });
3693
+ } catch (err) {
3694
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3695
+ }
3696
+ });
3697
+
3698
+ // Settings → "Delete all chat history": the counts the confirm dialog quotes,
3699
+ // read fresh right before it opens.
3700
+ app.get('/api/ask/history', (req, res) => {
3701
+ try {
3702
+ res.json({
3703
+ threads: askCountThreads(),
3704
+ worktrees: askCountWorktrees(),
3705
+ attachments: askCountAttachments(),
3706
+ inFlight: askRunningCount(),
3707
+ });
3708
+ } catch (err) {
3709
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3710
+ }
3711
+ });
3712
+
3713
+ // Bulk delete: every thread through deleteAskThreadFully, SEQUENTIALLY (each
3714
+ // worktree removal spawns git — never in parallel), best-effort per thread. The
3715
+ // ids come from listThreadIds (no cap), never from the LIMIT-ed listThreads.
3716
+ // One JSON at the end; then a seq-less out-of-turn frame so every open tab
3717
+ // drops its now-dead st.threadId (the panel would otherwise keep it until the
3718
+ // next 404).
3719
+ app.delete('/api/ask/threads', async (req, res) => {
3720
+ const removed = { threads: 0, worktrees: 0 };
3721
+ const failed = [];
3722
+ try {
3723
+ for (const id of askListThreadIds()) {
3724
+ try {
3725
+ const r = await deleteAskThreadFully(id);
3726
+ if (r.deleted) {
3727
+ removed.threads += 1;
3728
+ removed.worktrees += r.worktrees;
3729
+ } else failed.push(id);
3730
+ } catch {
3731
+ failed.push(id);
3732
+ }
3733
+ }
3734
+ res.json({ ok: true, removed, failed });
3735
+ broadcast({ type: 'ask-history-cleared' });
3582
3736
  } catch (err) {
3583
3737
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
3584
3738
  }
@@ -3614,12 +3768,9 @@ app.get('/api/ask/threads/:id', (req, res) => {
3614
3768
  messages: askListMessages(id),
3615
3769
  attachments: askListAttachments(id),
3616
3770
  runLinks: askListRunLinks(id),
3617
- // P4 §10: the SAME narrow envelope the list_worktrees MCP tool returns
3618
- // never the full row (threadId/projectDir/updatedAt stay server-side).
3619
- worktrees: askListWorktrees(id).map((w) => ({
3620
- worktreeId: w.worktreeId, projectKey: w.projectKey, ref: w.ref,
3621
- commit: w.commit, path: w.path, createdAt: w.createdAt,
3622
- })),
3771
+ // P4 §10: the SAME narrow envelope the list_worktrees MCP tool and the
3772
+ // ask-worktrees frame carry — never the full row.
3773
+ worktrees: askWorktreesEnvelope(id),
3623
3774
  inFlight: job && job.messageId ? { messageId: job.messageId } : null, // null while the slot is only reserved
3624
3775
  });
3625
3776
  } catch (err) {
@@ -3631,11 +3782,32 @@ app.patch('/api/ask/threads/:id', (req, res) => {
3631
3782
  const id = askIdParam(res, req.params.id, 'thread');
3632
3783
  if (!id) return;
3633
3784
  try {
3634
- const raw = (req.body || {}).title;
3635
- if (typeof raw !== 'string' || !raw.trim() || raw.length > 120) {
3636
- return badRequest(res, 'title must be a non-empty string of at most 120 characters');
3785
+ const body = req.body || {};
3786
+ const patch = {};
3787
+ // Title keeps its original contract exactly: a PATCH that names neither field
3788
+ // still earns the title error, so pre-#397 callers see identical behaviour.
3789
+ if (body.title !== undefined || body.scope === undefined) {
3790
+ const raw = body.title;
3791
+ if (typeof raw !== 'string' || !raw.trim() || raw.length > 120) {
3792
+ return badRequest(res, 'title must be a non-empty string of at most 120 characters');
3793
+ }
3794
+ patch.title = raw.trim();
3795
+ }
3796
+ if (body.scope !== undefined) {
3797
+ // #397: the Ask panel's scope selector. Merged per field into the stored
3798
+ // context — the pin replaces only the target keys, so the last page
3799
+ // context (view, run, diff file) survives a selector change.
3800
+ const sv = askValidateScope(body.scope);
3801
+ if (!sv.ok) return badRequest(res, sv.error);
3802
+ const cur = askGetThread(id);
3803
+ if (!cur) return res.status(404).json({ error: 'thread not found' });
3804
+ const base = cur.context && typeof cur.context === 'object' && !Array.isArray(cur.context) ? { ...cur.context } : {};
3805
+ delete base.projectDir;
3806
+ delete base.projectKey;
3807
+ delete base.workspaceId;
3808
+ patch.context = { ...base, ...sv.scope };
3637
3809
  }
3638
- const thread = askUpdateThread(id, { title: raw.trim() });
3810
+ const thread = askUpdateThread(id, patch);
3639
3811
  if (!thread) return res.status(404).json({ error: 'thread not found' });
3640
3812
  res.json({ thread });
3641
3813
  } catch (err) {
@@ -3645,13 +3817,13 @@ app.patch('/api/ask/threads/:id', (req, res) => {
3645
3817
 
3646
3818
  // §7.5 order: abort the in-flight turn -> detach followers -> remove the chat's
3647
3819
  // worktrees git-properly -> delete the row (tx + cascades) + rm -rf inside
3648
- // deleteThread -> drop the job entry.
3649
- app.delete('/api/ask/threads/:id', async (req, res) => {
3650
- const id = askIdParam(res, req.params.id, 'thread');
3651
- if (!id) return;
3820
+ // deleteThread -> drop the job entry. Shared by the per-thread DELETE and the
3821
+ // bulk DELETE; askDeleting brackets the whole thing per id (POST /messages
3822
+ // refuses the thread while its delete is past the first await).
3823
+ // Returns { deleted, worktrees } — worktrees = rows removeThreadWorktrees removed.
3824
+ async function deleteAskThreadFully(id) {
3825
+ askDeleting.add(id);
3652
3826
  try {
3653
- if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
3654
- askDeleting.add(id);
3655
3827
  const stopJob = () => {
3656
3828
  const job = askJobs.get(id);
3657
3829
  if (job && job.turn && typeof job.turn.stop === 'function') {
@@ -3670,20 +3842,30 @@ app.delete('/api/ask/threads/:id', async (req, res) => {
3670
3842
  // P4 §5: git-proper removal of every worktree BEFORE the row cascade — the
3671
3843
  // rmSync inside askDeleteThread alone would leave stale `git worktree`
3672
3844
  // registrations in the source repos. Never throws (best-effort per row).
3673
- await askRemoveThreadWorktrees(id);
3845
+ const { removed } = await askRemoveThreadWorktrees(id);
3674
3846
  // Re-read the job AFTER the await: askDeleting blocks new turns, but a turn
3675
3847
  // that was already mid-start is stopped here rather than left running.
3676
3848
  const job = stopJob();
3677
- askDeleteThread(id);
3849
+ const deleted = askDeleteThread(id);
3678
3850
  if (job) {
3679
3851
  if (job.graceTimer) clearTimeout(job.graceTimer);
3680
3852
  askJobs.delete(id);
3681
3853
  }
3854
+ return { deleted, worktrees: removed };
3855
+ } finally {
3856
+ askDeleting.delete(id);
3857
+ }
3858
+ }
3859
+
3860
+ app.delete('/api/ask/threads/:id', async (req, res) => {
3861
+ const id = askIdParam(res, req.params.id, 'thread');
3862
+ if (!id) return;
3863
+ try {
3864
+ if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
3865
+ await deleteAskThreadFully(id);
3682
3866
  res.json({ ok: true });
3683
3867
  } catch (err) {
3684
3868
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
3685
- } finally {
3686
- askDeleting.delete(id);
3687
3869
  }
3688
3870
  });
3689
3871
 
@@ -3697,6 +3879,7 @@ app.delete('/api/ask/threads/:id/worktrees/:wtId', async (req, res) => {
3697
3879
  try {
3698
3880
  if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
3699
3881
  const out = await askRemoveWorktree({ threadId: id, wtId });
3882
+ emitAskWorktrees(id); // every open tab's count/popover follows the delete
3700
3883
  res.json(out);
3701
3884
  } catch (err) {
3702
3885
  if (err && err.name === 'AskWorktreeError') return res.status(404).json({ error: err.message });
@@ -3711,11 +3894,34 @@ app.get('/api/ask/threads/:id/attachments/:attId', (req, res) => {
3711
3894
  if (!attId) return;
3712
3895
  try {
3713
3896
  if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
3714
- const att = askReadAttachmentText(id, attId);
3715
- if (!att) return res.status(404).json({ error: 'attachment not found' });
3716
- res.set('X-Content-Type-Options', 'nosniff');
3717
- res.set('Content-Disposition', 'inline');
3718
- res.type('text/plain; charset=utf-8').send(att.text);
3897
+ const att = askGetAttachment(id, attId);
3898
+ const file = att ? askAttachmentPath(id, attId) : null;
3899
+ if (!file) return res.status(404).json({ error: 'attachment not found' });
3900
+ // Text bodies serve as utf-8 text/plain (pre-#398, byte-for-byte: the body
3901
+ // was UTF-8-validated at upload and stored verbatim). Only sniff-verified
3902
+ // allowlisted mimes are ever stored (never scriptable markup like SVG/HTML),
3903
+ // so serving the real mime inline is safe — and it is what lets the
3904
+ // transcript render <img> thumbnails (#398).
3905
+ const type = att.kind === 'text' ? 'text/plain; charset=utf-8' : (att.mime || 'application/octet-stream');
3906
+ // Streamed, not readFileSync + send: a body is immutable under its
3907
+ // store-minted id, so a stat-based ETag/Last-Modified plus a year-long
3908
+ // private immutable cache replaces a 5 MB sync read and sha1 per request —
3909
+ // the transcript re-creates every <img> on each structural render.
3910
+ res.sendFile(path.basename(file), {
3911
+ root: path.dirname(file),
3912
+ dotfiles: 'deny',
3913
+ cacheControl: false,
3914
+ headers: {
3915
+ 'Content-Type': type,
3916
+ 'X-Content-Type-Options': 'nosniff',
3917
+ 'Content-Disposition': 'inline',
3918
+ 'Cache-Control': 'private, max-age=31536000, immutable',
3919
+ },
3920
+ }, (err) => {
3921
+ if (!err || res.headersSent) return;
3922
+ if (err.code === 'ENOENT' || err.status === 404) return res.status(404).json({ error: 'attachment not found' });
3923
+ res.status(500).json({ error: err.message || String(err) });
3924
+ });
3719
3925
  } catch (err) {
3720
3926
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
3721
3927
  }
@@ -3755,12 +3961,44 @@ function askRunFromPipelineRow(row) {
3755
3961
  };
3756
3962
  }
3757
3963
 
3964
+ /** #397: the user-pinned scope of an ask context — {projectKey} | {workspaceId} | null. */
3965
+ function askPinnedScope(context) {
3966
+ if (!context || typeof context !== 'object' || context.pinned !== true) return null;
3967
+ if (typeof context.projectKey === 'string' && context.projectKey) return { projectKey: context.projectKey };
3968
+ if (typeof context.workspaceId === 'string' && context.workspaceId) return { workspaceId: context.workspaceId };
3969
+ return null;
3970
+ }
3971
+
3972
+ /** #397 per-field merge: the pinned scope replaces the page context's TARGET keys
3973
+ * (projectDir/projectKey/workspaceId); view, run, pipeline and diff-file context
3974
+ * still follow the page. */
3975
+ function askApplyPin(ctx, pin) {
3976
+ const out = { ...ctx, pinned: true };
3977
+ delete out.projectDir;
3978
+ delete out.projectKey;
3979
+ delete out.workspaceId;
3980
+ return { ...out, ...pin };
3981
+ }
3982
+
3983
+ /** #397 selector PATCH body: {pinned:false} | {pinned:true, projectKey|workspaceId}. */
3984
+ function askValidateScope(raw) {
3985
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, error: 'scope must be an object' };
3986
+ if (typeof raw.pinned !== 'boolean') return { ok: false, error: 'scope.pinned must be true or false' };
3987
+ if (!raw.pinned) return { ok: true, scope: { pinned: false } };
3988
+ const cv = validateClientContext({ projectKey: raw.projectKey, workspaceId: raw.workspaceId });
3989
+ if (!cv.ok) return { ok: false, error: cv.error.replace('context.', 'scope.') };
3990
+ const keys = ['projectKey', 'workspaceId'].filter((k) => cv.context[k]);
3991
+ if (keys.length !== 1) return { ok: false, error: 'scope needs exactly one of projectKey / workspaceId' };
3992
+ return { ok: true, scope: { pinned: true, [keys[0]]: cv.context[keys[0]] } };
3993
+ }
3994
+
3758
3995
  /** Resolve the VALIDATED client context into the server-side shape
3759
3996
  * buildContextHeader consumes (§6.5: server-resolved rows only — never
3760
3997
  * client-supplied titles or paths). Every lookup is individually guarded:
3761
3998
  * a vanished row degrades to an absent header line, never a 500. */
3762
3999
  async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], currentMessageId = null) {
3763
4000
  const out = { now: new Date().toISOString() };
4001
+ if (ctx.pinned === true) out.pinned = true; // #397: rendered as the [pinned by the user] marker
3764
4002
  if (ctx.view) out.view = ctx.view;
3765
4003
  if (ctx.diffPath) out.diffPath = ctx.diffPath; // client-supplied, already length-checked by validateClientContext
3766
4004
  try {
@@ -3831,8 +4069,8 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
3831
4069
  .filter((a) => !currentMessageId || a.messageId !== currentMessageId)
3832
4070
  .slice(-ASK_LIMITS.headerAttachments)
3833
4071
  .reverse()
3834
- .map((a) => ({ id: a.id, name: a.name, bytes: a.bytes }));
3835
- const atts = [...listedAttachments.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes })), ...earlier];
4072
+ .map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime }));
4073
+ const atts = [...listedAttachments.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime })), ...earlier];
3836
4074
  if (atts.length) out.attachments = atts.slice(0, ASK_LIMITS.headerAttachments);
3837
4075
  } catch { /* absent lines */ }
3838
4076
  return out;
@@ -3872,6 +4110,16 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
3872
4110
  if (!mv.ok) return badRequest(res, mv.error);
3873
4111
  const cv = validateClientContext(body.context);
3874
4112
  if (!cv.ok) return badRequest(res, cv.error);
4113
+ // #397: explicit pin beats page context, per field. A context carrying its own
4114
+ // `pinned` verdict is authoritative — the selector-aware client already merged
4115
+ // (true) or explicitly chose Auto (false). A context WITHOUT one comes from a
4116
+ // pre-selector tab, and inherits the thread's stored pin so a stale tab can
4117
+ // never silently unpin (or re-scope) the conversation.
4118
+ let ctx = cv.context;
4119
+ if (ctx.pinned === undefined) {
4120
+ const inherited = askPinnedScope(thread.context);
4121
+ if (inherited) ctx = askApplyPin(ctx, inherited);
4122
+ }
3875
4123
 
3876
4124
  // §7.3 — validate EVERY attachment before ANY write (all-or-nothing).
3877
4125
  const files = [];
@@ -3885,19 +4133,30 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
3885
4133
  const name = a && typeof a.name === 'string' ? a.name : '';
3886
4134
  const dot = name.lastIndexOf('.');
3887
4135
  const ext = dot === -1 ? '' : name.slice(dot).toLowerCase();
3888
- if (!ASK_LIMITS.attachment.extensions.includes(ext)) {
3889
- return badRequest(res, `attachment type not allowed: ${name || '(unnamed)'}`);
3890
- }
4136
+ // #398: the extension CLAIMS a type; text kinds are then proven by UTF-8
4137
+ // decoding (as before), binary kinds by their magic number — a body that
4138
+ // does not match its claim is refused here, before any write.
4139
+ const cls = askClassifyExtension(ext);
4140
+ if (!cls) return badRequest(res, `attachment type not allowed: ${name || '(unnamed)'}`);
3891
4141
  const raw = typeof a.dataBase64 === 'string' ? a.dataBase64 : '';
3892
4142
  const buf = raw ? Buffer.from(raw, 'base64') : Buffer.alloc(0);
3893
4143
  if (!buf.length) return badRequest(res, `attachment is empty or not valid base64: ${name}`);
3894
- if (buf.length > ASK_LIMITS.attachment.maxBytesPerFile) {
3895
- return res.status(413).json({ error: `attachment over ${ASK_LIMITS.attachment.maxBytesPerFile} bytes: ${name}` });
4144
+ const cap = cls.kind === 'text' ? ASK_LIMITS.attachment.maxBytesPerFile : ASK_LIMITS.attachment.maxBytesPerBinaryFile;
4145
+ if (buf.length > cap) {
4146
+ return res.status(413).json({ error: `attachment over ${cap} bytes: ${name}` });
4147
+ }
4148
+ if (cls.kind !== 'text') {
4149
+ const sniffed = askSniffMime(buf);
4150
+ if (sniffed !== cls.mime) {
4151
+ return badRequest(res, `attachment content does not match its extension: ${name}`);
4152
+ }
4153
+ files.push({ name, kind: cls.kind, mime: cls.mime, data: buf, bytes: buf.length });
4154
+ continue;
3896
4155
  }
3897
4156
  let bodyText;
3898
4157
  try { bodyText = dec.decode(buf); } catch { return badRequest(res, `attachment is not valid UTF-8: ${name}`); }
3899
4158
  if (bodyText.includes('\u0000')) return badRequest(res, `attachment contains NUL bytes: ${name}`);
3900
- files.push({ name, text: bodyText, bytes: buf.length });
4159
+ files.push({ name, kind: 'text', mime: cls.mime, text: bodyText, bytes: buf.length });
3901
4160
  }
3902
4161
  const total = askThreadAttachmentBytes(id) + files.reduce((s, f) => s + f.bytes, 0);
3903
4162
  if (total > ASK_LIMITS.attachment.maxBytesPerThread) {
@@ -3929,24 +4188,33 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
3929
4188
 
3930
4189
  let asstMsg = null;
3931
4190
  let turn;
4191
+ let echoAttachments = [];
3932
4192
  try {
3933
4193
  // Writes. Store the LAST context + model/effort on the thread (§6.5 tail, D8).
3934
- askUpdateThread(id, { context: cv.context, model: mv.model, effort: mv.effort });
3935
- let deterministicTitle = thread.title;
3936
- let titleWasAuto = false;
3937
- if (thread.title == null) {
3938
- // §7.4 no frame for the deterministic title. titleWasAuto gates the
3939
- // D13 background replacement: a title given at THREAD CREATION is the
3940
- // user's, and the haiku call must never fire for it (§17 Q&A 1).
3941
- deterministicTitle = askSanitizeTitle(text.slice(0, 80)) || 'New chat';
3942
- askSetThreadTitle(id, deterministicTitle);
3943
- titleWasAuto = true;
3944
- }
4194
+ // `ctx` (pin-merged) rather than cv.context: the stored row is what restores
4195
+ // the selector on reopen and what the MCP child reads for tool defaulting.
4196
+ askUpdateThread(id, { context: ctx, model: mv.model, effort: mv.effort });
4197
+ // §7.4 NOTHING is stamped on the row before the 202: the thread stays
4198
+ // untitled (the header reads "Ask Worca") until the D13 background title
4199
+ // announces itself. titleWasAuto gates that call: a title given at THREAD
4200
+ // CREATION is the user's, and the haiku call must never fire for it
4201
+ // (§17 Q&A 1). deterministicTitle is only the turn's fallback for an
4202
+ // empty haiku result (turn.mjs _kickoffTitle), never written here.
4203
+ const titleWasAuto = thread.title == null;
4204
+ const deterministicTitle = titleWasAuto ? (askSanitizeTitle(text.slice(0, 80)) || 'New chat') : thread.title;
3945
4205
  const userMsg = askAppendMessage(id, { role: 'user', text });
3946
4206
  job.userMessageId = userMsg.id;
3947
- const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, text: f.text }));
4207
+ const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, kind: f.kind, mime: f.mime, text: f.text, data: f.data }));
4208
+ // The decoded binary bodies are on disk now. `files` is captured by this
4209
+ // scope's closures (settleJob, the turn listeners, onOutOfTurn) for the whole
4210
+ // turn plus jobGraceMs, so up to 25 MB of dead Buffers would otherwise stay
4211
+ // reachable per running thread.
4212
+ for (const f of files) f.data = null;
4213
+ echoAttachments = attRows.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime }));
3948
4214
  if (attRows.length) {
3949
- askSetMessageBlocks(userMsg.id, attRows.map((a) => ({ kind: 'attachment', id: a.id, name: a.name, bytes: a.bytes })));
4215
+ // `kind` is the BLOCK kind, so the attachment's own kind rides as attKind
4216
+ // (the UI keys image thumbnails off it, #398).
4217
+ askSetMessageBlocks(userMsg.id, attRows.map((a) => ({ kind: 'attachment', id: a.id, name: a.name, bytes: a.bytes, attKind: a.kind, mime: a.mime })));
3950
4218
  }
3951
4219
  broadcast({ type: 'ask-message', threadId: id, message: askGetMessage(userMsg.id) }); // echo for other tabs
3952
4220
  asstMsg = askAppendMessage(id, { role: 'assistant', text: '', status: 'streaming', model: mv.model, effort: mv.effort });
@@ -3955,9 +4223,9 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
3955
4223
  // Prompt assembly (§6.5) — the route owns it; the turn only spawns.
3956
4224
  const catalog = await askBuildCatalog();
3957
4225
  const systemPrompt = askBuildSystemPrompt(catalog);
3958
- const withText = attRows.map((a, i) => ({ id: a.id, name: a.name, bytes: a.bytes, text: files[i].text }));
4226
+ 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 }));
3959
4227
  const { inline, listed } = askSelectInlineAttachments(withText);
3960
- const headerCtx = await resolveAskContext(id, cv.context, listed, userMsg.id);
4228
+ const headerCtx = await resolveAskContext(id, ctx, listed, userMsg.id);
3961
4229
  const header = askBuildContextHeader(headerCtx);
3962
4230
  const prompt = askBuildTurnPrompt(header, text, inline);
3963
4231
  const prior = askListMessages(id).filter((m) => m.seq < userMsg.seq);
@@ -3973,12 +4241,14 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
3973
4241
  firstTurn: userMsg.seq === 1 && titleWasAuto, // D13 guard: never replace a user-authored title
3974
4242
  firstText: text,
3975
4243
  deterministicTitle,
3976
- mock: mockEnabled({}) ? { card: mockAskCard(cv.context, text) } : null, // R-F
4244
+ pinnedScope: askPinnedScope(ctx), // #397: proposal defaulting + mismatch flag
4245
+ mock: mockEnabled({}) ? { card: mockAskCard(ctx, text) } : null, // R-F
3977
4246
  attachmentNames,
3978
4247
  deps: {
3979
4248
  onFrame: stampAskFrames(id, job),
3980
4249
  onOutOfTurn: (f) => broadcast({ ...f, threadId: id }),
3981
4250
  onCommentMutation: ({ runId }) => { emitDiffCommentsChanged(runId); },
4251
+ onWorktreeMutation: () => { emitAskWorktrees(id); },
3982
4252
  },
3983
4253
  });
3984
4254
  job.turn = turn;
@@ -4013,7 +4283,10 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
4013
4283
  console.error(`[worca-ui] ask turn crashed: ${err && err.message ? err.message : err}`);
4014
4284
  settleJob('error');
4015
4285
  });
4016
- res.status(202).json({ userMessageId: job.userMessageId, assistantMessageId: job.messageId });
4286
+ // `attachments` carries the store-minted ids so the sender's own echo can key
4287
+ // image thumbnails and the thread budget off them (the ask-message broadcast
4288
+ // may have raced ahead of this response, or been missed on a brand-new thread).
4289
+ res.status(202).json({ userMessageId: job.userMessageId, assistantMessageId: job.messageId, attachments: echoAttachments });
4017
4290
  } catch (err) {
4018
4291
  // Only pre-reservation throws land here (`job` is block-scoped to the outer
4019
4292
  // try and every post-reservation failure returned from the inner catch), so
@@ -5143,36 +5416,60 @@ if (isMain) {
5143
5416
  console.error(`[worca-ui] boot maintenance failed: ${err && err.message ? err.message : err}`);
5144
5417
  });
5145
5418
 
5419
+ // A port that is already taken is an EXPECTED state (the UI is usually already
5420
+ // up), not a crash: one line, no stack, exit 1. `worca ui` probes the port
5421
+ // before spawning this process and prints the friendlier "already running"
5422
+ // block itself; this branch is for `node ui/server.mjs` run by hand or a race.
5146
5423
  server.on('error', (err) => {
5424
+ if (err && err.code === 'EADDRINUSE') {
5425
+ console.error(`[worca-ui] port ${PORT} is already in use — is the UI already running?`);
5426
+ console.error(`[worca-ui] check with \`worca ui status\`, restart with \`worca ui restart\`, or pick a port: \`worca ui --port <n>\``);
5427
+ process.exit(1);
5428
+ }
5147
5429
  console.error(`[worca-ui] server error: ${err && err.message ? err.message : err}`);
5148
5430
  });
5149
5431
 
5432
+ // Channel workers must die with the server (design §9: persistent-process
5433
+ // hygiene). Graceful shutdown frame -> 5s grace -> SIGKILL, then exit. The
5434
+ // same path serves POST /api/shutdown (`worca ui stop`), which exits 0.
5435
+ let shuttingDown = false;
5436
+ let wroteInstanceFile = false;
5437
+ const exitCodeFor = (signal) => (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 0);
5438
+ const shutdown = (signal) => {
5439
+ if (shuttingDown) return;
5440
+ shuttingDown = true;
5441
+ channelHost.stop().finally(() => process.exit(exitCodeFor(signal)));
5442
+ };
5443
+ process.on('SIGINT', () => shutdown('SIGINT'));
5444
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
5445
+ // 'exit' handlers must be synchronous; removeUiInstance is. `ifPid` keeps an
5446
+ // old server exiting late from deleting the file a newer one just wrote.
5447
+ process.on('exit', () => { if (wroteInstanceFile) removeUiInstance({ ifPid: process.pid }); });
5448
+
5150
5449
  server.listen(PORT, HOST, () => {
5151
- const shown = HOST === '127.0.0.1' || HOST === '::1' ? 'localhost' : HOST;
5152
- const url = `http://${shown}:${PORT}`;
5450
+ const port = server.address().port;
5451
+ const url = uiUrl({ host: HOST, port });
5153
5452
  console.log(`[worca-ui] listening on ${url} (bound to ${HOST})`);
5154
- console.log(`[worca-ui] WebSocket on ws://${shown}:${PORT}/ws`);
5453
+ uiControl.token = newUiToken();
5454
+ uiControl.onShutdown = shutdown;
5455
+ uiControl.startedAt = new Date().toISOString();
5456
+ writeUiInstance({
5457
+ pid: process.pid, host: HOST, port, token: uiControl.token,
5458
+ version: PKG_VERSION, startedAt: uiControl.startedAt,
5459
+ }).then(() => { wroteInstanceFile = true; }, (err) => {
5460
+ console.error(`[worca-ui] could not write the instance file (\`worca ui stop\` will fall back to a signal): ${err && err.message ? err.message : err}`);
5461
+ });
5155
5462
  try { channelHost.start(); } catch (err) {
5156
5463
  console.error(`[worca-ui] chat channel host failed to start: ${err && err.message ? err.message : err}`);
5157
5464
  }
5158
5465
  });
5159
-
5160
- // Channel workers must die with the server (design §9: persistent-process
5161
- // hygiene). Graceful shutdown frame -> 5s grace -> SIGKILL, then exit.
5162
- let shuttingDown = false;
5163
- const shutdownChat = (signal) => {
5164
- if (shuttingDown) return;
5165
- shuttingDown = true;
5166
- channelHost.stop().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
5167
- };
5168
- process.on('SIGINT', () => shutdownChat('SIGINT'));
5169
- process.on('SIGTERM', () => shutdownChat('SIGTERM'));
5170
5466
  }
5171
5467
 
5172
5468
  export { app, server, runs };
5173
5469
  export const _testing = {
5174
5470
  wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen,
5175
5471
  chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
5176
- chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, resolveAskContext, flipCard,
5177
- emitDiffCommentsChanged,
5472
+ chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
5473
+ emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
5474
+ uiControl, bearerMatches,
5178
5475
  };