talon-agent 3.33.3 → 3.34.0

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 (75) hide show
  1. package/package.json +6 -5
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/stream.ts +76 -53
  4. package/src/backend/remote-server/turn.ts +58 -55
  5. package/src/bootstrap.ts +120 -79
  6. package/src/cli/setup.ts +375 -349
  7. package/src/core/background/cron-spec.ts +273 -0
  8. package/src/core/background/heartbeat/agent.ts +167 -104
  9. package/src/core/engine/backend-controller/pool.ts +2 -0
  10. package/src/core/engine/backend-controller/state.ts +25 -12
  11. package/src/core/engine/gateway-actions/cron.ts +28 -292
  12. package/src/core/engine/gateway-routes.ts +239 -0
  13. package/src/core/engine/gateway.ts +66 -238
  14. package/src/core/vfs/mounts/files.ts +128 -115
  15. package/src/core/weaver/shuttle.ts +29 -2
  16. package/src/core/weaver/weaver.ts +37 -6
  17. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  18. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  19. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  20. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  21. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  22. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  23. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  24. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  25. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  26. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  27. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  28. package/src/frontend/discord/callbacks/index.ts +4 -4
  29. package/src/frontend/discord/connection.ts +36 -0
  30. package/src/frontend/discord/diagnostics.ts +62 -0
  31. package/src/frontend/discord/guild-policy.ts +89 -0
  32. package/src/frontend/discord/index.ts +44 -305
  33. package/src/frontend/discord/outbound.ts +61 -0
  34. package/src/frontend/discord/ready.ts +73 -0
  35. package/src/frontend/discord/runtime.ts +55 -0
  36. package/src/frontend/native/chat-lifecycle.ts +39 -0
  37. package/src/frontend/native/chat-wire.ts +69 -0
  38. package/src/frontend/native/context.ts +106 -0
  39. package/src/frontend/native/control.ts +78 -0
  40. package/src/frontend/native/emit.ts +167 -0
  41. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  42. package/src/frontend/native/handlers.ts +121 -0
  43. package/src/frontend/native/history.ts +101 -0
  44. package/src/frontend/native/index.ts +90 -1293
  45. package/src/frontend/native/media.ts +43 -0
  46. package/src/frontend/native/models.ts +221 -0
  47. package/src/frontend/native/queue.ts +47 -0
  48. package/src/frontend/native/reset.ts +50 -0
  49. package/src/frontend/native/routes/chats.ts +115 -0
  50. package/src/frontend/native/routes/daemon.ts +70 -0
  51. package/src/frontend/native/routes/host.ts +151 -0
  52. package/src/frontend/native/routes/index.ts +22 -0
  53. package/src/frontend/native/routes/mesh.ts +72 -0
  54. package/src/frontend/native/routes/models.ts +54 -0
  55. package/src/frontend/native/routes/params.ts +29 -0
  56. package/src/frontend/native/routes/pre-auth.ts +94 -0
  57. package/src/frontend/native/routes/table.ts +92 -0
  58. package/src/frontend/native/runtime.ts +109 -0
  59. package/src/frontend/native/server.ts +30 -555
  60. package/src/frontend/native/status.ts +26 -0
  61. package/src/frontend/native/tool-result.ts +48 -0
  62. package/src/frontend/native/turn.ts +341 -0
  63. package/src/frontend/whatsapp/access.ts +67 -0
  64. package/src/frontend/whatsapp/connection.ts +280 -0
  65. package/src/frontend/whatsapp/inbound.ts +327 -0
  66. package/src/frontend/whatsapp/index.ts +28 -599
  67. package/src/frontend/whatsapp/runtime.ts +74 -0
  68. package/src/plugins/github/provision.ts +1 -1
  69. package/src/storage/metrics.ts +18 -0
  70. package/src/storage/session-record.ts +29 -0
  71. package/src/storage/sessions.ts +24 -0
  72. package/src/util/boot-timer.ts +31 -0
  73. package/src/util/concurrency.ts +28 -0
  74. package/src/frontend/discord/callbacks/components.ts +0 -793
  75. package/src/frontend/discord/callbacks/shared.ts +0 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.33.3",
3
+ "version": "3.34.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -68,8 +68,8 @@
68
68
  "test:integration": "vitest run --reporter=verbose --reporter=json --outputFile=integration-results.json src/__tests__/integration/talon-mcp-functional.test.ts",
