talon-agent 5.18.1 → 5.19.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 (194) hide show
  1. package/LICENSE +202 -21
  2. package/LICENSE-MIT +21 -0
  3. package/NOTICE +16 -0
  4. package/README.md +8 -3
  5. package/package.json +4 -2
  6. package/prompts/system/agent-brief.md +20 -3
  7. package/src/app.ts +13 -0
  8. package/src/backend/claude-sdk/handler.ts +4 -4
  9. package/src/backend/claude-sdk/mcp-ready.ts +16 -2
  10. package/src/backend/claude-sdk/stream.ts +2 -2
  11. package/src/backend/codex/auth.ts +1 -1
  12. package/src/backend/codex/handler/message.ts +11 -11
  13. package/src/backend/codex/init.ts +4 -9
  14. package/src/backend/codex/mcp-config.ts +1 -2
  15. package/src/backend/codex/oauth-incompat.ts +8 -4
  16. package/src/backend/codex/one-shot.ts +1 -1
  17. package/src/backend/openai-agents/builtins.ts +55 -27
  18. package/src/backend/openai-agents/factory.ts +3 -3
  19. package/src/backend/openai-agents/handler/message.ts +3 -5
  20. package/src/backend/openai-agents/mcp-pool.ts +10 -27
  21. package/src/backend/remote-server/chat-turn.ts +6 -6
  22. package/src/backend/remote-server/events.ts +1 -5
  23. package/src/backend/remote-server/index.ts +0 -1
  24. package/src/backend/remote-server/messages.ts +3 -7
  25. package/src/backend/remote-server/one-shot.ts +1 -3
  26. package/src/backend/remote-server/session-helpers.ts +1 -4
  27. package/src/backend/remote-server/sse-stream.ts +8 -9
  28. package/src/backend/runtime/metrics.ts +7 -13
  29. package/src/backend/runtime/sleep.ts +1 -2
  30. package/src/backend/runtime/turn/handle-retry.ts +48 -2
  31. package/src/backend/runtime/turn/handler-to-events.ts +3 -4
  32. package/src/bootstrap.ts +9 -1
  33. package/src/cli/doctor.ts +3 -0
  34. package/src/cli/index.ts +8 -10
  35. package/src/cli/logs.ts +148 -9
  36. package/src/cli/setup.ts +9 -11
  37. package/src/cli/status.ts +29 -0
  38. package/src/core/agent-runtime/README.md +5 -19
  39. package/src/core/agent-runtime/events.ts +3 -39
  40. package/src/core/agent-runtime/model-ref.ts +0 -8
  41. package/src/core/agents/registry.ts +49 -2
  42. package/src/core/auth/expiry-monitor.ts +9 -1
  43. package/src/core/auth/login-flow.ts +9 -1
  44. package/src/core/auth/status.ts +31 -3
  45. package/src/core/background/cron/scheduler.ts +25 -5
  46. package/src/core/background/dream/index.ts +29 -10
  47. package/src/core/background/failure-backoff.ts +30 -0
  48. package/src/core/background/heartbeat/agent.ts +2 -27
  49. package/src/core/background/heartbeat/index.ts +0 -2
  50. package/src/core/background/heartbeat/scheduler.ts +21 -13
  51. package/src/core/background/heartbeat/state.ts +10 -2
  52. package/src/core/background/isolated-agent.ts +6 -2
  53. package/src/core/background/pulse/pulse.ts +9 -0
  54. package/src/core/background/triggers/exit.ts +54 -0
  55. package/src/core/background/triggers/index.ts +1 -3
  56. package/src/core/background/triggers/resume.ts +2 -4
  57. package/src/core/backup/archive/tar.ts +14 -4
  58. package/src/core/backup/restore.ts +21 -13
  59. package/src/core/backup/scheduler.ts +28 -9
  60. package/src/core/backup/snapshot.ts +43 -10
  61. package/src/core/backup/store.ts +7 -19
  62. package/src/core/backup/targets.ts +69 -17
  63. package/src/core/config/index.ts +14 -0
  64. package/src/core/daemon/crash-marker.ts +141 -0
  65. package/src/core/daemon/crash.ts +9 -2
  66. package/src/core/daemon/handoff.ts +15 -0
  67. package/src/core/daemon/health-alerts.ts +297 -0
  68. package/src/core/daemon/log-reader.ts +289 -0
  69. package/src/core/doctor/index.ts +18 -2
  70. package/src/core/doctor/logs.ts +124 -0
  71. package/src/core/doctor/types.ts +1 -1
  72. package/src/core/engine/backend-controller/index.ts +1 -13
  73. package/src/core/engine/backend-router/router.ts +1 -1
  74. package/src/core/engine/dispatcher.ts +55 -2
  75. package/src/core/engine/fault-text.ts +40 -0
  76. package/src/core/engine/gateway-actions/agents/index.ts +3 -2
  77. package/src/core/engine/gateway-actions/agents/report.ts +62 -0
  78. package/src/core/engine/gateway-actions/history.ts +2 -4
  79. package/src/core/engine/gateway-actions/native/exec.ts +13 -16
  80. package/src/core/engine/gateway.ts +60 -1
  81. package/src/core/engine/turn-health.ts +222 -0
  82. package/src/core/errors.ts +2 -2
  83. package/src/core/frontend-runtime/admin-notify.ts +1 -1
  84. package/src/core/frontend-runtime/alerts.ts +130 -0
  85. package/src/core/mcp-hub/children.ts +78 -29
  86. package/src/core/mcp-hub/index.ts +21 -18
  87. package/src/core/mcp-hub/proxy-server.ts +8 -4
  88. package/src/core/mcp-hub/talon-server.ts +5 -12
  89. package/src/core/mesh/credentials/store.ts +16 -1
  90. package/src/core/mesh/devices/service.ts +26 -15
  91. package/src/core/mesh/devices/teleport.ts +14 -2
  92. package/src/core/mesh/links/node-binaries.ts +13 -6
  93. package/src/core/mesh/persist.ts +22 -10
  94. package/src/core/mesh/transfers/device-files.ts +5 -23
  95. package/src/core/mesh/transfers/transfers.ts +16 -3
  96. package/src/core/models/active-model.ts +2 -55
  97. package/src/core/plugin/actions.ts +19 -20
  98. package/src/core/plugin/builtins.ts +80 -90
  99. package/src/core/plugin/index.ts +1 -4
  100. package/src/core/plugin/loader.ts +25 -33
  101. package/src/core/plugin/mcp.ts +3 -5
  102. package/src/core/plugin/registry.ts +19 -35
  103. package/src/core/plugin/types.ts +2 -5
  104. package/src/core/prompt/assemble.ts +15 -3
  105. package/src/core/scripts/lua.ts +6 -2
  106. package/src/core/tasks/table.ts +8 -2
  107. package/src/core/tools/bridge.ts +2 -4
  108. package/src/core/tools/chat/cross-send.ts +1 -1
  109. package/src/core/tools/chat/messaging.ts +1 -1
  110. package/src/core/tools/index.ts +2 -2
  111. package/src/core/tools/mcp-env.ts +2 -59
  112. package/src/core/tools/ops/agents.ts +22 -1
  113. package/src/core/tools/schemas.ts +4 -9
  114. package/src/core/vfs/fusefs.ts +0 -5
  115. package/src/core/vfs/index.ts +9 -2
  116. package/src/core/vfs/mounts/diagnostics.ts +109 -0
  117. package/src/core/vfs/mounts/proc.ts +17 -1
  118. package/src/core/vfs/workspace.ts +7 -3
  119. package/src/core/weaver/shuttle.ts +8 -1
  120. package/src/core/weaver/turn-log.ts +320 -0
  121. package/src/core/weaver/weaver.ts +48 -5
  122. package/src/frontend/discord/actions/index.ts +8 -1
  123. package/src/frontend/discord/diagnostics.ts +82 -6
  124. package/src/frontend/discord/handlers/index.ts +0 -2
  125. package/src/frontend/discord/middleware.ts +7 -13
  126. package/src/frontend/discord/runtime.ts +1 -3
  127. package/src/frontend/health/delivery.ts +115 -0
  128. package/src/frontend/health/outage.ts +116 -0
  129. package/src/frontend/native/bridge/routes/chats.ts +3 -5
  130. package/src/frontend/native/bridge/server.ts +106 -21
  131. package/src/frontend/native/index.ts +1 -1
  132. package/src/frontend/native/media/media.ts +5 -1
  133. package/src/frontend/native/runtime.ts +12 -7
  134. package/src/frontend/native/surface/handlers.ts +1 -1
  135. package/src/frontend/native/surface/memory.ts +1 -1
  136. package/src/frontend/native/surface/models.ts +3 -3
  137. package/src/frontend/native/surface/settings.ts +20 -8
  138. package/src/frontend/native/turn/context.ts +6 -8
  139. package/src/frontend/native/turn/turn-meta.ts +2 -5
  140. package/src/frontend/native/turn/turn.ts +8 -10
  141. package/src/frontend/presentation/format.ts +2 -4
  142. package/src/frontend/presentation/session-status.ts +2 -6
  143. package/src/frontend/teams/actions.ts +8 -1
  144. package/src/frontend/teams/graph.ts +0 -1
  145. package/src/frontend/teams/index.ts +1 -4
  146. package/src/frontend/teams/poll.ts +40 -2
  147. package/src/frontend/teams/runtime.ts +14 -5
  148. package/src/frontend/telegram/actions/index.ts +4 -1
  149. package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
  150. package/src/frontend/telegram/actions/send.ts +12 -0
  151. package/src/frontend/telegram/handlers/context.ts +13 -2
  152. package/src/frontend/telegram/handlers/delivery.ts +12 -9
  153. package/src/frontend/telegram/handlers/index.ts +0 -2
  154. package/src/frontend/telegram/index.ts +35 -9
  155. package/src/frontend/telegram/polling/poll-health.ts +110 -0
  156. package/src/frontend/telegram/userbot.ts +147 -41
  157. package/src/frontend/terminal/builtins/session.ts +2 -2
  158. package/src/frontend/terminal/index.ts +1 -3
  159. package/src/frontend/terminal/renderer.ts +2 -18
  160. package/src/frontend/whatsapp/actions/index.ts +12 -1
  161. package/src/frontend/whatsapp/actions/messaging.ts +7 -2
  162. package/src/frontend/whatsapp/connection/connection.ts +29 -9
  163. package/src/frontend/whatsapp/connection/health.ts +89 -0
  164. package/src/frontend/whatsapp/connection/identity.ts +4 -4
  165. package/src/frontend/whatsapp/runtime.ts +11 -5
  166. package/src/native/blake3.ts +28 -2
  167. package/src/native/fusefs.ts +23 -5
  168. package/src/native/registry.ts +1 -1
  169. package/src/native/warden.ts +33 -5
  170. package/src/plugins/github/index.ts +0 -1
  171. package/src/plugins/mempalace/index.ts +9 -3
  172. package/src/plugins/playwright/index.ts +2 -4
  173. package/src/plugins/playwright/provision.ts +12 -4
  174. package/src/storage/chat-settings.ts +6 -1
  175. package/src/storage/cron.ts +29 -4
  176. package/src/storage/daily-log.ts +43 -47
  177. package/src/storage/db.ts +61 -33
  178. package/src/storage/history.ts +6 -1
  179. package/src/storage/journal.ts +9 -2
  180. package/src/storage/kv.ts +19 -6
  181. package/src/storage/media-index.ts +28 -6
  182. package/src/storage/repositories/chat-settings-repo.ts +8 -2
  183. package/src/storage/repositories/sessions-repo.ts +10 -3
  184. package/src/storage/scripts.ts +24 -13
  185. package/src/storage/sessions.ts +11 -2
  186. package/src/storage/skills.ts +21 -2
  187. package/src/storage/stickers.ts +17 -3
  188. package/src/storage/triggers.ts +8 -3
  189. package/src/storage/turn-meta.ts +25 -7
  190. package/src/util/log.ts +189 -6
  191. package/src/util/logging/turn-scope.ts +85 -0
  192. package/src/util/time.ts +3 -3
  193. package/src/util/watchdog.ts +30 -0
  194. package/src/core/engine/backend-controller/legacy.ts +0 -111
