@worca/app 1.1.1 → 1.2.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +72 -16
- package/src/core/artifacts.mjs +10 -2
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/events.mjs +42 -3
- package/src/core/ask/follow.mjs +10 -4
- package/src/core/ask/limits.mjs +6 -3
- package/src/core/ask/prompt.mjs +37 -12
- package/src/core/ask/spawn.mjs +6 -3
- package/src/core/ask/store.mjs +89 -11
- package/src/core/ask/tool-deps.mjs +27 -3
- package/src/core/ask/tools.mjs +41 -10
- package/src/core/ask/turn.mjs +58 -12
- package/src/core/chat/command-router.mjs +8 -4
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +120 -18
- package/src/core/config.mjs +46 -3
- package/src/core/db.mjs +92 -9
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/graph/scheduler.mjs +8 -1
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +68 -0
- package/src/core/orchestrator.mjs +128 -35
- package/src/core/plugin-shim.mjs +3 -3
- package/src/core/run-harness.mjs +410 -61
- package/src/core/settings.mjs +76 -1
- package/ui/public/app.js +259 -39
- package/ui/public/ask-model.mjs +60 -7
- package/ui/public/ask-panel.mjs +314 -65
- package/ui/public/index.html +42 -0
- package/ui/public/style.css +28 -0
- package/ui/server.mjs +286 -65
|
@@ -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
|
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 =
|
|
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
|
|
107
|
-
* structured errcode
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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
|
|
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
|
|
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) {
|