69
69
  "test:integration:all": "vitest run --reporter=verbose src/__tests__/integration/",
70
70
  "test:claude:backend": "vitest run --reporter=verbose --reporter=json --outputFile=claude-backend-results.json src/__tests__/integration/claude-live-discovery.test.ts",
71
- "test:kilo:backend": "vitest run --reporter=verbose --reporter=json --outputFile=kilo-backend-results.json src/__tests__/integration/kilo-live-discovery.test.ts",
72
- "test:opencode:backend": "vitest run --reporter=verbose --reporter=json --outputFile=opencode-backend-results.json src/__tests__/integration/opencode-live-discovery.test.ts",
71
+ "test:kilo:backend": "vitest run --reporter=verbose --reporter=json --outputFile=kilo-backend-results.json src/__tests__/integration/kilo-live-discovery.test.ts src/__tests__/integration/kilo-real-bootstrap.test.ts",
72
+ "test:opencode:backend": "vitest run --reporter=verbose --reporter=json --outputFile=opencode-backend-results.json src/__tests__/integration/opencode-live-discovery.test.ts src/__tests__/integration/opencode-real-bootstrap.test.ts",
73
73
  "test:codex:backend": "vitest run --reporter=verbose --reporter=json --outputFile=codex-backend-results.json src/__tests__/integration/codex-live-discovery.test.ts",
74
74
  "test:openai-agents:backend": "vitest run --reporter=verbose --reporter=json --outputFile=openai-agents-backend-results.json src/__tests__/integration/openai-agents-live-discovery.test.ts",
75
75
  "build:sql": "tsx scripts/embed-sql.ts",
@@ -83,6 +83,7 @@
83
83
  "lint": "oxlint src/",
84
84
  "depcruise": "node scripts/check-architecture.mjs",
85
85
  "ratchets": "node scripts/check-ratchets.mjs",
86
+ "function-size": "node scripts/check-function-size.mjs",
86
87
  "knip": "knip",
87
88
  "format": "prettier --write src/ prompts/",
88
89
  "format:check": "prettier --check src/ prompts/",
@@ -142,14 +143,14 @@
142
143
  "@types/node": "^26.0.0",
143
144
  "@types/qrcode-terminal": "^0.12.2",
144
145
  "@types/write-file-atomic": "^4.0.3",
145
- "@vitest/coverage-v8": "^4.1.3",
146
+ "@vitest/coverage-v8": "^5.0.0",
146
147
  "dependency-cruiser": "^18.1.0",
147
148
  "fast-check": "^4.6.0",
148
149
  "knip": "^6.3.1",
149
150
  "oxlint": "^1.59.0",
150
151
  "prettier": "^3.8.1",
151
152
  "typescript": "^7.0.2",
152
- "vitest": "^4.1.3"
153
+ "vitest": "^5.0.0"
153
154
  },