@@ -31,9 +31,10 @@
31
31
  * shared prompt vocabulary applies uniformly.
32
32
  */
33
33
  import { tool } from "@openai/agents";
34
- import { spawn } from "node:child_process";
34
+ import { spawn, type ChildProcess } from "node:child_process";
35
35
  import { readFile, writeFile, mkdir, glob } from "node:fs/promises";
36
36
  import { dirname, resolve as resolvePath } from "node:path";
37
+ import { createOutputCapture } from "../../util/exec-output.js";
37
38
  import { expandFsPath as expandPath } from "../../util/fs-path.js";
38
39
 
39
40
  // ── Read ────────────────────────────────────────────────────────────────────
@@ -204,6 +205,12 @@ const editTool = tool({
204
205
 
205
206
  const BASH_DEFAULT_TIMEOUT_MS = 30_000;
206
207
  const BASH_MAX_TIMEOUT_MS = 600_000;
208
+ /**
209
+ * After a timeout kill, how long to wait for `close` before settling with
210
+ * what was captured. `close` waits for the stdio pipes to drain, which a
211
+ * descendant that escaped the kill can hold open indefinitely.
212
+ */
213
+ const BASH_CLOSE_GRACE_MS = 2_000;
207
214
 
208
215
  interface BashInput {
209
216
  command: string;
@@ -235,43 +242,64 @@ function runShell(
235
242
  ],
236
243
  }
237
244
  : { cmd: "bash", args: ["-lc", command] };
245
+ // detached → own process group on POSIX, so the timeout kill reaches
246
+ // the shell's children too. A surviving child (the `sleep` in
247
+ // `sleep 30; echo`) holds stdout open, and `close` waits for it.
248
+ const detached = process.platform !== "win32";
238
249
  const child = spawn(shell.cmd, shell.args, {
239
250
  cwd: process.cwd(),
240
251
  env: process.env,
241
252
  stdio: ["ignore", "pipe", "pipe"],
253
+ detached,
242
254
  });
243
- let stdout = "";
244
- let stderr = "";
245
- child.stdout.on("data", (b: Buffer) => {
246
- stdout += b.toString("utf8");
247
- });
248
- child.stderr.on("data", (b: Buffer) => {
249
- stderr += b.toString("utf8");
250
- });
255
+ // Bounded: an unbounded stream (`yes`, a verbose build) otherwise grows
256
+ // until V8's string limit throws inside the data listener.
257
+ const out = createOutputCapture();
258
+ const err = createOutputCapture();
259
+ child.stdout.on("data", out.push);
260
+ child.stderr.on("data", err.push);
261
+ let settled = false;
262
+ let timedOut = false;
263
+ let closeGrace: NodeJS.Timeout | undefined;
264
+ const finish = (code: number, stderr = err.value()): void => {
265
+ if (settled) return;
266
+ settled = true;
267
+ clearTimeout(killer);
268
+ if (closeGrace) clearTimeout(closeGrace);
269
+ resolveResult({ stdout: out.value(), stderr, code, timedOut });
270
+ };
251
271
  const killer = setTimeout(() => {
252
272
  timedOut = true;
253
- child.kill("SIGKILL");
273
+ killGroup(child, detached);
274
+ // A descendant that left the group can still hold the pipes open;
275
+ // don't let it pin the tool call past the timeout.
276
+ closeGrace = setTimeout(() => finish(-1), BASH_CLOSE_GRACE_MS);
254
277
  }, timeoutMs);
255
- let timedOut = false;
256
- child.on("error", (err) => {
257
- // spawn failed (e.g. bash not in PATH on Windows). Resolve instead
258
- // of letting Node emit an uncaught error — the tool returns the
259
- // diagnostic so callers can surface it rather than crashing.
260
- clearTimeout(killer);
261
- resolveResult({
262
- stdout,
263
- stderr: err.message,
264
- code: -1,
265
- timedOut: false,
266
- });
267
- });
268
- child.on("close", (code) => {
269
- clearTimeout(killer);
270
- resolveResult({ stdout, stderr, code: code ?? -1, timedOut });
271
- });
278
+ // spawn failed (e.g. bash not in PATH on Windows). Resolve instead
279
+ // of letting Node emit an uncaught error — the tool returns the
280
+ // diagnostic so callers can surface it rather than crashing.
281
+ child.on("error", (spawnErr) => finish(-1, spawnErr.message));
282
+ child.on("close", (code) => finish(code ?? -1));
272
283
  });
