talon-agent 5.18.2 → 5.19.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 (195) hide show
  1. package/README.md +2 -1
  2. package/package.json +2 -2
  3. package/prompts/system/agent-brief.md +20 -3
  4. package/src/app.ts +13 -0
  5. package/src/backend/claude-sdk/handler.ts +4 -4
  6. package/src/backend/claude-sdk/mcp-ready.ts +16 -2
  7. package/src/backend/claude-sdk/one-shot.ts +32 -2
  8. package/src/backend/claude-sdk/stream.ts +2 -2
  9. package/src/backend/codex/auth.ts +1 -1
  10. package/src/backend/codex/handler/message.ts +11 -11
  11. package/src/backend/codex/init.ts +4 -9
  12. package/src/backend/codex/mcp-config.ts +1 -2
  13. package/src/backend/codex/oauth-incompat.ts +8 -4
  14. package/src/backend/codex/one-shot.ts +1 -1
  15. package/src/backend/openai-agents/builtins.ts +55 -27
  16. package/src/backend/openai-agents/factory.ts +3 -3
  17. package/src/backend/openai-agents/handler/message.ts +3 -5
  18. package/src/backend/openai-agents/mcp-pool.ts +10 -27
  19. package/src/backend/remote-server/chat-turn.ts +6 -6
  20. package/src/backend/remote-server/events.ts +1 -5
  21. package/src/backend/remote-server/index.ts +0 -1
  22. package/src/backend/remote-server/messages.ts +3 -7
  23. package/src/backend/remote-server/one-shot.ts +1 -3
  24. package/src/backend/remote-server/session-helpers.ts +1 -4
  25. package/src/backend/remote-server/sse-stream.ts +8 -9
  26. package/src/backend/runtime/metrics.ts +7 -13
  27. package/src/backend/runtime/sleep.ts +1 -2
  28. package/src/backend/runtime/turn/handle-retry.ts +48 -2
  29. package/src/backend/runtime/turn/handler-to-events.ts +3 -4
  30. package/src/bootstrap.ts +9 -1
  31. package/src/cli/doctor.ts +3 -0
  32. package/src/cli/index.ts +8 -10
  33. package/src/cli/logs.ts +148 -9
  34. package/src/cli/setup.ts +9 -11
  35. package/src/cli/status.ts +29 -0
  36. package/src/core/agent-runtime/README.md +5 -19
  37. package/src/core/agent-runtime/events.ts +3 -39
  38. package/src/core/agent-runtime/model-ref.ts +0 -8
  39. package/src/core/agents/registry.ts +65 -2
  40. package/src/core/auth/expiry-monitor.ts +9 -1
  41. package/src/core/auth/login-flow.ts +9 -1
  42. package/src/core/auth/status.ts +31 -3
  43. package/src/core/background/cron/scheduler.ts +25 -5
  44. package/src/core/background/dream/index.ts +29 -10
  45. package/src/core/background/failure-backoff.ts +30 -0
  46. package/src/core/background/heartbeat/agent.ts +2 -27
  47. package/src/core/background/heartbeat/index.ts +0 -2
  48. package/src/core/background/heartbeat/scheduler.ts +21 -13
  49. package/src/core/background/heartbeat/state.ts +10 -2
  50. package/src/core/background/isolated-agent.ts +6 -2
  51. package/src/core/background/pulse/pulse.ts +9 -0
  52. package/src/core/background/triggers/exit.ts +54 -0
  53. package/src/core/background/triggers/index.ts +1 -3
  54. package/src/core/background/triggers/resume.ts +2 -4
  55. package/src/core/backup/archive/tar.ts +14 -4
  56. package/src/core/backup/plan.ts +1 -0
  57. package/src/core/backup/restore.ts +21 -13
  58. package/src/core/backup/scheduler.ts +28 -9
  59. package/src/core/backup/snapshot.ts +43 -10
  60. package/src/core/backup/store.ts +7 -19
  61. package/src/core/backup/targets.ts +69 -17
  62. package/src/core/config/index.ts +14 -0
  63. package/src/core/daemon/crash-marker.ts +141 -0
  64. package/src/core/daemon/crash.ts +9 -2
  65. package/src/core/daemon/handoff.ts +15 -0
  66. package/src/core/daemon/health-alerts.ts +297 -0
  67. package/src/core/daemon/log-reader.ts +289 -0
  68. package/src/core/doctor/index.ts +18 -2
  69. package/src/core/doctor/logs.ts +124 -0
  70. package/src/core/doctor/types.ts +1 -1
  71. package/src/core/engine/backend-controller/index.ts +1 -13
  72. package/src/core/engine/backend-router/router.ts +1 -1
  73. package/src/core/engine/dispatcher.ts +55 -2
  74. package/src/core/engine/fault-text.ts +40 -0
  75. package/src/core/engine/gateway-actions/agents/index.ts +3 -2
  76. package/src/core/engine/gateway-actions/agents/report.ts +62 -0
  77. package/src/core/engine/gateway-actions/history.ts +2 -4
  78. package/src/core/engine/gateway-actions/mesh.ts +26 -14
  79. package/src/core/engine/gateway-actions/native/exec.ts +13 -16
  80. package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
  81. package/src/core/engine/gateway.ts +60 -1
  82. package/src/core/engine/turn-health.ts +222 -0
  83. package/src/core/errors.ts +2 -2
  84. package/src/core/frontend-runtime/admin-notify.ts +1 -1
  85. package/src/core/frontend-runtime/alerts.ts +130 -0
  86. package/src/core/mcp-hub/children.ts +78 -29
  87. package/src/core/mcp-hub/index.ts +21 -18
  88. package/src/core/mcp-hub/proxy-server.ts +8 -4
  89. package/src/core/mcp-hub/talon-server.ts +5 -12
  90. package/src/core/mesh/credentials/store.ts +16 -1
  91. package/src/core/mesh/devices/registry.ts +1 -17
  92. package/src/core/mesh/devices/service.ts +49 -17
  93. package/src/core/mesh/devices/teleport.ts +14 -2
  94. package/src/core/mesh/links/node-binaries.ts +13 -6
  95. package/src/core/mesh/persist.ts +22 -10
  96. package/src/core/mesh/transfers/device-files.ts +5 -23
  97. package/src/core/mesh/transfers/transfers.ts +16 -3
  98. package/src/core/models/active-model.ts +2 -55
  99. package/src/core/plugin/actions.ts +19 -20
  100. package/src/core/plugin/builtins.ts +80 -90
  101. package/src/core/plugin/index.ts +1 -4
  102. package/src/core/plugin/loader.ts +25 -33
  103. package/src/core/plugin/mcp.ts +3 -5
  104. package/src/core/plugin/registry.ts +19 -35
  105. package/src/core/plugin/types.ts +2 -5
  106. package/src/core/prompt/assemble.ts +15 -3
  107. package/src/core/scripts/lua.ts +6 -2
  108. package/src/core/tasks/table.ts +8 -2
  109. package/src/core/tools/bridge.ts +2 -4
  110. package/src/core/tools/chat/cross-send.ts +1 -1
  111. package/src/core/tools/chat/messaging.ts +1 -1
  112. package/src/core/tools/index.ts +2 -2
  113. package/src/core/tools/mcp-env.ts +2 -59
  114. package/src/core/tools/ops/agents.ts +22 -1
  115. package/src/core/tools/schemas.ts +4 -9
  116. package/src/core/vfs/fusefs.ts +0 -5
  117. package/src/core/vfs/index.ts +9 -2
  118. package/src/core/vfs/mounts/diagnostics.ts +109 -0
  119. package/src/core/vfs/mounts/proc.ts +17 -1
  120. package/src/core/vfs/workspace.ts +7 -3
  121. package/src/core/weaver/shuttle.ts +8 -1
  122. package/src/core/weaver/turn-log.ts +320 -0
  123. package/src/core/weaver/weaver.ts +48 -5
  124. package/src/frontend/discord/actions/index.ts +8 -1
  125. package/src/frontend/discord/diagnostics.ts +82 -6
  126. package/src/frontend/discord/handlers/index.ts +0 -2
  127. package/src/frontend/discord/middleware.ts +7 -13
  128. package/src/frontend/discord/runtime.ts +1 -3
  129. package/src/frontend/health/delivery.ts +115 -0
  130. package/src/frontend/health/outage.ts +116 -0
  131. package/src/frontend/native/bridge/routes/chats.ts +3 -5
  132. package/src/frontend/native/bridge/server.ts +106 -21
  133. package/src/frontend/native/index.ts +1 -1
  134. package/src/frontend/native/media/media.ts +5 -1
  135. package/src/frontend/native/runtime.ts +12 -7
  136. package/src/frontend/native/surface/handlers.ts +1 -1
  137. package/src/frontend/native/surface/memory.ts +1 -1
  138. package/src/frontend/native/surface/models.ts +3 -3
  139. package/src/frontend/native/surface/settings.ts +20 -8
  140. package/src/frontend/native/turn/context.ts +6 -8
  141. package/src/frontend/native/turn/turn-meta.ts +2 -5
  142. package/src/frontend/native/turn/turn.ts +8 -10
  143. package/src/frontend/presentation/format.ts +2 -4
  144. package/src/frontend/presentation/session-status.ts +2 -6
  145. package/src/frontend/teams/actions.ts +8 -1
  146. package/src/frontend/teams/graph.ts +0 -1
  147. package/src/frontend/teams/index.ts +1 -4
  148. package/src/frontend/teams/poll.ts +40 -2
  149. package/src/frontend/teams/runtime.ts +14 -5
  150. package/src/frontend/telegram/actions/index.ts +4 -1
  151. package/src/frontend/telegram/actions/send.ts +8 -0
  152. package/src/frontend/telegram/handlers/context.ts +13 -2
  153. package/src/frontend/telegram/handlers/delivery.ts +12 -9
  154. package/src/frontend/telegram/handlers/index.ts +0 -2
  155. package/src/frontend/telegram/index.ts +35 -9
  156. package/src/frontend/telegram/polling/poll-health.ts +110 -0
  157. package/src/frontend/telegram/userbot.ts +100 -36
  158. package/src/frontend/terminal/builtins/session.ts +2 -2
  159. package/src/frontend/terminal/index.ts +1 -3
  160. package/src/frontend/terminal/renderer.ts +2 -18
  161. package/src/frontend/whatsapp/actions/index.ts +12 -1
  162. package/src/frontend/whatsapp/actions/messaging.ts +7 -2
  163. package/src/frontend/whatsapp/connection/connection.ts +29 -9
  164. package/src/frontend/whatsapp/connection/health.ts +89 -0
  165. package/src/frontend/whatsapp/connection/identity.ts +4 -4
  166. package/src/frontend/whatsapp/runtime.ts +11 -5
  167. package/src/native/blake3.ts +28 -2
  168. package/src/native/fusefs.ts +23 -5
  169. package/src/native/registry.ts +1 -1
  170. package/src/native/warden.ts +33 -5
  171. package/src/plugins/github/index.ts +0 -1
  172. package/src/plugins/mempalace/index.ts +9 -3
  173. package/src/plugins/playwright/index.ts +2 -4
  174. package/src/plugins/playwright/provision.ts +12 -4
  175. package/src/storage/chat-settings.ts +6 -1
  176. package/src/storage/cron.ts +29 -4
  177. package/src/storage/daily-log.ts +43 -47
  178. package/src/storage/db.ts +61 -33
  179. package/src/storage/history.ts +6 -1
  180. package/src/storage/journal.ts +9 -2
  181. package/src/storage/kv.ts +19 -6
  182. package/src/storage/media-index.ts +28 -6
  183. package/src/storage/repositories/chat-settings-repo.ts +8 -2
  184. package/src/storage/repositories/sessions-repo.ts +10 -3
  185. package/src/storage/scripts.ts +24 -13
  186. package/src/storage/sessions.ts +11 -2
  187. package/src/storage/skills.ts +21 -2
  188. package/src/storage/stickers.ts +17 -3
  189. package/src/storage/triggers.ts +8 -3
  190. package/src/storage/turn-meta.ts +25 -7
  191. package/src/util/log.ts +189 -6
  192. package/src/util/logging/turn-scope.ts +85 -0
  193. package/src/util/time.ts +3 -3
  194. package/src/util/watchdog.ts +30 -0
  195. package/src/core/engine/backend-controller/legacy.ts +0 -111
