@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.
@@ -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
 
@@ -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 entirely. */
60
- export function buildSettingsPayload(permissionRules) {
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 hint = /ENOENT|EINVAL/.test(String(err && err.code || err && err.message || ''))
111
- ? explainUnspawnableClaude(bin) : null;
112
- return new Error(`${prefix}: ${err.message}${hint ? ` — ${hint}` : ''}`);
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
- const v = process.env.WORCA_SUBAGENT_HOOKS;
154
- return !!v && v !== '0' && v.toLowerCase() !== 'false';
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
- const v = process.env.WORCA_MOCK ?? process.env.ORCH_MOCK;
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, permissionMode, model: wireModel, effort, allowedTools, resumeSessionId,
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, {
@@ -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`). Opus 5
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', label: 'Fable 5 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
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