273
284
  }
274
285
 
286
+ /** SIGKILL the child's whole process group, falling back to the child. */
287
+ function killGroup(child: ChildProcess, detached: boolean): void {
288
+ if (detached && child.pid) {
289
+ try {
290
+ process.kill(-child.pid, "SIGKILL");
291
+ return;
292
+ } catch {
293
+ // group already gone — fall through to the direct kill
294
+ }
295
+ }
296
+ try {
297
+ child.kill("SIGKILL");
298
+ } catch {
299
+ // already dead
300
+ }
301
+ }
302
+
275
303
  const bashTool = tool({
276
304
  name: "Bash",
277
305
  description:
@@ -93,9 +93,9 @@ const openAIAgentsFactory: BackendFactory = {
93
93
 
94
94
  return {
95
95
  backend,
96
- // Cleanup: close every per-chat MCP bundle in the pool so the
97
- // ~15 plugin subprocesses per active chat don't outlive the
98
- // backend itself, then drop the cached state.
96
+ // Cleanup: close every per-chat MCP bundle in the pool so its hub
97
+ // sessions don't outlive the backend itself, then drop the cached
98
+ // state.
99
99
  cleanup: async () => {
100
100
  await releaseAllBundles();
101
101
  resetState();
@@ -118,8 +118,7 @@ function buildTurnPrompt(
118
118
 
119
119
  /**
120
120
  * Acquire the per-chat MCP bundle. Persistent across turns — built on
121
- * first use, kept alive until `releaseBundle(chatId)`. Avoids the
122
- * ~15-subprocess re-spawn the original per-turn build caused.
121
+ * first use, kept until the backend's cleanup releases the pool.
123
122
  */
124
123
  async function acquireMcpBundle(
125
124
  chatId: string,
@@ -366,7 +365,7 @@ export async function handleMessage(
366
365
  } catch (err) {
367
366
  // Swallow the terminator abort — the turn completed via a delivery tool.
368
367
  if (!isTerminatorAbort(streamState, err)) {
369
- // MCP bundle is retained across a retry — subprocesses are
368
+ // MCP bundle is retained across a retry — its servers are
370
369
  // stateless wrt the model conversation. See `mcp-pool.ts`.
371
370
  const outcome = await applyRetryDecision({
372
371
  err,
@@ -400,8 +399,7 @@ export async function handleMessage(
400
399
  activeAborts.delete(chatId);
401
400
  }
402
401
  // MCP bundle is NOT closed here — it persists across turns via the
403
- // pool in `mcp-pool.ts`. Release happens on chat rebind, `/reset`, and
404
- // at backend cleanup.
402
+ // pool in `mcp-pool.ts` and is released at backend cleanup.
405
403
  }
406
404
 
407
405
  // ── Post-loop accounting ──────────────────────────────────────────────────
@@ -4,17 +4,11 @@
4
4
  * Every server is a lightweight `MCPServerStreamableHttp` client
5
5
  * pointing at the daemon's MCP hub (`core/mcp-hub`): Talon's own tools
6
6
  * run in-process there, and plugin/brave servers are hub-managed
7
- * children shared across chats and reaped when idle.
7
+ * children shared across chats and reaped when idle. A bundle is just
8
+ * HTTP client objects; the process count is owned and bounded by the hub.
8
9
  *
9
- * Historical note: this pool used to hold one **subprocess set** per
10
- * chat (every plugin × every chat, held until release) — the daemon's
11
- * memory grew linearly with the number of chats. With the hub, a
12
- * bundle is just HTTP client objects; the process count is owned and
13
- * bounded by the hub.
14
- *
15
- * The bundle is still cached per chat (and released on reset/rebind)
16
- * so `cacheToolsList` survives across turns and each turn skips the
17
- * connect handshake.
10
+ * The bundle is cached per chat so `cacheToolsList` survives across
11
+ * turns and each turn skips the connect handshake.
18
12
  *
19
13
  * Concurrency: `getOrCreateBundle` serialises the build-or-return
20
14
  * decision on a per-chat in-flight Promise so two concurrent turns from
@@ -36,18 +30,13 @@ import { frontendsForChat } from "../runtime/frontends.js";
36
30
  import { log, logWarn } from "../../util/log.js";
37
31
 
38
32
  /**
39
- * One per-chat bundle. Subprocesses stay alive until `close()` is
33
+ * One per-chat bundle. Its hub sessions stay open until `close()` is
40
34
  * called via `releaseBundle()` / `releaseAllBundles()`.
41
35
  */
42
36
  export interface OpenAIAgentsMcpBundle {
43
- /**
44
- * Connected MCP servers ready to pass to `new Agent({ mcpServers })`.
45
- * `connectMcpServers` returns the structural `MCPServer` type — the
46
- * underlying instances are `MCPServerStdio` but the Agent constructor
47
- * only needs the interface.
48
- */
37
+ /** Connected MCP servers ready to pass to `new Agent({ mcpServers })`. */
49
38
  servers: MCPServer[];
50
- /** Close every spawned subprocess. Safe to call multiple times. */
39
+ /** Close every server's hub session. Safe to call multiple times. */
51
40
  close: () => Promise<void>;
52
41
  /** Servers that failed to connect — exposed for diagnostics. */
53
42
  failed: ReadonlyArray<{ name: string; error: string }>;
@@ -115,14 +104,8 @@ export async function getOrCreateBundle(
115
104
  * Close the bundle for `chatId` and drop it from the pool. No-op when
116
105
  * the chat has no live bundle.
117
106
  *
118
- * Call when:
119
- * - The chat rebinds to a non-openai-agents backend.
120
- * - The user runs `/reset`.
121
- * - The chat is destroyed.
122
- *
123
107
  * If `getOrCreateBundle` is in flight when called, releases the bundle
124
- * once the in-flight build resolves to avoid leaving an unreleased
125
- * subprocess set.
108
+ * once the in-flight build resolves so it is not left open.
126
109
  */
127
110
  export async function releaseBundle(chatId: string): Promise<void> {
128
111
  // If a build is in flight, wait for it then close the result.
@@ -152,8 +135,8 @@ export async function releaseBundle(chatId: string): Promise<void> {
152
135
 
153
136
  /**
154
137
  * Close every live bundle. Used by the backend factory's `cleanup`
155
- * hook so unbinding the openai-agents backend leaves no orphan MCP
156
- * subprocesses.
138
+ * hook so unbinding the openai-agents backend leaves no hub sessions
139
+ * behind.
157
140
  */
158
141
  export async function releaseAllBundles(): Promise<void> {
159
142
  const ids = [...bundles.keys()];
@@ -253,12 +253,6 @@ export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
253
253
  });
254
254
  }
255
255
 
256
- /**
257
- * If the SSE loop missed the usage info, fall back to the session summary
258
- * endpoint (which always reflects the final server state). Best-effort:
259
- * session summaries can race on cancellation, so a failure leaves the
260
- * counts at zero.
261
- */
262
256
  /**
263
257
  * The turn's user prompt: the shared framing every backend emits, plus
264
258
  * whatever this turn's memory retrieval produced. `formatUserPrompt` is
@@ -276,6 +270,12 @@ function buildTurnPrompt(params: QueryParams): string {
276
270
  });
277
271
  }
278
272
 
273
+ /**
274
+ * If the SSE loop missed the usage info, fall back to the session summary
275
+ * endpoint (which always reflects the final server state). Best-effort:
276
+ * session summaries can race on cancellation, so a failure leaves the
277
+ * counts at zero.
278
+ */
279
279
  async function fillUsageFromSummary(
280
280
  oc: RemoteSessionClient,
281
281
  sessionId: string,
@@ -39,11 +39,7 @@ import {
39
39
  type RemoteAssistantInfo,
40
40
  } from "./session-helpers.js";
41
41
  import { log, logDebug } from "../../util/log.js";
42
-
43
- /** Format an error for a debug log line. */
44
- function errMsg(err: unknown): string {
45
- return err instanceof Error ? err.message : String(err);
46
- }
42
+ import { errMsg } from "./state.js";
47
43
 
48
44
  // ── Streaming timing ───────────────────────────────────────────────────────
49
45
 
@@ -19,7 +19,6 @@
19
19
  * - Bindings (server-bindings.ts, chat-turn.ts, turn.ts, factory.ts) —
20
20
  * the helpers closed over one backend's state, the SSE-driven turn,
21
21
  * the chat-turn orchestration, and the registry factory composition.
22
- * This is where the code that used to be copied per backend lives.
23
22
  *
24
23
  * - Profiles (`profiles/bind.ts` + `profiles/{kilo,opencode}.ts`) —
25
24
  * `bindRemoteProfile` closes all of the above over one driver's
@@ -3,15 +3,11 @@
3
3
  *
4
4
  * Both `KiloClient.session.messages` and `OpencodeClient.session.messages`
5
5
  * return the same wire format: an array of `{ info: { role, ... }, parts: [...] }`
6
- * objects. Kilo and OpenCode previously duplicated the same `findLastAssistantMessage`
7
- * helper — same body, drifted formatting, identical `info as unknown as
8
- * KiloAssistantInfo` / `OpenCodeAssistantInfo` casts. This module centralises
9
- * the walker and pushes the `info` typing out as a generic the caller supplies.
6
+ * objects. The `info` typing is a generic the caller supplies.
10
7
  *
11
8
  * The runtime guard (`info.role === "assistant"`) lives here and runs once;
12
- * the generic just labels what the caller intends to consume. No new
13
- * unsafe-cast surface is introduced — callers no longer cast through
14
- * `unknown` themselves.
9
+ * the generic just labels what the caller intends to consume, so callers
10
+ * never cast through `unknown` themselves.
15
11
  */
16
12
 
17
13
  /**
@@ -9,9 +9,7 @@
9
9
  * frontend), creating an ephemeral session, running the prompt,
10
10
  * rendering the response parts into the run log, and cleaning up.
11
11
  *
12
- * Both backends previously carried byte-for-byte copies of this file
13
- * that drifted (the abort handler landed in one and not the other);
14
- * the per-backend differences are exactly the `RemoteOneShotBindings`
12
+ * The per-backend differences are exactly the `RemoteOneShotBindings`
15
13
  * fields — server bootstrap, model-selection parsing, and the
16
14
  * delivery-contract suffix.
17
15
  *
@@ -32,6 +32,7 @@
32
32
 
33
33
  import { logWarn } from "../../util/log.js";
34
34
  import type { RemoteAgentClient } from "./client.js";
35
+ import { errMsg } from "./state.js";
35
36
 
36
37
  // ── Constants ───────────────────────────────────────────────────────────────
37
38
 
@@ -153,10 +154,6 @@ export interface RemoteSessionClient extends RemoteAgentClient {
153
154
 
154
155
  // ── Local utility ───────────────────────────────────────────────────────────
155
156
 
156
- function errMsg(e: unknown): string {
157
- return e instanceof Error ? e.message : String(e);
158
- }
159
-
160
157
  function hasAssistantUsage(info: RemoteAssistantInfo | undefined): boolean {
161
158
  return Boolean(
162
159
  info?.tokens?.input ||
@@ -5,17 +5,12 @@
5
5
  * `client.global.event()` which (per the upstream wire format) returns
6
6
  * a `ServerSentEventsResult` whose `stream` field is an async iterable
7
7
  * of typed events. The SDK's published types under-promise this shape —
8
- * the call signature returns a wider type than the value it produces.
9
- *
10
- * Previously, every backend that consumed this called
11
- * `await oc.global.event() as unknown as { stream?: AsyncIterable<unknown> }`
12
- * inline. Three copies of the same lie. This helper centralises it
13
- * behind one narrowing function with a runtime guard, so subsequent
14
- * backends (or the next SDK revision) can update the typing in one
15
- * place.
8
+ * the call signature returns a wider type than the value it produces,
9
+ * so the narrowing lives here once, behind a runtime guard.
16
10
  */
17
11
 
18
- import { logWarn } from "../../util/log.js";
12
+ import { faultText } from "../../core/engine/fault-text.js";
13
+ import { logDebug, logWarn } from "../../util/log.js";
19
14
 
20
15
  /**
21
16
  * Minimal client surface — what both `KiloClient` and `OpencodeClient`
@@ -54,6 +49,10 @@ export async function subscribeSseStream(
54
49
  lastError = err;
55
50
  }
56
51
  if (attempt < 3) {
52
+ logDebug(
53
+ "agent",
54
+ `sse.subscribe.retry chat=${chatId} attempt=${attempt} delay_ms=${150 * attempt} error="${faultText(lastError)}"`,
55
+ );
57
56
  await new Promise((resolve) => setTimeout(resolve, 150 * attempt));
58
57
  }
59
58
  }
@@ -84,11 +84,7 @@ export type TurnMetricInputs = {
84
84
  usage?: TokenUsageSnapshot;
85
85
  };
86
86
 
87
- /**
88
- * Record the uniform per-turn metric set. Replaces the per-backend
89
- * `recordHistogram("response_latency_ms")` + `incrementCounter
90
- * ("queries_total")` pairs (and codex's private `codex.*` family).
91
- */
87
+ /** Record the uniform per-turn metric set. */
92
88
  export function recordTurnMetrics(inputs: TurnMetricInputs): void {
93
89
  recordSessionMetrics(inputs.chatId, inputs);
94
90
  }
@@ -97,11 +93,10 @@ export function recordTurnMetrics(inputs: TurnMetricInputs): void {
97
93
  * Terminal-failure accounting — call right before re-throwing a turn
98
94
  * that exhausted its retries.
99
95
  *
100
- * Historically every backend skipped BOTH `recordTurnMetrics` and
101
- * `recordUsage` when a turn errored: the tokens the failed turn burned
102
- * vanished from /status and /metrics, latency histograms only sampled
103
- * successes, and the live-turn overlay leaked until the next turn.
104
- * This helper closes all three gaps in one call:
96
+ * A failed turn still burned tokens. Without this, they vanish from
97
+ * /status and /metrics, latency histograms sample only successes, and
98
+ * the live-turn overlay leaks until the next turn. One call covers all
99
+ * three:
105
100
  *
106
101
  * - per-turn metrics with `failed: true` (also feeds
107
102
  * `backend.<id>.turn_failed`)
@@ -158,9 +153,8 @@ export function recordFailedTurnAccounting(inputs: {
158
153
  /**
159
154
  * Record a flow violation (prose written without a delivery tool).
160
155
  * Always counts the dropped text; the outcome picks the second
161
- * counter. Centralised because openai-agents historically skipped the
162
- * cap-exhausted counter, making "how often do models ignore the
163
- * reminder" unanswerable for that backend.
156
+ * counter. Centralised so every backend counts cap exhaustion — "how
157
+ * often do models ignore the reminder" needs both halves.
164
158
  */
165
159
  export function recordFlowViolation(
166
160
  chatId: string,
@@ -3,8 +3,7 @@
3
3
  *
4
4
  * Resolves after `ms` milliseconds, or immediately when the optional
5
5
  * `AbortSignal` fires. Used by the remote-server backends (kilo,
6
- * opencode) for throttled retries and stream pacing. Previously
7
- * duplicated inline in `kilo/handler.ts` and `opencode/handler.ts`.
6
+ * opencode) for throttled retries and stream pacing.
8
7
  */
9
8
 
10
9
  export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
@@ -24,12 +24,42 @@
24
24
 
25
25
  import type { QueryParams, QueryResult } from "./handler-types.js";
26
26
  import { classify, type TalonError } from "../../../core/errors.js";
27
- import { logWarn } from "../../../util/log.js";
27
+ import { faultText } from "../../../core/engine/fault-text.js";
28
+ import { log, logWarn } from "../../../util/log.js";
28
29
  import { incrementCounter } from "../../../storage/metrics.js";
29
30
  import { resetSession } from "../../../storage/sessions.js";
30
- import { classifyRetry } from "./model-retry.js";
31
+ import { classifyRetry, type RetryDecision } from "./model-retry.js";
31
32
  import type { AgentEvent } from "../../../core/agent-runtime/events.js";
32
33
 
34
+ /**
35
+ * One greppable line per recovery decision — what failed, how it was
36
+ * classified, and what the ladder does next. A retry is a warning (the
37
+ * turn is about to be re-run); a propagate is the turn's final error
38
+ * leaving the backend, and says so.
39
+ */
40
+ function logRetryDecision(
41
+ chatId: string,
42
+ backendLabel: string | undefined,
43
+ activeModel: string,
44
+ retried: boolean,
45
+ classified: TalonError,
46
+ decision: RetryDecision,
47
+ ): void {
48
+ const action =
49
+ decision.kind === "reset_and_retry"
50
+ ? `reset_and_retry(${decision.reason})`
51
+ : decision.kind === "fallback_model"
52
+ ? `fallback_model(${decision.fallbackModelId})`
53
+ : "propagate";
54
+ const line =
55
+ `retry.decision chat=${chatId} backend=${(backendLabel || "claude").toLowerCase().replace(/\s+/g, "-")} ` +
56
+ `model=${activeModel} attempt=${retried ? 2 : 1} reason=${classified.reason} ` +
57
+ `retryable=${classified.retryable} status=${classified.status ?? "-"} ` +
58
+ `decision=${action} error="${faultText(classified)}"`;
59
+ if (decision.kind === "propagate") log("agent", line);
60
+ else logWarn("agent", line);
61
+ }
62
+
33
63
  /** Inputs for `applyRetryDecision`. */
34
64
  export interface ApplyRetryDecisionInputs {
35
65
  /** The error caught by the backend's handler. */
@@ -112,6 +142,14 @@ export async function applyRetryDecision(
112
142
  });
113
143
 
114
144
  const prefix = backendLabel ? `${backendLabel} ` : "";
145
+ logRetryDecision(
146
+ chatId,
147
+ backendLabel,
148
+ activeModel,
149
+ retried,
150
+ classified,
151
+ decision,
152
+ );
115
153
 
116
154
  if (decision.kind === "reset_and_retry") {
117
155
  logWarn(
@@ -218,6 +256,14 @@ export async function* applyRetryDecisionStream(
218
256
  });
219
257
 
220
258
  const prefix = backendLabel ? `${backendLabel} ` : "";
259
+ logRetryDecision(
260
+ chatId,
261
+ backendLabel,
262
+ activeModel,
263
+ retried,
264
+ classified,
265
+ decision,
266
+ );
221
267
 
222
268
  if (decision.kind === "reset_and_retry") {
223
269
  logWarn(
@@ -10,10 +10,9 @@
10
10
  * skip this entirely and yield events directly — `runChatTurn` lives
11
11
  * in the backend module and owns its own stream surface.
12
12
  *
13
- * Replaces the historical `to-event-stream.ts` shim that lived under
14
- * "shared". The reframing matters: this is not a "legacy adapter,"
15
- * it's the canonical bridge between an SDK that emits via callbacks
16
- * and the `AgentEvent` contract every consumer reads.
13
+ * This is not a legacy adapter: it is the canonical bridge between an
14
+ * SDK that emits via callbacks and the `AgentEvent` contract every
15
+ * consumer reads.
17
16
  */
18
17
 
19
18
  import {
package/src/bootstrap.ts CHANGED
@@ -28,6 +28,7 @@ import { initPulse, resetPulseTimer } from "./core/background/pulse/pulse.js";
28
28
  import { initCron } from "./core/background/cron/scheduler.js";
29
29
  import { initPlanAlerts } from "./core/background/pulse/plan-alerts.js";
30
30
  import { setAdminNotifier } from "./core/frontend-runtime/admin-notify.js";
31
+ import { configureAlerts } from "./core/frontend-runtime/alerts.js";
31
32
  import { startAuthExpiryMonitor } from "./core/auth/expiry-monitor.js";
32
33
  import {
33
34
  initTriggers,
@@ -629,12 +630,19 @@ function initWakeSubsystems(config: TalonConfig): void {
629
630
  * Wire the admin notification seam to the admin's frontend, and start the
630
631
  * login-expiry monitor that rides it: the CLIs' "N days to log in again"
631
632
  * banner, delivered to the admin instead of a terminal nobody is watching
632
- * (/auth then completes the sign-in from the chat).
633
+ * (/auth then completes the sign-in from the chat). Operator alerts ride
634
+ * the same seam, so their settings are applied here too.
633
635
  */
634
636
  function wireAdminNotifier(
635
637
  config: TalonConfig,
636
638
  frontends: Parameters<typeof resolveFrontendByNumericId>[2],
637
639
  ): void {
640
+ if (config.alerts) {
641
+ configureAlerts({
642
+ enabled: config.alerts.enabled,
643
+ cooldownMs: config.alerts.cooldownMinutes * 60_000,
644
+ });
645
+ }
638
646
  if (!config.adminUserId) return;
639
647
  const adminChatId = config.adminUserId;
640
648
  setAdminNotifier(async (text: string) =>
package/src/cli/doctor.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import pc from "picocolors";
7
7
  import { existsSync } from "node:fs";
8
8
  import { findRunningInstance } from "../core/daemon/discovery.js";
9
+ import { formatAlertLines } from "./status.js";
9
10
  import { printBanner, loadConfig } from "./config.js";
10
11
  import { CONFIG_FILE } from "./context.js";
11
12
 
@@ -56,6 +57,8 @@ export async function runDoctor(): Promise<void> {
56
57
  const instance = await findRunningInstance();
57
58
  if (instance) {
58
59
  console.log(` ${pc.green("✓")} Bot is running (PID ${instance.pid})`);
60
+ for (const line of formatAlertLines(instance.health ?? {}))
61
+ console.log(line);
59
62
  } else {
60
63
  console.log(` ${pc.dim("-")} Bot is not running`);
61
64
  }
package/src/cli/index.ts CHANGED
@@ -28,7 +28,7 @@ import { printBanner, ConfigFileError } from "./config.js";
28
28
  import { runSetup } from "./setup.js";
29
29
  import { showStatus } from "./status.js";
30
30
  import { viewConfig } from "./config-view.js";
31
- import { tailLogs } from "./logs.js";
31
+ import { runLogsCommand } from "./logs.js";
32
32
  import { runDoctor } from "./doctor.js";
33
33
  import { startChat } from "./chat.js";
34
34
  import { daemonStart, daemonStop, daemonRestart } from "./daemon.js";
@@ -116,7 +116,9 @@ function printHelp(): void {
116
116
  ` ${pc.cyan("mesh")} Device credentials (list/revoke/rotate/scopes)`,
117
117
  );
118
118
  console.log(` ${pc.cyan("config")} View/edit configuration`);
119
- console.log(` ${pc.cyan("logs")} Tail log file`);
119
+ console.log(
120
+ ` ${pc.cyan("logs")} Tail log file (--errors, --since 1h, --component, --grep, --turn)`,
121
+ );
120
122
  console.log(` ${pc.cyan("doctor")} Validate environment`);
121
123
  console.log(` ${pc.cyan("--version")} Print the package version`);
122
124
  console.log();
@@ -129,13 +131,9 @@ export async function runCli(): Promise<void> {
129
131
  try {
130
132
  await dispatch(command);
131
133
  } catch (err) {
132
- // A present-but-invalid config.json: every command below that reads
133
- // config (directly, or via `mainMenu`'s "is this configured?" check)
134
- // is async but was previously invoked without `await`, so this throw
135
- // would otherwise surface as a bare unhandled-rejection stack trace —
136
- // or, worse for the main menu, never happen at all, because the old
137
- // loader swallowed the error and returned defaults, sending a broken
138
- // install into the first-run wizard, which then saves over the file.
134
+ // A present-but-invalid config.json: say so and exit non-zero, rather
135
+ // than a stack trace — or, for the main menu, a first-run wizard that
136
+ // would save defaults over the operator's real file.
139
137
  if (err instanceof ConfigFileError) {
140
138
  console.error(`\n ${pc.red("✖")} ${err.message}\n`);
141
139
  process.exitCode = 1;
@@ -152,7 +150,7 @@ const COMMANDS: Record<string, CommandHandler> = {
152
150
  setup: () => runSetup(),
153
151
  status: () => showStatus(),
154
152
  config: () => viewConfig(),
155
- logs: () => tailLogs(),
153
+ logs: (args) => runLogsCommand(args),
156
154
  start: async () => {
157
155
  printBanner();
158
156
  await daemonStart();