@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
package/ui/server.mjs CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  setPipelineCostLimitUsd, setTotalCostLimitUsd, setCostLimitResetPeriod, assertCostLimitInputs,
45
45
  humanRateUsdPerHour, setHumanRateUsdPerHour, assertHumanRateInput,
46
46
  askMaxTurns, askMaxBudgetUsd, setAskMaxTurns, setAskMaxBudgetUsd, assertAskLimitInputs,
47
+ askWeb, assertAskWebInput, setAskWeb, addAskWebHost,
47
48
  chatPrefs, setChatPrefs,
48
49
  debugSpawnEnabled as storedDebugSpawnEnabled, effectiveDebugSpawn, setDebugSpawnEnabled, assertDebugSpawnInput, SETTINGS_POST_KEYS,
49
50
  titleModel as storedTitleModel, setTitleModel, assertTitleModelInput,
@@ -74,6 +75,7 @@ import {
74
75
  } from '../src/core/ask/store.mjs';
75
76
  import { sanitizeTitle as askSanitizeTitle } from '../src/core/title.mjs';
76
77
  import { ASK_LIMITS } from '../src/core/ask/limits.mjs';
78
+ import { askWebAccess, WEB_OFF } from '../src/core/ask/web-access.mjs';
77
79
  import { askCatalog, validateModelEffort } from '../src/core/ask/models.mjs';
78
80
  import { buildCatalog as askBuildCatalog } from '../src/core/ask/catalog.mjs';