154
155
  "overrides": {
155
156
  "@anthropic-ai/sdk": "^0.104.1",
package/src/app.ts CHANGED
@@ -27,6 +27,7 @@ import { pruneSettledTriggers } from "./storage/trigger-store.js";
27
27
  import { startWatchdog, stopWatchdog } from "./util/watchdog.js";
28
28
  import { spawnSuccessor } from "./util/respawn.js";
29
29
  import { log, logError, logWarn } from "./util/log.js";
30
+ import { bootPhase, bootReport } from "./util/boot-timer.js";
30
31
  import {
31
32
  getVfs,
32
33
  mountNamespaceFs,
@@ -51,7 +52,7 @@ import {
51
52
  removePidRecordIfOwnedBy,
52
53
  } from "./core/daemon/pidfile.js";
53
54
 
54
- const { config } = await bootstrap();
55
+ const { config } = await bootPhase("bootstrap", () => bootstrap());
55
56
 
56
57
  // Record this process as the daemon. The gateway port is appended once
57
58
  // the gateway binds (it may fall back from the default on EADDRINUSE).
@@ -69,15 +70,19 @@ gateway.onShutdownRequest((reason) => void gracefulShutdown(reason));
69
70
  const configuredFrontends = [...new Set(getFrontends(config))];
70
71
 
71
72
  const frontends: Frontend[] = [];
72
- for (const name of configuredFrontends) {
73
- const frontend = await createFrontendById(name, config, gateway);
74
- frontends.push(frontend);
75
- log("bot", `Frontend: ${getFrontendDescriptor(name)?.label ?? name}`);
76
- }
73
+ await bootPhase("frontends create", async () => {
74
+ for (const name of configuredFrontends) {
75
+ const frontend = await createFrontendById(name, config, gateway);
76
+ frontends.push(frontend);
77
+ log("bot", `Frontend: ${getFrontendDescriptor(name)?.label ?? name}`);
78
+ }
79
+ });
77
80
 
78
81
  // ── Create backend + wire dispatcher ─────────────────────────────────────────
79
82
 
80
- const { backend } = await initBackendAndDispatcher(config, frontends);
83
+ const { backend } = await bootPhase("backend + dispatcher", () =>
84
+ initBackendAndDispatcher(config, frontends),
85
+ );
81
86
  gateway.backend = backend;
82
87
 
83
88
  // Subscribe the gateway to chat-role rebinds so `/model`, `/settings`,
@@ -303,7 +308,10 @@ async function main(): Promise<void> {
303
308
  "Terminal frontend shares stdin with the other frontends; it will run alongside them without blocking startup.",
304
309
  );
305
310
  }
306
- await Promise.all(blockingFrontends.map((frontend) => frontend.start()));
311
+ await bootPhase("frontends start", () =>
312
+ Promise.all(blockingFrontends.map((frontend) => frontend.start())),
313
+ );
314
+ log("bot", `Ready in ${bootReport()}`);
307
315
  for (const frontend of stdinFrontends) {
308
316
  void frontend
309
317
  .start()
@@ -334,6 +334,79 @@ export function extractToolResults(msg: SDKUserMessage): ToolResultInfo[] {
334
334
  return results;
335
335
  }
336
336
 
337
+ /** Context fill of the last API iteration + whether the turn's first request hit the cache. */
338
+ function readContextFill(
339
+ state: StreamState,
340
+ usage: SDKResultMessage["usage"],
341
+ ): void {
342
+ if (
343
+ !usage ||
344
+ !Array.isArray(usage.iterations) ||
345
+ usage.iterations.length === 0
346
+ )
347
+ return;
348
+ const last = usage.iterations[usage.iterations.length - 1];
349
+ state.contextTokens =
350
+ (last.input_tokens ?? 0) +
351
+ (last.cache_read_input_tokens ?? 0) +
352
+ (last.cache_creation_input_tokens ?? 0);
353
+ // The same array answers a question the per-turn totals can't: whether
354
+ // the FIRST request of this turn read a cache or paid to write one.
355
+ state.cacheStats = turnCacheStats(usage.iterations);
356
+ }
357
+
358
+ /**
359
+ * Token counts from the ACTIVE model's usage only. modelUsage is keyed by
360
+ * the exact SDK model string (e.g. "sonnet[1m]") and holds cumulative
361
+ * session totals per model — summing all entries double-counts when
362
+ * switching models mid-session.
363
+ */
364
+ function readModelUsage(
365
+ state: StreamState,
366
+ modelUsage: Record<string, ModelUsage>,
367
+ sdkModel: string,
368
+ ): void {
369
+ // Drift check: modelUsage is keyed by the model that ACTUALLY served
370
+ // the turn. If none of the keys is (an alias of) the model we asked
371
+ // for, the API substituted — warn instead of burying it in the
372
+ // accounting line.
373
+ checkModelDrift(sdkModel, Object.keys(modelUsage ?? {}));
374
+ const mu = modelUsage[sdkModel] ?? Object.values(modelUsage).at(-1);
375
+ if (!mu) return;
376
+ state.sdkInputTokens = mu.inputTokens ?? 0;
377
+ state.sdkOutputTokens = mu.outputTokens ?? 0;
378
+ state.sdkCacheRead = mu.cacheReadInputTokens ?? 0;
379
+ state.sdkCacheWrite = mu.cacheCreationInputTokens ?? 0;
380
+ if (mu.contextWindow > 0) {
381
+ state.contextWindow = mu.contextWindow;
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Failed turn. Two shapes (see StreamState.resultErrorText):
387
+ * - error subtype: no `result` field, but `errors[]` carries diagnostics.
388
+ * - success subtype with is_error: `result` (and the trailing assistant
389
+ * text) IS the API error message, e.g. "You've hit your weekly limit
390
+ * · resets Jul 10, 9am". Prefer that text — it's the user-facing one.
391
+ */
392
+ function readResultError(msg: SDKResultMessage, state: StreamState): void {
393
+ if (msg.subtype !== "success") {
394
+ const diagnostics = (msg.errors ?? [])
395
+ .filter((e) => typeof e === "string" && !e.startsWith("[ede_diagnostic]"))
396
+ .join("; ")
397
+ .slice(0, 500);
398
+ state.resultErrorText =
399
+ state.lastTrailingText.trim() ||
400
+ diagnostics ||
401
+ `Claude SDK turn failed (${msg.subtype})`;
402
+ } else if (msg.is_error) {
403
+ state.resultErrorText =
404
+ (typeof msg.result === "string" && msg.result.trim()) ||
405
+ state.lastTrailingText.trim() ||
406
+ "Claude SDK turn failed (API error)";
407
+ }
408
+ }
409
+
337
410
  /**
338
411
  * Process the final result message — extracts token counts, context info,
339
412
  * and API call counts from the typed SDK result.
@@ -350,39 +423,8 @@ export function processResultMessage(
350
423
  state.costUsd = msg.total_cost_usd;
351
424
  }
352
425
 
353
- // Context fill from last API iteration
354
- const usage = msg.usage;
355
- if (usage && Array.isArray(usage.iterations) && usage.iterations.length > 0) {
356
- const last = usage.iterations[usage.iterations.length - 1];
357
- state.contextTokens =
358
- (last.input_tokens ?? 0) +
359
- (last.cache_read_input_tokens ?? 0) +
360
- (last.cache_creation_input_tokens ?? 0);
361
- // The same array answers a question the per-turn totals can't: whether
362
- // the FIRST request of this turn read a cache or paid to write one.
363
- state.cacheStats = turnCacheStats(usage.iterations);
364
- }
365
-
366
- // Read token counts from the ACTIVE model's usage only.
367
- // modelUsage is keyed by the exact SDK model string (e.g. "sonnet[1m]")
368
- // and contains cumulative session totals per model — summing all entries
369
- // double-counts when switching models mid-session.
370
- const modelUsage: Record<string, ModelUsage> = msg.modelUsage;
371
- // Drift check: modelUsage is keyed by the model that ACTUALLY served
372
- // the turn. If none of the keys is (an alias of) the model we asked
373
- // for, the API substituted — warn instead of burying it in the
374
- // accounting line.
375
- checkModelDrift(sdkModel, Object.keys(modelUsage ?? {}));
376
- const mu = modelUsage[sdkModel] ?? Object.values(modelUsage).at(-1);
377
- if (mu) {
378
- state.sdkInputTokens = mu.inputTokens ?? 0;
379
- state.sdkOutputTokens = mu.outputTokens ?? 0;
380
- state.sdkCacheRead = mu.cacheReadInputTokens ?? 0;
381
- state.sdkCacheWrite = mu.cacheCreationInputTokens ?? 0;
382
- if (mu.contextWindow > 0) {
383
- state.contextWindow = mu.contextWindow;
384
- }
385
- }
426
+ readContextFill(state, msg.usage);
427
+ readModelUsage(state, msg.modelUsage, sdkModel);
386
428
 
387
429
  log(
388
430
  "agent",
@@ -400,26 +442,7 @@ export function processResultMessage(
400
442
  state.currentBlockText = msg.result;
401
443
  }
402
444
 
403
- // Failed turn. Two shapes (see StreamState.resultErrorText):
404
- // - error subtype: no `result` field, but `errors[]` carries diagnostics.
405
- // - success subtype with is_error: `result` (and the trailing assistant
406
- // text) IS the API error message, e.g. "You've hit your weekly limit
407
- // · resets Jul 10, 9am". Prefer that text — it's the user-facing one.
408
- if (msg.subtype !== "success") {
409
- const diagnostics = (msg.errors ?? [])
410
- .filter((e) => typeof e === "string" && !e.startsWith("[ede_diagnostic]"))
411
- .join("; ")
412
- .slice(0, 500);
413
- state.resultErrorText =
414
- state.lastTrailingText.trim() ||
415
- diagnostics ||
416
- `Claude SDK turn failed (${msg.subtype})`;
417
- } else if (msg.is_error) {
418
- state.resultErrorText =
419
- (typeof msg.result === "string" && msg.result.trim()) ||
420
- state.lastTrailingText.trim() ||
421
- "Claude SDK turn failed (API error)";
422
- }
445
+ readResultError(msg, state);
423
446
  }
424
447
 
425
448
  // ── Trailing-text fallback dedup ────────────────────────────────────────────
@@ -241,6 +241,60 @@ interface SubscribeInputs {
241
241
  abortSignal: AbortSignal;
242
242
  }
243
243
 
244
+ /** The SSE wire format wraps every event in `{payload: {type, properties}}`. */
245
+ function unwrapSseEvent(
246
+ evt: unknown,
247
+ ): { type?: string; properties?: Record<string, unknown> } | undefined {
248
+ if (!evt || typeof evt !== "object") return undefined;
249
+ const payload =
250
+ "payload" in evt ? (evt as { payload?: unknown }).payload : evt;
251
+ if (!payload || typeof payload !== "object") return undefined;
252
+ return payload as { type?: string; properties?: Record<string, unknown> };
253
+ }
254
+
255
+ /**
256
+ * A `session.error` on the global stream: ignored when it belongs to
257
+ * another session (a heartbeat's error must not pollute this chat's log),
258
+ * stashed as the turn's synthetic error otherwise. Returns whether the
259
+ * event was ours — ours ends the turn, since the server produces nothing
260
+ * more for this prompt.
261
+ */
262
+ function noteSessionError(
263
+ props: Record<string, unknown>,
264
+ inputs: Pick<SubscribeInputs, "label" | "sessionId" | "state" | "chatId">,
265
+ ): "ours" | "other" {
266
+ const evtSessionID =
267
+ typeof props.sessionID === "string" ? props.sessionID : undefined;
268
+ if (evtSessionID && evtSessionID !== inputs.sessionId) return "other";
269
+ const errProp = props.error as
270
+ | { name?: string; message?: string; data?: Record<string, unknown> }
271
+ | undefined;
272
+ // MessageAbortedError is expected for an explicit user interrupt.
273
+ const isOurAbort =
274
+ inputs.state.turnTerminated &&
275
+ (errProp?.name === "MessageAbortedError" ||
276
+ /abort/i.test(errProp?.name ?? "") ||
277
+ /abort/i.test(errProp?.message ?? ""));
278
+ if (errProp && !isOurAbort) {
279
+ const detail = [
280
+ errProp.name && `name=${errProp.name}`,
281
+ errProp.message && `message=${errProp.message}`,
282
+ errProp.data && `data=${JSON.stringify(errProp.data)}`,
283
+ ]
284
+ .filter(Boolean)
285
+ .join(" ");
286
+ logWarn(
287
+ "agent",
288
+ `[${inputs.chatId}] ${inputs.label} session.error: ${detail}`,
289
+ );
290
+ // Stash the error message so the handler's delivery branch can
291
+ // surface it as `⚠️ <label>: <message>` instead of silence.
292
+ const msg = errProp.message ?? errProp.name;
293
+ if (msg) inputs.state.syntheticError = msg;
294
+ }
295
+ return "ours";
296
+ }
297
+
244
298
  /**
245
299
  * Subscribe to the server's global SSE event stream and translate relevant
246
300
  * events into stream-state mutations / callback firings. Only events scoped
@@ -271,61 +325,12 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
271
325
  try {
272
326
  for await (const evt of stream) {
273
327
  if (abortSignal.aborted) break;
274
- if (!evt || typeof evt !== "object") continue;
275
-
276
- // The SSE wire format wraps every event in `{payload: {type,
277
- // properties}}`. Unwrap here so type/properties land where the rest
278
- // of the loop expects them.
279
- const payload =
280
- "payload" in evt ? (evt as { payload?: unknown }).payload : evt;
281
- if (!payload || typeof payload !== "object") continue;
282
- const event = payload as {
283
- type?: string;
284
- properties?: Record<string, unknown>;
285
- };
328
+ const event = unwrapSseEvent(evt);
329
+ if (!event) continue;
286
330
 
287
- // session.error is observed here for logging; everything else goes
288
- // through the shared pure helper. The SSE stream is global, so
289
- // scope-filter to our own sessionId before attributing the error to
290
- // this chat — a heartbeat session.error would otherwise pollute the
291
- // chat's log.
292
331
  if (event.type === "session.error") {
293
- const props = event.properties ?? {};
294
- const evtSessionID =
295
- typeof props.sessionID === "string" ? props.sessionID : undefined;
296
- if (evtSessionID && evtSessionID !== sessionId) {
297
- continue;
298
- }
299
- const errProp = props.error as
300
- | {
301
- name?: string;
302
- message?: string;
303
- data?: Record<string, unknown>;
304
- }
305
- | undefined;
306
- // MessageAbortedError is expected for an explicit user interrupt.
307
- const isOurAbort =
308
- state.turnTerminated &&
309
- (errProp?.name === "MessageAbortedError" ||
310
- /abort/i.test(errProp?.name ?? "") ||
311
- /abort/i.test(errProp?.message ?? ""));
312
- if (errProp && !isOurAbort) {
313
- const detail = [
314
- errProp.name && `name=${errProp.name}`,
315
- errProp.message && `message=${errProp.message}`,
316
- errProp.data && `data=${JSON.stringify(errProp.data)}`,
317
- ]
318
- .filter(Boolean)
319
- .join(" ");
320
- logWarn("agent", `[${chatId}] ${label} session.error: ${detail}`);
321
- // Stash the error message so the handler's delivery branch can
322
- // surface it as `⚠️ <label>: <message>` instead of silence.
323
- const msg = errProp.message ?? errProp.name;
324
- if (msg) state.syntheticError = msg;
325
- }
326
- // session.error for OUR session ends the turn — the server isn't
327
- // going to produce more events for this prompt.
328
- return;
332
+ if (noteSessionError(event.properties ?? {}, inputs) === "ours") return;
333
+ continue;
329
334
  }
330
335
 
331
336
  const outcome = await processStreamEvent(event, {
@@ -338,7 +343,6 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
338
343
  onTextBlock,
339
344
  onToolUse,
340
345
  });
341
-
342
346
  if (outcome.kind === "terminator_fired") {
343
347
  // tool_calls counter increment happens per-tool inside
344
348
  // events.ts processPartUpdate. Don't double-count here.
@@ -347,7 +351,6 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
347
351
  // Delivery is already complete, so wait for natural idle instead.
348
352
  continue;
349
353
  }
350
-
351
354
  if (outcome.kind === "stop") {
352
355
  if (outcome.reason === "out_of_scope") continue;
353
356
  return; // turn.close or idle — stop iterating
package/src/bootstrap.ts CHANGED
@@ -35,6 +35,8 @@ import {
35
35
  import { initDream, maybeStartDream } from "./core/background/dream.js";
36
36
  import { initHeartbeat } from "./core/background/heartbeat/index.js";
37
37
  import { log, logWarn, logDebug } from "./util/log.js";
38
+ import { bootPhase } from "./util/boot-timer.js";
39
+ import { mapConcurrent } from "./util/concurrency.js";
38
40
  import type { TalonConfig } from "./util/config.js";
39
41
  import { resolveFrontendIdAmong } from "./core/frontend-runtime/routing.js";
40
42
  import type { Frontend } from "./core/frontend-runtime/index.js";
@@ -122,11 +124,11 @@ export async function bootstrap(
122
124
  const frontends =
123
125
  options.frontendNames ??
124
126
  (Array.isArray(config.frontend) ? config.frontend : [config.frontend]);
125
- await loadPlugins(config.plugins, frontends);
127
+ await bootPhase("plugins", () => loadPlugins(config.plugins, frontends));
126
128
  }
127
129
 
128
130
  // Built-in plugins (GitHub, MemPalace, mem0, Playwright) — shared with hot-reload
129
- await loadBuiltinPlugins(config);
131
+ await bootPhase("builtin plugins", () => loadBuiltinPlugins(config));
130
132
 
131
133
  rebuildSystemPrompt(config, getPluginPromptAdditions());
132
134
  }
@@ -143,12 +145,14 @@ export async function bootstrap(
143
145
  });
144
146
 
145
147
  initWorkspace(config.workspace);
146
- loadSessions();
147
- loadChatSettings();
148
- loadCronJobs();
149
- loadTriggers();
150
- loadHistory();
151
- loadMediaIndex();
148
+ await bootPhase("stores", () => {
149
+ loadSessions();
150
+ loadChatSettings();
151
+ loadCronJobs();
152
+ loadTriggers();
153
+ loadHistory();
154
+ loadMediaIndex();
155
+ });
152
156
  cleanupOldLogs();
153
157
 
154
158
  return { config };
@@ -165,6 +169,104 @@ export async function bootstrap(
165
169
  * heartbeat all read through `getActiveBackend()` so a runtime swap
166
170
  * via `switchBackend(id, config)` propagates without any re-init.
167
171
  */
172
+ /** The backend-controller surface `reconcileChatBindings` needs. */
173
+ type ChatBindingDeps = {
174
+ isBackendAvailable: (id: string, config: TalonConfig) => boolean;
175
+ releaseChat: (chatId: string) => Promise<void>;
176
+ rebindChat: (
177
+ chatId: string,
178
+ backendId: string,
179
+ config: TalonConfig,
180
+ ) => Promise<{ ok: boolean; error?: string }>;
181
+ getBackendIdForChat: (chatId: string) => string;
182
+ getBackendForChat: (chatId: string) => Backend;
183
+ isModelValidForBackend: (backend: Backend, model: string) => Promise<boolean>;
184
+ };
185
+
186
+ const CHAT_BINDING_CONCURRENCY = 8;
187
+ const REBIND_RETRY_DELAY_MS = 1_500;
188
+
189
+ /**
190
+ * Re-establish every chat's stored backend/model override against the
191
+ * backends this boot actually has. Chats are independent, so they are
192
+ * reconciled `CHAT_BINDING_CONCURRENCY` at a time; a shared backend that
193
+ * two chats need at once is initialised exactly once by the pool.
194
+ */
195
+ export async function reconcileChatBindings(
196
+ config: TalonConfig,
197
+ deps: ChatBindingDeps,
198
+ ): Promise<void> {
199
+ const { getAllChatSettings } = await import("./storage/chat-settings.js");
200
+ await mapConcurrent(
201
+ Object.entries(getAllChatSettings()),
202
+ CHAT_BINDING_CONCURRENCY,
203
+ ([cid, settings]) => reconcileChatBinding(cid, settings, config, deps),
204
+ );
205
+ }
206
+
207
+ async function reconcileChatBinding(
208
+ cid: string,
209
+ settings: { backend?: string; model?: string },
210
+ config: TalonConfig,
211
+ deps: ChatBindingDeps,
212
+ ): Promise<void> {
213
+ const { getAllChatSettings, setChatBackend, setChatModel } =
214
+ await import("./storage/chat-settings.js");
215
+ let resetVolatileState = false;
216
+ if (settings.backend) {
217
+ if (!deps.isBackendAvailable(settings.backend, config)) {
218
+ log(
219
+ "bot",
220
+ `Per-chat backend ${settings.backend} for ${cid} is no longer available — resetting chat to default backend`,
221
+ );
222
+ await deps.releaseChat(cid);
223
+ setChatBackend(cid, undefined);
224
+ setChatModel(cid, undefined);
225
+ resetVolatileState = true;
226
+ } else {
227
+ let result = await deps.rebindChat(cid, settings.backend, config);
228
+ if (!result.ok) {
229
+ await new Promise((r) => setTimeout(r, REBIND_RETRY_DELAY_MS));
230
+ result = await deps.rebindChat(cid, settings.backend, config);
231
+ }
232
+ if (!result.ok) {
233
+ log(
234
+ "bot",
235
+ `Per-chat backend rebind failed for ${cid} → ${settings.backend}: ${result.error} — keeping the setting; will serve on the default backend until re-selected`,
236
+ );
237
+ }
238
+ }
239
+ }
240
+ const bindingMatchesSetting =
241
+ !settings.backend || deps.getBackendIdForChat(cid) === settings.backend;
242
+ const currentModel = getAllChatSettings()[cid]?.model;
243
+ if (currentModel && bindingMatchesSetting) {
244
+ const be = deps.getBackendForChat(cid);
245
+ try {
246
+ const valid = await deps.isModelValidForBackend(be, currentModel);
247
+ if (!valid) {
248
+ log(
249
+ "bot",
250
+ `Per-chat model ${currentModel} for ${cid} is not valid for its backend — resetting model to default`,
251
+ );
252
+ setChatModel(cid, undefined);
253
+ resetVolatileState = true;
254
+ }
255
+ } catch (err) {
256
+ log(
257
+ "bot",
258
+ `Per-chat model validation failed for ${cid} (${currentModel}): ${
259
+ err instanceof Error ? err.message : String(err)
260
+ } — keeping stored model`,
261
+ );
262
+ }
263
+ }
264
+ if (resetVolatileState) {
265
+ resetSession(cid);
266
+ clearHistory(cid);
267
+ }
268
+ }
269
+
168
270
  export async function initBackendAndDispatcher(
169
271
  config: TalonConfig,
170
272
  frontend: FrontendSelection,
@@ -258,77 +360,16 @@ export async function initBackendAndDispatcher(
258
360
  // for the backend that would serve it, clear the override and reset
259
361
  // volatile chat state so the next user message starts a fresh default
260
362
  // session instead of crashing on an orphaned model id.
261
- const { getAllChatSettings, setChatBackend, setChatModel } =
262
- await import("./storage/chat-settings.js");
263
- for (const [cid, settings] of Object.entries(getAllChatSettings())) {
264
- let resetVolatileState = false;
265
-
266
- if (settings.backend) {
267
- if (!isBackendAvailable(settings.backend, config)) {
268
- log(
269
- "bot",
270
- `Per-chat backend ${settings.backend} for ${cid} is no longer available — resetting chat to default backend`,
271
- );
272
- await releaseChat(cid);
273
- setChatBackend(cid, undefined);
274
- setChatModel(cid, undefined);
275
- resetVolatileState = true;
276
- } else {
277
- // A boot-time rebind failure is usually TRANSIENT (backend slow to
278
- // spawn, auth endpoint briefly unreachable) — it must not destroy
279
- // the user's persisted choice, session, and history. Retry once,
280
- // then keep the setting and move on: the chat runs on the default
281
- // backend until a later switch succeeds, and clients keep showing
282
- // the user's chosen backend from settings (the source of truth).
283
- let result = await rebindChat(cid, settings.backend, config);
284
- if (!result.ok) {
285
- await new Promise((r) => setTimeout(r, 1500));
286
- result = await rebindChat(cid, settings.backend, config);
287
- }
288
- if (!result.ok) {
289
- log(
290
- "bot",
291
- `Per-chat backend rebind failed for ${cid} → ${settings.backend}: ${result.error} — keeping the setting; will serve on the default backend until re-selected`,
292
- );
293
- }
294
- }
295
- }
296
-
297
- // Only validate the stored model against the backend that will really
298
- // serve this chat. After a kept-but-unbound override (transient rebind
299
- // failure above) the chat temporarily runs on the default backend — the
300
- // stored model belongs to the chosen backend, so validating it against
301
- // the default would wrongly clear it.
302
- const bindingMatchesSetting =
303
- !settings.backend || getBackendIdForChat(cid) === settings.backend;
304
- const currentModel = getAllChatSettings()[cid]?.model;
305
- if (currentModel && bindingMatchesSetting) {
306
- const be = getBackendForChat(cid);
307
- try {
308
- const valid = await isModelValidForBackend(be, currentModel);
309
- if (!valid) {
310
- log(
311
- "bot",
312
- `Per-chat model ${currentModel} for ${cid} is not valid for its backend — resetting model to default`,
313
- );
314
- setChatModel(cid, undefined);
315
- resetVolatileState = true;
316
- }
317
- } catch (err) {
318
- log(
319
- "bot",
320
- `Per-chat model validation failed for ${cid} (${currentModel}): ${
321
- err instanceof Error ? err.message : String(err)
322
- } — keeping stored model`,
323
- );
324
- }
325
- }
326
-
327
- if (resetVolatileState) {
328
- resetSession(cid);
329
- clearHistory(cid);
330
- }
331
- }
363
+ await bootPhase("chat bindings", () =>
364
+ reconcileChatBindings(config, {
365
+ isBackendAvailable,
366
+ releaseChat,
367
+ rebindChat,
368
+ getBackendIdForChat,
369
+ getBackendForChat,
370
+ isModelValidForBackend,
371
+ }),
372
+ );
332
373
 
333
374
  initDispatcher({
334
375
  // Dispatcher reads the backend per query so per-chat overrides