@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.
@@ -60,7 +60,9 @@ export const ASK_DENY_RULES = Object.freeze([
60
60
  export const ASK_SPAWN_ENV = Object.freeze({ CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1' });
61
61
 
62
62
  /**
63
- * The per-thread Read allow rule of the chat's worktrees (P4 §6). Explicit
63
+ * The per-thread Read allow rules: the chat's worktrees (P4 §6) and its stored
64
+ * attachment bodies (#398 — read_attachment hands the model an `att/` path for
65
+ * an image or PDF, and the model views it with its own Read tool). Explicit
64
66
  * intent more than enforcement: under the engine's measured `unmatched ⇒ allow`
65
67
  * (gate E1, claude 2.1.241 — a path in neither list is read, verified OUTSIDE
66
68
  * the process cwd; and Grep ignored both `Read(<path>)` and `Grep(<path>)`
@@ -72,13 +74,14 @@ export const ASK_SPAWN_ENV = Object.freeze({ CLAUDE_CODE_DISABLE_BACKGROUND_TASK
72
74
  */
73
75
  export function askWorktreeAllowRules(threadId) {
74
76
  if (typeof threadId !== 'string' || !/^ask_[0-9a-f]{8}$/.test(threadId)) return [];
75
- return [`Read(//**/.worca-cc/ask/${threadId}/wt/**)`];
77
+ return [`Read(//**/.worca-cc/ask/${threadId}/wt/**)`, `Read(//**/.worca-cc/ask/${threadId}/att/**)`];
76
78
  }
77
79
 
78
80
  export const SANDBOX_NOTE =
79
81
  "You are a sub-agent of Worca's assistant and run in the same sandbox: the only tools available are Task, Read, Grep, Glob and " +
80
82
  'the worca MCP tools (mcp__worca__*). You cannot run commands, edit files or use the network — do not try. ' +
81
- "The only view into a repository is this chat's read-only detached worktrees: list_worktrees/open_worktree give the path; Read, Grep and Glob work under that path (never elsewhere on disk), and the worca `git` tool serves history and diffs. " +
83
+ "The only view into a repository is this chat's read-only detached worktrees: list_worktrees/open_worktree give the path; Read, Grep and Glob work under that path, and the worca `git` tool serves history and diffs. " +
84
+ 'The one other place Read may go is the file path read_attachment returns for an image or PDF attachment of this chat; never read anywhere else on disk. ' +
82
85
  'Answer from tool results only; never invent run data; return a short report.';
83
86
 
84
87
  /** System-prompt-only mock markers (the runner parses the ask role from the SYSTEM prompt, Task 16). */
@@ -3,13 +3,16 @@
3
3
  // SYNCHRONOUS (node:sqlite) and goes through getDb()/prepare()/tx() — never
4
4
  // node:sqlite directly. tx() is NOT re-entrant (db.mjs:897): the server must never
5
5
  // call a writer from inside its own tx(). Attachment bodies live on disk under
6
- // <worcaHome>/ask/<threadId>/att/<attachmentId>.txt — the path is built from the
7
- // ROW ID only, never from the user-supplied name.
6
+ // <worcaHome>/ask/<threadId>/att/<attachmentId><ext> — the path is built from the
7
+ // ROW ID plus the row's kind/mime (attachment-kind.mjs), never from the
8
+ // user-supplied name. Text kinds stay `.txt`/utf8; binary kinds (#398) keep the
9
+ // extension of their SNIFFED mime and raw bytes.
8
10
  import { randomBytes } from 'node:crypto';
9
- import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
11
+ import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
10
12
  import { basename, join } from 'node:path';
11
13
  import { getDb, prepare, tx } from '../db.mjs';
12
14
  import { worcaHome } from '../projects.mjs';
15
+ import { extensionForAttachment } from './attachment-kind.mjs';
13
16
 
14
17
  export const ASK_ID_RE = /^[a-z]+_[0-9a-f]{8}$/;
15
18
  const ROLES = new Set(['user', 'assistant', 'system']);
@@ -53,7 +56,11 @@ function rowToMessage(r) {
53
56
  };
54
57
  }
55
58
  function rowToAttachment(r) {
56
- return { id: r.id, threadId: r.thread_id, messageId: r.message_id ?? null, name: r.name, bytes: r.bytes, createdAt: r.created_at };
59
+ return {
60
+ id: r.id, threadId: r.thread_id, messageId: r.message_id ?? null, name: r.name, bytes: r.bytes,
61
+ kind: r.kind ?? 'text', mime: r.mime ?? null, // pre-v27 rows carry neither column value: they are text
62
+ createdAt: r.created_at,
63
+ };
57
64
  }
58
65
  function rowToRunLink(r) {
59
66
  return {
@@ -92,6 +99,33 @@ export function listThreads({ limit = 50 } = {}) {
92
99
  return rows.map((r) => ({ ...rowToThread(r), runLinks: r.run_links, worktrees: r.worktrees }));
93
100
  }
94
101
 
102
+ /** Total saved chats — the History popover shows this, not the capped page listThreads returns. */
103
+ export function countThreads() {
104
+ getDb();
105
+ const row = prepare('SELECT count(*) AS n FROM ask_threads').get();
106
+ return row ? Number(row.n) : 0;
107
+ }
108
+
109
+ /** Every thread id, oldest-updated first, NO limit — the bulk delete walks all of them. */
110
+ export function listThreadIds() {
111
+ getDb();
112
+ return prepare('SELECT id FROM ask_threads ORDER BY updated_at, id').all().map((r) => r.id);
113
+ }
114
+
115
+ /** Global ask_worktrees row count (the per-thread count rides listThreads rows). */
116
+ export function countWorktrees() {
117
+ getDb();
118
+ const row = prepare('SELECT count(*) AS n FROM ask_worktrees').get();
119
+ return row ? Number(row.n) : 0;
120
+ }
121
+
122
+ /** Global ask_attachments row count. */
123
+ export function countAttachments() {
124
+ getDb();
125
+ const row = prepare('SELECT count(*) AS n FROM ask_attachments').get();
126
+ return row ? Number(row.n) : 0;
127
+ }
128
+
95
129
  const THREAD_PATCH_COLS = { title: 'title', model: 'model', effort: 'effort', sessionId: 'session_id', context: 'context' };
96
130
 
97
131
  /** Patch ⊆ {title, model, effort, sessionId, context}; unknown keys ignored; always bumps updated_at. */
@@ -260,20 +294,36 @@ export function sweepStreamingMessages({ text = 'interrupted by restart' } = {})
260
294
 
261
295
  // ── attachments ─────────────────────────────────────────────────────────────
262
296
 
263
- export function addAttachment(threadId, messageId, { name, text } = {}) {
297
+ /**
298
+ * Text kinds pass `{name, text}` (the pre-#398 signature, kind defaults 'text');
299
+ * binary kinds pass `{name, kind, mime, data}` with a Buffer that has already
300
+ * been sniffed by the route (attachment-kind.mjs) — the store trusts kind/mime
301
+ * only to pick the on-disk extension, never to build a path from `name`.
302
+ * NOTE (#398, issue point 8): binary bodies are raw pixel/PDF bytes — the
303
+ * redactAskText guard that runs over text attachment content structurally
304
+ * cannot apply to them; the model reads them via its Read tool as-is.
305
+ */
306
+ export function addAttachment(threadId, messageId, { name, text, kind = 'text', mime = null, data = null } = {}) {
264
307
  getDb();
265
308
  if (!prepare('SELECT 1 FROM ask_threads WHERE id = ?').get(threadId)) {
266
309
  throw new Error(`addAttachment: unknown thread ${threadId}`);
267
310
  }
268
311
  const id = newAskId('att');
269
312
  const safeName = (basename(String(name ?? '')).slice(0, 255)) || 'attachment.txt';
270
- const body = String(text ?? '');
271
- const bytes = Buffer.byteLength(body, 'utf8');
272
313
  const dir = attachmentsDir(threadId);
273
314
  mkdirSync(dir, { recursive: true });
274
- writeFileSync(join(dir, `${id}.txt`), body, 'utf8'); // file FIRST: a row without a file would 404 on read
275
- prepare('INSERT INTO ask_attachments (id, thread_id, message_id, name, bytes, created_at) VALUES (?, ?, ?, ?, ?, ?)')
276
- .run(id, threadId, messageId ?? null, safeName, bytes, now());
315
+ let bytes;
316
+ if (kind === 'text') {
317
+ const body = String(text ?? '');
318
+ bytes = Buffer.byteLength(body, 'utf8');
319
+ writeFileSync(join(dir, `${id}.txt`), body, 'utf8'); // file FIRST: a row without a file would 404 on read
320
+ } else {
321
+ if (!Buffer.isBuffer(data)) throw new Error('addAttachment: a non-text attachment needs a Buffer body');
322
+ bytes = data.length;
323
+ writeFileSync(join(dir, `${id}${extensionForAttachment(kind, mime)}`), data); // no encoding: raw bytes
324
+ }
325
+ prepare('INSERT INTO ask_attachments (id, thread_id, message_id, name, bytes, kind, mime, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
326
+ .run(id, threadId, messageId ?? null, safeName, bytes, kind, mime, now());
277
327
  return getAttachment(threadId, id);
278
328
  }
279
329
 
@@ -298,7 +348,7 @@ export function getAttachment(threadId, id) {
298
348
  */
299
349
  export function readAttachmentText(threadId, id) {
300
350
  const a = getAttachment(threadId, id);
301
- if (!a || !ASK_ID_RE.test(a.id)) return null;
351
+ if (!a || !ASK_ID_RE.test(a.id) || a.kind !== 'text') return null; // a binary body is not utf8-readable
302
352
  try {
303
353
  return { ...a, text: readFileSync(join(attachmentsDir(threadId), `${a.id}.txt`), 'utf8') };
304
354
  } catch {
@@ -306,6 +356,34 @@ export function readAttachmentText(threadId, id) {
306
356
  }
307
357
  }
308
358
 
359
+ /**
360
+ * Absolute on-disk path of an attachment's body — the pointer the read_attachment
361
+ * tool hands the model for binary kinds (its Read tool renders images and PDFs
362
+ * natively; the ask/ subtree is deliberately outside spawn.mjs ASK_DENY_RULES).
363
+ * Same guards as readAttachmentText: row must exist, id must be store-minted —
364
+ * and the body must actually be on disk. A row that outlived its file (DB-only
365
+ * restore, an external sweep of ask/<thread>/att) is the same `null` as a
366
+ * missing row: the model must never be handed a path whose Read fails ENOENT.
367
+ */
368
+ export function attachmentPath(threadId, id) {
369
+ const a = getAttachment(threadId, id);
370
+ if (!a || !ASK_ID_RE.test(a.id)) return null;
371
+ const path = join(attachmentsDir(threadId), `${a.id}${extensionForAttachment(a.kind, a.mime)}`);
372
+ return existsSync(path) ? path : null;
373
+ }
374
+
375
+ /** Raw thread-scoped read for the download route: any kind, body as a Buffer. */
376
+ export function readAttachmentRaw(threadId, id) {
377
+ const a = getAttachment(threadId, id);
378
+ const path = attachmentPath(threadId, id);
379
+ if (!a || !path) return null;
380
+ try {
381
+ return { ...a, buffer: readFileSync(path) };
382
+ } catch {
383
+ return null;
384
+ }
385
+ }
386
+
309
387
  export function threadAttachmentBytes(threadId) {
310
388
  getDb();
311
389
  return prepare('SELECT COALESCE(SUM(bytes), 0) AS n FROM ask_attachments WHERE thread_id = ?').get(threadId).n;
@@ -11,7 +11,7 @@ import { DIFF_PATCH_FILE } from '../results.mjs';
11
11
  import { GUARDRAIL_PRESETS } from '../guardrails.mjs';
12
12
  import { buildCatalog } from './catalog.mjs';
13
13
  import { validateProposal } from './proposal.mjs';
14
- import { readAttachmentText } from './store.mjs';
14
+ import { readAttachmentText, getAttachment, attachmentPath, getThread } from './store.mjs';
15
15
  import { redactAskText } from './redact.mjs';
16
16
  import { ASK_LIMITS } from './limits.mjs';
17
17
 
@@ -49,10 +49,34 @@ export function defaultToolDeps({ threadId }) {
49
49
  readDiffPatch,
50
50
  hasDiffPatch,
51
51
  readAttachment: (id) => {
52
- const a = threadId ? readAttachmentText(threadId, id) : null;
53
- return a ? { name: a.name, text: a.text } : null;
52
+ const row = threadId ? getAttachment(threadId, id) : null;
53
+ if (!row) return null;
54
+ if (row.kind === 'text') {
55
+ const a = readAttachmentText(threadId, id);
56
+ return a ? { name: a.name, kind: 'text', text: a.text } : null;
57
+ }
58
+ // Binary kinds (#398): metadata plus the on-disk path — the model views the
59
+ // body with its own Read tool; sliceBytes over raw bytes would be garbage.
60
+ // attachmentPath is null when the body is gone (DB-only restore, an external
61
+ // sweep of ask/<t>/att): the same not-found the text branch reports, never a
62
+ // path whose Read then fails with a raw ENOENT the model may retry.
63
+ const path = attachmentPath(threadId, id);
64
+ return path ? { name: row.name, kind: row.kind, mime: row.mime, bytes: row.bytes, path } : null;
54
65
  },
55
66
  validateProposal,
67
+ // #397: the user-pinned scope of the owning thread — {projectKey}|{workspaceId}|
68
+ // null — read fresh from the thread row per call, so a selector change lands on
69
+ // the very next tool call. A missing thread or an unreadable DB means "nothing
70
+ // pinned", never an error.
71
+ pinnedScope: () => {
72
+ if (!threadId) return null;
73
+ let c = null;
74
+ try { c = getThread(threadId)?.context ?? null; } catch { return null; }
75
+ if (!c || c.pinned !== true) return null;
76
+ if (typeof c.projectKey === 'string' && c.projectKey) return { projectKey: c.projectKey };
77
+ if (typeof c.workspaceId === 'string' && c.workspaceId) return { workspaceId: c.workspaceId };
78
+ return null;
79
+ },
56
80
  // The SECURE preset is the floor, not the run's own set: guardrailsId defaults
57
81
  // to 'permissive' (empty protectedPaths), so resolving per row would show the
58
82
  // model every credential file on most runs. This only ever omits more.
@@ -298,6 +298,14 @@ const SCHEMA = {
298
298
  export function createAskTools(deps) {
299
299
  const L = deps.limits;
300
300
 
301
+ // #397: the user-pinned scope of this conversation — {projectKey}|{workspaceId}|
302
+ // null — re-read per call so a mid-conversation selector change is honoured.
303
+ // Optional dep: an absent or failing reader means "nothing pinned", never an error.
304
+ const pinnedScope = () => {
305
+ try { return typeof deps.pinnedScope === 'function' ? (deps.pinnedScope() || null) : null; }
306
+ catch { return null; }
307
+ };
308
+
301
309
  const defs = [
302
310
  { name: 'list_projects',
303
311
  description: 'List the registered projects (key, name, path) and workspaces (id, name, member project keys). Use the key / id in the other tools.',
@@ -310,7 +318,7 @@ export function createAskTools(deps) {
310
318
  inputSchema: SCHEMA.obj({ projectKey: SCHEMA.s('project key from list_projects'), workspaceId: SCHEMA.s('workspace id from list_projects'),
311
319
  status: SCHEMA.s('run status to match'), limit: SCHEMA.i('max results (1-100)', 1, L.listRunsMaxLimit), query: SCHEMA.s('case-insensitive title substring') }) },
312
320
  { name: 'get_run',
313
- description: 'Read one run: its metadata and the user\'s original prompt. Give projectKey or workspaceId when known; without them the id is searched everywhere.',
321
+ description: 'Read one run: its metadata and the user\'s original prompt. Give projectKey or workspaceId when known; without them the user-pinned scope (when the chat has one) is tried first, then the id is searched everywhere.',
314
322
  inputSchema: SCHEMA.obj({ id: SCHEMA.s('run id (8 hex)'), projectKey: SCHEMA.s('scope to a project'), workspaceId: SCHEMA.s('scope to a workspace') }, ['id']) },
315
323
  { name: 'get_run_diff',
316
324
  description: 'Read the unified diff of a run, paged by byte offset (use nextOffset until truncated is false). Optional path = one file only. files[] lists every file with added/removed counts; credential files are omitted.',
@@ -318,7 +326,7 @@ export function createAskTools(deps) {
318
326
  path: SCHEMA.s('only this file path'), offset: SCHEMA.i('byte offset to start at', 0, Number.MAX_SAFE_INTEGER),
319
327
  maxBytes: SCHEMA.i('bytes per page (default 60000, max 200000)', 1, L.diffMaxBytes) }, ['id']) },
320
328
  { name: 'propose_run',
321
- description: 'Propose a pipeline run for the user to confirm — it never starts anything. Exactly one of projectKey / workspaceId. guardrailsId defaults to "normal"; "permissive" is not allowed. Returns {ok:true, card} or {ok:false, errors}.',
329
+ description: 'Propose a pipeline run for the user to confirm — it never starts anything. Exactly one of projectKey / workspaceId; omitting both targets the scope the user pinned for this chat, when there is one. guardrailsId defaults to "normal"; "permissive" is not allowed. Returns {ok:true, card} or {ok:false, errors}.',
322
330
  inputSchema: SCHEMA.obj({ projectKey: SCHEMA.s('target project key'), workspaceId: SCHEMA.s('target workspace id'), workflowId: SCHEMA.s('workflow id (default wf_default)'),
323
331
  brief: SCHEMA.s('the full task description for the run (≤ 8000 chars)'), title: SCHEMA.s('short run title'), guardrailsId: SCHEMA.s('guardrail set id (default normal)'),
324
332
  sourceBranch: SCHEMA.s('branch to start from (default: current)'), featureBranch: SCHEMA.s('feature branch name'),
@@ -326,7 +334,7 @@ export function createAskTools(deps) {
326
334
  commentIds: { type: 'array', items: { type: 'string' },
327
335
  description: 'diff comment ids (dc_…) this run is meant to address. They are stamped with the run id once the user confirms the card AND the run actually starts; nothing is resolved.' } }, ['brief']) },
328
336
  { name: 'read_attachment',
329
- description: 'Read an attachment of this conversation by id, paged by byte offset (default 32000 bytes per page).',
337
+ description: 'Read an attachment of this conversation by id. Text attachments return their content, paged by byte offset (default 32000 bytes per page). Image and PDF attachments return metadata plus a file path — pass that path to your Read tool to view the content.',
330
338
  inputSchema: SCHEMA.obj({ id: SCHEMA.s('attachment id'), offset: SCHEMA.i('byte offset', 0, Number.MAX_SAFE_INTEGER), maxBytes: SCHEMA.i('bytes per page', 1, L.attachmentReadMaxBytes) }, ['id']) },
331
339
  { name: 'list_diff_comments',
332
340
  description: 'List the internal review comments anchored to a run\'s diff lines, ordered by file then line then when they were written. status filters them (all | unresolved | resolved, default all); path narrows to one file. Every comment carries line_text — the snapshot of the line it was anchored to, taken when it was written, so it stays readable even though the source branch has moved on. When the patch is still readable, a few surrounding hunk lines come with each comment. Comments on credential files are never listed.',
@@ -373,11 +381,19 @@ export function createAskTools(deps) {
373
381
  const projectKey = str(input.projectKey);
374
382
  const workspaceId = str(input.workspaceId);
375
383
  if (projectKey && workspaceId) throw new AskToolError(`${tool}: give projectKey OR workspaceId, not both`);
376
- const row = projectKey
377
- ? deps.lookupPipelineRow(projectKey, id)
378
- : workspaceId
379
- ? deps.lookupPipelineRow(`workspaces/${workspaceId}`, id)
380
- : deps.findPipelineRowById(id);
384
+ let row;
385
+ if (projectKey) row = deps.lookupPipelineRow(projectKey, id);
386
+ else if (workspaceId) row = deps.lookupPipelineRow(`workspaces/${workspaceId}`, id);
387
+ else {
388
+ // #397: an unscoped id tries the user-pinned scope first (disambiguation
389
+ // when the same short id exists in two stores), then everywhere — never
390
+ // fewer results than an unpinned chat.
391
+ const pin = pinnedScope();
392
+ row = (pin && pin.projectKey ? deps.lookupPipelineRow(pin.projectKey, id)
393
+ : pin && pin.workspaceId ? deps.lookupPipelineRow(`workspaces/${pin.workspaceId}`, id)
394
+ : null)
395
+ || deps.findPipelineRowById(id);
396
+ }
381
397
  if (!row) throw new AskToolError(`${tool}: run not found`);
382
398
  return row;
383
399
  }
@@ -617,7 +633,15 @@ export function createAskTools(deps) {
617
633
  return { available: true, files, ...sliceBytes(filtered(str(input.path)), offset, maxBytes) };
618
634
  },
619
635
  async propose_run(input) {
620
- const r = await deps.validateProposal(input);
636
+ // #397: a proposal naming NO target defaults to the user-pinned scope. The
637
+ // parent turn applies the same default before its authoritative
638
+ // re-validation, so the card the user sees matches what the model got.
639
+ let inp = input;
640
+ if (!str(input.projectKey) && !str(input.workspaceId)) {
641
+ const pin = pinnedScope();
642
+ if (pin) inp = { ...input, ...pin };
643
+ }
644
+ const r = await deps.validateProposal(inp);
621
645
  // commentIds are a ONE-WAY hand-off: a comment cited here is stamped
622
646
  // "sent to #<runId>" the moment the user starts the run, and nothing ever
623
647
  // un-stamps it. Refuse ids from a different project/workspace than this
@@ -729,10 +753,17 @@ export function createAskTools(deps) {
729
753
  if (!id) throw new AskToolError('read_attachment: id is required');
730
754
  const a = deps.readAttachment(id);
731
755
  if (!a) throw new AskToolError('read_attachment: attachment not found');
756
+ if (a.kind && a.kind !== 'text') {
757
+ // #398: never a sliceBytes view of binary garbage — and deps.redact is a
758
+ // TEXT guard, so the body deliberately does not pass through it (the
759
+ // model reads the raw file; nothing here can scrub pixels).
760
+ return { name: a.name, kind: a.kind, mime: a.mime, totalBytes: a.bytes, path: a.path,
761
+ note: 'binary attachment: pass `path` to your Read tool to view the content' };
762
+ }
732
763
  const offset = clampInt(input.offset, 0, Number.MAX_SAFE_INTEGER, 0);
733
764
  const maxBytes = clampInt(input.maxBytes, 1, L.attachmentReadMaxBytes, L.attachmentReadDefaultBytes);
734
765
  const { text, truncated, totalBytes, nextOffset } = sliceBytes(deps.redact(a.text), offset, maxBytes);
735
- return { name: a.name, text, truncated, totalBytes, nextOffset };
766
+ return { name: a.name, kind: 'text', text, truncated, totalBytes, nextOffset };
736
767
  },
737
768
  async open_worktree(input) {
738
769
  try {
@@ -16,7 +16,7 @@ import { join, dirname, resolve as pathResolve } from 'node:path';
16
16
  import { mkdir, writeFile, unlink } from 'node:fs/promises';
17
17
 
18
18
  import { runClaude } from '../claude-runner.mjs';
19
- import { resolveModelEnv, resolveModelCost } from '../config.mjs';
19
+ import { resolveModelEnv, resolveModelCost, estimateCost, liveCostRates as defaultLiveCostRates } from '../config.mjs';
20
20
  import { worcaHome } from '../projects.mjs';
21
21
  import { generateTitle } from '../title.mjs';
22
22
  import { createTurnReducer } from './events.mjs';
@@ -40,6 +40,7 @@ class AskTurn extends EventEmitter {
40
40
  model, effort, resumeSessionId = null,
41
41
  firstTurn = false, firstText = '', deterministicTitle = null,
42
42
  mock = null, attachmentNames = {},
43
+ pinnedScope = null,
43
44
  deps = {},
44
45
  } = {}) {
45
46
  super();
@@ -57,6 +58,8 @@ class AskTurn extends EventEmitter {
57
58
  this.deterministicTitle = deterministicTitle ?? null;
58
59
  this.mock = mock || null;
59
60
  this.attachmentNames = attachmentNames || {};
61
+ // #397: {projectKey}|{workspaceId}|null — the user-pinned scope at POST time.
62
+ this.pinnedScope = pinnedScope && typeof pinnedScope === 'object' ? pinnedScope : null;
60
63
  this.deps = {
61
64
  runClaudeImpl: deps.runClaudeImpl ?? runClaude,
62
65
  store: {
@@ -84,6 +87,11 @@ class AskTurn extends EventEmitter {
84
87
  onFrame: deps.onFrame ?? (() => {}),
85
88
  onOutOfTurn: deps.onOutOfTurn ?? (() => {}),
86
89
  onCommentMutation: deps.onCommentMutation ?? (() => {}),
90
+ onWorktreeMutation: deps.onWorktreeMutation ?? (() => {}),
91
+ // DISPLAY-ONLY rates for the footer's live "≈" estimate (config.mjs
92
+ // liveCostRates: override → list price → null). Injectable so tests pin
93
+ // the frame arithmetic without the catalog.
94
+ liveCostRates: deps.liveCostRates ?? defaultLiveCostRates,
87
95
  };
88
96
  this.abort = new AbortController();
89
97
  this.status = 'created';
@@ -93,6 +101,7 @@ class AskTurn extends EventEmitter {
93
101
  this.sessionId = this.resumeSessionId;
94
102
  this.scratchDir = null;
95
103
  this.titlePromise = Promise.resolve();
104
+ this._titleKicked = false;
96
105
  this._completed = false;
97
106
  }
98
107
 
@@ -125,10 +134,23 @@ class AskTurn extends EventEmitter {
125
134
  async _onProposal(input) {
126
135
  const d = this.deps;
127
136
  const cardId = d.newAskId('card');
137
+ const raw = input && typeof input === 'object' ? input : {};
138
+ // #397: a proposal that names NO target falls back to the user-pinned scope.
139
+ // Mirrors the MCP child's own defaulting, so this authoritative re-validation
140
+ // builds the same card the model was shown.
141
+ const pin = this.pinnedScope;
142
+ const hasTarget = (typeof raw.projectKey === 'string' && raw.projectKey.trim())
143
+ || (typeof raw.workspaceId === 'string' && raw.workspaceId.trim());
144
+ const inp = pin && !hasTarget ? { ...raw, ...pin } : raw;
128
145
  try {
129
- const r = await d.validateProposal(input && typeof input === 'object' ? input : {}, { cardId });
146
+ const r = await d.validateProposal(inp, { cardId });
130
147
  if (r && r.ok) {
131
- this.reducer.addBlock({ kind: 'card', id: cardId, state: 'proposed', card: r.card });
148
+ // #397 guardrail: a proposal targeting a DIFFERENT project/workspace than
149
+ // the pinned one is accepted but flagged — the card renders the mismatch
150
+ // instead of silently absorbing it.
151
+ const scopeMismatch = !!pin && ((pin.projectKey && r.card.projectKey !== pin.projectKey)
152
+ || (pin.workspaceId && r.card.workspaceId !== pin.workspaceId));
153
+ this.reducer.addBlock({ kind: 'card', id: cardId, state: 'proposed', card: r.card, ...(scopeMismatch ? { scopeMismatch: true } : {}) });
132
154
  // commentIds are propose_run INPUT only: they never enter the card block (its
133
155
  // key set is pinned in test/ask-proposal.test.mjs) nor CARD_PATCH_KEYS. Parked
134
156
  // against the card id until the user starts the run; unknown ids are dropped,
@@ -148,6 +170,10 @@ class AskTurn extends EventEmitter {
148
170
 
149
171
  _makeReducer() {
150
172
  const d = this.deps;
173
+ // One settings read per attempt, never per frame. null → the frames carry
174
+ // estimatedCostUsd:null and the footer keeps today's behaviour.
175
+ let liveRates = null;
176
+ try { liveRates = d.liveCostRates(this.model) ?? null; } catch { liveRates = null; }
151
177
  this.reducer = createTurnReducer({
152
178
  onFrame: (f) => this._frame(f),
153
179
  now: d.now,
@@ -165,6 +191,13 @@ class AskTurn extends EventEmitter {
165
191
  // The MCP child cannot broadcast; the parent turns its comment writes into
166
192
  // the same poke the REST routes emit.
167
193
  onCommentMutation: (e) => { try { this.deps.onCommentMutation(e); } catch { /* a broken sink never breaks the turn */ } },
194
+ // Same shape for worktrees: open/remove/navigate in the child → the server
195
+ // broadcasts the thread's worktree envelope (ui/server.mjs emitAskWorktrees).
196
+ onWorktreeMutation: (e) => { try { this.deps.onWorktreeMutation(e); } catch { /* a broken sink never breaks the turn */ } },
197
+ // DISPLAY ONLY — never a sink input: prices the running usage sum (main +
198
+ // sub-agent tokens) at the TURN model's rates; the "≈" in the footer owns
199
+ // that approximation. _complete() reads summary.costUsd, not this.
200
+ estimateLiveCost: liveRates ? (usage) => estimateCost(usage, liveRates) : null,
168
201
  });
169
202
  return this.reducer;
170
203
  }
@@ -275,6 +308,11 @@ class AskTurn extends EventEmitter {
275
308
  const scratchDir = join(d.worcaHome(), 'tmp', 'ask');
276
309
  this.scratchDir = scratchDir;
277
310
  await d.fs.mkdir(scratchDir, { recursive: true });
311
+ // D13 title runs CONCURRENTLY with the turn from here — the haiku call
312
+ // cwd's into scratchDir, so not a line earlier. Idempotent: the call after
313
+ // _attempts below is the backstop for a mkdir/write failure, so "fires
314
+ // after ANY terminal status of the first turn" stays true.
315
+ this._kickoffTitle();
278
316
  const homeBase = process.env.WORCA_HOME?.trim()
279
317
  ? pathResolve(process.env.WORCA_HOME)
280
318
  : dirname(d.worcaHome());
@@ -387,12 +425,14 @@ class AskTurn extends EventEmitter {
387
425
  }
388
426
 
389
427
  _kickoffTitle() {
390
- if (!this.firstTurn) return;
428
+ if (!this.firstTurn || this._titleKicked) return;
429
+ this._titleKicked = true;
391
430
  const d = this.deps;
392
- // Fire-and-forget after ANY terminal status of the first turn (§7.4).
431
+ // Fire-and-forget: kicked off at the START of the first turn (right after
432
+ // the scratch dir exists) and backstopped after its terminal status (§7.4).
393
433
  // Stored for test determinism, never awaited by run() (orchestrator.mjs:3821).
394
- // NO signal: after a user stop this.abort is already aborted and would kill
395
- // the call before it spawns. permissionMode 'dontAsk' is the B-1 fix.
434
+ // NO signal: a user stop aborts this.abort mid-turn and would kill the call
435
+ // before it spawns. permissionMode 'dontAsk' is the B-1 fix.
396
436
  this.titlePromise = Promise.resolve()
397
437
  .then(() => d.generateTitle(this.firstText, {
398
438
  cwd: this.scratchDir || join(d.worcaHome(), 'tmp', 'ask'),
@@ -400,12 +440,18 @@ class AskTurn extends EventEmitter {
400
440
  disableSlashCommands: true, envScrub: true, envAllowlist: [],
401
441
  permissionMode: 'dontAsk',
402
442
  }))
403
- .then((title) => {
404
- if (!title || title === this.deterministicTitle) return;
405
- // setThreadTitle's onlyIf is the rename guard: a PATCHed or deleted
406
- // thread makes the UPDATE match 0 rows and the frame is suppressed.
443
+ .then((generated) => {
444
+ // The route stamps NOTHING before the 202 (the header reads "Ask Worca"
445
+ // until this frame lands), so an empty result generateTitle swallows
446
+ // every failure/abort/refusal into '' falls back to the route's
447
+ // deterministicTitle (sanitized first 80 chars, or "New chat"). That is
448
+ // the ONLY moment the prompt text may become the title.
449
+ const title = generated || this.deterministicTitle;
450
+ if (!title) return;
451
+ // `onlyIf: null` (title IS NULL) is the rename guard: a PATCHed or
452
+ // deleted thread makes the UPDATE match 0 rows and the frame is suppressed.
407
453
  let applied = false;
408
- try { applied = d.store.setThreadTitle(this.threadId, title, { onlyIf: this.deterministicTitle }); }
454
+ try { applied = d.store.setThreadTitle(this.threadId, title, { onlyIf: null }); }
409
455
  catch { /* deleted thread */ }
410
456
  if (applied) {
411
457
  try { d.onOutOfTurn({ type: 'ask-title', title }); } catch { /* sink */ }
@@ -14,6 +14,7 @@ import { parseCommand } from './parser.mjs';
14
14
  import { BOOKEND_EXECUTION_IDS } from '../../shared/graph/constants.mjs';
15
15
  import { createAllowlistGuard, parseIdList } from './allowlist.mjs';
16
16
  import { runRef, fmtUsd, fmtMs } from './renderers.mjs';
17
+ import { giveUpOption, describePauseReason, pauseConsequences } from '../failure-policy.mjs';
17
18
 
18
19
  const md = (value) => ({ kind: 'markdown', value });
19
20
  const reply = (text, severity = 'info') => ({ title: null, body: [md(text)], severity });
@@ -44,7 +45,7 @@ const HELP_TEXT = [
44
45
  '`/status [*ref]` — run detail · `/cost [*ref]` — run cost',
45
46
  '`/pause [*ref]` · `/stop [*ref]` · `/resume [*ref]`',
46
47
  '`/approve [*ref]` — continue past a gate · `/retry [*ref]` — another cycle',
47
- '`/abort [*ref]` — abort a recovery prompt',
48
+ '`/abort [*ref]` — give up on a recovery prompt (pauses the run; nothing is discarded)',
48
49
  '`/answer [*ref] <n|text> [| …]` — answer clarify questions (option number, or text for free-text)',
49
50
  '`/projects` · `/use <name>` — scope commands to one project',
50
51
  '`/mute 30m|2h|1d` · `/unmute` — silence notifications for this chat',
@@ -174,7 +175,8 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
174
175
  const r = t.row;
175
176
  return reply([runLine({ ...r, runId: r.id }),
176
177
  ...(fmtUsd(r.totalCostUsd) ? [` **Cost:** ${fmtUsd(r.totalCostUsd)}`] : []),
177
- ...(r.pauseReason ? [` **Pause reason:** ${r.pauseReason}`] : []),
178
+ ...(r.pauseReason ? [` **Pause reason:** ${describePauseReason(r.pauseReason) || r.pauseReason}`] : []),
179
+ ...(r.pauseDetail ? [` **${pauseConsequences(r.pauseReason).severity === 'error' ? 'Error' : 'Cause'}:** ${r.pauseDetail}`] : []),
178
180
  ].join('\n'));
179
181
  }
180
182
  const r = t.run;
@@ -322,14 +324,16 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
322
324
  if (verb === 'abort') return reply(`Gates have no abort — \`/approve ${ref}\`, \`/retry ${ref}\`, or \`/stop ${ref}\`.`, 'warning');
323
325
  payload = { decision: verb === 'approve' ? 'continue' : 'another' };
324
326
  } else if (pq.kind === 'recovery') {
325
- payload = { decision: verb === 'abort' ? 'abort' : 'retry' };
327
+ // /abort is the give-up choice; what it does (pause or abort) is the row's
328
+ // option (failure-policy.mjs) — the option id is the wire decision.
329
+ payload = { decision: verb === 'abort' ? giveUpOption(pq.recovery?.options).id : 'retry' };
326
330
  } else {
327
331
  return reply(`\`${ref}\` is waiting on ${pq.kind} — use \`/answer ${ref} <n>\`.`, 'warning');
328
332
  }
329
333
  await actions.answer(t.run.runId, pq.id, payload);
330
334
  const what = pq.kind === 'gate'
331
335
  ? (payload.decision === 'continue' ? 'approved — continuing' : 'sent back for another cycle')
332
- : (payload.decision === 'retry' ? 'retrying' : 'aborting');
336
+ : (payload.decision === 'retry' ? 'retrying' : payload.decision === 'abort' ? 'aborting the run' : 'pausing the run');
333
337
  return reply(`✅ \`${ref}\` ${what}.`, 'success');
334
338
  }
335
339
 
@@ -14,6 +14,7 @@ import { readPluginConfig } from '../plugin-config.mjs';
14
14
  import { parseIdList } from './allowlist.mjs';
15
15
  import { createRateLimiter } from './rate-limiter.mjs';
16
16
  import { renderDone, renderError, renderQuestion } from './renderers.mjs';
17
+ import { pauseConsequences } from '../failure-policy.mjs';
17
18
 
18
19
  /**
19
20
  * @param {{channelHost: object, getPrefs: () => {notify:object, channels:object},
@@ -89,7 +90,11 @@ export function createNotifier({ channelHost, getPrefs, chatContext, logger = ()
89
90
  const status = payload?.status || 'done';
90
91
  if (status === 'error') return; // the richer 'error' event already went out
91
92
  const prefs = getPrefsSafe().notify;
92
- if (status === 'paused' ? prefs.paused === false : prefs.done === false) return;
93
+ // Which preference gates a pause follows its reason (failure-policy.mjs):
94
+ // an error-pause IS the failure notification (no 'error' event precedes
95
+ // it), so notify.error gates it, not notify.paused.
96
+ const gate = status === 'paused' ? prefs[pauseConsequences(payload?.reason).notifyPref] : prefs.done;
97
+ if (gate === false) return;
93
98
  deliver(renderDone(meta(), payload || {}));
94
99
  }));
95
100
 
@@ -9,6 +9,8 @@
9
9
  // ordinals and embeds the exact reply commands (/approve, /retry, /answer n…)
10
10
  // using the run-id wildcard-suffix convention the command router resolves.
11
11
 
12
+ import { pauseConsequences, describePauseReason, giveUpOption } from '../failure-policy.mjs';
13
+
12
14
  const md = (value) => ({ kind: 'markdown', value });
13
15
 
14
16
  export function fmtMs(ms) {
@@ -41,10 +43,6 @@ function head(icon, meta) {
41
43
  return parts;
42
44
  }
43
45
 
44
- const PAUSE_REASONS = {
45
- cost_pipeline: 'pipeline cost limit reached',
46
- cost_total: 'total cost limit reached',
47
- };
48
46
 
49
47
  /**
50
48
  * done event: status done|stopped|paused (+reason for limit pauses).
@@ -53,11 +51,20 @@ const PAUSE_REASONS = {
53
51
  export function renderDone(meta, payload = {}) {
54
52
  const status = payload.status || 'done';
55
53
  if (status === 'paused') {
56
- const reason = payload.reason ? (PAUSE_REASONS[payload.reason] || payload.reason) : null;
57
- const parts = head('', meta);
54
+ // The icon, severity and wording follow the pause's reason (failure-policy.mjs):
55
+ // an error-pause IS the failure notification (no 'error' event precedes it).
56
+ const { severity } = pauseConsequences(payload.reason);
57
+ const isError = severity === 'error';
58
+ const reason = payload.reason ? (describePauseReason(payload.reason) || payload.reason) : null;
59
+ const parts = head(isError ? '\u{1F534}' : '⏸', meta);
58
60
  parts.push(` **Status:** paused${reason ? ` — ${reason}` : ''}`);
61
+ if (payload.detail && payload.reason) {
62
+ // Already bounded (PAUSE_DETAIL_MAX, middle-clipped so the runner's trailing
63
+ // cause survives) — a head clip here would throw exactly that tail away.
64
+ parts.push(` **${isError ? 'Error' : 'Cause'}:** ${String(payload.detail)}`);
65
+ }
59
66
  parts.push(` Resume from the worca-cc UI, or reply: /resume ${runRef(meta.runId)}`);
60
- return mdMsg(parts.join('\n'), 'warning');
67
+ return mdMsg(parts.join('\n'), isError ? 'error' : 'warning');
61
68
  }
62
69
  if (status === 'stopped') {
63
70
  const parts = head('⏹', meta);
@@ -109,7 +116,7 @@ export function renderQuestion(meta, payload = {}) {
109
116
  }
110
117
  parts.push(kind === 'gate'
111
118
  ? ` Reply: /approve ${ref} to continue · /retry ${ref} for another cycle`
112
- : ` Reply: /approve ${ref} to retry · /abort ${ref} to abort`);
119
+ : ` Reply: /approve ${ref} to retry · /abort ${ref} to ${giveUpOption(payload.recovery?.options).id === 'abort' ? 'abort the run' : 'pause the run'}`);
113
120
  return mdMsg(parts.join('\n'), 'warning');
114
121
  }
115
122