@worca/app 1.5.0 → 1.6.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.
Files changed (101) hide show
  1. package/README.md +7 -1
  2. package/docker/compose.broker.yml +79 -0
  3. package/docker/compose.isolation.yml +40 -0
  4. package/package.json +2 -1
  5. package/src/broker/config.mjs +200 -0
  6. package/src/broker/copilot.mjs +124 -0
  7. package/src/broker/limits.mjs +88 -0
  8. package/src/broker/main.mjs +129 -0
  9. package/src/broker/scrub.mjs +45 -0
  10. package/src/broker/service.mjs +558 -0
  11. package/src/broker/slots.mjs +180 -0
  12. package/src/broker/store.mjs +171 -0
  13. package/src/broker/tokens.mjs +116 -0
  14. package/src/broker/ui/page.css +54 -0
  15. package/src/broker/ui/page.html +25 -0
  16. package/src/broker/ui/page.mjs +202 -0
  17. package/src/broker/ui-server.mjs +217 -0
  18. package/src/broker/usage.mjs +108 -0
  19. package/src/broker/vault.mjs +37 -0
  20. package/src/cli/models.mjs +46 -14
  21. package/src/cli/render.mjs +21 -0
  22. package/src/cli/runs.mjs +296 -0
  23. package/src/cli/worca-cc.mjs +14 -2
  24. package/src/core/agent-pool.mjs +76 -0
  25. package/src/core/artifacts.mjs +40 -8
  26. package/src/core/ask/events.mjs +11 -0
  27. package/src/core/ask/html-text.mjs +113 -0
  28. package/src/core/ask/limits.mjs +5 -0
  29. package/src/core/ask/mcp-stdio.mjs +74 -20
  30. package/src/core/ask/prompt.mjs +25 -6
  31. package/src/core/ask/spawn.mjs +55 -5
  32. package/src/core/ask/store.mjs +1 -1
  33. package/src/core/ask/tools.mjs +66 -1
  34. package/src/core/ask/turn.mjs +52 -3
  35. package/src/core/ask/web-access.mjs +26 -0
  36. package/src/core/ask/web-deps.mjs +56 -0
  37. package/src/core/ask/web-fetch.mjs +271 -0
  38. package/src/core/ask/web-proposal.mjs +76 -0
  39. package/src/core/auto/classify.mjs +20 -8
  40. package/src/core/auto/runnable.mjs +28 -0
  41. package/src/core/billing.mjs +53 -0
  42. package/src/core/bridge/errors.mjs +77 -5
  43. package/src/core/bridge/openrouter.mjs +59 -0
  44. package/src/core/bridge/provider-ops.mjs +165 -9
  45. package/src/core/bridge/providers/endpoint.mjs +112 -7
  46. package/src/core/bridge/registry.mjs +13 -0
  47. package/src/core/bridge/server.mjs +16 -3
  48. package/src/core/bridge/telemetry.mjs +44 -12
  49. package/src/core/bridge/translate/common.mjs +31 -0
  50. package/src/core/bridge/translate/request.mjs +4 -1
  51. package/src/core/bridge/translate/response.mjs +3 -0
  52. package/src/core/bridge/translate/schema-keywords.mjs +75 -0
  53. package/src/core/bridge/translate/stream.mjs +50 -4
  54. package/src/core/bridge/upstream.mjs +102 -17
  55. package/src/core/broker-boot.mjs +57 -0
  56. package/src/core/broker-client.mjs +206 -0
  57. package/src/core/broker-guard.mjs +112 -0
  58. package/src/core/broker-routing.mjs +138 -0
  59. package/src/core/claude-auth.mjs +29 -0
  60. package/src/core/claude-runner.mjs +200 -9
  61. package/src/core/config.mjs +9 -12
  62. package/src/core/failure-policy.mjs +4 -2
  63. package/src/core/git-info.mjs +49 -5
  64. package/src/core/github-credentials.mjs +44 -1
  65. package/src/core/graph/script-runner.mjs +3 -1
  66. package/src/core/list-prices.mjs +29 -0
  67. package/src/core/mcp-secrets.mjs +80 -0
  68. package/src/core/metrics/sync.mjs +2 -1
  69. package/src/core/model-env.mjs +72 -0
  70. package/src/core/model-test.mjs +17 -5
  71. package/src/core/onboarding.mjs +12 -6
  72. package/src/core/openrouter-free.mjs +159 -0
  73. package/src/core/orchestrator.mjs +100 -12
  74. package/src/core/policy/effective.mjs +18 -1
  75. package/src/core/policy/local.mjs +4 -1
  76. package/src/core/policy/registry.mjs +12 -2
  77. package/src/core/preflight.mjs +116 -0
  78. package/src/core/recoverable-error.mjs +95 -0
  79. package/src/core/recovery-backoff.mjs +84 -0
  80. package/src/core/redact.mjs +25 -0
  81. package/src/core/run-context.mjs +6 -0
  82. package/src/core/run-harness.mjs +112 -30
  83. package/src/core/run-report.mjs +2 -1
  84. package/src/core/settings.mjs +90 -5
  85. package/src/core/title.mjs +7 -2
  86. package/src/core/web-allowlist.mjs +95 -0
  87. package/ui/public/app.js +244 -43
  88. package/ui/public/ask-model.mjs +1 -0
  89. package/ui/public/ask-panel.mjs +160 -23
  90. package/ui/public/bridge-view.mjs +175 -6
  91. package/ui/public/chat-settings-view.mjs +52 -1
  92. package/ui/public/credential-badges.mjs +63 -0
  93. package/ui/public/credentials-view.mjs +57 -0
  94. package/ui/public/index.html +33 -6
  95. package/ui/public/models-view.mjs +19 -1
  96. package/ui/public/openrouter-free-view.mjs +118 -0
  97. package/ui/public/stats-view.mjs +56 -0
  98. package/ui/public/style.css +99 -50
  99. package/ui/public/team-policy-view.mjs +2 -1
  100. package/ui/public/ws-seq.mjs +24 -0
  101. package/ui/server.mjs +319 -23
