@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.
@@ -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
package/src/core/db.mjs CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { createRequire } from 'node:module';
20
20
  import { mkdirSync, existsSync } from 'node:fs';
21
- import { join } from 'node:path';
21
+ import { join, dirname } from 'node:path';
22
22
  import { worcaHome } from './projects.mjs';
23
23
  import { maybeMigrateFromFs } from './migrate-fs-to-db.mjs';
24
24
  import { SEED_TEMPLATES, NODE_ID_MAP, FB_WIRE_MAP } from './graph/seed-templates.mjs';
@@ -54,7 +54,7 @@ const OPEN_BACKOFF_MS = 15;
54
54
  /** Latest schema version. Bump + append a new migration step when the DDL grows.
55
55
  * Exported so migration tests assert "reached the module's current version"
56
56
  * instead of hardcoding the number — a schema bump then touches no test file. */
57
- export const SCHEMA_VERSION = 25;
57
+ export const SCHEMA_VERSION = 27;
58
58
 
59
59
  /** Absolute path to the database file: <worcaHome>/worca-cc.db. */
60
60
  export function dbPath() {
@@ -103,15 +103,22 @@ function _openConfiguredMigrated() {
103
103
  }
104
104
 
105
105
  /**
106
- * True when err is a transient SQLite lock/busy that retrying can clear. Prefers the
107
- * structured errcode (5 = SQLITE_BUSY, 6 = SQLITE_LOCKED) and falls back to the message
108
- * so a lock is still caught on any node:sqlite build that doesn't populate errcode. A
109
- * false positive only costs a bounded retry that still re-throws the original error.
106
+ * True when err is a transient SQLite error that retrying the open can clear. Prefers
107
+ * the structured errcode 5 = SQLITE_BUSY, 6 = SQLITE_LOCKED, and primary code 10 =
108
+ * SQLITE_IOERR (extended codes carry it in the low byte) and falls back to the
109
+ * message so a lock is still caught on any node:sqlite build that doesn't populate
110
+ * errcode. IOERR is here for the first-launch race on Windows: while one process
111
+ * performs the journal_mode=WAL switch, a competitor opening the same file can get
112
+ * "disk I/O error" from the -wal/-shm files being created and unlinked under it
113
+ * (seen on the Windows 11 VM with 12 concurrent openers). A persistent I/O error
114
+ * still surfaces: the retry is bounded and re-throws the original error. A false
115
+ * positive only costs that bounded retry.
110
116
  */
111
117
  function _isBusyError(err) {
112
118
  if (err && (err.errcode === 5 || err.errcode === 6)) return true;
119
+ if (err && Number.isInteger(err.errcode) && (err.errcode & 0xff) === 10) return true;
113
120
  const msg = err && err.message ? err.message : String(err);
114
- return /locked|busy/i.test(msg);
121
+ return /locked|busy|disk I\/O error/i.test(msg);
115
122
  }
116
123
 
117
124
  /** Synchronous sleep (node:sqlite is sync; we must block this thread, not yield it). */
@@ -745,6 +752,8 @@ const INCREMENTAL_COLUMNS = {
745
752
  workflows: { domain: 'TEXT', origin: 'TEXT', graph: 'TEXT', archived_at: 'TEXT' },
746
753
  config_workflow_nodes: { ask_questions: 'INTEGER', subagent_model: 'TEXT' }, // v25: sub-agent model policy
747
754
  ask_run_links: { comment_ids: 'TEXT' }, // v22: JSON array of dc_ ids pending at launch
755
+ ask_attachments: { kind: "TEXT NOT NULL DEFAULT 'text'", // v27: text | image | binary (#398)
756
+ mime: 'TEXT' }, // v27: sniffed mime; NULL on pre-v27 rows (= text)
748
757
  };
749
758
 
750
759
  /** v23: per-loop-wire cycle budgets, the graph-engine twin of
@@ -1080,6 +1089,77 @@ function applySchemaV25(db) {
1080
1089
  repairSchemaGaps(db, schemaGaps(db));
1081
1090
  }
1082
1091
 
1092
+ /** v26 (Fable 5.1 replaces Fable 5 in PREDEFINED_MODELS): a pin left on the
1093
+ * retired id would render as "(default model)" in every picker — its option is
1094
+ * gone — and be rejected on the next write (config.mjs `unknown model
1095
+ * "claude-fable-5"`), while the run itself kept passing the old id to
1096
+ * `claude --model`. So every stored pin moves to the successor: the
1097
+ * config_workflow_nodes.model column, the per-role project_config.steps JSON,
1098
+ * and node defaults inside workflows.graph (nodes[].config.model is the shape
1099
+ * workflows.mjs validates; a bare nodes[].model is covered too). Ids match
1100
+ * case-insensitively, as config.mjs compares them. History (pipelines,
1101
+ * sub_agents.run_model, ask_*) records what actually ran and is left alone.
1102
+ * A one-word rename is reversible, so unlike V24 it takes no backup. JSON
1103
+ * that does not parse is left exactly as found — a broken row must not take
1104
+ * the ladder down. */
1105
+ const V26_MODEL_RENAMES = [['claude-fable-5', 'claude-fable-5-1']];
1106
+
1107
+ function applySchemaV26(db) {
1108
+ for (const [from, to] of V26_MODEL_RENAMES) renameStoredModelPins(db, from, to);
1109
+ }
1110
+
1111
+ /** v27 (Ask Worca binary attachments, #398): ask_attachments.kind/mime — plain
1112
+ * additive columns declared in INCREMENTAL_COLUMNS, applySchemaV25's shape: this
1113
+ * repairSchemaGaps call is what CREATES them on the ladder path (a DB stamped
1114
+ * exactly 26), reconcileSchema covers the fast path. Existing rows keep the
1115
+ * column DEFAULT 'text', which is exactly what every pre-v27 attachment is. */
1116
+ function applySchemaV27(db) {
1117
+ repairSchemaGaps(db, schemaGaps(db));
1118
+ }
1119
+
1120
+ /** Move every stored pin on model id `from` (lower-case) to `to`. Each table
1121
+ * is guarded like V24's: hand-seeded upgrade fixtures (and a DB from before the
1122
+ * fs->db import) reach this step without some of them. */
1123
+ function renameStoredModelPins(db, from, to) {
1124
+ const hasTable = (t) => hasSqliteTable(db, t);
1125
+ const isFrom = (v) => typeof v === 'string' && v.trim().toLowerCase() === from;
1126
+ const renameIn = (sel) => {
1127
+ if (!sel || typeof sel !== 'object' || !isFrom(sel.model)) return false;
1128
+ sel.model = to;
1129
+ return true;
1130
+ };
1131
+ if (hasTable('config_workflow_nodes')) {
1132
+ db.prepare('UPDATE config_workflow_nodes SET model = ? WHERE lower(trim(model)) = ?').run(to, from);
1133
+ }
1134
+
1135
+ const like = `%${from}%`; // cheap pre-filter; the JSON walk below decides
1136
+ const setSteps = hasTable('project_config') && db.prepare('UPDATE project_config SET steps = ? WHERE project_key = ?');
1137
+ for (const row of setSteps ? db.prepare('SELECT project_key, steps FROM project_config WHERE steps LIKE ?').all(like) : []) {
1138
+ let steps;
1139
+ try { steps = JSON.parse(row.steps); } catch { continue; }
1140
+ if (!steps || typeof steps !== 'object' || Array.isArray(steps)) continue;
1141
+ let changed = false;
1142
+ for (const sel of Object.values(steps)) changed = renameIn(sel) || changed;
1143
+ if (changed) setSteps.run(JSON.stringify(steps), row.project_key);
1144
+ }
1145
+
1146
+ const hasGraphColumn = () => db.prepare('PRAGMA table_info(workflows)').all().some((c) => c.name === 'graph');
1147
+ const setGraph = hasTable('workflows') && hasGraphColumn()
1148
+ && db.prepare('UPDATE workflows SET graph = ? WHERE id = ?');
1149
+ for (const row of setGraph ? db.prepare('SELECT id, graph FROM workflows WHERE graph LIKE ?').all(like) : []) {
1150
+ let graph;
1151
+ try { graph = JSON.parse(row.graph); } catch { continue; }
1152
+ if (!graph || typeof graph !== 'object' || !Array.isArray(graph.nodes)) continue;
1153
+ let changed = false;
1154
+ for (const node of graph.nodes) {
1155
+ if (!node || typeof node !== 'object') continue;
1156
+ changed = renameIn(node.config) || changed;
1157
+ changed = renameIn(node) || changed;
1158
+ }
1159
+ if (changed) setGraph.run(JSON.stringify(graph), row.id);
1160
+ }
1161
+ }
1162
+
1083
1163
  /** Audit channel for V24 (dev convention: one console.warn per decision). */
1084
1164
  const auditV24 = (msg) => console.warn(`[worca] V24: ${msg}`);
1085
1165
 
@@ -1111,7 +1191,8 @@ function usableBackup(bak) {
1111
1191
  }
1112
1192
 
1113
1193
  /**
1114
- * V24 is the ONLY ladder step that rewrites user data, so an existing DB is
1194
+ * V24 is the only ladder step that rewrites user data destructively (V26 renames
1195
+ * one model id, reversibly), so an existing DB is
1115
1196
  * snapshotted BEFORE the transaction opens (`VACUUM INTO` cannot run inside one
1116
1197
  * — measured: "cannot VACUUM from within a transaction"). Skipped for a fresh
1117
1198
  * file (nothing to lose) and for `:memory:` (PRAGMA database_list gives file '').
@@ -1141,7 +1222,7 @@ function backupBeforeV24(db) {
1141
1222
  throw new Error(`worca cannot take the pre-v24 database backup at ${bak}: `
1142
1223
  + `${err && err.message ? err.message : err}. The v2 upgrade rewrites saved `
1143
1224
  + 'pipelines, so it refuses to run without one — free disk space or make '
1144
- + `${file.replace(/\/[^/]*$/, '')} writable and start worca again.`, { cause: err });
1225
+ + `${dirname(file)} writable and start worca again.`, { cause: err });
1145
1226
  }
1146
1227
  }
1147
1228
 
@@ -1417,6 +1498,8 @@ export function migrate(db) {
1417
1498
  if (current < 23) applySchemaV23(db); // graph columns + config_workflow_wires
1418
1499
  if (current < 24) applySchemaV24(db, { existing: current >= 1 }); // the v2 break
1419
1500
  if (current < 25) applySchemaV25(db); // sub-agent model policy + recorded child model
1501
+ if (current < 26) applySchemaV26(db); // Fable 5 pins -> Fable 5.1 (catalog swap)
1502
+ if (current < 27) applySchemaV27(db); // ask_attachments.kind/mime (#398)
1420
1503
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
1421
1504
  db.exec('COMMIT');
1422
1505
  } catch (err) {