@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/README.md +8 -0
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +72 -16
- package/src/core/artifacts.mjs +10 -2
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/events.mjs +42 -3
- package/src/core/ask/follow.mjs +10 -4
- package/src/core/ask/limits.mjs +6 -3
- package/src/core/ask/prompt.mjs +37 -12
- package/src/core/ask/spawn.mjs +6 -3
- package/src/core/ask/store.mjs +89 -11
- package/src/core/ask/tool-deps.mjs +27 -3
- package/src/core/ask/tools.mjs +41 -10
- package/src/core/ask/turn.mjs +58 -12
- package/src/core/chat/command-router.mjs +8 -4
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +120 -18
- package/src/core/config.mjs +46 -3
- package/src/core/db.mjs +92 -9
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/graph/scheduler.mjs +8 -1
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +68 -0
- package/src/core/orchestrator.mjs +128 -35
- package/src/core/plugin-shim.mjs +3 -3
- package/src/core/run-harness.mjs +410 -61
- package/src/core/settings.mjs +76 -1
- package/ui/public/app.js +259 -39
- package/ui/public/ask-model.mjs +60 -7
- package/ui/public/ask-panel.mjs +314 -65
- package/ui/public/index.html +42 -0
- package/ui/public/style.css +28 -0
- package/ui/server.mjs +286 -65
package/src/core/ask/spawn.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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). */
|
package/src/core/ask/store.mjs
CHANGED
|
@@ -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
|
|
7
|
-
// ROW ID
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
53
|
-
|
|
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.
|
package/src/core/ask/tools.mjs
CHANGED
|
@@ -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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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 {
|
package/src/core/ask/turn.mjs
CHANGED
|
@@ -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(
|
|
146
|
+
const r = await d.validateProposal(inp, { cardId });
|
|
130
147
|
if (r && r.ok) {
|
|
131
|
-
|
|
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
|
|
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:
|
|
395
|
-
//
|
|
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((
|
|
404
|
-
|
|
405
|
-
//
|
|
406
|
-
//
|
|
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:
|
|
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]` —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|