79
81
  import {
@@ -121,6 +123,14 @@ import {
121
123
  } from '../src/core/remote-access.mjs';
122
124
  import { detectDeployment, deploymentFacts } from '../src/core/deployment.mjs';
123
125
  import { resolveIdentity, startedByOf, prAttributionFooter, actorOf, isSharedIdentity, byActor } from '../src/core/identity.mjs';
126
+ import { withBillTo, currentBillTo, currentOwner } from '../src/core/billing.mjs';
127
+ import { agentIdentity } from '../src/core/agent-user.mjs';
128
+ import { createAskToolServer } from '../src/core/ask/mcp-stdio.mjs';
129
+ import { webMcpEnv as askWebMcpEnv } from '../src/core/ask/spawn.mjs';
130
+ import { brokerEnabled, brokerInfo, personSlots, brokerUsageSummary, foldUsageByPerson } from '../src/core/broker-client.mjs';
131
+ import { freeDailyStatus } from '../src/core/openrouter-free.mjs';
132
+ import { checkBrokerAtBoot } from '../src/core/broker-boot.mjs';
133
+ import { modelSlot, missingCredentials, describeMissing } from '../src/core/broker-routing.mjs';
124
134
  import { planClone, cloneProject, CloneError } from '../src/core/clone-project.mjs';
125
135
  import { listFolders } from '../src/core/fs-browse.mjs';
126
136
  import {
@@ -165,6 +175,8 @@ import { scheduleEventPrompt, scheduleNoticeText } from '../src/core/ask/schedul
165
175
  import { applyModelChange } from '../src/core/ask/model-deps.mjs';
166
176
  import { modelEventPrompt, modelNoticeText } from '../src/core/ask/model-proposal.mjs';
167
177
  import { cloneEventPrompt, cloneNoticeText } from '../src/core/ask/clone-proposal.mjs';
178
+ import { webEventPrompt, webNoticeText, chatWebHosts } from '../src/core/ask/web-proposal.mjs';
179
+ import { hostAllowed as askHostAllowed } from '../src/core/web-allowlist.mjs';
168
180
  import { registryPortsFn } from '../src/core/graph/registry-ports.mjs';
169
181
  import { sweepV1Runs, V1_RUN_RETIRED, getDb } from '../src/core/db.mjs';
170
182
  import { exportWorkflow, exportWorkflowPlugin, ON_CONFLICT_MODES, RESOLUTION_CHOICES } from '../src/core/workflow-export.mjs';
@@ -194,8 +206,10 @@ import {
194
206
  } from '../src/core/memory-store.mjs';
195
207
  import { memoryCaps } from '../src/core/settings.mjs';
196
208
  import { onboardingPrefs, setOnboardingPrefs } from '../src/core/settings.mjs';
197
- import { onboardingStatus } from '../src/core/onboarding.mjs'; // a THIRD settings import line (the two blocks above are unrelated readers)
209
+ import { configuredClaudeBin, onboardingStatus } from '../src/core/onboarding.mjs'; // a THIRD settings import line (the two blocks above are unrelated readers)
198
210
  import { createWorkspaceScan } from '../src/core/workspace-scan.mjs';
211
+ import { probeClaudeAuth, CLAUDE_SIGNED_OUT_CODE, CLAUDE_SIGNED_OUT_MESSAGE } from '../src/core/preflight.mjs';
212
+ import { failedBecauseSignedOut } from '../src/core/claude-auth.mjs';
199
213
  import { createAgentGen } from '../src/core/agent-gen.mjs';
200
214
  import { listAgents, readAgent, createAgent, updateAgent, deleteAgent, AGENT_KEY_RE } from '../src/core/agent-store.mjs';
201
215
  import {
@@ -459,6 +473,32 @@ wss.on('error', () => {});
459
473
  /** All currently connected sockets. */
460
474
  const sockets = new Set();
461
475
 
476
+ /** This process's id, sent in `hello`: a client that sees it change knows event seqs restarted. */
477
+ const BOOT_ID = randomUUID();
478
+
479
+ // Heartbeat. A proxy in front of worca (Cloudflare closes an idle WebSocket after ~100 s)
480
+ // drops a socket that carries no frames — a run parked on an open Auto proposal is silent
481
+ // for minutes — and every reconnect re-subscribes and replays. A ping every 30 s keeps the
482
+ // socket busy; one that has not answered the previous ping is dead and is terminated.
483
+ const HEARTBEAT_MS = 30_000;
484
+ function trackHeartbeat(ws) {
485
+ ws.isAlive = true;
486
+ ws.on('pong', () => { ws.isAlive = true; });
487
+ }
488
+ function heartbeatTick(set = sockets) {
489
+ for (const ws of set) {
490
+ if (ws.isAlive === false) {
491
+ try { ws.terminate(); } catch { /* already gone */ }
492
+ set.delete(ws);
493
+ continue;
494
+ }
495
+ ws.isAlive = false;
496
+ try { ws.ping(); } catch { /* closing */ }
497
+ }
498
+ }
499
+ const heartbeat = setInterval(() => heartbeatTick(), HEARTBEAT_MS);
500
+ heartbeat.unref();
501
+
462
502
  // server.close() only calls back once every connection is gone, and Node's
463
503
  // closeAllConnections() skips UPGRADED sockets — a WebSocket whose close
464
504
  // handshake has not completed (a client that vanished, or a test tearing down
@@ -481,6 +521,7 @@ wss.on('connection', (ws, req) => {
481
521
  return;
482
522
  }
483
523
  sockets.add(ws);
524
+ trackHeartbeat(ws);
484
525
  // Whose Ask threads this socket may see (a shared sign-in's name, else null = all).
485
526
  ws.worcaViewer = askViewer(req);
486
527
  // Optional ?runId=... (or ?scanId=.../?genId=...) -> replay that entry's buffered
@@ -508,7 +549,7 @@ wss.on('connection', (ws, req) => {
508
549
  }
509
550
  const id = requestedRunId || requestedScanId || requestedGenId || requestedBenchId;
510
551
 
511
- send(ws, { type: 'hello', runs: summarizeRuns(), ask: askHello(ws) });
552
+ send(ws, { type: 'hello', bootId: BOOT_ID, runs: summarizeRuns(), ask: askHello(ws) });
512
553
 
513
554
  if (id && runs.has(id)) {
514
555
  replayEntry(ws, runs.get(id));
@@ -661,8 +702,12 @@ function emitDiffCommentsChanged(runId) {
661
702
  // Append a tagged event to an entry's ring buffer (runId LAST so the runs-Map key
662
703
  // always wins over any id the orchestrator stamped). Shared by the live wire
663
704
  // (record) and out-of-band resolutions (resolvePending) so both honor MAX_BUFFER.
705
+ // `seq` numbers the entry's events so a client that reconnects (and is replayed the whole
706
+ // buffer) skips what it already applied (ui/public/ws-seq.mjs); BOOT_ID in `hello` tells it
707
+ // when a restarted server numbers from 1 again.
664
708
  function bufferEvent(entry, event) {
665
- const tagged = { ...event, runId: entry.id };
709
+ entry.seq = (entry.seq || 0) + 1;
710
+ const tagged = { ...event, runId: entry.id, seq: entry.seq };
666
711
  entry.events.push(tagged);
667
712
  if (entry.events.length > MAX_BUFFER) entry.events.splice(0, entry.events.length - MAX_BUFFER);
668
713
  return tagged;
@@ -1156,6 +1201,11 @@ app.post('/api/ask/threads/:id/messages', express.json({ limit: '64mb' }));
1156
1201
  app.put('/api/scripts/:key/cases', express.json({ limit: '64mb' }));
1157
1202
  app.use(express.json({ limit: '8mb' }));
1158
1203
 
1204
+ // Credential broker billing (src/core/billing.mjs): every claude spawn a request causes —
1205
+ // a run it starts, an Ask turn, a model Test — is billed to the person behind the request.
1206
+ // AFTER the body parsers: their stream callbacks would otherwise run outside this context.
1207
+ app.use((req, _res, next) => withBillTo(resolveIdentity(req).name, next));
1208
+
1159
1209
  if (HLJS_ASSETS) {
1160
1210
  const sendHljsModule = (file) => (_req, res, next) => {
1161
1211
  res.type('text/javascript');
@@ -1560,6 +1610,14 @@ const startRunHandler = async (req, res) => {
1560
1610
  return badRequest(res, 'workspaceId or projectDir is required');
1561
1611
  }
1562
1612
 
1613
+ // Credential broker: the run's own model, when the request names one, is checked here
1614
+ // (an instant refusal); the harness then checks every node's model before it spawns
1615
+ // anything (run-harness.mjs#_brokerPreflight).
1616
+ if (!internal && body.mock !== true && !mockEnabled({}) && typeof body.model === 'string' && body.model.trim()) {
1617
+ const refusal = await brokerStartRefusal(req, [body.model.trim()]);
1618
+ if (refusal) return res.status(409).json({ error: refusal, code: 'credential-missing' });
1619
+ }
1620
+
1563
1621
  // Ask Worca card link (§8.1): both or neither; the thread must exist and
1564
1622
  // the card must still be `proposed` BEFORE any run state is created.
1565
1623
  const hasAskThread = body.askThreadId !== undefined && body.askThreadId !== null;
@@ -3219,7 +3277,9 @@ app.get('/api/runs/:id/recovery-patch', async (req, res) => {
3219
3277
  // ---------------------------------------------------------------------------
3220
3278
  // POST /api/runs/:id/overview -> Layer-2 on-demand overview agent.
3221
3279
  // Accepts ?key=<storeKey> (preferred; history detail uses it) or ?projectDir=...
3222
- // ?force=1 bypasses the cached overview.json. 200 { overview } | 404 | 500.
3280
+ // ?force=1 bypasses the cached overview.json. 200 { overview } | 404 | 500, or
3281
+ // 409 code 'claude-signed-out' when the CLI is signed out (a cached overview
3282
+ // needs no Claude, so this maps the failure instead of probing up front).
3223
3283
  // ---------------------------------------------------------------------------
3224
3284
  app.post('/api/runs/:id/overview', async (req, res) => {
3225
3285
  const id = req.params.id;
@@ -3235,6 +3295,9 @@ app.post('/api/runs/:id/overview', async (req, res) => {
3235
3295
  res.json({ overview });
3236
3296
  } catch (err) {
3237
3297
  const msg = err && err.message ? err.message : String(err);
3298
+ if (msg !== 'pipeline not found' && await failedBecauseSignedOut({ message: msg })) {
3299
+ return res.status(409).json({ code: CLAUDE_SIGNED_OUT_CODE, error: CLAUDE_SIGNED_OUT_MESSAGE });
3300
+ }
3238
3301
  const code = msg === 'pipeline not found' ? 404 : 500;
3239
3302
  res.status(code).json({ error: msg });
3240
3303
  }
@@ -3268,8 +3331,9 @@ app.get('/api/history', async (_req, res) => {
3268
3331
  // POST writes ONLY those flags ({hidden?, welcomeSeen?}; booleans; unknown keys
3269
3332
  // 400) and answers with the same full payload, so one round trip repaints.
3270
3333
  // ---------------------------------------------------------------------------
3271
- app.get('/api/onboarding', async (_req, res) => {
3272
- try { res.json(await onboardingStatus()); }
3334
+ // ?recheck=1 skips the remembered Claude sign-in answer (the dialog's Check again).
3335
+ app.get('/api/onboarding', async (req, res) => {
3336
+ try { res.json(await onboardingStatus({ recheck: isTruthy(req.query.recheck) })); }
3273
3337
  catch (err) { res.status(500).json({ error: err && err.message ? err.message : String(err) }); }
3274
3338
  });
3275
3339
  app.post('/api/onboarding', async (req, res) => {
@@ -3300,16 +3364,34 @@ app.get('/api/counts', (req, res) => {
3300
3364
  // GET /api/stats?range=today|week|month|all -> the Statistics view payload (§6.9).
3301
3365
  // Pure DB reads; an unknown range is the caller's fault, so getStats' RangeError
3302
3366
  // maps to 400 while anything else keeps bubbling to the error handler.
3303
- app.get('/api/stats', (req, res) => {
3367
+ app.get('/api/stats', async (req, res) => {
3368
+ let stats;
3304
3369
  try {
3305
3370
  const range = typeof req.query.range === 'string' && req.query.range ? req.query.range : 'month';
3306
- res.json(getStats({ range }));
3371
+ stats = getStats({ range });
3307
3372
  } catch (err) {
3308
3373
  if (err instanceof RangeError) return badRequest(res, err.message);
3309
3374
  throw err;
3310
3375
  }
3376
+ // Credential broker: model spend per person in the same window, as the broker metered it
3377
+ // (the authoritative figure: it saw every request). Absent with the broker off.
3378
+ if (brokerEnabled()) stats.byPerson = await statsByPerson(stats.windowStartMs, stats.windowEndMs);
3379
+ res.json(stats);
3311
3380
  });
3312
3381
 
3382
+ /** Broker usage in [startMs, endMs) folded per person: {people:[…]} or {error}. */
3383
+ async function statsByPerson(startMs, endMs) {
3384
+ try {
3385
+ const { rows } = await brokerUsageSummary({
3386
+ since: Number.isFinite(startMs) ? new Date(startMs).toISOString() : undefined,
3387
+ until: Number.isFinite(endMs) ? new Date(endMs).toISOString() : undefined,
3388
+ });
3389
+ return { people: foldUsageByPerson(rows) };
3390
+ } catch (err) {
3391
+ return { error: err && err.message ? err.message : String(err) };
3392
+ }
3393
+ }
3394
+
3313
3395
  // ---- Team metrics (team-metrics-design.md §4.6–§4.10) ----------------------------------
3314
3396
  function metricsErrorStatus(code) {
3315
3397
  switch (code) {
@@ -4716,6 +4798,20 @@ function startScan({ projectPaths, name, workspaceId }) {
4716
4798
  return scanId;
4717
4799
  }
4718
4800
 
4801
+ /**
4802
+ * Refuse a detached Claude job (workspace scan, agent generation) up front when
4803
+ * the CLI is signed out (preflight probeClaudeAuth): otherwise it starts and its
4804
+ * first agent dies ~30 s in with "Not logged in". Only a definite 'signed-out'
4805
+ * refuses — mock, an auth env var, or an unknown answer (an older CLI) all pass.
4806
+ * Sends the 409 and returns true when it refused.
4807
+ */
4808
+ async function refuseSignedOutClaude(res) {
4809
+ const { state } = await probeClaudeAuth({ bin: configuredClaudeBin(), mock: isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK) });
4810
+ if (state !== 'signed-out') return false;
4811
+ res.status(409).json({ code: CLAUDE_SIGNED_OUT_CODE, error: CLAUDE_SIGNED_OUT_MESSAGE });
4812
+ return true;
4813
+ }
4814
+
4719
4815
  // POST /api/workspaces/scan (pre-persist, Step 2->3). Takes projectPaths directly:
4720
4816
  // validate >=2 paths + fs.existsSync each + reject non-git-repos (400); the deep
4721
4817
  // git work happens inside the engine.
@@ -4730,6 +4826,7 @@ app.post('/api/workspaces/scan', async (req, res) => {
4730
4826
  if (!fs.existsSync(dir)) return badRequest(res, `member path is missing: ${dir}`);
4731
4827
  if (!isGitRepo(dir)) return badRequest(res, `member is not a git repository: ${dir}`);
4732
4828
  }
4829
+ if (await refuseSignedOutClaude(res)) return;
4733
4830
  const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : undefined;
4734
4831
  const scanId = startScan({ projectPaths, name });
4735
4832
  res.json({ scanId });
@@ -4751,6 +4848,7 @@ app.post('/api/workspaces/:id/scan', async (req, res) => {
4751
4848
  r.workspaceId === id && r.kind === 'workspace-run' &&
4752
4849
  ['running', 'starting', 'created'].includes(String(r.status || '').toLowerCase()));
4753
4850
  if (liveRun) return res.status(409).json({ error: 'a live run exists for this workspace' });
4851
+ if (await refuseSignedOutClaude(res)) return;
4754
4852
  const scanId = startScan({ projectPaths: ws.projectPaths, name: ws.name, workspaceId: ws.id });
4755
4853
  res.json({ scanId });
4756
4854
  } catch (err) {
@@ -4882,6 +4980,7 @@ const settingsState = () => ({
4882
4980
  humanRateUsdPerHour: humanRateUsdPerHour(),
4883
4981
  askMaxTurns: askMaxTurns(),
4884
4982
  askMaxBudgetUsd: askMaxBudgetUsd(),
4983
+ askWeb: askWeb(), // Ask Worca web access (off by default)
4885
4984
  debugSpawnEnabled: storedDebugSpawnEnabled(), // what is STORED (the checkbox)
4886
4985
  debugSpawnEffective: effectiveDebugSpawn(), // what the next spawn will DO, and why
4887
4986
  titleModel: storedTitleModel(), // the STORED id (the select), null = run's model
@@ -4938,6 +5037,112 @@ app.get('/api/whoami', (req, res) => {
4938
5037
  res.json(who.source === 'local' ? { name: null, source: 'local', shared: false } : { ...who, shared: isSharedIdentity(who.source) });
4939
5038
  });
4940
5039
 
5040
+ // The signed-in person's model credentials, as the credential broker sees them
5041
+ // (docs/credential-broker.md). Status only: which slots have a key, never a key.
5042
+ // { enabled:false } with the broker off.
5043
+ // OpenRouter's daily allowance of `:free` requests (src/core/openrouter-free.mjs), for the
5044
+ // signed-in person with the credential broker, else for the install's key. The sidebar line,
5045
+ // the new-run warning and the Providers card read it; ?refresh=1 asks OpenRouter now.
5046
+ app.get('/api/openrouter/free-daily', async (req, res) => {
5047
+ try {
5048
+ res.json(await freeDailyStatus({ person: currentBillTo(), force: req.query.refresh === '1' }));
5049
+ } catch (err) {
5050
+ res.status(500).json({ enabled: false, error: err && err.message ? err.message : String(err) });
5051
+ }
5052
+ });
5053
+
5054
+ app.get('/api/credentials', async (req, res) => {
5055
+ if (!brokerEnabled()) return res.json({ enabled: false });
5056
+ try {
5057
+ const info = await brokerInfo();
5058
+ const who = resolveIdentity(req);
5059
+ // Which slot each catalog model spends from: the pickers' "your key / no key" badges.
5060
+ const models = {};
5061
+ try {
5062
+ for (const m of await listModels()) {
5063
+ const r = modelSlot(m.id);
5064
+ if (r) models[m.id] = r.slot ? { slot: r.slot } : r.keyless ? { keyless: true } : { error: r.error };
5065
+ }
5066
+ } catch { /* badges are a nicety: an unreadable catalog leaves them off */ }
5067
+ const base = { enabled: true, mode: info.mode, keyPage: info.publicUrl || null, models };
5068
+ if (info.mode !== 'multi') return res.json({ ...base, person: null, slots: (await personSlots('local')).slots });
5069
+ if (!isSharedIdentity(who.source)) return res.json({ ...base, person: null, slots: [] });
5070
+ const r = await personSlots(who.name);
5071
+ res.json({ ...base, person: r.person, slots: r.slots });
5072
+ } catch (err) {
5073
+ res.status(502).json({ enabled: true, error: err && err.message ? err.message : String(err) });
5074
+ }
5075
+ });
5076
+
5077
+ // ── Ask Worca tool relay (agent users, agent-pool.mjs) ───────────────────────────────
5078
+ // When agents run under their own users, a chat's claude runs as the person's agent user,
5079
+ // which cannot read worca's database. Its MCP child then only relays each JSON-RPC line
5080
+ // here; the worca tools run in this process (createAskToolServer), in the chat owner's
5081
+ // billing context. One token per turn, loopback callers only, dropped when the turn ends.
5082
+ const askRelays = new Map(); // token -> { rpc, out, billTo, owner }
5083
+
5084
+ function askAgentRelay({ threadId, reader, web = null }) {
5085
+ const token = randomBytes(24).toString('base64url');
5086
+ const life = new AbortController();
5087
+ const entry = { out: [], billTo: currentBillTo(), owner: currentOwner() };
5088
+ // The tools run here, so this turn's web access rides a private env copy (never process.env itself);
5089
+ // the search key is read from worca's own environment, where it was set.
5090
+ const env = { ...process.env, ...askWebMcpEnv(web) };
5091
+ entry.rpc = createAskToolServer({ threadId, reader, signal: life.signal, env, write: (s) => { entry.out.push(s); } });
5092
+ askRelays.set(token, entry);
5093
+ const port = server.address()?.port || PORT;
5094
+ return { url: `http://127.0.0.1:${port}/api/ask/relay`, token, dispose: () => { life.abort(); askRelays.delete(token); } };
5095
+ }
5096
+
5097
+ app.post('/api/ask/relay', async (req, res) => {
5098
+ if (!isInContainer(req)) return res.status(403).json({ error: 'relay: loopback callers only' });
5099
+ const entry = askRelays.get(String(req.headers['x-worca-relay'] || ''));
5100
+ if (!entry) return res.status(403).json({ error: 'relay: unknown or finished turn' });
5101
+ const line = req.body && typeof req.body.line === 'string' ? req.body.line : null;
5102
+ if (line === null) return badRequest(res, 'relay: line is required');
5103
+ // One line at a time per turn (the child sends sequentially), so the output collected
5104
+ // between feed and idle is this line's answer.
5105
+ await withBillTo(entry.billTo, async () => {
5106
+ entry.out = [];
5107
+ await entry.rpc.feed(line);
5108
+ await entry.rpc.idle();
5109
+ }, { owner: entry.owner });
5110
+ res.json({ out: entry.out });
5111
+ });
5112
+
5113
+ /**
5114
+ * Credential-broker preflight for starting work with known models (an Ask message, a run
5115
+ * started with an explicit model): refused up front, naming each missing key and the key
5116
+ * page, instead of the first spawn failing. In single mode the one set of keys is checked.
5117
+ * Never blocks on a broker that can't be asked.
5118
+ * @param {string[]} modelIds
5119
+ * @returns {Promise<string|null>} the refusal, or null to proceed
5120
+ */
5121
+ async function brokerStartRefusal(req, modelIds) {
5122
+ if (!brokerEnabled() || !modelIds || !modelIds.length) return null;
5123
+ try {
5124
+ const info = await brokerInfo();
5125
+ let person = 'local';
5126
+ if (info.mode === 'multi') {
5127
+ const who = resolveIdentity(req);
5128
+ if (!isSharedIdentity(who.source)) return null;
5129
+ person = who.name;
5130
+ }
5131
+ const r = missingCredentials(modelIds, modelSlot, (await personSlots(person)).slots || []);
5132
+ if (!r.missing.length && !r.errors.length) return null;
5133
+ const one = r.missing.length === 1 && !r.errors.length ? r.missing[0] : null;
5134
+ const where = info.publicUrl ? ` Add it on the key page (${info.publicUrl}), then try again.` : ' Add it to the credential broker, then try again.';
5135
+ if (one) {
5136
+ return one.state === 'invalid'
5137
+ ? `Your ${one.label} was rejected by the provider.${where}`
5138
+ : `You haven't added your ${one.label} yet, and ${one.models.join(', ')} needs it.${where}`;
5139
+ }
5140
+ return `Can't start: ${describeMissing(r, info.publicUrl)}`;
5141
+ } catch {
5142
+ return null;
5143
+ }
5144
+ }
5145
+
4941
5146
  /** Constant-time bearer check; `expected` is the boot-time token from ui.json. */
4942
5147
  function bearerMatches(header, expected) {
4943
5148
  if (!expected || typeof header !== 'string') return false;
@@ -4980,6 +5185,7 @@ app.post('/api/settings', async (req, res) => {
4980
5185
  const hasBudgetKey = has('pipelineCostLimitUsd') || has('totalCostLimitUsd') || has('costLimitResetPeriod');
4981
5186
  const hasHumanRateKey = has('humanRateUsdPerHour');
4982
5187
  const hasAskKey = has('askMaxTurns') || has('askMaxBudgetUsd');
5188
+ const hasAskWeb = has('askWeb'); // null clears back to unset (a team default applies)
4983
5189
  const hasDebugSpawnKey = has('debugSpawnEnabled');
4984
5190
  const hasTitleModelKey = has('titleModel');
4985
5191
  const hasHideBuiltinKey = has('hideBuiltinModels');
@@ -5016,6 +5222,7 @@ app.post('/api/settings', async (req, res) => {
5016
5222
  assertCostLimitInputs(budget);
5017
5223
  if (hasHumanRateKey) assertHumanRateInput(body.humanRateUsdPerHour ?? '');
5018
5224
  assertAskLimitInputs(ask);
5225
+ if (hasAskWeb && body.askWeb !== null) assertAskWebInput(body.askWeb);
5019
5226
  if (hasDebugSpawnKey) assertDebugSpawnInput(body.debugSpawnEnabled);
5020
5227
  if (hasTitleModelKey) {
5021
5228
  assertTitleModelInput(titleModelInput);
@@ -5046,6 +5253,7 @@ app.post('/api/settings', async (req, res) => {
5046
5253
  if (hasHumanRateKey) await setHumanRateUsdPerHour(body.humanRateUsdPerHour ?? '');
5047
5254
  if (has('askMaxTurns')) await setAskMaxTurns(ask.askMaxTurns);
5048
5255
  if (has('askMaxBudgetUsd')) await setAskMaxBudgetUsd(ask.askMaxBudgetUsd);
5256
+ if (hasAskWeb) await setAskWeb(body.askWeb);
5049
5257
  if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
5050
5258
  if (hasTitleModelKey) await setTitleModel(titleModelInput);
5051
5259
  if (hasHideBuiltinKey) await setHideBuiltinModels(body.hideBuiltinModels);
@@ -5057,7 +5265,7 @@ app.post('/api/settings', async (req, res) => {
5057
5265
  if (hasBudgetKey) emitChanged('budget-changed');
5058
5266
  // Other open tabs repaint their Settings cards (a stale tab could otherwise
5059
5267
  // "save" its old checkbox state over this one with no feedback to either).
5060
- if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey || hasThemeKey || hasUiLevelKey || hasAutoKey || hasHumanRateKey || hasMemoryDefragKey || has('schedule')) emitChanged('settings-changed');
5268
+ if (hasAskKey || hasAskWeb || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey || hasThemeKey || hasUiLevelKey || hasAutoKey || hasHumanRateKey || hasMemoryDefragKey || has('schedule')) emitChanged('settings-changed');
5061
5269
  res.json({ ...settingsState(), ...(await autoModelState()), chat: chatPrefs() });
5062
5270
  } catch (err) {
5063
5271
  // The setters throw only on an unusable path -> client error (400).
@@ -6276,21 +6484,32 @@ app.get('/api/ask/runs/:id', (req, res) => {
6276
6484
  }
6277
6485
  });
6278
6486
 
6279
- app.patch('/api/ask/threads/:id', (req, res) => {
6487
+ app.patch('/api/ask/threads/:id', async (req, res) => {
6280
6488
  const id = askIdParam(res, req.params.id, 'thread');
6281
6489
  if (!id) return;
6282
6490
  try {
6283
6491
  const body = req.body || {};
6284
6492
  const patch = {};
6285
- // Title keeps its original contract exactly: a PATCH that names neither field
6286
- // still earns the title error, so pre-#397 callers see identical behaviour.
6287
- if (body.title !== undefined || body.scope === undefined) {
6493
+ // The Ask panel's model · effort picker, moved while this chat is open: the pair
6494
+ // travels together, so a switch away and back finds it before any send.
6495
+ const pick = body.model !== undefined || body.effort !== undefined;
6496
+ // Title keeps its original contract exactly: a PATCH that names none of the
6497
+ // fields still earns the title error, so pre-#397 callers see identical behaviour.
6498
+ if (body.title !== undefined || (body.scope === undefined && !pick)) {
6288
6499
  const raw = body.title;
6289
6500
  if (typeof raw !== 'string' || !raw.trim() || raw.length > 120) {
6290
6501
  return badRequest(res, 'title must be a non-empty string of at most 120 characters');
6291
6502
  }
6292
6503
  patch.title = raw.trim();
6293
6504
  }
6505
+ if (pick) {
6506
+ // The same check as the message POST. Awaited BEFORE the scope branch, so its
6507
+ // read-modify-write of the stored context stays synchronous.
6508
+ const mv = await validateModelEffort(body.model, body.effort);
6509
+ if (!mv.ok) return badRequest(res, mv.error);
6510
+ patch.model = mv.model;
6511
+ patch.effort = mv.effort;
6512
+ }
6294
6513
  if (body.scope !== undefined) {
6295
6514
  // #397: the Ask panel's scope selector. Merged per field into the stored
6296
6515
  // context — the pin replaces only the target keys, so the last page
@@ -6493,11 +6712,24 @@ function askValidateScope(raw) {
6493
6712
  return { ok: true, scope: { pinned: true, [keys[0]]: cv.context[keys[0]] } };
6494
6713
  }
6495
6714
 
6715
+ /** One chat turn's web access: settings ⊕ the pinned project's team policy ⊕ the hosts this chat's web
6716
+ * cards allowed "for this chat". Never throws: a failure reads as off. */
6717
+ function askWebAccessFor(threadId, ctx) {
6718
+ try {
6719
+ const pinned = askPinnedScope(ctx || {});
6720
+ return askWebAccess({ projectKey: pinned?.projectKey || null, chatHosts: chatWebHosts(askListMessages(threadId)) });
6721
+ } catch (err) {
6722
+ console.warn(`[worca-ask] web access resolve failed (${err?.message || err}) — web stays off`);
6723
+ return WEB_OFF;
6724
+ }
6725
+ }
6726
+
6496
6727
  /** The system prompt of ONE Ask turn: the rules, the catalog, and — only when the chat's
6497
6728
  * "Create and run scripts" pref is on (W20) — the scripts section with the runtimes this host
6498
- * actually has (the python probe, cached 60 s). Memory is mounted, not rendered. */
6499
- async function askSystemPromptFor(catalog) {
6500
- return askBuildSystemPrompt(catalog, { scripts: await askScriptPromptInput(), deployment: DEPLOYMENT });
6729
+ * actually has (the python probe, cached 60 s); plus the web section when `web` (askWebAccess()
6730
+ * for this turn) is on. Memory is mounted, not rendered. */
6731
+ async function askSystemPromptFor(catalog, { web = null } = {}) {
6732
+ return askBuildSystemPrompt(catalog, { scripts: await askScriptPromptInput(), deployment: DEPLOYMENT, web });
6501
6733
  }
6502
6734
 
6503
6735
  /** "scheduled Sat Sep 19, 02:00 (run 1a2b…)" / "repeats: Every weekday at 02:00 (sch_…)" / "proposes: …" — or ''. */
@@ -6618,7 +6850,7 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
6618
6850
  // workflowId once the user saved it; a run card keeps its pre-P3 line byte for byte.
6619
6851
  const wf = !!(b.card && b.card.type === 'workflow');
6620
6852
  if (wf && b.state === 'building') continue; // transient (no name yet) — never worth a header line
6621
- if (b.card && (b.card.type === 'metrics' || b.card.type === 'policy' || b.card.type === 'clone')) {
6853
+ if (b.card && (b.card.type === 'metrics' || b.card.type === 'policy' || b.card.type === 'clone' || b.card.type === 'web')) {
6622
6854
  cards.push({ id: b.id, type: b.card.type, state: b.state, summary: b.card.summary || '' });
6623
6855
  continue;
6624
6856
  }
@@ -6756,7 +6988,11 @@ async function startAskTurn({ threadId: id, thread, ctx, model, effort, text, fi
6756
6988
  const withText = attRows.map((a, i) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime, text: files[i].text }));
6757
6989
  const { inline, listed } = askSelectInlineAttachments(withText);
6758
6990
  const headerCtx = await resolveAskContext(id, ctx, listed, userMsg.id, { signedIn });
6759
- const systemPrompt = await askSystemPromptFor(catalog);
6991
+ // Web access (docs/guardrails.md "Web access"): resolved ONCE per turn — local settings ⊕ the pinned project's
6992
+ // team policy — so the prompt section, the sub-agent note and the MCP child's tools agree.
6993
+ const pinned = askPinnedScope(ctx);
6994
+ const web = askWebAccessFor(id, ctx);
6995
+ const systemPrompt = await askSystemPromptFor(catalog, { web });
6760
6996
  const header = askBuildContextHeader(headerCtx);
6761
6997
  const prompt = askBuildTurnPrompt(header, text, inline);
6762
6998
  const prior = askListMessages(id).filter((m) => m.seq < userMsg.seq);
@@ -6774,12 +7010,16 @@ async function startAskTurn({ threadId: id, thread, ctx, model, effort, text, fi
6774
7010
  firstTurn: !synthetic && userMsg.seq === 1 && titleWasAuto, // P3: an event never titles the thread (D13 guard kept)
6775
7011
  firstText: text,
6776
7012
  deterministicTitle,
6777
- pinnedScope: askPinnedScope(ctx), // #397: proposal defaulting + mismatch flag
7013
+ pinnedScope: pinned, // #397: proposal defaulting + mismatch flag
7014
+ web,
6778
7015
  timeZone: ctx.timeZone || (thread.context && thread.context.timeZone) || null, // scheduled runs: the user's clock
6779
7016
  memoryProject: headerCtx.project ? { key: headerCtx.project.key, name: headerCtx.project.name || '' } : null, // native-rules revision: the turn mounts global + this project through --add-dir
6780
7017
  mock: mockEnabled({}) ? { card: mockAskCard(ctx, text) } : null, // R-F
6781
7018
  attachmentNames,
6782
7019
  deps: {
7020
+ // Agents under their own users (agent-pool.mjs): the chat runs as the person's agent
7021
+ // user and its worca tools run here, through the relay. null = the classic MCP child.
7022
+ agentRelay: agentIdentity() ? askAgentRelay : null,
6783
7023
  onFrame: stampAskFrames(id, job),
6784
7024
  onOutOfTurn: (f) => broadcast({ ...f, threadId: id }),
6785
7025
  onCommentMutation: ({ runId }) => { emitDiffCommentsChanged(runId); },
@@ -6858,6 +7098,10 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
6858
7098
  if (!text.trim()) return badRequest(res, 'text is required');
6859
7099
  const mv = await validateModelEffort(body.model, body.effort);
6860
7100
  if (!mv.ok) return badRequest(res, mv.error);
7101
+ if (!mockEnabled({})) {
7102
+ const refusal = await brokerStartRefusal(req, [String(body.model)]);
7103
+ if (refusal) return res.status(409).json({ error: refusal, code: 'credential-missing' });
7104
+ }
6861
7105
  const cv = validateClientContext(body.context);
6862
7106
  if (!cv.ok) return badRequest(res, cv.error);
6863
7107
  // #397: explicit pin beats page context, per field. A context carrying its own
@@ -7058,9 +7302,9 @@ async function startMetricsEventTurn(threadId, block) {
7058
7302
  const state = block.state === 'declined' ? 'declined' : block.state === 'failed' ? 'failed' : 'applied';
7059
7303
  const result = card.result || null;
7060
7304
  // One event turn for every non-workflow card; the type picks the wording. Metrics is the fallback.
7061
- const kind = card.type === 'policy' || card.type === 'schedule' || card.type === 'model' || card.type === 'clone' ? card.type : 'metrics';
7062
- const eventPrompt = { policy: policyEventPrompt, schedule: scheduleEventPrompt, model: modelEventPrompt, clone: cloneEventPrompt, metrics: metricsEventPrompt }[kind];
7063
- const noticeText = { policy: policyNoticeText, schedule: scheduleNoticeText, model: modelNoticeText, clone: cloneNoticeText, metrics: metricsNoticeText }[kind];
7305
+ const kind = card.type === 'policy' || card.type === 'schedule' || card.type === 'model' || card.type === 'clone' || card.type === 'web' ? card.type : 'metrics';
7306
+ const eventPrompt = { policy: policyEventPrompt, schedule: scheduleEventPrompt, model: modelEventPrompt, clone: cloneEventPrompt, web: webEventPrompt, metrics: metricsEventPrompt }[kind];
7307
+ const noticeText = { policy: policyNoticeText, schedule: scheduleNoticeText, model: modelNoticeText, clone: cloneNoticeText, web: webNoticeText, metrics: metricsNoticeText }[kind];
7064
7308
  const text = eventPrompt({ cardId: block.id, state, card, result });
7065
7309
  const notice = noticeText({ state, card, result });
7066
7310
  let mv = await validateModelEffort(thread.model, thread.effort);
@@ -7162,6 +7406,38 @@ app.post('/api/ask/threads/:id/cards/:cardId', async (req, res) => {
7162
7406
  const turn = await startMetricsEventTurn(id, block);
7163
7407
  return res.json({ block, turn });
7164
7408
  }
7409
+ if (found.block.card && found.block.card.type === 'web') {
7410
+ // Web card (docs/guardrails.md "Web access"): proposed → applied | failed | declined. "For this chat" is
7411
+ // recorded on the card itself (chatWebHosts reads it back each turn); "always" adds the exact host to
7412
+ // settings. Re-checked HERE against the chat's current web access: off, or outside the team cap, fails.
7413
+ if (body.state !== 'applied' && body.state !== 'declined') return badRequest(res, 'state must be "applied" or "declined"');
7414
+ if (body.state === 'applied' && body.scope !== 'chat' && body.scope !== 'always') return badRequest(res, 'scope must be "chat" or "always"');
7415
+ if (found.block.state !== 'proposed') return res.status(409).json({ error: `card is ${found.block.state}` });
7416
+ if (askCardBusy.has(cardId)) return res.status(409).json({ error: 'card is being applied' });
7417
+ if (body.state === 'declined') {
7418
+ const block = flipCard(id, cardId, { state: 'declined' });
7419
+ if (!block) return res.status(409).json({ error: 'card vanished' });
7420
+ const turn = await startMetricsEventTurn(id, block);
7421
+ return res.json({ block, turn });
7422
+ }
7423
+ askCardBusy.add(cardId);
7424
+ let block;
7425
+ try {
7426
+ let result;
7427
+ try {
7428
+ const host = found.block.card.change?.host;
7429
+ const now = askWebAccessFor(id, askGetThread(id)?.context || {});
7430
+ if (!now.enabled) throw new Error('web access is off for this chat — switch it on in Settings → Ask Worca → Web access');
7431
+ if (Array.isArray(now.teamCap) && !askHostAllowed(host, now.teamCap)) throw new Error(`${host} is outside the team policy's web allowlist`);
7432
+ if (body.scope === 'always') { await addAskWebHost(host); emitChanged('settings-changed'); }
7433
+ result = { ok: true, scope: body.scope };
7434
+ } catch (err) { result = { ok: false, error: err && err.message ? err.message : String(err) }; }
7435
+ block = flipCard(id, cardId, result.ok ? { state: 'applied', card: { result } } : { state: 'failed', error: result.error, card: { result } });
7436
+ } finally { askCardBusy.delete(cardId); }
7437
+ if (!block) return res.status(409).json({ error: 'card vanished' });
7438
+ const turn = await startMetricsEventTurn(id, block);
7439
+ return res.json({ block, turn });
7440
+ }
7165
7441
  if (found.block.card && found.block.card.type === 'clone') {
7166
7442
  // Clone card (docs/deploy-railway.md "First project"): proposed → cloning → applied | failed, or declined.
7167
7443
  // The clone happens HERE, behind the click, as the same job the Projects view starts (startCloneJob);
@@ -7363,6 +7639,7 @@ app.post('/api/agents/generate', async (req, res) => {
7363
7639
  const allAgents = await listAgents();
7364
7640
  const byKey = Object.fromEntries(allAgents.map((m) => [m.key, m]));
7365
7641
  const pick = (keys) => (Array.isArray(keys) ? keys : []).map((k) => byKey[k]).filter(Boolean);
7642
+ if (await refuseSignedOutClaude(res)) return;
7366
7643
  const genId = startAgentGen({
7367
7644
  name, purpose: String(body.purpose || ''), details: String(body.details || ''),
7368
7645
  expectedBefore: pick(body.expectedBefore), expectedAfter: pick(body.expectedAfter),
@@ -8728,6 +9005,24 @@ if (isMain) {
8728
9005
  }
8729
9006
  for (const w of REMOTE_ACCESS_CHECK.warnings) console.warn(`[worca-ui] remote access: ${w}`);
8730
9007
 
9008
+ // Credential broker (docs/credential-broker.md): with WORCA_BROKER_URL set, worca holds no
9009
+ // model credential. Refuse to serve while one is still reachable by agents, or while the
9010
+ // broker can't be reached.
9011
+ {
9012
+ const shared = !!identityCheck || !!String(process.env.WORCA_IDENTITY_HEADER || '').trim();
9013
+ const b = await checkBrokerAtBoot({ shared, log: (l) => console.log(l) });
9014
+ for (const w of b.warnings) console.warn(`[worca-ui] credentials: ${w}`);
9015
+ if (b.fatal.length) {
9016
+ for (const e of b.fatal) console.error(e.startsWith('worca:') ? e : `[worca-ui] credentials: ${e}`);
9017
+ console.error('[worca-ui] not starting.');
9018
+ process.exit(78);
9019
+ }
9020
+ if (b.on) {
9021
+ const slots = (b.info?.slots || []).map((s) => s.id).join(', ');
9022
+ console.log(`[worca-ui] credentials: broker ${b.info?.mode}${b.info?.publicUrl ? `, key page ${b.info.publicUrl}` : ''}, slots ${slots}`);
9023
+ }
9024
+ }
9025
+
8731
9026
  try {
8732
9027
  seedBuiltinMarketplace();
8733
9028
  } catch (err) {
@@ -8819,10 +9114,11 @@ export { app, server, runs };
8819
9114
  export const _testing = {
8820
9115
  wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen, wireScriptBench, startScriptBench,
8821
9116
  chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork, answerRun,
8822
- chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
9117
+ chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard, askWebAccessFor,
8823
9118
  startCloneJob, followCloneCard, CLONE_JOBS,
8824
9119
  emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
8825
9120
  askTrackRun, liveRunEntry, liveDefragRun, memoryScopeKey, startRunHandler, emitMemoryChanged, askSystemPromptFor,
8826
9121
  uiControl, bearerMatches,
8827
9122
  broadcast, askFilesRunDir,
9123
+ trackHeartbeat, heartbeatTick, BOOT_ID,
8828
9124
  };