@worca/app 1.1.1 → 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.
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,7 +90,7 @@ 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';
90
96
  import { validateGuardrails } from '../src/core/guardrails.mjs';
@@ -484,6 +490,9 @@ function summarizeRuns() {
484
490
  // 'cost_pipeline'/'cost_total') instead of showing a plain "Paused" card
485
491
  // until the next event.
486
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,
487
496
  startedAt: r.startedAt,
488
497
  pendingQuestion: r.pendingQuestion || null,
489
498
  // kind discriminator so the client routes runs vs scans vs agent generations
@@ -564,14 +573,21 @@ function wireRun(entry) {
564
573
  // Remember the pause reason for summarizeRuns (hello). Reset on every
565
574
  // done so a later reasonless finish cannot leave a stale cost banner.
566
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;
567
578
  resolvePending(entry, { reason: entry.status });
568
579
  if (payload?.reason === 'cost_pipeline' || payload?.reason === 'cost_total') {
569
580
  emitChanged('budget-changed');
570
581
  }
571
582
  }
572
583
  if (name === 'error') {
573
- entry.status = 'error';
574
- 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
+ }
575
591
  }
576
592
  if (name === 'exec') {
577
593
  entry.status = 'running';
@@ -738,6 +754,15 @@ app.use((req, res, next) => {
738
754
  next();
739
755
  });
740
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' }));
741
766
  app.use(express.json({ limit: '8mb' }));
742
767
 
743
768
  if (HLJS_ASSETS) {
@@ -2752,6 +2777,8 @@ const settingsState = () => ({
2752
2777
  costLimitResetPeriod: costLimitResetPeriod(),
2753
2778
  askMaxTurns: askMaxTurns(),
2754
2779
  askMaxBudgetUsd: askMaxBudgetUsd(),
2780
+ debugSpawnEnabled: storedDebugSpawnEnabled(), // what is STORED (the checkbox)
2781
+ debugSpawnEffective: effectiveDebugSpawn(), // what the next spawn will DO, and why
2755
2782
  });
2756
2783
 
2757
2784
  app.get('/api/settings', (_req, res) => {
@@ -2767,6 +2794,7 @@ app.post('/api/settings', async (req, res) => {
2767
2794
  const has = (k) => Object.prototype.hasOwnProperty.call(body, k);
2768
2795
  const hasBudgetKey = has('pipelineCostLimitUsd') || has('totalCostLimitUsd') || has('costLimitResetPeriod');
2769
2796
  const hasAskKey = has('askMaxTurns') || has('askMaxBudgetUsd');
2797
+ const hasDebugSpawnKey = has('debugSpawnEnabled');
2770
2798
  // Normalize the budget keys first, then validate them as a SET before ANY write.
2771
2799
  // Each setter persists on its own, so a two-key POST whose second key is invalid
2772
2800
  // used to answer 400 with the first key already on disk, no budget-changed
@@ -2787,6 +2815,15 @@ app.post('/api/settings', async (req, res) => {
2787
2815
  try {
2788
2816
  assertCostLimitInputs(budget);
2789
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
+ }
2790
2827
  if (has('chat')) await setChatPrefs(body.chat);
2791
2828
  if (has('projectsRoot')) {
2792
2829
  await setProjectsRoot(typeof body.projectsRoot === 'string' ? body.projectsRoot : '');
@@ -2796,12 +2833,11 @@ app.post('/api/settings', async (req, res) => {
2796
2833
  if (has('costLimitResetPeriod')) await setCostLimitResetPeriod(budget.costLimitResetPeriod);
2797
2834
  if (has('askMaxTurns')) await setAskMaxTurns(ask.askMaxTurns);
2798
2835
  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
- }
2836
+ if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
2804
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');
2805
2841
  res.json({ ...settingsState(), chat: chatPrefs() });
2806
2842
  } catch (err) {
2807
2843
  // The setters throw only on an unusable path -> client error (400).
@@ -2987,8 +3023,7 @@ app.delete('/api/config/models', async (req, res) => {
2987
3023
  // back means "keep" and is dropped from the write.
2988
3024
  // ---------------------------------------------------------------------------
2989
3025
 
2990
- const maskEnvValue = (v) =>
2991
- (modelEnvRef(v) ? v : (v.length > 8 ? `••••••${v.slice(-4)}` : '••••••'));
3026
+ const maskEnvValue = (v) => (modelEnvRef(v) ? v : maskModelEnvValue(v));
2992
3027
  const maskedGlobalModel = (m) => (m.env
2993
3028
  ? { ...m, env: Object.fromEntries(Object.entries(m.env).map(([k, v]) => [k, maskEnvValue(v)])) }
2994
3029
  : m);
@@ -3563,6 +3598,29 @@ function stampAskFrames(threadId, job) {
3563
3598
  };
3564
3599
  }
3565
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
+
3566
3624
  /** 400 on shape (spec §8.1 — a DELIBERATE divergence from the house 404-on-
3567
3625
  * malformed-param style), null-return contract like badRequest. */
3568
3626
  function askIdParam(res, value, kind) {
@@ -3578,7 +3636,51 @@ app.get('/api/ask/threads', (req, res) => {
3578
3636
  const raw = Number.parseInt(String(req.query.limit ?? ''), 10);
3579
3637
  const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 200) : 50;
3580
3638
  const threads = askListThreads({ limit }).map((t) => ({ ...t, inFlight: !!askInFlight(t.id) }));
3581
- res.json({ threads });
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' });
3582
3684
  } catch (err) {
3583
3685
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
3584
3686
  }
@@ -3614,12 +3716,9 @@ app.get('/api/ask/threads/:id', (req, res) => {
3614
3716
  messages: askListMessages(id),
3615
3717
  attachments: askListAttachments(id),
3616
3718
  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
- })),
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),
3623
3722
  inFlight: job && job.messageId ? { messageId: job.messageId } : null, // null while the slot is only reserved
3624
3723
  });
3625
3724
  } catch (err) {
@@ -3631,11 +3730,32 @@ app.patch('/api/ask/threads/:id', (req, res) => {
3631
3730
  const id = askIdParam(res, req.params.id, 'thread');
3632
3731
  if (!id) return;
3633
3732
  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');
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 };
3637
3757
  }
3638
- const thread = askUpdateThread(id, { title: raw.trim() });
3758
+ const thread = askUpdateThread(id, patch);
3639
3759
  if (!thread) return res.status(404).json({ error: 'thread not found' });
3640
3760
  res.json({ thread });
3641
3761
  } catch (err) {
@@ -3645,13 +3765,13 @@ app.patch('/api/ask/threads/:id', (req, res) => {
3645
3765
 
3646
3766
  // §7.5 order: abort the in-flight turn -> detach followers -> remove the chat's
3647
3767
  // 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;
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);
3652
3774
  try {
3653
- if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
3654
- askDeleting.add(id);
3655
3775
  const stopJob = () => {
3656
3776
  const job = askJobs.get(id);
3657
3777
  if (job && job.turn && typeof job.turn.stop === 'function') {
@@ -3670,20 +3790,30 @@ app.delete('/api/ask/threads/:id', async (req, res) => {
3670
3790
  // P4 §5: git-proper removal of every worktree BEFORE the row cascade — the
3671
3791
  // rmSync inside askDeleteThread alone would leave stale `git worktree`
3672
3792
  // registrations in the source repos. Never throws (best-effort per row).
3673
- await askRemoveThreadWorktrees(id);
3793
+ const { removed } = await askRemoveThreadWorktrees(id);
3674
3794
  // Re-read the job AFTER the await: askDeleting blocks new turns, but a turn
3675
3795
  // that was already mid-start is stopped here rather than left running.
3676
3796
  const job = stopJob();
3677
- askDeleteThread(id);
3797
+ const deleted = askDeleteThread(id);
3678
3798
  if (job) {
3679
3799
  if (job.graceTimer) clearTimeout(job.graceTimer);
3680
3800
  askJobs.delete(id);
3681
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);
3682
3814
  res.json({ ok: true });
3683
3815
  } catch (err) {
3684
3816
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
3685
- } finally {
3686
- askDeleting.delete(id);
3687
3817
  }
3688
3818
  });
3689
3819
 
@@ -3697,6 +3827,7 @@ app.delete('/api/ask/threads/:id/worktrees/:wtId', async (req, res) => {
3697
3827
  try {
3698
3828
  if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
3699
3829
  const out = await askRemoveWorktree({ threadId: id, wtId });
3830
+ emitAskWorktrees(id); // every open tab's count/popover follows the delete
3700
3831
  res.json(out);
3701
3832
  } catch (err) {
3702
3833
  if (err && err.name === 'AskWorktreeError') return res.status(404).json({ error: err.message });
@@ -3711,11 +3842,34 @@ app.get('/api/ask/threads/:id/attachments/:attId', (req, res) => {
3711
3842
  if (!attId) return;
3712
3843
  try {
3713
3844
  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);
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
+ });
3719
3873
  } catch (err) {
3720
3874
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
3721
3875
  }
@@ -3755,12 +3909,44 @@ function askRunFromPipelineRow(row) {
3755
3909
  };
3756
3910
  }
3757
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
+
3758
3943
  /** Resolve the VALIDATED client context into the server-side shape
3759
3944
  * buildContextHeader consumes (§6.5: server-resolved rows only — never
3760
3945
  * client-supplied titles or paths). Every lookup is individually guarded:
3761
3946
  * a vanished row degrades to an absent header line, never a 500. */
3762
3947
  async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], currentMessageId = null) {
3763
3948
  const out = { now: new Date().toISOString() };
3949
+ if (ctx.pinned === true) out.pinned = true; // #397: rendered as the [pinned by the user] marker
3764
3950
  if (ctx.view) out.view = ctx.view;
3765
3951
  if (ctx.diffPath) out.diffPath = ctx.diffPath; // client-supplied, already length-checked by validateClientContext
3766
3952
  try {
@@ -3831,8 +4017,8 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
3831
4017
  .filter((a) => !currentMessageId || a.messageId !== currentMessageId)
3832
4018
  .slice(-ASK_LIMITS.headerAttachments)
3833
4019
  .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];
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];
3836
4022
  if (atts.length) out.attachments = atts.slice(0, ASK_LIMITS.headerAttachments);
3837
4023
  } catch { /* absent lines */ }
3838
4024
  return out;
@@ -3872,6 +4058,16 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
3872
4058
  if (!mv.ok) return badRequest(res, mv.error);
3873
4059
  const cv = validateClientContext(body.context);
3874
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
+ }
3875
4071
 
3876
4072
  // §7.3 — validate EVERY attachment before ANY write (all-or-nothing).
3877
4073
  const files = [];
@@ -3885,19 +4081,30 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
3885
4081
  const name = a && typeof a.name === 'string' ? a.name : '';
3886
4082
  const dot = name.lastIndexOf('.');
3887
4083
  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
- }
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)'}`);
3891
4089
  const raw = typeof a.dataBase64 === 'string' ? a.dataBase64 : '';
3892
4090
  const buf = raw ? Buffer.from(raw, 'base64') : Buffer.alloc(0);
3893
4091
  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}` });
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;
3896
4103
  }
3897
4104
  let bodyText;
3898
4105
  try { bodyText = dec.decode(buf); } catch { return badRequest(res, `attachment is not valid UTF-8: ${name}`); }
3899
4106
  if (bodyText.includes('\u0000')) return badRequest(res, `attachment contains NUL bytes: ${name}`);
3900
- files.push({ name, text: bodyText, bytes: buf.length });
4107
+ files.push({ name, kind: 'text', mime: cls.mime, text: bodyText, bytes: buf.length });
3901
4108
  }
3902
4109
  const total = askThreadAttachmentBytes(id) + files.reduce((s, f) => s + f.bytes, 0);
3903
4110
  if (total > ASK_LIMITS.attachment.maxBytesPerThread) {
@@ -3929,24 +4136,33 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
3929
4136
 
3930
4137
  let asstMsg = null;
3931
4138
  let turn;
4139
+ let echoAttachments = [];
3932
4140
  try {
3933
4141
  // 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
- }
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;
3945
4153
  const userMsg = askAppendMessage(id, { role: 'user', text });
3946
4154
  job.userMessageId = userMsg.id;
3947
- const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, text: f.text }));
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 }));
3948
4162
  if (attRows.length) {
3949
- askSetMessageBlocks(userMsg.id, attRows.map((a) => ({ kind: 'attachment', id: a.id, name: a.name, bytes: a.bytes })));
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 })));
3950
4166
  }
3951
4167
  broadcast({ type: 'ask-message', threadId: id, message: askGetMessage(userMsg.id) }); // echo for other tabs
3952
4168
  asstMsg = askAppendMessage(id, { role: 'assistant', text: '', status: 'streaming', model: mv.model, effort: mv.effort });
@@ -3955,9 +4171,9 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
3955
4171
  // Prompt assembly (§6.5) — the route owns it; the turn only spawns.
3956
4172
  const catalog = await askBuildCatalog();
3957
4173
  const systemPrompt = askBuildSystemPrompt(catalog);
3958
- const withText = attRows.map((a, i) => ({ id: a.id, name: a.name, bytes: a.bytes, text: files[i].text }));
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 }));
3959
4175
  const { inline, listed } = askSelectInlineAttachments(withText);
3960
- const headerCtx = await resolveAskContext(id, cv.context, listed, userMsg.id);
4176
+ const headerCtx = await resolveAskContext(id, ctx, listed, userMsg.id);
3961
4177
  const header = askBuildContextHeader(headerCtx);
3962
4178
  const prompt = askBuildTurnPrompt(header, text, inline);
3963
4179
  const prior = askListMessages(id).filter((m) => m.seq < userMsg.seq);
@@ -3973,12 +4189,14 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
3973
4189
  firstTurn: userMsg.seq === 1 && titleWasAuto, // D13 guard: never replace a user-authored title
3974
4190
  firstText: text,
3975
4191
  deterministicTitle,
3976
- mock: mockEnabled({}) ? { card: mockAskCard(cv.context, text) } : null, // R-F
4192
+ pinnedScope: askPinnedScope(ctx), // #397: proposal defaulting + mismatch flag
4193
+ mock: mockEnabled({}) ? { card: mockAskCard(ctx, text) } : null, // R-F
3977
4194
  attachmentNames,
3978
4195
  deps: {
3979
4196
  onFrame: stampAskFrames(id, job),
3980
4197
  onOutOfTurn: (f) => broadcast({ ...f, threadId: id }),
3981
4198
  onCommentMutation: ({ runId }) => { emitDiffCommentsChanged(runId); },
4199
+ onWorktreeMutation: () => { emitAskWorktrees(id); },
3982
4200
  },
3983
4201
  });
3984
4202
  job.turn = turn;
@@ -4013,7 +4231,10 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
4013
4231
  console.error(`[worca-ui] ask turn crashed: ${err && err.message ? err.message : err}`);
4014
4232
  settleJob('error');
4015
4233
  });
4016
- res.status(202).json({ userMessageId: job.userMessageId, assistantMessageId: job.messageId });
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 });
4017
4238
  } catch (err) {
4018
4239
  // Only pre-reservation throws land here (`job` is block-scoped to the outer
4019
4240
  // try and every post-reservation failure returned from the inner catch), so
@@ -5173,6 +5394,6 @@ export { app, server, runs };
5173
5394
  export const _testing = {
5174
5395
  wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen,
5175
5396
  chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
5176
- chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, resolveAskContext, flipCard,
5177
- emitDiffCommentsChanged,
5397
+ chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
5398
+ emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
5178
5399
  };