@@ -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();
package/src/cli/logs.ts CHANGED
@@ -1,13 +1,72 @@
1
1
  /**
2
2
  * `talon logs` — pretty-print the last lines of the JSON log file and then
3
3
  * tail it live.
4
+ *
5
+ * Filters (`--errors`, `--component`, `--since`, `--grep`, `--turn`)
6
+ * switch to the shared log reader (core/daemon/log-reader.ts — the same
7
+ * parser behind proc/log, proc/errors and doctor): the backlog is read
8
+ * across rotated generations, and the live tail applies the same filter.
9
+ * `--no-follow` prints the backlog and exits, for scripts and agents.
4
10
  */
5
11
 
6
12
  import pc from "picocolors";
7
13
  import { existsSync, readFileSync, watchFile } from "node:fs";
14
+ import type { LogFilter, LogRecord } from "../core/daemon/log-reader.js";
8
15
  import { printBanner } from "./config.js";
9
16
  import { LOG_FILE } from "./context.js";
10
17
 
18
+ export type LogsOptions = {
19
+ filter: LogFilter;
20
+ /** Keep tailing after the backlog (default true). */
21
+ follow: boolean;
22
+ /** Backlog size; undefined = 30, or 1000 under `--since`. */
23
+ lines?: number;
24
+ };
25
+
26
+ const LOGS_USAGE =
27
+ "talon logs [--errors] [--component <c>] [--since <30m|2h|1d>] " +
28
+ "[--grep <text>] [--turn <id>] [-n <lines>] [--no-follow]";
29
+
30
+ /** Parse `talon logs` argv; a string is a usage error to print. */
31
+ export async function parseLogsArgs(
32
+ args: string[],
33
+ now: number = Date.now(),
34
+ ): Promise<LogsOptions | string> {
35
+ const { parseLogDuration } = await import("../core/daemon/log-reader.js");
36
+ const opts: LogsOptions = { filter: {}, follow: true };
37
+ for (let i = 0; i < args.length; i++) {
38
+ const flag = args[i];
39
+ const value = (): string | undefined => args[++i];
40
+ if (flag === "--errors") opts.filter.minLevel = "warn";
41
+ else if (flag === "--no-follow") opts.follow = false;
42
+ else if (flag === "--component") opts.filter.component = value();
43
+ else if (flag === "--grep") opts.filter.grep = value();
44
+ else if (flag === "--turn") opts.filter.turn = value();
45
+ else if (flag === "--since") {
46
+ const ms = parseLogDuration(value() ?? "");
47
+ if (ms === null) return `--since needs a duration like 30m, 2h, 1d`;
48
+ opts.filter.since = now - ms;
49
+ } else if (flag === "-n" || flag === "--lines") {
50
+ const n = Number(value());
51
+ if (!Number.isInteger(n) || n <= 0) return `${flag} needs a count`;
52
+ opts.lines = n;
53
+ } else return `unknown option: ${flag}\n usage: ${LOGS_USAGE}`;
54
+ if (i >= args.length) return `${flag} needs a value`;
55
+ }
56
+ return opts;
57
+ }
58
+
59
+ /** `parseLogsArgs` then run; usage errors exit non-zero. */
60
+ export async function runLogsCommand(args: string[]): Promise<void> {
61
+ const opts = await parseLogsArgs(args);
62
+ if (typeof opts === "string") {
63
+ console.error(` ${pc.red("✖")} ${opts}`);
64
+ process.exitCode = 1;
65
+ return;
66
+ }
67
+ await tailLogs(opts);
68
+ }
69
+
11
70
  const LEVEL_LABELS: Record<number, string> = {
12
71
  10: pc.dim("TRC"),
13
72
  20: pc.dim("DBG"),
@@ -17,6 +76,25 @@ const LEVEL_LABELS: Record<number, string> = {
17
76
  60: pc.bgRed(pc.white("FTL")),
18
77
  };
19
78
 
79
+ const LEVEL_NAME_LABELS: Record<LogRecord["level"], string> = {
80
+ trace: LEVEL_LABELS[10],
81
+ debug: LEVEL_LABELS[20],
82
+ info: LEVEL_LABELS[30],
83
+ warn: LEVEL_LABELS[40],
84
+ error: LEVEL_LABELS[50],
85
+ fatal: LEVEL_LABELS[60],
86
+ };
87
+
88
+ /** A parsed record in the same shape as {@link formatLogLine}. */
89
+ function formatRecord(rec: LogRecord): string {
90
+ const time = pc.dim(new Date(rec.ts).toTimeString().slice(0, 8));
91
+ const comp = pc.cyan((rec.component ?? "?").padEnd(10));
92
+ const turn =
93
+ rec.turn && !rec.msg.includes("turn=") ? ` turn=${rec.turn}` : "";
94
+ const err = rec.err ? pc.red(` (${rec.err})`) : "";
95
+ return ` ${time} ${LEVEL_NAME_LABELS[rec.level]} ${comp} ${rec.msg}${turn}${err}`;
96
+ }
97
+
20
98
  function formatLogLine(line: string): string {
21
99
  try {
22
100
  const obj = JSON.parse(line);
@@ -31,7 +109,13 @@ function formatLogLine(line: string): string {
31
109
  }
32
110
  }
33
111
 
34
- export async function tailLogs(): Promise<void> {
112
+ function hasFilter(filter: LogFilter): boolean {
113
+ return Object.values(filter).some((v) => v !== undefined);
114
+ }
115
+
116
+ export async function tailLogs(
117
+ opts: LogsOptions = { filter: {}, follow: true },
118
+ ): Promise<void> {
35
119
  printBanner();
36
120
  if (!existsSync(LOG_FILE)) {
37
121
  console.log(
@@ -39,18 +123,73 @@ export async function tailLogs(): Promise<void> {
39
123
  );
40
124
  return;
41
125
  }
42
- console.log(
43
- ` ${pc.dim("Tailing")} ${pc.dim(LOG_FILE)}\n ${pc.dim("Press Ctrl+C to stop")}\n`,
44
- );
126
+ if (hasFilter(opts.filter)) return tailFiltered(opts);
127
+ if (opts.follow) {
128
+ console.log(
129
+ ` ${pc.dim("Tailing")} ${pc.dim(LOG_FILE)}\n ${pc.dim("Press Ctrl+C to stop")}\n`,
130
+ );
131
+ }
45
132
  const content = readFileSync(LOG_FILE, "utf-8");
46
133
  const lines = content.trim().split("\n");
47
- for (const line of lines.slice(-30)) console.log(formatLogLine(line));
48
- let lastSize = lines.length;
49
- watchFile(LOG_FILE, { interval: 500 }, () => {
134
+ for (const line of lines.slice(-(opts.lines ?? 30)))
135
+ console.log(formatLogLine(line));
136
+ if (!opts.follow) return;
137
+ await followLog(lines.length, (line) => console.log(formatLogLine(line)));
138
+ }
139
+
140
+ /** The filtered path: backlog across generations, then a filtered tail. */
141
+ async function tailFiltered(opts: LogsOptions): Promise<void> {
142
+ const { readLogRecords, parseLogLine, matchesLogFilter } =
143
+ await import("../core/daemon/log-reader.js");
144
+ const limit = opts.lines ?? (opts.filter.since !== undefined ? 1000 : 30);
145
+ const backlog = readLogRecords(LOG_FILE, {
146
+ limit,
147
+ filter: opts.filter,
148
+ // The CLI owns its process: read whole generations, not a window.
149
+ maxBytesPerFile: 64 * 1024 * 1024,
150
+ });
151
+ const desc = describeFilter(opts.filter);
152
+ console.log(
153
+ ` ${pc.dim(`${backlog.length} matching entr${backlog.length === 1 ? "y" : "ies"} (${desc})`)}` +
154
+ (opts.follow ? `\n ${pc.dim("Following — Ctrl+C to stop")}` : "") +
155
+ "\n",
156
+ );
157
+ for (const rec of backlog) console.log(formatRecord(rec));
158
+ if (!opts.follow) return;
159
+ const start = readFileSync(LOG_FILE, "utf-8").trim().split("\n").length;
160
+ await followLog(start, (line) => {
161
+ const rec = parseLogLine(line);
162
+ if (rec && matchesLogFilter(rec, opts.filter))
163
+ console.log(formatRecord(rec));
164
+ });
165
+ }
166
+
167
+ function describeFilter(filter: LogFilter): string {
168
+ const parts: string[] = [];
169
+ if (filter.minLevel) parts.push(`>=${filter.minLevel}`);
170
+ if (filter.component) parts.push(`component=${filter.component}`);
171
+ if (filter.since !== undefined)
172
+ parts.push(`since ${new Date(filter.since).toTimeString().slice(0, 8)}`);
173
+ if (filter.grep) parts.push(`grep "${filter.grep}"`);
174
+ if (filter.turn) parts.push(`turn=${filter.turn}`);
175
+ return parts.join(", ");
176
+ }
177
+
178
+ /** Print each line appended to the log from line `from` on, forever. */
179
+ async function followLog(
180
+ from: number,
181
+ onLine: (line: string) => void,
182
+ ): Promise<void> {
183
+ let lastSize = from;
184
+ watchFile(LOG_FILE, { interval: 500 }, (curr, prev) => {
185
+ // A rotation (daemon start → talon.log.old, or the sink's runtime
186
+ // shift to talon.log.1) leaves a new file under the name, and counting
187
+ // its lines against the old one's would stay silent until it outgrew
188
+ // the file it replaced.
189
+ if (curr.ino !== prev.ino || curr.size < prev.size) lastSize = 0;
50
190
  try {
51
191
  const nl = readFileSync(LOG_FILE, "utf-8").trim().split("\n");
52
- for (let i = lastSize; i < nl.length; i++)
53
- console.log(formatLogLine(nl[i]));
192
+ for (let i = lastSize; i < nl.length; i++) onLine(nl[i]);
54
193
  lastSize = nl.length;
55
194
  } catch {
56
195
  /* ignore */
package/src/cli/setup.ts CHANGED
@@ -26,11 +26,9 @@ const trimmedOrUndefined = (raw: string) => raw.trim() || undefined;
26
26
  /**
27
27
  * Await a clack prompt; on Esc/Ctrl-C say so and leave the wizard.
28
28
  *
29
- * Every prompt used to be followed by the same four-line `isCancel` guard,
30
- * and because `@clack/core` narrows `isCancel` to its own unique symbol
31
- * the unguarded remainder still needed an `as string` cast. clack only
32
- * ever resolves a symbol to mean "cancelled", so narrowing on `typeof`
33
- * here removes both.
29
+ * clack only ever resolves a symbol to mean "cancelled", so narrowing on
30
+ * `typeof` replaces a per-prompt `isCancel` guard and the `as` cast that
31
+ * `@clack/core`'s own unique-symbol narrowing would still need.
34
32
  *
35
33
  * The answer type is subtracted with `Exclude` rather than inferred from a
36
34
  * `Promise<T | symbol>` parameter: clack 1.8.1 retyped `CANCEL_SYMBOL` as a
@@ -667,12 +665,12 @@ function telegramAccess(
667
665
  *
668
666
  * Extracted from `runSetup` so the merge is testable without driving the
669
667
  * prompts — the behaviour that matters here is what it *doesn't* touch.
670
- * The wizard models roughly half of ~/.talon/config.json; it used to
671
- * rebuild the file from its own named fields alone, which silently
672
- * deleted every other key (whatsapp, native, soul, memory, github,
673
- * heartbeat/dream, allowlists, plugin blocks…). Spreading `existing`
674
- * first keeps them. Fields below still override, and an explicit
675
- * `undefined` still deletes, because `saveConfig` strips undefined.
668
+ * The wizard models roughly half of ~/.talon/config.json. Spreading
669
+ * `existing` first keeps every key it does not model (whatsapp, native,
670
+ * memory, github, heartbeat/dream, allowlists, plugin blocks…) — building
671
+ * from the named fields alone silently deletes them. Fields below still
672
+ * override, and an explicit `undefined` still deletes, because
673
+ * `saveConfig` strips undefined.
676
674
  */
677
675
  export function buildSetupConfig(
678
676
  existing: Config,
package/src/cli/status.ts CHANGED
@@ -15,6 +15,29 @@ function formatUptime(seconds: number): string {
15
15
  return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
16
16
  }
17
17
 
18
+ type HealthAlert = { key: string; severity: string; message: string };
19
+
20
+ /** The daemon's active alerts from its /health body (older daemons: none). */
21
+ function healthAlerts(health: Record<string, unknown>): HealthAlert[] {
22
+ const raw = health.alerts;
23
+ if (!Array.isArray(raw)) return [];
24
+ return raw.filter(
25
+ (a): a is HealthAlert =>
26
+ typeof a === "object" &&
27
+ a !== null &&
28
+ typeof a.key === "string" &&
29
+ typeof a.message === "string",
30
+ );
31
+ }
32
+
33
+ /** One line per active alert, coloured by severity. Shared with doctor. */
34
+ export function formatAlertLines(health: Record<string, unknown>): string[] {
35
+ return healthAlerts(health).map((a) => {
36
+ const dot = a.severity === "warn" ? pc.yellow("●") : pc.red("●");
37
+ return ` ${dot} ${pc.bold(a.key)} ${a.message}`;
38
+ });
39
+ }
40
+
18
41
  export async function showStatus(): Promise<void> {
19
42
  printBanner();
20
43
  const instance = await findRunningInstance();
@@ -38,6 +61,12 @@ export async function showStatus(): Promise<void> {
38
61
  console.log(` ${pc.dim("Queue")} ${h.queue} pending`);
39
62
  console.log(` ${pc.dim("Errors")} ${h.errors}`);
40
63
  console.log(` ${pc.dim("Last active")} ${h.lastActivity}\n`);
64
+ const alerts = formatAlertLines(h);
65
+ if (alerts.length > 0) {
66
+ console.log(` ${pc.bold("Active alerts")}\n`);
67
+ for (const line of alerts) console.log(line);
68
+ console.log();
69
+ }
41
70
  return;
42
71
  }
43
72
 
@@ -41,15 +41,16 @@ error | completed
41
41
  ```
42
42
 
43
43
  Plus `UsageSnapshot`, `AgentError` (with `AgentErrorKind`),
44
- `AgentResult`. Helpers: `emptyUsage`, `addUsage`, `isAgentEventOf`,
45
- `isAgentRunTerminator`.
44
+ `AgentResult`, and `AgentRunError` — what the dispatcher rethrows an
45
+ `error` terminator as. Helpers: `emptyUsage`, `toolInputToRecord`,
46
+ `classifiedToAgentError`.
46
47
 
47
48
  ### `model-ref.ts`
48
49
 
49
50
  Typed model identity. `ModelRef = { backend: BackendId, id,
50
51
  displayName, ... }`. Owns the `BACKEND_IDS` literal — single source of
51
52
  truth for which backends the typed union can route to. Helpers:
52
- `isBackendId`, `sameModelRef`, `makeBareModelRef`.
53
+ `isBackendId`, `makeBareModelRef`.
53
54
 
54
55
  ### `capabilities.ts`
55
56
 
@@ -101,16 +102,6 @@ Backend contract assertions any conforming `Backend` must pass:
101
102
 
102
103
  Each throws `ContractViolation` with a descriptive message.
103
104
 
104
- ### `event-bridge.ts`
105
-
106
- The bridge between the canonical `AgentEvent` stream and the
107
- callback-shaped consumer contract the dispatcher uses upstream of the
108
- backend. `pipeEventsToCallbacks(stream, callbacks)` consumes an
109
- `AgentEvent` stream and invokes the supplied callbacks (`onStreamDelta`
110
- / `onTextBlock` / `onToolUse`), returns the final `AgentResult`, and
111
- throws `BridgedAgentError` carrying the original `AgentError` if the
112
- stream terminates with an error event.
113
-
114
105
  ## Migration cookbook
115
106
 
116
107
  ### Adding a new backend
@@ -164,11 +155,6 @@ returns `{ model: string | null, ref: ModelRef | null, source }`:
164
155
  - `source` carries the chain step that produced the model, useful
165
156
  for toast wording and stale-slot cleanup.
166
157
 
167
- Convenience wrappers:
168
-
169
- - `getActiveModelForChat(...)` → `model`
170
- - `getActiveModelRefForChat(...)` → `ref`
171
-
172
158
  ### Adding a new store
173
159
 
174
160
  New structured state goes in the SQLite layer — see the layering doc
@@ -178,7 +164,7 @@ in `src/storage/db.ts` (sql/<store>.sql → repositories/<store>-repo.ts
178
164
  ## Invariants
179
165
 
180
166
  - `BACKEND_IDS` in `model-ref.ts` is the source of truth for the typed
181
- union. `src/util/config.ts` zod enums are wired to the same literal.
167
+ union. `core/config/index.ts` zod enums are wired to the same literal.
182
168
  - `AgentEvent.type` is the ONLY discrimination mechanism. No class
183
169
  hierarchy, no `instanceof` checks.
184
170
  - Every `ChatBackend.runChatTurn` stream terminates with `completed`