@@ -89,7 +89,8 @@ async function defaultGit(cwd, args, { timeoutMs = 60_000, env = null } = {}) {
89
89
  // Local commands (commit, worktree, read-tree…) get no credential at all.
90
90
  let base;
91
91
  if (NETWORK_GIT.has(args[0])) {
92
- const cred = await githubEnv('write', { repo: await originRepo(cwd) });
92
+ // Team data, not a person's work: always worca's own credential, never "push as me".
93
+ const cred = await githubEnv('write', { repo: await originRepo(cwd), asPerson: false });
93
94
  if (cred.error) console.warn(`[worca] metrics/policy git ${args[0]}: ${cred.error}`);
94
95
  base = cred.env;
95
96
  } else {
@@ -406,6 +406,73 @@ export function isLocalBaseUrl(v) {
406
406
  return a === 127 || a === 10 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31);
407
407
  }
408
408
 
409
+ /**
410
+ * Whether a base URL is OpenRouter's (openrouter.ai or a subdomain). The
411
+ * bridge then speaks OpenRouter's dialect of chat completions: usage
412
+ * accounting, unified `reasoning`, provider routing and attribution headers.
413
+ */
414
+ export function isOpenRouterBaseUrl(v) {
415
+ if (!isUpstreamBaseUrl(v)) return false;
416
+ const host = new URL(v.trim()).hostname.toLowerCase();
417
+ return host === 'openrouter.ai' || host.endsWith('.openrouter.ai');
418
+ }
419
+
420
+ /** OpenRouter's provider sort orders (provider routing). */
421
+ export const OPENROUTER_SORTS = Object.freeze(['price', 'throughput', 'latency']);
422
+
423
+ /** A list of non-empty trimmed strings, or a throw naming the field. */
424
+ function stringList(v, field) {
425
+ if (!Array.isArray(v)) throw new Error(`${field} must be an array of strings`);
426
+ const out = [];
427
+ for (const s of v) {
428
+ if (typeof s !== 'string') throw new Error(`${field} must be an array of strings`);
429
+ if (s.trim()) out.push(s.trim());
430
+ }
431
+ return out;
432
+ }
433
+
434
+ /**
435
+ * Validate an `upstream.openrouter` block — OpenRouter's request options,
436
+ * sent only when the entry's base URL is OpenRouter's:
437
+ * `{ models?: string[], provider?: { order?: string[], allow_fallbacks?: boolean, sort?: 'price'|'throughput'|'latency' } }`.
438
+ * `models` is the fallback list tried after the entry's own model. Returns the
439
+ * normalized block, or undefined when nothing is set. Throws.
440
+ */
441
+ export function assertOpenRouterOptions(o) {
442
+ if (o === undefined || o === null) return undefined;
443
+ if (typeof o !== 'object' || Array.isArray(o)) throw new Error('upstream.openrouter must be an object');
444
+ const out = {};
445
+ for (const k of Object.keys(o)) {
446
+ if (k !== 'models' && k !== 'provider') throw new Error(`unknown upstream.openrouter key ${JSON.stringify(k)} — allowed: models, provider`);
447
+ }
448
+ if (o.models !== undefined && o.models !== null) {
449
+ const models = stringList(o.models, 'upstream.openrouter.models');
450
+ if (models.length) out.models = models;
451
+ }
452
+ const p = o.provider;
453
+ if (p !== undefined && p !== null) {
454
+ if (typeof p !== 'object' || Array.isArray(p)) throw new Error('upstream.openrouter.provider must be an object');
455
+ const provider = {};
456
+ for (const k of Object.keys(p)) {
457
+ if (!['order', 'allow_fallbacks', 'sort'].includes(k)) throw new Error(`unknown upstream.openrouter.provider key ${JSON.stringify(k)} — allowed: order, allow_fallbacks, sort`);
458
+ }
459
+ if (p.order !== undefined && p.order !== null) {
460
+ const order = stringList(p.order, 'upstream.openrouter.provider.order');
461
+ if (order.length) provider.order = order;
462
+ }
463
+ if (p.allow_fallbacks !== undefined && p.allow_fallbacks !== null) {
464
+ if (typeof p.allow_fallbacks !== 'boolean') throw new Error('upstream.openrouter.provider.allow_fallbacks must be true or false');
465
+ provider.allow_fallbacks = p.allow_fallbacks;
466
+ }
467
+ if (p.sort !== undefined && p.sort !== null && p.sort !== '') {
468
+ if (!OPENROUTER_SORTS.includes(p.sort)) throw new Error(`upstream.openrouter.provider.sort must be one of ${OPENROUTER_SORTS.join(' | ')}`);
469
+ provider.sort = p.sort;
470
+ }
471
+ if (Object.keys(provider).length) out.provider = provider;
472
+ }
473
+ return Object.keys(out).length ? out : undefined;
474
+ }
475
+
409
476
  /**
410
477
  * Validate a model `upstream` block. Returns the normalized shape or undefined
411
478
  * (for null/undefined); THROWS on malformed input with a message naming the
@@ -453,6 +520,11 @@ export function assertModelUpstream(upstream) {
453
520
  }
454
521
  const caps = assertModelCapabilities(upstream.capabilities);
455
522
  if (caps) out.capabilities = caps;
523
+ if (upstream.openrouter !== undefined && upstream.openrouter !== null) {
524
+ if (provider !== 'openai') throw new Error('upstream.openrouter is only for the openai provider (an OpenRouter base URL)');
525
+ const or = assertOpenRouterOptions(upstream.openrouter);
526
+ if (or) out.openrouter = or;
527
+ }
456
528
  return out;
457
529
  }
458
530
 
@@ -8,7 +8,8 @@
8
8
  import { runClaude } from './claude-runner.mjs';
9
9
  import { resolveModelEnv } from './config.mjs';
10
10
  import { AUX_EFFORT } from './model-env.mjs';
11
- import { classifyError } from './recoverable-error.mjs';
11
+ import { classifyError, isFreeDailyLimit, freeDailyHint } from './recoverable-error.mjs';
12
+ import { failedBecauseSignedOut } from './claude-auth.mjs';
12
13
  import { bridgeEvents } from './bridge/telemetry.mjs';
13
14
 
14
15
  const TEST_TIMEOUT_MS = 60_000;
@@ -30,6 +31,8 @@ export function hintFor(errorClass) {
30
31
  }
31
32
  }
32
33
 
34
+ export const CLAUDE_SIGNED_OUT_HINT = "Claude Code isn't signed in — run `claude` in a terminal and type /login";
35
+
33
36
  /** Actionable hint for a bridge readiness failure (config.mjs resolveModelEnv). Pure. */
34
37
  export function bridgeHintFor(reason, provider = 'the provider') {
35
38
  switch (reason) {
@@ -44,11 +47,11 @@ export function bridgeHintFor(reason, provider = 'the provider') {
44
47
  * Live connectivity check for a catalog model id (global or plugin — the
45
48
  * resolution precedence is resolveModelEnv's). Explicit user action only.
46
49
  * @param {string} id catalog model id
47
- * @param {{signal?:AbortSignal, bin?:string, run?:typeof runClaude}} [opts]
48
- * `run` is injectable for unit tests.
50
+ * @param {{signal?:AbortSignal, bin?:string, run?:typeof runClaude, signedOut?:typeof failedBecauseSignedOut}} [opts]
51
+ * `run` / `signedOut` are injectable for unit tests.
49
52
  * @returns {Promise<{ok:true, text:string}|{ok:false, errorClass:(string|null), message:string, hint?:string}>}
50
53
  */
51
- export async function testModel(id, { signal, bin, run = runClaude } = {}) {
54
+ export async function testModel(id, { signal, bin, run = runClaude, signedOut = failedBecauseSignedOut } = {}) {
52
55
  const ctrl = new AbortController();
53
56
  const onOuterAbort = () => ctrl.abort();
54
57
  if (signal) {
@@ -81,6 +84,7 @@ export async function testModel(id, { signal, bin, run = runClaude } = {}) {
81
84
  signal: ctrl.signal,
82
85
  bin,
83
86
  onEvent: () => {},
87
+ spawnKind: 'test', // credential broker: a 10-minute token (broker-client.mjs)
84
88
  });
85
89
  const reply = String(text || '').split(/\r?\n/).map((l) => l.trim()).find((l) => l) || '';
86
90
  if (!reply) {
@@ -106,8 +110,16 @@ export async function testModel(id, { signal, bin, run = runClaude } = {}) {
106
110
  // names the fix instead of the generic credential advice. A bridge failure
107
111
  // classed `network` ("endpoint unreachable", "upstream error (500)") is not
108
112
  // an ANTHROPIC_BASE_URL problem: no hint, so the UI shows the message.
113
+ // A signed-out CLI is not this model's token: a first-party model needs the
114
+ // Claude Code sign-in (and signed out the CLI may only say `unrecognized_model`),
115
+ // so say that instead of the generic advice.
116
+ const cliSignedOut = !(err && err.bridgeReason) && !bridgeFailure
117
+ && await signedOut({ message, model: id, ...(bin ? { bin } : {}) });
109
118
  const hint = err && err.bridgeReason ? bridgeHintFor(err.bridgeReason, err.bridgeProvider)
110
- : bridgeFailure && bridgeFailure.message && errorClass === 'network' ? '' : hintFor(errorClass);
119
+ : bridgeFailure && bridgeFailure.message && errorClass === 'network' ? ''
120
+ : cliSignedOut ? CLAUDE_SIGNED_OUT_HINT
121
+ : isFreeDailyLimit(message) ? freeDailyHint(message)
122
+ : hintFor(errorClass);
111
123
  return { ok: false, errorClass, message, ...(hint ? { hint } : {}) };
112
124
  } finally {
113
125
  bridgeEvents.off('failure', onBridgeFailure);
@@ -13,7 +13,7 @@ import { countWorkspaces } from './workspaces.mjs';
13
13
  import { countThreads } from './ask/store.mjs';
14
14
  import { listScopes } from './metrics/read.mjs';
15
15
  import { listPolicyScopes } from './policy/sync.mjs';
16
- import { explainUnspawnableClaude, resolveClaudeBin } from './preflight.mjs';
16
+ import { explainUnspawnableClaude, probeClaudeAuth, resolveClaudeBin } from './preflight.mjs';
17
17
  import { onboardingPrefs } from './settings.mjs';
18
18
 
19
19
  /** Step ids in shelf order. The UI (ui/public/getting-started.mjs) carries the
@@ -70,14 +70,20 @@ export function claudeReady(bin = configuredClaudeBin(), opts = {}) {
70
70
  }
71
71
 
72
72
  /**
73
- * The nine ticks plus the two stored flags, in one call.
73
+ * The nine ticks plus the two stored flags, in one call. The Claude step needs
74
+ * the CLI found AND not signed out (`auth` is preflight's probeClaudeAuth state;
75
+ * 'unknown' — mock, an older CLI — never un-ticks it). `recheck` skips the
76
+ * probe's remembered answer (the Connect dialog's Check again).
77
+ * @param {{recheck?:boolean}} [opts]
74
78
  * @returns {Promise<{steps:Record<string,boolean>, done:number, total:number,
75
- * claude:{bin:string, hint:string|null}, hidden:boolean, welcomeSeen:boolean}>}
79
+ * claude:{bin:string, hint:string|null, auth:'signed-in'|'signed-out'|'unknown'|null},
80
+ * hidden:boolean, welcomeSeen:boolean}>}
76
81
  */
77
- export async function onboardingStatus() {
82
+ export async function onboardingStatus({ recheck = false } = {}) {
78
83
  const db = getDb();
79
84
  const count = (sql) => { const row = db.prepare(sql).get(); return row ? Number(row.n) : 0; };
80
85
  const claude = claudeReady();
86
+ const auth = claude.ready ? (await probeClaudeAuth({ bin: configuredClaudeBin(), force: recheck })).state : null;
81
87
  let teamMetrics = false;
82
88
  // Cached status only (no discovery): this is read at boot and after every change
83
89
  // broadcast, and the Team metrics page owns the expensive refresh.
@@ -87,7 +93,7 @@ export async function onboardingStatus() {
87
93
  let teamPolicy = false;
88
94
  try { teamPolicy = !!(await listPolicyScopes()).anyEnabled; } catch { /* offline / no git: not enabled */ }
89
95
  const steps = {
90
- claude: claude.ready,
96
+ claude: claude.ready && auth !== 'signed-out',
91
97
  project: countProjects() > 0,
92
98
  run: count("SELECT COUNT(*) AS n FROM pipelines WHERE status = 'done'") > 0,
93
99
  realRun: count('SELECT COUNT(*) AS n FROM pipelines WHERE total_cost_usd > 0') > 0,
@@ -101,5 +107,5 @@ export async function onboardingStatus() {
101
107
  teamPolicy,
102
108
  };
103
109
  const done = ONBOARDING_STEPS.filter((id) => steps[id]).length;
104
- return { steps, done, total: ONBOARDING_STEPS.length, claude: { bin: claude.bin, hint: claude.hint }, ...onboardingPrefs() };
110
+ return { steps, done, total: ONBOARDING_STEPS.length, claude: { bin: claude.bin, hint: claude.hint, auth }, ...onboardingPrefs() };
105
111
  }
@@ -0,0 +1,159 @@
1
+ // src/core/openrouter-free.mjs
2
+ // OpenRouter's daily allowance of `:free` requests (docs/models.md › OpenRouter): how many
3
+ // are left today. 1000 a day on an account that bought $10 of credit, 50 below; every model
4
+ // call is one request (each agent turn, each tool round trip, each retry), and a small
5
+ // pipeline run takes about 90.
6
+ //
7
+ // OpenRouter's GET /key is the truth (free_model_daily_requests). Between readings every
8
+ // `:free` call the bridge forwards lowers the count, and a 429 for the daily limit sets it to
9
+ // 0; the next reading corrects any drift. A reading is kept for READ_TTL_MS.
10
+ //
11
+ // Whose allowance: without the credential broker, the key on Settings › Providers (one per
12
+ // install). With the broker, each person's own key, read through the broker with a
13
+ // short-lived token billed to them; the bridge can't tell whose key a call used there, so
14
+ // the count refreshes more often instead of being lowered per call.
15
+ import { createHash } from 'node:crypto';
16
+ import { bridgeEvents } from './bridge/telemetry.mjs';
17
+ import { openRouterKeyInfo } from './bridge/provider-ops.mjs';
18
+ import { isOpenRouter } from './bridge/openrouter.mjs';
19
+ import { providerConfig, resolveProviderSecret, listGlobalModels } from './settings.mjs';
20
+ import { listPluginModels } from './plugin-models.mjs';
21
+ import { brokerEnabled, brokerInfo, mintSpawnToken, revokeSpawnToken, slotBaseUrl } from './broker-client.mjs';
22
+ import { isFreeDailyLimit } from './recoverable-error.mjs';
23
+
24
+ const READ_TTL_MS = 5 * 60_000;
25
+ const READ_TTL_BROKER_MS = 60_000;
26
+
27
+ /** Whether an upstream (upstreamSettings shape) is an OpenRouter `:free` model. Pure. */
28
+ export function isOpenRouterFree(us) {
29
+ return !!us && /:free$/i.test(String(us.model || '')) && isOpenRouter(String(us.baseUrl || ''));
30
+ }
31
+
32
+ /** A stable, non-reversible name for a key: which allowance a call spent. */
33
+ export function keyAccount(apiKey) {
34
+ return apiKey ? `key:${createHash('sha256').update(String(apiKey)).digest('hex').slice(0, 16)}` : null;
35
+ }
36
+
37
+ /** The next 00:00 UTC after `now` (ms): when OpenRouter resets the daily allowance. Pure. */
38
+ export function nextUtcMidnight(now = Date.now()) {
39
+ const d = new Date(now);
40
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
41
+ }
42
+
43
+ /** The catalog's OpenRouter `:free` models (ids). Never throws. */
44
+ export function freeModelIds() {
45
+ const openaiBase = (() => { try { return providerConfig('openai').baseUrl || ''; } catch { return ''; } })();
46
+ const out = [];
47
+ let all = [];
48
+ try { all = [...listGlobalModels(), ...listPluginModels()]; } catch { all = []; }
49
+ for (const m of all) {
50
+ const u = m && m.upstream;
51
+ if (!u || u.provider !== 'openai') continue;
52
+ if (isOpenRouterFree({ model: u.model, baseUrl: u.baseUrl || openaiBase })) out.push(m.id);
53
+ }
54
+ return out;
55
+ }
56
+
57
+ // account -> { used, limit, remaining, readAt, resetAt }
58
+ const state = new Map();
59
+ const inflight = new Map();
60
+
61
+ function remember(account, info, now) {
62
+ const fd = info && info.freeDaily;
63
+ if (!fd) { state.set(account, { none: true, readAt: now }); return state.get(account); }
64
+ const s = { used: fd.used, limit: fd.limit, remaining: fd.remaining, readAt: now, resetAt: nextUtcMidnight(now) };
65
+ state.set(account, s);
66
+ return s;
67
+ }
68
+
69
+ /** Lower an account's count by one call (a `:free` call the bridge forwarded). */
70
+ export function tallyFreeCall(account, now = Date.now()) {
71
+ const s = account && state.get(account);
72
+ if (!s || s.none) return;
73
+ if (now >= s.resetAt) { state.delete(account); return; } // a new day: the next reading says
74
+ s.used = Math.min(s.limit, s.used + 1);
75
+ s.remaining = Math.max(0, s.remaining - 1);
76
+ }
77
+
78
+ /** The daily limit was hit: nothing is left until the reset. */
79
+ export function markFreeDailySpent(account) {
80
+ const s = account && state.get(account);
81
+ if (!s || s.none) return;
82
+ s.used = s.limit;
83
+ s.remaining = 0;
84
+ }
85
+
86
+ // The bridge's telemetry: every forwarded `:free` call lowers the count of the key it used;
87
+ // a daily-limit refusal empties it.
88
+ bridgeEvents.on('call', (e) => { if (e && e.free && e.account) tallyFreeCall(e.account); });
89
+ bridgeEvents.on('failure', (e) => { if (e && e.account && isFreeDailyLimit(e.message)) markFreeDailySpent(e.account); });
90
+
91
+ /** Which allowance `person` spends from, and how to read it: {account, read:(fetch)=>info}. */
92
+ async function source(person) {
93
+ if (brokerEnabled()) {
94
+ const info = await brokerInfo();
95
+ const who = info.mode === 'multi' ? person : 'local';
96
+ if (!who || who === 'local' && info.mode === 'multi') return null;
97
+ const base = slotBaseUrl('openrouter');
98
+ if (!base || !(info.slots || []).some((s) => s.id === 'openrouter')) return null;
99
+ return {
100
+ account: `person:${who}`,
101
+ ttl: READ_TTL_BROKER_MS,
102
+ read: async (f) => {
103
+ const minted = await mintSpawnToken({ billTo: who, slots: ['openrouter'], kind: 'aux' });
104
+ try { return await openRouterKeyInfo(`${base}/api/v1`, minted.token, { fetch: f }); } finally { revokeSpawnToken(minted.spawnId); }
105
+ },
106
+ };
107
+ }
108
+ const cfg = providerConfig('openai');
109
+ const base = String(cfg.baseUrl || '').replace(/\/+$/, '');
110
+ const key = resolveProviderSecret(cfg.apiKey);
111
+ if (!isOpenRouter(base) || !key) return null;
112
+ return { account: keyAccount(key), ttl: READ_TTL_MS, read: (f) => openRouterKeyInfo(base, key, { fetch: f }) };
113
+ }
114
+
115
+ /**
116
+ * Today's free-model allowance for `person` (the signed-in viewer; ignored without the broker).
117
+ * `models`: the catalog's OpenRouter `:free` model ids (the new-run form checks its nodes against them).
118
+ * @returns {Promise<{enabled:false}|{enabled:true, known:false, models:string[], reason?:string}|
119
+ * {enabled:true, known:true, models:string[], used:number, limit:number, remaining:number, resetAt:string, readAt:string}>}
120
+ */
121
+ export async function freeDailyStatus({ person = null, force = false, fetch: f = globalThis.fetch, now = Date.now } = {}) {
122
+ const models = freeModelIds();
123
+ if (!models.length) return { enabled: false };
124
+ let src;
125
+ try { src = await source(person); } catch (err) { return { enabled: true, known: false, models, reason: err.message }; }
126
+ if (!src) return { enabled: true, known: false, models, reason: 'no OpenRouter key to read' };
127
+ let s = state.get(src.account);
128
+ const t = now();
129
+ if (force || !s || t - s.readAt > src.ttl || (s.resetAt && t >= s.resetAt)) {
130
+ if (!inflight.has(src.account)) {
131
+ inflight.set(src.account, (async () => {
132
+ try { return remember(src.account, await src.read(f), now()); } finally { inflight.delete(src.account); }
133
+ })());
134
+ }
135
+ try { s = await inflight.get(src.account); } catch { /* keep the last reading */ }
136
+ }
137
+ if (!s || s.none) return { enabled: true, known: false, models, reason: 'OpenRouter did not report a daily allowance for this key' };
138
+ return {
139
+ enabled: true, known: true, models, used: s.used, limit: s.limit, remaining: s.remaining,
140
+ resetAt: new Date(s.resetAt).toISOString(), readAt: new Date(s.readAt).toISOString(),
141
+ };
142
+ }
143
+
144
+ /** The last reading for `person`'s allowance, without a network call: {used, limit} or {}. */
145
+ export function cachedFreeDailyCounts(person = null) {
146
+ let account = null;
147
+ if (brokerEnabled()) account = person ? `person:${person}` : 'person:local';
148
+ else {
149
+ try {
150
+ const cfg = providerConfig('openai');
151
+ if (isOpenRouter(String(cfg.baseUrl || ''))) account = keyAccount(resolveProviderSecret(cfg.apiKey));
152
+ } catch { account = null; }
153
+ }
154
+ const s = account && state.get(account);
155
+ return s && !s.none ? { used: s.used, limit: s.limit } : {};
156
+ }
157
+
158
+ /** Test hook. */
159
+ export function _resetFreeDaily() { state.clear(); inflight.clear(); }
@@ -36,12 +36,15 @@ import { humanEstimateOverrides, memoryDefragModel } from './settings.mjs';
36
36
  import { renderPromptArtifact } from './phases.mjs';
37
37
  import { listModels, modelHasBaseUrlRouting, resolveRunConfig } from './config.mjs';
38
38
  import { resolveDefragModel, agentPairText } from './memory-defrag-model.mjs';
39
- import { assembleShape, ShapeError } from '../shared/graph/assemble.mjs';
39
+ import { assembleShape, normalizeShape, ShapeError } from '../shared/graph/assemble.mjs';
40
+ import { RECIPE_SHAPES } from './auto/recipes.mjs';
40
41
  import { fingerprintProject } from './auto/fingerprint.mjs';
41
42
  import { classifyTask, ClassifierError } from './auto/classify.mjs';
42
43
  import { autoCandidates, findEquivalentWorkflow } from './auto/match.mjs';
43
44
  import { buildProposal, sanitizeProposalAnswer, remapTunables, mintAutoWorkflowId } from './auto/proposal.mjs';
44
45
  import { resolveAutoModel } from './auto/model.mjs';
46
+ import { autoModelsFor } from './auto/runnable.mjs';
47
+ import { probeClaudeAuth } from './preflight.mjs';
45
48
  import {
46
49
  appendAudit, writeReview, reviewKindOf, writeDecomposition, updateTaskStatus,
47
50
  updatePhaseStatus, writeStepQuestions, readStepQuestions,
@@ -49,6 +52,7 @@ import {
49
52
  import { readAskFile } from './protocol.mjs';
50
53
  import { prepareFormAsk, formAnswerValidator, downgradeQuestion } from './ask-forms.mjs';
51
54
  import { classifyError } from './recoverable-error.mjs';
55
+ import { withRecoveryRetry, RETRYABLE_CLASSES, HELPER_RETRY_ATTEMPTS } from './recovery-backoff.mjs';
52
56
  import { resolveFailure, markTerminal, isTerminal } from './failure-policy.mjs';
53
57
  import { byActor } from './identity.mjs';
54
58
 
@@ -97,6 +101,9 @@ export class GraphOrchestrator extends RunHarness {
97
101
  // `classify` is the test seam.
98
102
  this._auto = { feedback: [], round: 0, prior: null, costUsd: 0, pending: null };
99
103
  this._classify = typeof opts?.classify === 'function' ? opts.classify : null;
104
+ // Whether Claude Code is signed in decides which models Auto may design with
105
+ // (auto/runnable.mjs). `claudeAuth` is the test seam.
106
+ this._claudeAuth = typeof opts?.claudeAuth === 'function' ? opts.claudeAuth : () => probeClaudeAuth({ bin: this.claude.bin || undefined });
100
107
  Object.assign(this.state, {
101
108
  engine: 2,
102
109
  active: [], // [{nodeId, executionId}]
@@ -170,7 +177,7 @@ export class GraphOrchestrator extends RunHarness {
170
177
  * @returns {Promise<{pair: ({model:string, effort:(string|null)}|null), warning: (string|null)}>}
171
178
  */
172
179
  async _defragAgentPair() {
173
- const explicit = { model: this.claude.model, effort: this.opts.claude?.effort };
180
+ const explicit = { model: this.claude.model, effort: this.claude.effort };
174
181
  const stored = memoryDefragModel();
175
182
  // The catalog read only when the setting is the one that decides.
176
183
  const models = !(typeof explicit.model === 'string' && explicit.model.trim()) && stored.model
@@ -233,7 +240,14 @@ export class GraphOrchestrator extends RunHarness {
233
240
 
234
241
  async _decideTopologyInner() {
235
242
  const registry = this.registry;
236
- const models = await listModels(this.projectDir);
243
+ // Only models this install can run (auto/runnable.mjs): signed out, a first-party pick —
244
+ // or the CLI default a stage without a model falls back to — dies at its first spawn.
245
+ let auth = 'unknown';
246
+ try { auth = (await this._claudeAuth())?.state || 'unknown'; } catch { /* unknown narrows nothing */ }
247
+ const runnable = autoModelsFor(await listModels(this.projectDir), { auth, routed: modelHasBaseUrlRouting });
248
+ const models = runnable.models;
249
+ const requireModel = runnable.requireModel;
250
+ if (runnable.note) this._log('orchestrator', requireModel ? 'info' : 'warn', `auto: ${runnable.note}`);
237
251
  const model = resolveAutoModel(models);
238
252
  const fingerprint = await fingerprintProject(this.projectDir);
239
253
  this._log('orchestrator', 'info', `auto: fingerprint ${Buffer.byteLength(fingerprint, 'utf8')} B`);
@@ -260,11 +274,11 @@ export class GraphOrchestrator extends RunHarness {
260
274
  } else {
261
275
  this._auto.round += 1;
262
276
  round = this._auto.round;
263
- classifyFor = classify;
277
+ classifyFor = (input) => this._classifyWithRetry(classify, input);
264
278
  }
265
279
  let outcome;
266
280
  try {
267
- outcome = await this._autoRound({ registry, models, model, fingerprint, extras, taskText, classify: classifyFor, round });
281
+ outcome = await this._autoRound({ registry, models, requireModel, model, fingerprint, extras, taskText, classify: classifyFor, round });
268
282
  } catch (err) {
269
283
  if (isAbort(err) || isPause(err)) throw err;
270
284
  if (pending && err instanceof ShapeError) {
@@ -274,6 +288,13 @@ export class GraphOrchestrator extends RunHarness {
274
288
  this._auto.pending = null;
275
289
  continue;
276
290
  }
291
+ if (err instanceof ClassifierError && !pending && RETRYABLE_CLASSES.includes(classifyError(err))) {
292
+ // A provider 429 / dropped connection that outlasted _classifyWithRetry says
293
+ // nothing about the task: run the default workflow rather than park a run
294
+ // that has not started. Only a transient cause falls back — an unusable reply
295
+ // or a timeout keeps the D17 pause below.
296
+ return await this._autoFallbackDefault({ err, registry, round, nodeModel: requireModel ? model : null });
297
+ }
277
298
  if (err instanceof ClassifierError || err instanceof ShapeError) {
278
299
  // spec D17 / §5.6: the shell's failure policy parks the run (setup site ⇒
279
300
  // pause, reason 'error', detail = the message) and the resume point keeps
@@ -318,9 +339,9 @@ export class GraphOrchestrator extends RunHarness {
318
339
  }
319
340
 
320
341
  /** One round: classifier call (one assembler-driven retry), match, proposal. */
321
- async _autoRound({ registry, models, model, fingerprint, extras, taskText, classify, round }) {
342
+ async _autoRound({ registry, models, requireModel = false, model, fingerprint, extras, taskText, classify, round }) {
322
343
  const input = {
323
- taskText, extras, fingerprint, models, registry,
344
+ taskText, extras, fingerprint, models, requireModel, registry,
324
345
  domain: 'coding', // the domain the assembler stamps: coding + shared + general agents are offered
325
346
  humanInLoop: this.humanInLoop, feedback: [...this._auto.feedback], priorShape: this._auto.prior,
326
347
  // D6 amendment (2026-09-07): the classifier may Grep/Glob/Read the RUN'S OWN checkout to
@@ -408,8 +429,9 @@ export class GraphOrchestrator extends RunHarness {
408
429
  return raw && raw.decision ? raw : validate(raw); // auto mode answers { decision: 'accept' } without the validator
409
430
  }
410
431
 
411
- /** Reuse the twin or save a new row, resolve it with the accepted tunables as the ONLY overlay, re-stamp the manifest. */
412
- async _autoAdopt({ template, match, tunables, shape, answer, registry, round }) {
432
+ /** Reuse the twin or save a new row, resolve it with the accepted tunables as the ONLY overlay, re-stamp the manifest.
433
+ * `fallback` = the classifier failure a default-workflow fallback adopts in place of a proposal (_autoFallbackDefault). */
434
+ async _autoAdopt({ template, match, tunables, shape, answer, registry, round, fallback = null }) {
413
435
  const name = answer.name || shape.name;
414
436
  if (!match) {
415
437
  // B3: the twin search ran at proposal time; another Auto run, the composer or the chat may
@@ -450,7 +472,10 @@ export class GraphOrchestrator extends RunHarness {
450
472
  const manifest = buildGraphManifest(this.resolved.template, this.resolved.agentsByKey, {
451
473
  overlays: { nodes: this.resolved.nodeCtx, wires: this.resolved.wires }, scripts: this.resolved.scriptsByKey,
452
474
  });
453
- manifest.auto = { status: 'decided', via, rounds: round, humanInLoop: this.humanInLoop, workflowId };
475
+ // A fallback records what it saved (`saved`) and why (`reason`) beside via 'fallback'.
476
+ manifest.auto = fallback
477
+ ? { status: 'decided', via: 'fallback', saved: via, reason: fallback, rounds: round, humanInLoop: this.humanInLoop, workflowId }
478
+ : { status: 'decided', via, rounds: round, humanInLoop: this.humanInLoop, workflowId };
454
479
  this._preflightAgentKeys(this.resolved.agentKeys);
455
480
  this._preflightScriptKeys(this.resolved.scriptKeys);
456
481
  await this._preflightScriptRuntimes();
@@ -467,11 +492,69 @@ export class GraphOrchestrator extends RunHarness {
467
492
  this.state.resumePoint = null; // decided: the engine's onSnapshot owns the point from here
468
493
  this._emit('state', this.getState());
469
494
  await this._persist();
470
- this._log('orchestrator', 'info', `auto: accepted → "${name}" (${workflowId}, ${via})`);
471
- await appendAudit(this.pipeline.dir, `Auto workflow: **${name}** — ${via === 'reused' ? `reusing saved workflow ${workflowId}` : `saved as ${workflowId}`}.`).catch(() => {});
495
+ if (fallback) {
496
+ await appendAudit(this.pipeline.dir, `Auto workflow: the classifier was unreachable (${fallback}) — running the default workflow **${name}** (${via === 'reused' ? `saved workflow ${workflowId}` : `saved as ${workflowId}`}).`).catch(() => {});
497
+ } else {
498
+ this._log('orchestrator', 'info', `auto: accepted → "${name}" (${workflowId}, ${via})`);
499
+ await appendAudit(this.pipeline.dir, `Auto workflow: **${name}** — ${via === 'reused' ? `reusing saved workflow ${workflowId}` : `saved as ${workflowId}`}.`).catch(() => {});
500
+ }
472
501
  return { manifest, agentKeys: new Set(this.resolved.agentKeys), workflow: { id: workflowId, name: this.resolved.template.name || name } };
473
502
  }
474
503
 
504
+ /**
505
+ * The Auto classifier call with the shared recovery backoff (recovery-backoff.mjs):
506
+ * a rate_limit / network failure — a provider 429 the CLI already retried, a dropped
507
+ * connection — is retried before the round gives up. What the failed attempts spent
508
+ * rides the result (or the final error), so the round books it once (D14).
509
+ */
510
+ async _classifyWithRetry(classify, input) {
511
+ let spent = 0;
512
+ try {
513
+ const res = await withRecoveryRetry(() => classify(input), {
514
+ signal: input.signal,
515
+ onRetry: ({ attempt, cls, delayMs, err }) => {
516
+ spent += Number(err?.costUsd) || 0;
517
+ this._log('orchestrator', 'warn',
518
+ `auto: classifier ${cls} — retrying in ${Math.round(delayMs / 100) / 10}s (retry ${attempt}/${HELPER_RETRY_ATTEMPTS}): ${firstLine(err?.detail || err?.message)}`);
519
+ },
520
+ });
521
+ if (spent) res.costUsd = (Number(res.costUsd) || 0) + spent;
522
+ return res;
523
+ } catch (err) {
524
+ if (spent && err && typeof err === 'object') err.costUsd = (Number(err.costUsd) || 0) + spent;
525
+ throw err;
526
+ }
527
+ }
528
+
529
+ /** The classifier stayed unreachable (a transient class after its retries): run the
530
+ * default workflow — adopted like an accepted proposal, so it is saying why in the run
531
+ * log, the audit and the manifest (auto.via 'fallback') — instead of parking a run
532
+ * that has not started. With a human in the loop that is the standard recipe, whose
533
+ * exact twin is the built-in Default (wf_default); with nobody in the loop it is the
534
+ * same recipe without Clarify, because spec D3 forbids stopping the run to ask. */
535
+ async _autoFallbackDefault({ err, registry, round, nodeModel = null }) {
536
+ const cause = firstLine(err?.detail || err?.message);
537
+ const cls = classifyError(err);
538
+ const recipe = RECIPE_SHAPES.find((r) => r.id === (this.humanInLoop ? 'prompt' : 'plan-partial'));
539
+ const assembled = assembleShape(normalizeShape(jsonClone(recipe.shape)), { registry, humanInLoop: this.humanInLoop });
540
+ // Signed out (auto/runnable.mjs), the recipe's nodes have no model to fall back to: run
541
+ // every one on the model Auto itself runs on — routed by construction.
542
+ if (nodeModel) {
543
+ for (const n of assembled.template.nodes || []) {
544
+ if (n.kind === 'agent') assembled.tunables[n.id] = { ...(assembled.tunables[n.id] || {}), model: nodeModel, effort: '' };
545
+ }
546
+ }
547
+ const match = findEquivalentWorkflow(assembled.template, await autoCandidates());
548
+ this._log('orchestrator', 'warn',
549
+ `auto: the workflow classifier failed (${cls}) after ${HELPER_RETRY_ATTEMPTS} retries — running the default workflow "${match ? match.candidate.name : assembled.shape.name}" instead: ${cause}`);
550
+ return this._autoAdopt({
551
+ template: match ? match.candidate : assembled.template, match,
552
+ tunables: match ? remapTunables(assembled.tunables, match.nodeMap) : assembled.tunables,
553
+ shape: assembled.shape, answer: { decision: 'accept', name: assembled.shape.name, nodes: {} },
554
+ registry, round, fallback: `${cls}: ${cause}`,
555
+ });
556
+ }
557
+
475
558
  /** Attached files as the classifier sees them: names, plus the first 2 KB of text files. */
476
559
  async _autoExtras() {
477
560
  const out = [];
@@ -752,6 +835,11 @@ export class GraphOrchestrator extends RunHarness {
752
835
  // advance it), so a resume credits the paused execution's pre-pause work at its terminal.
753
836
  humanCursor: this._humanCursorReady ? (this._humanCursor ?? { files: 0, insertions: 0, deletions: 0 }) : null,
754
837
  stepModels: this.stepModels,
838
+ // The run-level model the run was started with (restored by the harness
839
+ // constructor); only the fields that are set, so an older reader sees none.
840
+ ...(this.claude.model || this.claude.effort
841
+ ? { claude: { ...(this.claude.model ? { model: this.claude.model } : {}), ...(this.claude.effort ? { effort: this.claude.effort } : {}) } }
842
+ : {}),
755
843
  workflowId: this.workflowId,
756
844
  // Auto workflow: the decision state while UNDECIDED (spec §5.6); null once
757
845
  // the graph is adopted (workflowId is then the real id) and on saved workflows.
@@ -4,6 +4,7 @@
4
4
  // snapshot of local settings comes from policy/local.mjs, the document from policy/sync.mjs.
5
5
 
6
6
  import { FIELDS, effectiveKind, tierRank, semverAtLeast } from './registry.mjs';
7
+ import { capDomainList } from '../web-allowlist.mjs';
7
8
 
8
9
  /**
9
10
  * The team entries that apply to ONE run kind: `workspaceRuns` replaces `fields` for
@@ -45,6 +46,14 @@ export function effectiveDefault({ local = null, team = null } = {}) {
45
46
  return { value: local ? local.value : null, source: local ? 'default' : 'none' };
46
47
  }
47
48
 
49
+ /** Ask web access on/off: only the developer switches it on; a team value can only switch it off. */
50
+ export function effectiveWebEnabled({ local = null, team = null } = {}) {
51
+ if (team && team.value === false) return false;
52
+ return !!(local && local.value === true);
53
+ }
54
+ /** The Ask web allowlist: the developer's own list, capped by the team's when the policy sets one. */
55
+ export function capWebDomains(local, team) { return capDomainList(local, Array.isArray(team) ? team : null); }
56
+
48
57
  const fmtUsd = (n) => `$${Number(n).toFixed(2)}`;
49
58
  // Built-in guardrail ids render as their display names everywhere (`secure` shows as "Strict",
50
59
  // the guardrail-store.mjs BUILTIN_META rule); a policy set reads "gp:<id>".
@@ -75,7 +84,15 @@ export function effectiveRows({ doc = null, workspaceRun = false, local = {} } =
75
84
  const t = team[meta.key] || null;
76
85
  const l = local[meta.key] || null;
77
86
  let effective; let note = null;
78
- if (meta.cap) {
87
+ if (meta.key === 'ask.webEnabled') {
88
+ const v = effectiveWebEnabled({ local: l, team: t });
89
+ effective = { value: v, display: fmtValue(meta, v), source: t && t.value === false ? 'team' : 'local' };
90
+ } else if (meta.key === 'ask.webAllowedDomains') {
91
+ const v = capWebDomains(l?.value, t?.value);
92
+ effective = { value: v, display: fmtValue(meta, v), source: t ? 'team' : 'local' };
93
+ const dropped = (Array.isArray(l?.value) ? l.value.length : 0) - v.length;
94
+ if (t && dropped > 0) note = `${dropped} of yours ${dropped === 1 ? 'is' : 'are'} outside the team list`;
95
+ } else if (meta.cap) {
79
96
  const r = effectiveCap({ local: l?.set ? l.value : null, team: t });
80
97
  effective = { value: r.cap, display: fmtValue(meta, r.cap), source: r.binding || 'none' };
81
98
  if (r.binding === 'team' && l?.set && l.value > t.value) note = `yours (${fmtUsd(l.value)}) is looser; the team cap applies`;
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { createRequire } from 'node:module';
8
8
  import {
9
- readSettings, pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod, humanRateUsdPerHour, askMaxTurns, askMaxBudgetUsd, hideBuiltinModels,
9
+ readSettings, pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod, humanRateUsdPerHour, askMaxTurns, askMaxBudgetUsd, hideBuiltinModels, askWeb,
10
10
  } from '../settings.mjs';
11
11
  import { readConfigRow, readTeamMetricsPrefs } from '../config.mjs';
12
12
  import { projectKey } from '../store.mjs';
@@ -27,6 +27,7 @@ const parseJson = (s, fallback) => { try { const v = JSON.parse(s); return v ??
27
27
  export function localSnapshot(projectDir = null) {
28
28
  const raw = readSettings();
29
29
  const has = (k) => raw[k] !== undefined && raw[k] !== null && raw[k] !== '';
30
+ const web = askWeb();
30
31
  const out = {
31
32
  'cost.pipelineLimitUsd': { value: pipelineCostLimitUsd(), set: pipelineCostLimitUsd() != null },
32
33
  'cost.totalLimitUsd': { value: totalCostLimitUsd(), set: totalCostLimitUsd() != null },
@@ -34,6 +35,8 @@ export function localSnapshot(projectDir = null) {
34
35
  'cost.humanRateUsd': { value: humanRateUsdPerHour(), set: humanRateUsdPerHour() != null },
35
36
  'ask.maxTurns': { value: askMaxTurns(), set: has('askMaxTurns') },
36
37
  'ask.maxBudgetUsd': { value: askMaxBudgetUsd(), set: raw.askMaxBudgetUsd !== undefined }, // literal null = "no cap", a choice
38
+ 'ask.webEnabled': { value: web.enabled, set: typeof raw.askWeb?.enabled === 'boolean' },
39
+ 'ask.webAllowedDomains': { value: web.allowedDomains, set: web.allowedDomains.length > 0 },
37
40
  'models.hideBuiltins': { value: hideBuiltinModels(), set: has('hideBuiltinModels') || raw.hideBuiltinModelsChosen === true },
38
41
  'plugins.marketplaces': { value: Object.values(readMarketplaces().marketplaces).map((m) => m.url), set: true },
39
42
  'plugins.required': { value: Object.entries(readPluginsLock()).map(([name, e]) => ({ name, version: e?.version ?? null, enabled: e?.enabled !== false })), set: true },