@worca/app 1.1.1 → 1.2.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -1
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +247 -27
- 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/src/core/ui-instance.mjs +235 -0
- 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 +377 -80
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
|
|
|
@@ -34,9 +34,11 @@
|
|
|
34
34
|
|
|
35
35
|
import { spawn } from 'node:child_process';
|
|
36
36
|
import { createInterface } from 'node:readline';
|
|
37
|
-
import { prepareModelEnv } from './model-env.mjs';
|
|
37
|
+
import { prepareModelEnv, envFlag, describeModelEnv } from './model-env.mjs';
|
|
38
|
+
import { effectiveDebugSpawn } from './settings.mjs';
|
|
38
39
|
import { classifyError, strongestClass } from './recoverable-error.mjs';
|
|
39
40
|
import { explainUnspawnableClaude, resolveClaudeBin } from './preflight.mjs';
|
|
41
|
+
import { hostGuardEnabled, hostGuardHookEntry, hostGuardSystemPrompt } from './host-guard.mjs';
|
|
40
42
|
import { writeFile, mkdir, appendFile, readFile, access } from 'node:fs/promises';
|
|
41
43
|
import { constants as FS, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
42
44
|
import { dirname, join } from 'node:path';
|
|
@@ -56,9 +58,14 @@ export function sigkillGraceMs() {
|
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/** What `--settings` carries, or null when there is nothing to carry (no hook
|
|
59
|
-
* telemetry, no permission rules) — then the flag is omitted
|
|
60
|
-
|
|
61
|
+
* telemetry, no permission rules, no host guard) — then the flag is omitted
|
|
62
|
+
* entirely. `hostGuard` (set by runReal, gated by hostGuardEnabled) merges the
|
|
63
|
+
* host-process-protection PreToolUse hook into the SAME single payload; the
|
|
64
|
+
* returned `hook` flag stays telemetry-only (it drives --include-hook-events,
|
|
65
|
+
* which the guard does not need). */
|
|
66
|
+
export function buildSettingsPayload(permissionRules, { hostGuard = false } = {}) {
|
|
61
67
|
const hook = buildHookSettings();
|
|
68
|
+
const guard = hostGuard && hostGuardEnabled() ? hostGuardHookEntry() : null;
|
|
62
69
|
const hasRules = !!permissionRules && Object.values(permissionRules).some((a) => Array.isArray(a) && a.length);
|
|
63
70
|
// Present-but-malformed rules (e.g. `{deny: 'Bash(curl:*)'}`) make the object
|
|
64
71
|
// truthy while hasRules stays false, so the whole policy would drop out of
|
|
@@ -69,9 +76,13 @@ export function buildSettingsPayload(permissionRules) {
|
|
|
69
76
|
&& Object.values(permissionRules).some((a) => a != null && !Array.isArray(a))) {
|
|
70
77
|
console.warn('[worca] guardrails: permissionRules is malformed (deny/allow/ask must be arrays of strings) — ignoring it; this spawn carries NO permission rules');
|
|
71
78
|
}
|
|
72
|
-
if (!hook && !hasRules) return null;
|
|
79
|
+
if (!hook && !hasRules && !guard) return null;
|
|
73
80
|
const settings = {};
|
|
74
|
-
if (hook) settings.hooks = hook.hooks;
|
|
81
|
+
if (hook) settings.hooks = { ...hook.hooks };
|
|
82
|
+
if (guard) {
|
|
83
|
+
settings.hooks = settings.hooks ?? {};
|
|
84
|
+
settings.hooks.PreToolUse = [...(settings.hooks.PreToolUse ?? []), guard];
|
|
85
|
+
}
|
|
75
86
|
if (hasRules) settings.permissions = permissionRules;
|
|
76
87
|
return { hook: !!hook, settings };
|
|
77
88
|
}
|
|
@@ -100,6 +111,21 @@ export function argvLength(bin, args) {
|
|
|
100
111
|
return String(bin || '').length + args.reduce((n, a) => n + String(a).length + 3, 0);
|
|
101
112
|
}
|
|
102
113
|
|
|
114
|
+
const ARGV_VALUE_PREVIEW = 64;
|
|
115
|
+
|
|
116
|
+
/** A copy of `args` safe to log: EVERY token longer than ARGV_VALUE_PREVIEW is
|
|
117
|
+
* shortened to a 64-char prefix + "…(<N> chars)". Token-level, not flag-aware, on
|
|
118
|
+
* purpose: an inline prompt, the `--settings` JSON (uncapped for a custom rule
|
|
119
|
+
* set), `--allowedTools`, `--mcp-config` — any free-text value buildClaudeArgs
|
|
120
|
+
* adds later — is capped without this list having to track it. Flags and short
|
|
121
|
+
* values pass through verbatim, so argv order is always preserved. Pure. */
|
|
122
|
+
export function redactArgvForLog(args) {
|
|
123
|
+
return args.map((a) => {
|
|
124
|
+
const v = String(a);
|
|
125
|
+
return v.length > ARGV_VALUE_PREVIEW ? `${v.slice(0, ARGV_VALUE_PREVIEW)}…(${v.length} chars)` : v;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
103
129
|
/** Log each npm-shim resolution once per process, not once per spawn. */
|
|
104
130
|
const _resolveNoted = new Set();
|
|
105
131
|
|
|
@@ -107,9 +133,15 @@ const _resolveNoted = new Set();
|
|
|
107
133
|
* explanation when that is what actually went wrong (ENOENT on a bare name
|
|
108
134
|
* whose only PATH hit is claude.cmd; EINVAL on an explicit .cmd). */
|
|
109
135
|
function spawnFailure(bin, err, prefix) {
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
136
|
+
const unspawnable = /ENOENT|EINVAL/.test(String(err && err.code || err && err.message || ''));
|
|
137
|
+
const hint = unspawnable ? explainUnspawnableClaude(bin) : null;
|
|
138
|
+
const out = new Error(`${prefix}: ${err.message}${hint ? ` — ${hint}` : ''}`);
|
|
139
|
+
// An unspawnable CLI (not installed / not on PATH) is user-fixable, not a
|
|
140
|
+
// pipeline bug: stamp the recovery class so the orchestrator's gate pauses
|
|
141
|
+
// the run for manual resume instead of hard-failing it (ENOENT matches no
|
|
142
|
+
// message-sniff pattern, so without the stamp it would classify null).
|
|
143
|
+
if (unspawnable) out.errorClass = 'network';
|
|
144
|
+
return out;
|
|
113
145
|
}
|
|
114
146
|
|
|
115
147
|
// Cap for the stderr detail embedded in a non-zero-exit Error message. The
|
|
@@ -150,10 +182,30 @@ export function buildEffortArgs(effort) {
|
|
|
150
182
|
* and the baseline sub-agent lifecycle (tool_use/tool_result) is unaffected.
|
|
151
183
|
*/
|
|
152
184
|
export function subagentHooksEnabled() {
|
|
153
|
-
|
|
154
|
-
|
|
185
|
+
return envFlag('WORCA_SUBAGENT_HOOKS');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Opt-in spawn diagnostics, DEFAULT OFF. A NON-EMPTY WORCA_DEBUG_SPAWN in the
|
|
190
|
+
* environment wins (envFlag rule: any value but "0"/"false" turns it on, so an
|
|
191
|
+
* exported "0" is an explicit OFF); otherwise the stored `debugSpawnEnabled`
|
|
192
|
+
* setting applies — read fresh per spawn (settings.mjs#effectiveDebugSpawn, the
|
|
193
|
+
* one precedence rule the settings API also reports), so the UI checkbox reaches
|
|
194
|
+
* the next spawn in this process AND in a CLI run with no restart and no env
|
|
195
|
+
* mutation. OFF ⇒ runReal emits NO spawn-debug event and does not touch
|
|
196
|
+
* argv/env, so the spawn path is byte-identical to today. (The once-per-process
|
|
197
|
+
* "routing env applied" confirmation below is a separate, always-on line: it
|
|
198
|
+
* fires only for a model env that carries an ANTHROPIC_* routing key, once per
|
|
199
|
+
* distinct model + env, never per spawn.) Read directly in runReal (not a
|
|
200
|
+
* runClaude option) so it bypasses the runClaude→runReal gate by construction.
|
|
201
|
+
*/
|
|
202
|
+
export function debugSpawnEnabled() {
|
|
203
|
+
return effectiveDebugSpawn().enabled;
|
|
155
204
|
}
|
|
156
205
|
|
|
206
|
+
/** Once per process per distinct (model, described env): see runReal. */
|
|
207
|
+
const _routingNoted = new Set();
|
|
208
|
+
|
|
157
209
|
// ── Sub-agent telemetry + the --settings seam ────────────────────────────────
|
|
158
210
|
// Telemetry is GATED (subagentHooksEnabled) and OFF by default. When on it adds
|
|
159
211
|
// `--include-hook-events` (surfaces hook lifecycle on the SAME stdout stream)
|
|
@@ -179,10 +231,11 @@ export function buildHookSettings() {
|
|
|
179
231
|
* [] when there is nothing to say, so the baseline argv is byte-identical.
|
|
180
232
|
* @param {{deny?:string[],allow?:string[],ask?:string[]}|null|undefined} permissionRules
|
|
181
233
|
* @param {string|null} [settingsFile] staged path (GH #380): `--settings <path>` carries the same JSON
|
|
234
|
+
* @param {{hostGuard?:boolean}} [opts] host-process guard (runReal sets it; see buildSettingsPayload)
|
|
182
235
|
* @returns {string[]}
|
|
183
236
|
*/
|
|
184
|
-
export function buildSettingsArgs(permissionRules, settingsFile = null) {
|
|
185
|
-
const payload = buildSettingsPayload(permissionRules);
|
|
237
|
+
export function buildSettingsArgs(permissionRules, settingsFile = null, { hostGuard = false } = {}) {
|
|
238
|
+
const payload = buildSettingsPayload(permissionRules, { hostGuard });
|
|
186
239
|
if (!payload) return [];
|
|
187
240
|
const args = [];
|
|
188
241
|
if (payload.hook) args.push('--include-hook-events');
|
|
@@ -245,8 +298,7 @@ export function buildSpawnEnv(envScrub, envAllowlist) {
|
|
|
245
298
|
*/
|
|
246
299
|
export function mockEnabled(opts) {
|
|
247
300
|
if (opts && opts.mock) return true;
|
|
248
|
-
|
|
249
|
-
return !!v && v !== '0' && v.toLowerCase() !== 'false';
|
|
301
|
+
return envFlag('WORCA_MOCK', 'ORCH_MOCK');
|
|
250
302
|
}
|
|
251
303
|
|
|
252
304
|
/**
|
|
@@ -403,7 +455,7 @@ export function buildClaudeArgs({
|
|
|
403
455
|
// way in because the legacy body below already owns a local `tools` (the
|
|
404
456
|
// --allowedTools union).
|
|
405
457
|
tools: builtinTools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages,
|
|
406
|
-
maxTurns, maxBudgetUsd, appendSubagentSystemPrompt,
|
|
458
|
+
maxTurns, maxBudgetUsd, appendSubagentSystemPrompt, hostGuard,
|
|
407
459
|
}, delivery = {}) {
|
|
408
460
|
// delivery (GH #380, set only by planClaudeInvocation's staged branch):
|
|
409
461
|
// promptViaStdin -> bare `-p`; the prompt is written to the child's stdin
|
|
@@ -426,7 +478,7 @@ export function buildClaudeArgs({
|
|
|
426
478
|
// SINGLE inline JSON (two --settings flags would be last-wins at the CLI). [] when
|
|
427
479
|
// there is neither, so the baseline argv is unchanged; a CLI that rejects these
|
|
428
480
|
// flags would only ever fail when the operator opted in.
|
|
429
|
-
for (const a of buildSettingsArgs(permissionRules, settingsFile)) args.push(a);
|
|
481
|
+
for (const a of buildSettingsArgs(permissionRules, settingsFile, { hostGuard })) args.push(a);
|
|
430
482
|
if (mcpConfigPath) args.push('--mcp-config', mcpConfigPath);
|
|
431
483
|
const tools = Array.isArray(allowedTools) ? allowedTools.slice() : [];
|
|
432
484
|
for (const s of (Array.isArray(mcpServerGrants) ? mcpServerGrants : [])) {
|
|
@@ -496,7 +548,7 @@ export function planClaudeInvocation(opts, { bin = DEFAULT_BIN, dir = null, limi
|
|
|
496
548
|
files.push({ path: systemPromptFile, content: opts.systemPrompt });
|
|
497
549
|
}
|
|
498
550
|
let settingsFile = null;
|
|
499
|
-
const payload = buildSettingsPayload(opts.permissionRules);
|
|
551
|
+
const payload = buildSettingsPayload(opts.permissionRules, { hostGuard: opts.hostGuard });
|
|
500
552
|
if (payload) {
|
|
501
553
|
settingsFile = join(dir, 'settings.json');
|
|
502
554
|
files.push({ path: settingsFile, content: JSON.stringify(payload.settings) });
|
|
@@ -547,6 +599,23 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
547
599
|
} else if (wireModel !== model) {
|
|
548
600
|
console.warn(`[worca] model ${JSON.stringify(model ?? '')}: wire model ${JSON.stringify(wireModel)}`);
|
|
549
601
|
}
|
|
602
|
+
// Confirm a resolved card's routing env actually reached a spawn — even when
|
|
603
|
+
// the wire id equals the catalog id, the case the wire-model line above stays
|
|
604
|
+
// silent for (that silence is exactly what hid a gateway card whose
|
|
605
|
+
// ANTHROPIC_MODEL matched its catalog id). Fires only for an env that carries
|
|
606
|
+
// an ANTHROPIC_* routing key (Ask Worca merges a CLAUDE_CODE_* knob into
|
|
607
|
+
// EVERY turn's env, which is not routing) and once per process per distinct
|
|
608
|
+
// line, like _resolveNoted — never per spawn. describeModelEnv prints the
|
|
609
|
+
// routing keys readable (endpoint, wire id — the diagnostic) and every other
|
|
610
|
+
// key as `<set, N chars>`: ANTHROPIC_AUTH_TOKEN and plugin {secret} values live
|
|
611
|
+
// in this map and no part of them may reach a log. Worded WITHOUT the
|
|
612
|
+
// substrings "wire model"/"modelEnv" — test/spawn-args.test.mjs counts by those.
|
|
613
|
+
const routingApplied = safeModelEnv && Object.keys(safeModelEnv).some((k) => k.startsWith('ANTHROPIC_'))
|
|
614
|
+
? describeModelEnv(safeModelEnv) : null;
|
|
615
|
+
if (routingApplied) {
|
|
616
|
+
const line = `[worca] model ${JSON.stringify(model ?? '')}: routing env applied: ${routingApplied}`;
|
|
617
|
+
if (!_routingNoted.has(line)) { _routingNoted.add(line); console.warn(line); }
|
|
618
|
+
}
|
|
550
619
|
|
|
551
620
|
// Windows + npm-installed Claude Code: the bare name is a .cmd shim Node
|
|
552
621
|
// cannot spawn; resolveClaudeBin swaps in the package's native claude.exe.
|
|
@@ -562,10 +631,20 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
562
631
|
// ARGV_INLINE_LIMIT). The staging dir, when any, is removed on every
|
|
563
632
|
// terminal path below (finish) and on a failed spawn.
|
|
564
633
|
const limit = Number.isFinite(argvInlineLimit) && argvInlineLimit > 0 ? argvInlineLimit : ARGV_INLINE_LIMIT;
|
|
634
|
+
// Host guard (host-guard.mjs, 2026-08-31 incident): every REAL spawn — any
|
|
635
|
+
// role, custom agent, plugin agent, ask chat — carries the protection
|
|
636
|
+
// preamble, the PreToolUse hook (hostGuard -> the --settings payload), and
|
|
637
|
+
// WORCA_HOST_PID (below). One kill-switch: WORCA_HOST_GUARD=0. Mock spawns
|
|
638
|
+
// nothing, so runMock stays untouched.
|
|
639
|
+
const guardOn = hostGuardEnabled();
|
|
640
|
+
const guardedSystemPrompt = guardOn
|
|
641
|
+
? [hostGuardSystemPrompt(process.pid), systemPrompt].filter(Boolean).join('\n\n')
|
|
642
|
+
: systemPrompt;
|
|
565
643
|
let plan;
|
|
566
644
|
try {
|
|
567
645
|
plan = stageClaudeInvocation({
|
|
568
|
-
prompt, systemPrompt
|
|
646
|
+
prompt, systemPrompt: guardedSystemPrompt, hostGuard: guardOn,
|
|
647
|
+
permissionMode, model: wireModel, effort, allowedTools, resumeSessionId,
|
|
569
648
|
mcpConfigPath, mcpServerGrants, permissionRules,
|
|
570
649
|
tools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages,
|
|
571
650
|
maxTurns, maxBudgetUsd, appendSubagentSystemPrompt,
|
|
@@ -594,6 +673,29 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
594
673
|
let spawnEnv = guardrailEnv;
|
|
595
674
|
if (safeModelEnv) spawnEnv = { ...(guardrailEnv ?? process.env), ...safeModelEnv };
|
|
596
675
|
|
|
676
|
+
// WORCA_HOST_PID rides every guarded spawn (the hook reads it; scrub would
|
|
677
|
+
// drop it — WORCA_ is not an allowlisted prefix — so it is added AFTER).
|
|
678
|
+
if (guardOn) spawnEnv = { ...(spawnEnv ?? process.env), WORCA_HOST_PID: String(process.pid) };
|
|
679
|
+
|
|
680
|
+
// Opt-in spawn diagnostics (WORCA_DEBUG_SPAWN, default off — byte-identical spawn
|
|
681
|
+
// path when unset). Everything here is derived from values already computed above
|
|
682
|
+
// (safeModelEnv is null or non-empty, so the routing field is either the described
|
|
683
|
+
// env — secrets as `<set, N chars>` — or "(none)"). Emitted right before spawn so
|
|
684
|
+
// it reflects the exact bin/argv/env handed to the child, ONCE, as the same
|
|
685
|
+
// `stderr` event the child's own stderr rides (run-harness logs it at `warn`
|
|
686
|
+
// into the run stream and live-log.ndjson; nothing here also console.warns, so
|
|
687
|
+
// a run never prints the line twice). Field is `routingEnv`, not `modelEnv`:
|
|
688
|
+
// test/spawn-args.test.mjs counts "modelEnv" warnings for the dropped-key path.
|
|
689
|
+
if (debugSpawnEnabled()) {
|
|
690
|
+
const summary =
|
|
691
|
+
`[worca] spawn-debug: bin=${JSON.stringify(resolved.bin)} `
|
|
692
|
+
+ `argv=${JSON.stringify(redactArgvForLog(args))} `
|
|
693
|
+
+ `promptViaStdin=${plan.stdin != null} staged=${plan.staged} `
|
|
694
|
+
+ `envScrub=${guardrailEnv ? 'on' : 'off'} childEnvKeys=${Object.keys(spawnEnv ?? process.env).length} `
|
|
695
|
+
+ `routingEnv=[${safeModelEnv ? describeModelEnv(safeModelEnv) : '(none)'}]`;
|
|
696
|
+
safeEmit(onEvent, { type: 'stderr', stream: 'err', text: summary });
|
|
697
|
+
}
|
|
698
|
+
|
|
597
699
|
let child;
|
|
598
700
|
try {
|
|
599
701
|
child = spawn(resolved.bin, args, {
|
package/src/core/config.mjs
CHANGED
|
@@ -57,14 +57,16 @@ export { EFFORTS };
|
|
|
57
57
|
* The `[1m]` suffix selects the 1M-token long-context variant. Opus 4.6–4.8 and
|
|
58
58
|
* Sonnet 4.6 1M ids were verified to resolve via `claude --model`; Haiku 4.5 1M
|
|
59
59
|
* is intentionally omitted — the CLI rejects it ("long context beta is not yet
|
|
60
|
-
* available for this subscription"). Fable 5 needs no `[1m]` suffix: its context
|
|
61
|
-
* window is 1M by default (verified to resolve via `claude --model
|
|
60
|
+
* available for this subscription"). Fable 5.1 needs no `[1m]` suffix: its context
|
|
61
|
+
* window is 1M by default (verified to resolve via `claude --model`, CLI 2.1.257).
|
|
62
|
+
* It replaced Fable 5 (`claude-fable-5`) on 2026-09-01; db.mjs V26 moves every
|
|
63
|
+
* stored pin on the retired id to the successor, so nothing keeps it here. Opus 5
|
|
62
64
|
* (`claude-opus-5`) and Sonnet 5 (`claude-sonnet-5`) are likewise 1M-only and
|
|
63
65
|
* carry no `[1m]` twin.
|
|
64
66
|
*/
|
|
65
67
|
export const PREDEFINED_MODELS = [
|
|
66
68
|
{ id: 'claude-opus-5', label: 'Opus 5', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
67
|
-
{ id: 'claude-fable-5',
|
|
69
|
+
{ id: 'claude-fable-5-1', label: 'Fable 5.1 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
68
70
|
{ id: 'claude-opus-4-8', label: 'Opus 4.8', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
69
71
|
{ id: 'claude-opus-4-8[1m]', label: 'Opus 4.8 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
70
72
|
{ id: 'claude-opus-4-7', label: 'Opus 4.7', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
@@ -413,6 +415,47 @@ export function resolveModelCost(modelId, cliCostUsd, usage, costCfg = undefined
|
|
|
413
415
|
return cliCostUsd;
|
|
414
416
|
}
|
|
415
417
|
|
|
418
|
+
// ── display-only list prices ──────────────────────────────────────────────────
|
|
419
|
+
// USD per MILLION tokens for the built-in ids, from Anthropic's published
|
|
420
|
+
// pricing (platform.claude.com/docs/en/pricing — snapshot 2026-06-24). DISPLAY
|
|
421
|
+
// APPROXIMATION ONLY: it feeds the chat footer's live "≈" estimate while a turn
|
|
422
|
+
// streams (ask/events.mjs `estimatedCostUsd`). The CLI's result.total_cost_usd,
|
|
423
|
+
// re-priced by resolveModelCost, stays the ONLY figure any message row, thread
|
|
424
|
+
// total, ledger or budget ever books — nothing here is read by those paths.
|
|
425
|
+
// Ids missing here get no estimate (null), which is the pre-existing behaviour;
|
|
426
|
+
// `[1m]` twins and dated ids resolve to their base row (the long-context premium
|
|
427
|
+
// is not modelled). cacheWrite = 1.25× input (5-minute TTL), cacheWrite1h = 2×
|
|
428
|
+
// input, cacheRead = 0.1× input except Fable 5.1 (0.025×). Refresh by hand when
|
|
429
|
+
// Anthropic moves a price. PREDEFINED_MODELS itself stays untouched — its entry
|
|
430
|
+
// shape is pinned (test/config-models-global.test.mjs:205).
|
|
431
|
+
export const PREDEFINED_LIST_PRICES = Object.freeze({
|
|
432
|
+
'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5, cacheWrite1h: 20 },
|
|
433
|
+
'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
434
|
+
'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
435
|
+
'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
436
|
+
'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
437
|
+
'claude-sonnet-5': { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite1h: 4 },
|
|
438
|
+
'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 },
|
|
439
|
+
'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25, cacheWrite1h: 2 },
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const FREE_RATES = Object.freeze({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0 });
|
|
443
|
+
|
|
444
|
+
/** The per-Mtok rates a DISPLAY estimate may price `modelId` with: the operator's
|
|
445
|
+
* modelCostConfig override when one exists ({free} → all-zero rates, so a free
|
|
446
|
+
* model estimates $0 instead of a list price), else the built-in list price,
|
|
447
|
+
* else null (no estimate). Never throws. */
|
|
448
|
+
export function liveCostRates(modelId) {
|
|
449
|
+
const id = typeof modelId === 'string' ? modelId.trim() : '';
|
|
450
|
+
if (!id) return null;
|
|
451
|
+
let cfg = null;
|
|
452
|
+
try { cfg = modelCostConfig(id); } catch { cfg = null; }
|
|
453
|
+
if (cfg && cfg.free === true) return FREE_RATES;
|
|
454
|
+
if (cfg && cfg.perMtok && typeof cfg.perMtok === 'object') return cfg.perMtok;
|
|
455
|
+
const base = id.toLowerCase().replace(/\[1m\]$/, '').replace(/-\d{8}$/, '');
|
|
456
|
+
return PREDEFINED_LIST_PRICES[base] ?? null;
|
|
457
|
+
}
|
|
458
|
+
|
|
416
459
|
/**
|
|
417
460
|
* All selectable models for a project = the effective catalog (predefined ⊕
|
|
418
461
|
* global ⊕ this project's legacy custom models). Legacy custom models
|