talon-agent 5.18.2 → 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 (190) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  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/stream.ts +2 -2
  8. package/src/backend/codex/auth.ts +1 -1
  9. package/src/backend/codex/handler/message.ts +11 -11
  10. package/src/backend/codex/init.ts +4 -9
  11. package/src/backend/codex/mcp-config.ts +1 -2
  12. package/src/backend/codex/oauth-incompat.ts +8 -4
  13. package/src/backend/codex/one-shot.ts +1 -1
  14. package/src/backend/openai-agents/builtins.ts +55 -27
  15. package/src/backend/openai-agents/factory.ts +3 -3
  16. package/src/backend/openai-agents/handler/message.ts +3 -5
  17. package/src/backend/openai-agents/mcp-pool.ts +10 -27
  18. package/src/backend/remote-server/chat-turn.ts +6 -6
  19. package/src/backend/remote-server/events.ts +1 -5
  20. package/src/backend/remote-server/index.ts +0 -1
  21. package/src/backend/remote-server/messages.ts +3 -7
  22. package/src/backend/remote-server/one-shot.ts +1 -3
  23. package/src/backend/remote-server/session-helpers.ts +1 -4
  24. package/src/backend/remote-server/sse-stream.ts +8 -9
  25. package/src/backend/runtime/metrics.ts +7 -13
  26. package/src/backend/runtime/sleep.ts +1 -2
  27. package/src/backend/runtime/turn/handle-retry.ts +48 -2
  28. package/src/backend/runtime/turn/handler-to-events.ts +3 -4
  29. package/src/bootstrap.ts +9 -1
  30. package/src/cli/doctor.ts +3 -0
  31. package/src/cli/index.ts +8 -10
  32. package/src/cli/logs.ts +148 -9
  33. package/src/cli/setup.ts +9 -11
  34. package/src/cli/status.ts +29 -0
  35. package/src/core/agent-runtime/README.md +5 -19
  36. package/src/core/agent-runtime/events.ts +3 -39
  37. package/src/core/agent-runtime/model-ref.ts +0 -8
  38. package/src/core/agents/registry.ts +49 -2
  39. package/src/core/auth/expiry-monitor.ts +9 -1
  40. package/src/core/auth/login-flow.ts +9 -1
  41. package/src/core/auth/status.ts +31 -3
  42. package/src/core/background/cron/scheduler.ts +25 -5
  43. package/src/core/background/dream/index.ts +29 -10
  44. package/src/core/background/failure-backoff.ts +30 -0
  45. package/src/core/background/heartbeat/agent.ts +2 -27
  46. package/src/core/background/heartbeat/index.ts +0 -2
  47. package/src/core/background/heartbeat/scheduler.ts +21 -13
  48. package/src/core/background/heartbeat/state.ts +10 -2
  49. package/src/core/background/isolated-agent.ts +6 -2
  50. package/src/core/background/pulse/pulse.ts +9 -0
  51. package/src/core/background/triggers/exit.ts +54 -0
  52. package/src/core/background/triggers/index.ts +1 -3
  53. package/src/core/background/triggers/resume.ts +2 -4
  54. package/src/core/backup/archive/tar.ts +14 -4
  55. package/src/core/backup/restore.ts +21 -13
  56. package/src/core/backup/scheduler.ts +28 -9
  57. package/src/core/backup/snapshot.ts +43 -10
  58. package/src/core/backup/store.ts +7 -19
  59. package/src/core/backup/targets.ts +69 -17
  60. package/src/core/config/index.ts +14 -0
  61. package/src/core/daemon/crash-marker.ts +141 -0
  62. package/src/core/daemon/crash.ts +9 -2
  63. package/src/core/daemon/handoff.ts +15 -0
  64. package/src/core/daemon/health-alerts.ts +297 -0
  65. package/src/core/daemon/log-reader.ts +289 -0
  66. package/src/core/doctor/index.ts +18 -2
  67. package/src/core/doctor/logs.ts +124 -0
  68. package/src/core/doctor/types.ts +1 -1
  69. package/src/core/engine/backend-controller/index.ts +1 -13
  70. package/src/core/engine/backend-router/router.ts +1 -1
  71. package/src/core/engine/dispatcher.ts +55 -2
  72. package/src/core/engine/fault-text.ts +40 -0
  73. package/src/core/engine/gateway-actions/agents/index.ts +3 -2
  74. package/src/core/engine/gateway-actions/agents/report.ts +62 -0
  75. package/src/core/engine/gateway-actions/history.ts +2 -4
  76. package/src/core/engine/gateway-actions/native/exec.ts +13 -16
  77. package/src/core/engine/gateway.ts +60 -1
  78. package/src/core/engine/turn-health.ts +222 -0
  79. package/src/core/errors.ts +2 -2
  80. package/src/core/frontend-runtime/admin-notify.ts +1 -1
  81. package/src/core/frontend-runtime/alerts.ts +130 -0
  82. package/src/core/mcp-hub/children.ts +78 -29
  83. package/src/core/mcp-hub/index.ts +21 -18
  84. package/src/core/mcp-hub/proxy-server.ts +8 -4
  85. package/src/core/mcp-hub/talon-server.ts +5 -12
  86. package/src/core/mesh/credentials/store.ts +16 -1
  87. package/src/core/mesh/devices/service.ts +26 -15
  88. package/src/core/mesh/devices/teleport.ts +14 -2
  89. package/src/core/mesh/links/node-binaries.ts +13 -6
  90. package/src/core/mesh/persist.ts +22 -10
  91. package/src/core/mesh/transfers/device-files.ts +5 -23
  92. package/src/core/mesh/transfers/transfers.ts +16 -3
  93. package/src/core/models/active-model.ts +2 -55
  94. package/src/core/plugin/actions.ts +19 -20
  95. package/src/core/plugin/builtins.ts +80 -90
  96. package/src/core/plugin/index.ts +1 -4
  97. package/src/core/plugin/loader.ts +25 -33
  98. package/src/core/plugin/mcp.ts +3 -5
  99. package/src/core/plugin/registry.ts +19 -35
  100. package/src/core/plugin/types.ts +2 -5
  101. package/src/core/prompt/assemble.ts +15 -3
  102. package/src/core/scripts/lua.ts +6 -2
  103. package/src/core/tasks/table.ts +8 -2
  104. package/src/core/tools/bridge.ts +2 -4
  105. package/src/core/tools/chat/cross-send.ts +1 -1
  106. package/src/core/tools/chat/messaging.ts +1 -1
  107. package/src/core/tools/index.ts +2 -2
  108. package/src/core/tools/mcp-env.ts +2 -59
  109. package/src/core/tools/ops/agents.ts +22 -1
  110. package/src/core/tools/schemas.ts +4 -9
  111. package/src/core/vfs/fusefs.ts +0 -5
  112. package/src/core/vfs/index.ts +9 -2
  113. package/src/core/vfs/mounts/diagnostics.ts +109 -0
  114. package/src/core/vfs/mounts/proc.ts +17 -1
  115. package/src/core/vfs/workspace.ts +7 -3
  116. package/src/core/weaver/shuttle.ts +8 -1
  117. package/src/core/weaver/turn-log.ts +320 -0
  118. package/src/core/weaver/weaver.ts +48 -5
  119. package/src/frontend/discord/actions/index.ts +8 -1
  120. package/src/frontend/discord/diagnostics.ts +82 -6
  121. package/src/frontend/discord/handlers/index.ts +0 -2
  122. package/src/frontend/discord/middleware.ts +7 -13
  123. package/src/frontend/discord/runtime.ts +1 -3
  124. package/src/frontend/health/delivery.ts +115 -0
  125. package/src/frontend/health/outage.ts +116 -0
  126. package/src/frontend/native/bridge/routes/chats.ts +3 -5
  127. package/src/frontend/native/bridge/server.ts +106 -21
  128. package/src/frontend/native/index.ts +1 -1
  129. package/src/frontend/native/media/media.ts +5 -1
  130. package/src/frontend/native/runtime.ts +12 -7
  131. package/src/frontend/native/surface/handlers.ts +1 -1
  132. package/src/frontend/native/surface/memory.ts +1 -1
  133. package/src/frontend/native/surface/models.ts +3 -3
  134. package/src/frontend/native/surface/settings.ts +20 -8
  135. package/src/frontend/native/turn/context.ts +6 -8
  136. package/src/frontend/native/turn/turn-meta.ts +2 -5
  137. package/src/frontend/native/turn/turn.ts +8 -10
  138. package/src/frontend/presentation/format.ts +2 -4
  139. package/src/frontend/presentation/session-status.ts +2 -6
  140. package/src/frontend/teams/actions.ts +8 -1
  141. package/src/frontend/teams/graph.ts +0 -1
  142. package/src/frontend/teams/index.ts +1 -4
  143. package/src/frontend/teams/poll.ts +40 -2
  144. package/src/frontend/teams/runtime.ts +14 -5
  145. package/src/frontend/telegram/actions/index.ts +4 -1
  146. package/src/frontend/telegram/actions/send.ts +8 -0
  147. package/src/frontend/telegram/handlers/context.ts +13 -2
  148. package/src/frontend/telegram/handlers/delivery.ts +12 -9
  149. package/src/frontend/telegram/handlers/index.ts +0 -2
  150. package/src/frontend/telegram/index.ts +35 -9
  151. package/src/frontend/telegram/polling/poll-health.ts +110 -0
  152. package/src/frontend/telegram/userbot.ts +100 -36
  153. package/src/frontend/terminal/builtins/session.ts +2 -2
  154. package/src/frontend/terminal/index.ts +1 -3
  155. package/src/frontend/terminal/renderer.ts +2 -18
  156. package/src/frontend/whatsapp/actions/index.ts +12 -1
  157. package/src/frontend/whatsapp/actions/messaging.ts +7 -2
  158. package/src/frontend/whatsapp/connection/connection.ts +29 -9
  159. package/src/frontend/whatsapp/connection/health.ts +89 -0
  160. package/src/frontend/whatsapp/connection/identity.ts +4 -4
  161. package/src/frontend/whatsapp/runtime.ts +11 -5
  162. package/src/native/blake3.ts +28 -2
  163. package/src/native/fusefs.ts +23 -5
  164. package/src/native/registry.ts +1 -1
  165. package/src/native/warden.ts +33 -5
  166. package/src/plugins/github/index.ts +0 -1
  167. package/src/plugins/mempalace/index.ts +9 -3
  168. package/src/plugins/playwright/index.ts +2 -4
  169. package/src/plugins/playwright/provision.ts +12 -4
  170. package/src/storage/chat-settings.ts +6 -1
  171. package/src/storage/cron.ts +29 -4
  172. package/src/storage/daily-log.ts +43 -47
  173. package/src/storage/db.ts +61 -33
  174. package/src/storage/history.ts +6 -1
  175. package/src/storage/journal.ts +9 -2
  176. package/src/storage/kv.ts +19 -6
  177. package/src/storage/media-index.ts +28 -6
  178. package/src/storage/repositories/chat-settings-repo.ts +8 -2
  179. package/src/storage/repositories/sessions-repo.ts +10 -3
  180. package/src/storage/scripts.ts +24 -13
  181. package/src/storage/sessions.ts +11 -2
  182. package/src/storage/skills.ts +21 -2
  183. package/src/storage/stickers.ts +17 -3
  184. package/src/storage/triggers.ts +8 -3
  185. package/src/storage/turn-meta.ts +25 -7
  186. package/src/util/log.ts +189 -6
  187. package/src/util/logging/turn-scope.ts +85 -0
  188. package/src/util/time.ts +3 -3
  189. package/src/util/watchdog.ts +30 -0
  190. package/src/core/engine/backend-controller/legacy.ts +0 -111
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Reply delivery health — the worst silent failure is a turn that ran but
3
+ * whose answer never reached the chat. The model sees a failed tool call;
4
+ * the person who asked sees nothing, and so does the operator.
5
+ *
6
+ * Each frontend wraps its action handler with `trackDeliveries`. The
7
+ * reply actions (`send_message`, `send_message_with_buttons`, `reply_to`)
8
+ * are counted per chat: `DELIVERY_ALERT_AFTER` failures in a row for one
9
+ * chat raise `delivery.<frontend>` with that chat and the error, and the
10
+ * next success to it resolves the alert once no chat is still failing.
11
+ */
12
+
13
+ import type { ActionResult } from "../../core/types.js";
14
+ import {
15
+ raiseAlert,
16
+ resolveAlert,
17
+ } from "../../core/frontend-runtime/alerts.js";
18
+ import { log, logWarn, type LogComponent } from "../../util/log.js";
19
+ import { errorText } from "./outage.js";
20
+
21
+ /** Consecutive failed replies to one chat before the operator hears of it. */
22
+ const DELIVERY_ALERT_AFTER = 3;
23
+
24
+ const REPLY_ACTIONS = new Set([
25
+ "send_message",
26
+ "send_message_with_buttons",
27
+ "reply_to",
28
+ ]);
29
+
30
+ export type DeliveryTracker = {
31
+ failed(chat: string | number, err: unknown): void;
32
+ delivered(chat: string | number): void;
33
+ };
34
+
35
+ /**
36
+ * `label` is the operator-facing platform name ("Telegram"); the alert key
37
+ * is `delivery.<frontend>`.
38
+ */
39
+ export function createDeliveryTracker(
40
+ frontend: string,
41
+ label: string,
42
+ component: LogComponent,
43
+ ): DeliveryTracker {
44
+ const key = `delivery.${frontend}`;
45
+ const streaks = new Map<string, number>();
46
+ /** Chats that crossed the threshold and have not delivered since. */
47
+ const alerted = new Set<string>();
48
+
49
+ return {
50
+ failed(chat, err) {
51
+ const id = String(chat);
52
+ const streak = (streaks.get(id) ?? 0) + 1;
53
+ streaks.set(id, streak);
54
+ const error = errorText(err);
55
+ logWarn(
56
+ component,
57
+ `delivery.fail frontend=${frontend} chat=${id} streak=${streak} err=${error}`,
58
+ );
59
+ if (streak < DELIVERY_ALERT_AFTER) return;
60
+ alerted.add(id);
61
+ raiseAlert(
62
+ key,
63
+ `${label} replies to chat ${id} have failed ${streak} times in a row: ${error}. ` +
64
+ "Answers are not reaching that chat.",
65
+ );
66
+ },
67
+ delivered(chat) {
68
+ const id = String(chat);
69
+ const streak = streaks.get(id);
70
+ if (streak === undefined) return;
71
+ streaks.delete(id);
72
+ log(
73
+ component,
74
+ `delivery.recovered frontend=${frontend} chat=${id} after_failures=${streak}`,
75
+ );
76
+ if (alerted.delete(id) && alerted.size === 0) {
77
+ resolveAlert(key, `${label} replies are being delivered again.`);
78
+ }
79
+ },
80
+ };
81
+ }
82
+
83
+ type ActionHandler = (
84
+ body: Record<string, unknown>,
85
+ chatId: number,
86
+ ) => Promise<ActionResult | null>;
87
+
88
+ /**
89
+ * Wrap a frontend action handler so its reply actions feed `tracker`.
90
+ * Results and thrown errors pass through untouched. `chatOf` names the
91
+ * destination when an action can target a chat other than `chatId`.
92
+ */
93
+ export function trackDeliveries(
94
+ tracker: DeliveryTracker,
95
+ handler: ActionHandler,
96
+ chatOf: (body: Record<string, unknown>, chatId: number) => string | number = (
97
+ _body,
98
+ chatId,
99
+ ) => chatId,
100
+ ): ActionHandler {
101
+ return async (body, chatId) => {
102
+ if (!REPLY_ACTIONS.has(body.action as string)) return handler(body, chatId);
103
+ const chat = chatOf(body, chatId);
104
+ let result: ActionResult | null;
105
+ try {
106
+ result = await handler(body, chatId);
107
+ } catch (err) {
108
+ tracker.failed(chat, err);
109
+ throw err;
110
+ }
111
+ if (result?.ok) tracker.delivered(chat);
112
+ else if (result) tracker.failed(chat, result.error ?? "unknown error");
113
+ return result;
114
+ };
115
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Connection outages — the line between "a reconnect failed" (log it) and
3
+ * "this chat channel is down" (tell the operator).
4
+ *
5
+ * A frontend reports every failed poll / reconnect with `fail` and every
6
+ * healthy one with `ok`. The first failure arms a timer; if nothing healthy
7
+ * arrives before `thresholdMs`, the alert is raised with the latest error.
8
+ * The timer — not the next failure — decides, because a link that dies
9
+ * silently (a gateway that never reconnects) produces no further events.
10
+ * `ok` ends the outage and, when an alert went out, sends the recovery.
11
+ */
12
+
13
+ import {
14
+ raiseAlert,
15
+ resolveAlert,
16
+ type AlertSeverity,
17
+ } from "../../core/frontend-runtime/alerts.js";
18
+
19
+ export type OutageOptions = {
20
+ /** Stable alert key, e.g. "telegram.polling". */
21
+ key: string;
22
+ /** How long failures must persist before the operator hears of it. */
23
+ thresholdMs: number;
24
+ severity?: AlertSeverity;
25
+ /** Operator text for the raise, given the latest error and minutes down. */
26
+ describe: (lastError: string, downMin: number) => string;
27
+ /** Operator text for the recovery notice. */
28
+ recovered: string;
29
+ };
30
+
31
+ export type Outage = {
32
+ /** Record a failure. Returns the streak so the caller can log it. */
33
+ fail(err: unknown): { attempt: number; downMs: number };
34
+ /** Record a healthy round-trip. Returns the outage it ended, if any. */
35
+ ok(): { attempts: number; downMs: number } | null;
36
+ /** Raise immediately — for failures that will not heal on their own. */
37
+ raiseNow(message: string, severity?: AlertSeverity): void;
38
+ /** True between the first failure and the next `ok`. */
39
+ readonly down: boolean;
40
+ /** Forget the outage without resolving it — shutdown. */
41
+ dispose(): void;
42
+ };
43
+
44
+ const MAX_ERROR_CHARS = 200;
45
+
46
+ /**
47
+ * One-line error text fit for an alert or a log line: the message only,
48
+ * bounded, with bot tokens and webhook signatures masked.
49
+ */
50
+ export function errorText(err: unknown): string {
51
+ const raw = err instanceof Error ? err.message : String(err);
52
+ const text = raw
53
+ .replace(/bot\d+:[\w-]+/g, "bot<redacted>")
54
+ .replace(/([?&]sig=)[^&\s]+/g, "$1<redacted>")
55
+ .replace(/\s+/g, " ")
56
+ .trim();
57
+ return text.length > MAX_ERROR_CHARS
58
+ ? `${text.slice(0, MAX_ERROR_CHARS)}…`
59
+ : text;
60
+ }
61
+
62
+ export function createOutage(opts: OutageOptions): Outage {
63
+ let since = 0;
64
+ let attempts = 0;
65
+ let lastError = "";
66
+ let raised = false;
67
+ let timer: ReturnType<typeof setTimeout> | null = null;
68
+
69
+ const clearTimer = (): void => {
70
+ if (timer) clearTimeout(timer);
71
+ timer = null;
72
+ };
73
+ const raise = (message: string, severity?: AlertSeverity): void => {
74
+ raised = true;
75
+ raiseAlert(opts.key, message, { severity: severity ?? opts.severity });
76
+ };
77
+
78
+ return {
79
+ get down() {
80
+ return attempts > 0;
81
+ },
82
+ fail(err) {
83
+ const now = Date.now();
84
+ if (attempts === 0) {
85
+ since = now;
86
+ timer = setTimeout(() => {
87
+ timer = null;
88
+ const mins = Math.max(1, Math.round((Date.now() - since) / 60_000));
89
+ raise(opts.describe(lastError, mins));
90
+ }, opts.thresholdMs);
91
+ timer.unref?.();
92
+ }
93
+ attempts++;
94
+ lastError = errorText(err);
95
+ return { attempt: attempts, downMs: now - since };
96
+ },
97
+ ok() {
98
+ if (attempts === 0 && !raised) return null;
99
+ const ended = { attempts, downMs: attempts ? Date.now() - since : 0 };
100
+ clearTimer();
101
+ attempts = 0;
102
+ if (raised) resolveAlert(opts.key, opts.recovered);
103
+ raised = false;
104
+ return ended;
105
+ },
106
+ raiseNow(message, severity) {
107
+ clearTimer();
108
+ raise(message, severity);
109
+ },
110
+ dispose() {
111
+ clearTimer();
112
+ attempts = 0;
113
+ raised = false;
114
+ },
115
+ };
116
+ }
@@ -132,11 +132,9 @@ export function chatRoutes(
132
132
  const startedAt = Date.now();
133
133
  try {
134
134
  const attachment = await h.upload(filename, contentType, req);
135
- // Uploads are the one client action whose failure used to be visible
136
- // only in the app: the route answered in JSON and logged nothing, so
137
- // "attaching a file doesn't work — check the logs" had nothing to
138
- // read. Both outcomes are logged now, with the size and duration
139
- // that distinguish a rejected upload from a dropped connection.
135
+ // Log both outcomes, with the size and duration that tell a rejected
136
+ // upload from a dropped connection — the JSON answer is otherwise
137
+ // only visible in the app.
140
138
  log(
141
139
  "native",
142
140
  `upload ok: ${attachment.name} (${attachment.size} bytes, ${attachment.mimeType}) in ${Date.now() - startedAt}ms`,
@@ -22,9 +22,14 @@ import {
22
22
  } from "node:http";
23
23
  import { createServer as createTlsServer } from "node:https";
24
24
  import { createHash, timingSafeEqual } from "node:crypto";
25
- import { createReadStream } from "node:fs";
25
+ import { createReadStream, type ReadStream } from "node:fs";
26
26
  import { stat } from "node:fs/promises";
27
- import { log, logError, logDebug } from "../../../util/log.js";
27
+ import { log, logError, logDebug, logWarn } from "../../../util/log.js";
28
+ import {
29
+ raiseAlert,
30
+ resolveAlert,
31
+ } from "../../../core/frontend-runtime/alerts.js";
32
+ import { errorText } from "../../health/outage.js";
28
33
  import {
29
34
  formatFingerprint,
30
35
  isLoopbackHost,
@@ -63,6 +68,14 @@ type StreamSession = {
63
68
  };
64
69
 
65
70
  const SSE_PING_MS = 25_000;
71
+ /**
72
+ * Unsent bytes a stream may hold before it counts as dead. A client that
73
+ * stops reading without closing (phone asleep, network switch) otherwise
74
+ * buffers every broadcast in memory until TCP gives up on it, minutes later.
75
+ * Far past anything a live client falls behind by; evicted, it reconnects
76
+ * and gets a fresh `hello`.
77
+ */
78
+ const SSE_MAX_BACKLOG_BYTES = 16 * 1024 * 1024;
66
79
  const MAX_BODY_BYTES = 256 * 1024;
67
80
  const PORT_FALLBACKS = 5;
68
81
 
@@ -94,6 +107,15 @@ export const DEFAULT_BRIDGE_TIMEOUTS: BridgeTimeouts = {
94
107
  checkIntervalMs: 30_000,
95
108
  };
96
109
 
110
+ /**
111
+ * `pipe` never closes its source when the destination goes away, so a
112
+ * client that hangs up mid-download (app backgrounded, image scrolled
113
+ * away) would leave the paused read stream holding its fd forever.
114
+ */
115
+ function releaseOnClose(res: ServerResponse, stream: ReadStream): void {
116
+ res.once("close", () => stream.destroy());
117
+ }
118
+
97
119
  export class BridgeServer {
98
120
  private server: Server | null = null;
99
121
  /**
@@ -225,14 +247,14 @@ export class BridgeServer {
225
247
  *
226
248
  * Device commands are not public: their params carry one-time transfer
227
249
  * tokens, exec command lines, remote paths, and — on the chunked fallback —
228
- * whole base64 file bodies. Broadcasting them handed every connected client
229
- * another device's secrets and relied on each client discarding what wasn't
230
- * addressed to it, which is courtesy, not enforcement.
250
+ * whole base64 file bodies. Broadcasting them would hand every connected
251
+ * client another device's secrets and rely on each client discarding what
252
+ * isn't addressed to it, which is courtesy, not enforcement.
231
253
  *
232
254
  * A claim is an ADDRESS, not a credential: any client holding the bridge
233
255
  * token could claim any id, and the bridge token is (still) the only trust
234
- * boundary here. What this buys is that a device no longer passively
235
- * receives traffic meant for its peers.
256
+ * boundary here. What this buys is that a device does not passively
257
+ * receive traffic meant for its peers.
236
258
  *
237
259
  * Clients that claimed nothing are the fallback audience, and only when the
238
260
  * target claimed nothing either: a companion build that predates the claim
@@ -265,12 +287,24 @@ export class BridgeServer {
265
287
 
266
288
  private write(targets: Iterable<ServerResponse>, event: BridgeEvent): void {
267
289
  const payload = `data: ${JSON.stringify(event)}\n\n`;
268
- for (const res of targets) {
269
- try {
270
- res.write(payload);
271
- } catch {
272
- // Write on a half-closed socket — the 'close' handler will evict it.
273
- }
290
+ for (const res of targets) this.send(res, payload);
291
+ }
292
+
293
+ /** Write one frame to a stream, evicting it if its backlog never drains. */
294
+ private send(res: ServerResponse, frame: string): void {
295
+ if (res.writableLength > SSE_MAX_BACKLOG_BYTES) {
296
+ this.clients.delete(res);
297
+ logWarn(
298
+ "native",
299
+ `Dropped an SSE client that stopped reading (${res.writableLength} bytes unsent)`,
300
+ );
301
+ res.destroy();
302
+ return;
303
+ }
304
+ try {
305
+ res.write(frame);
306
+ } catch {
307
+ // Write on a half-closed socket — the 'close' handler will evict it.
274
308
  }
275
309
  }
276
310
 
@@ -281,7 +315,7 @@ export class BridgeServer {
281
315
  loopback: isLoopbackHost(this.opts.host),
282
316
  allowWeakToken: this.opts.allowWeakToken,
283
317
  });
284
- this.tlsIdentity = this.opts.tls ? await this.opts.tls() : null;
318
+ this.tlsIdentity = this.opts.tls ? await this.loadTls(this.opts.tls) : null;
285
319
  const onRequest = (req: IncomingMessage, res: ServerResponse): void => {
286
320
  this.handle(req, res).catch((err) => {
287
321
  logError("native", "Bridge request handler threw", err);
@@ -312,29 +346,70 @@ export class BridgeServer {
312
346
  : createServer(serverOpts, onRequest);
313
347
 
314
348
  this.pingTimer = setInterval(() => {
315
- for (const res of this.clients.keys()) {
316
- try {
317
- res.write(": ping\n\n");
318
- } catch {
319
- /* evicted on close */
320
- }
321
- }
349
+ for (const res of this.clients.keys()) this.send(res, ": ping\n\n");
322
350
  }, SSE_PING_MS);
323
351
  this.pingTimer.unref?.();
324
352
  this.unsubscribeRevocations = this.opts.credentials?.authority.onRevoked(
325
353
  (ids) => this.dropCredentialSessions(ids),
326
354
  );
327
355
 
356
+ return this.bind(server);
357
+ }
358
+
359
+ /**
360
+ * The TLS identity, or a thrown boot failure the operator hears about:
361
+ * without it no companion app can connect.
362
+ */
363
+ private async loadTls(
364
+ load: () => Promise<BridgeTlsIdentity>,
365
+ ): Promise<BridgeTlsIdentity> {
366
+ try {
367
+ const identity = await load();
368
+ resolveAlert(
369
+ "bridge.tls",
370
+ "The client bridge TLS certificate loads again.",
371
+ );
372
+ return identity;
373
+ } catch (err) {
374
+ logError("native", `bridge.tls.fail err=${errorText(err)}`, err);
375
+ raiseAlert(
376
+ "bridge.tls",
377
+ `The client bridge could not load its TLS certificate: ${errorText(err)}. Companion apps cannot connect.`,
378
+ { severity: "critical" },
379
+ );
380
+ throw err;
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Listen on the configured port, stepping up to PORT_FALLBACKS ports past
386
+ * it when one is taken. A bind that fails for good raises `bridge.listen`
387
+ * — the frontend has no surface at all without it.
388
+ */
389
+ private bind(server: Server): Promise<number> {
328
390
  return new Promise<number>((resolve, reject) => {
329
391
  let attempt = 0;
330
392
  const tryPort = (p: number): void => {
331
393
  server.once("error", (err: NodeJS.ErrnoException) => {
332
394
  if (err.code === "EADDRINUSE" && attempt < PORT_FALLBACKS) {
333
395
  attempt++;
396
+ logWarn(
397
+ "native",
398
+ `bridge.listen port=${p} in use — trying port=${p + 1} attempt=${attempt}/${PORT_FALLBACKS}`,
399
+ );
334
400
  server.removeAllListeners("error");
335
401
  server.removeAllListeners("listening");
336
402
  tryPort(p + 1);
337
403
  } else {
404
+ logError(
405
+ "native",
406
+ `bridge.listen.fail host=${this.opts.host} port=${p} attempt=${attempt} err=${errorText(err)}`,
407
+ );
408
+ raiseAlert(
409
+ "bridge.listen",
410
+ `The client bridge could not listen on ${this.opts.host}:${p}: ${errorText(err)}. Companion apps cannot connect.`,
411
+ { severity: "critical" },
412
+ );
338
413
  reject(err);
339
414
  }
340
415
  });
@@ -362,6 +437,10 @@ export class BridgeServer {
362
437
  `Bridge certificate fingerprint ${formatFingerprint(this.tlsIdentity.fingerprint)}`,
363
438
  );
364
439
  }
440
+ resolveAlert(
441
+ "bridge.listen",
442
+ "The client bridge is listening again.",
443
+ );
365
444
  resolve(this.port);
366
445
  });
367
446
  };
@@ -507,6 +586,7 @@ export class BridgeServer {
507
586
  });
508
587
  const stream = createReadStream(file.path);
509
588
  stream.on("error", () => res.destroy());
589
+ releaseOnClose(res, stream);
510
590
  stream.pipe(res);
511
591
  }
512
592
 
@@ -532,6 +612,7 @@ export class BridgeServer {
532
612
  if (!res.headersSent) res.writeHead(500);
533
613
  res.end();
534
614
  });
615
+ releaseOnClose(res, stream);
535
616
  stream.pipe(res);
536
617
  } catch {
537
618
  return this.json(res, 404, { ok: false, error: "No such media" });
@@ -599,6 +680,10 @@ export class BridgeServer {
599
680
  const ms = Math.round(max * (0.9 + Math.random() * 0.2));
600
681
  const timer = setTimeout(() => {
601
682
  logDebug("native", "bridge.sse event=max_lifetime reason=expired");
683
+ // Out of the fan-out before end(): a stream still flushing a backlog
684
+ // stays open until it drains, and a broadcast in that window is a
685
+ // write after end — an unhandled 'error' that takes the daemon down.
686
+ this.clients.delete(res);
602
687
  res.end();
603
688
  }, ms);
604
689
  timer.unref?.();
@@ -191,7 +191,7 @@ export function createNativeFrontend(
191
191
  await mesh.load();
192
192
  unregisterMeshTransport = registerMeshTransport(runtime, server);
193
193
  // Mesh tool actions (list_devices / get_device_location) are shared
194
- // gateway actions now — no native-only cases here.
194
+ // gateway actions — no native-only cases here.
195
195
  gateway.registerFrontendHandler(
196
196
  "native",
197
197
  createNativeActionHandler({
@@ -20,13 +20,17 @@ import type { NativeRuntime } from "../runtime.js";
20
20
  */
21
21
  export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
22
22
 
23
- /** Register a file for serving and return its short media id. */
23
+ /** Register a file for serving and return its short media id (stable per
24
+ * path for the life of the daemon). */
24
25
  export function registerMedia(
25
26
  runtime: NativeRuntime,
26
27
  filePath: string,
27
28
  ): string {
29
+ const known = runtime.mediaIds.get(filePath);
30
+ if (known) return known;
28
31
  const id = `m${runtime.nextId().toString(36)}`;
29
32
  runtime.media.set(id, filePath);
33
+ runtime.mediaIds.set(filePath, id);
30
34
  return id;
31
35
  }
32
36
 
@@ -1,11 +1,9 @@
1
1
  /**
2
- * Native frontend runtime — the state every bridge module shares.
3
- *
4
- * `createNativeFrontend` used to hold all of this as closure variables with
5
- * every handler nested inside it. It is now one explicit object, constructed
6
- * once, that each module (chat-wire, context, emit, turn, …) takes as its
7
- * first parameter. The runtime carries state plus two primitives — the
8
- * message-id minter and the broadcast sink; the modules own the behaviour.
2
+ * Native frontend runtime — the state every bridge module shares: one
3
+ * object, constructed once, that each module (chat-wire, context, emit,
4
+ * turn, …) takes as its first parameter. It carries state plus two
5
+ * primitives — the message-id minter and the broadcast sink; the modules
6
+ * own the behaviour.
9
7
  */
10
8
 
11
9
  import type { TalonConfig } from "../../core/config/index.js";
@@ -55,6 +53,12 @@ export type NativeRuntime = {
55
53
  * frontends behave).
56
54
  */
57
55
  readonly media: Map<string, string>;
56
+ /**
57
+ * `media` reversed (path → id). Every history page re-registers its
58
+ * attachments, so without this each fetch — and every client reconnect
59
+ * re-fetches — grew `media` by one entry per attachment, forever.
60
+ */
61
+ readonly mediaIds: Map<string, string>;
58
62
  /**
59
63
  * Uploads this daemon run has accepted, keyed by their media id. `/send`
60
64
  * resolves a client's attachment references through here rather than
@@ -110,6 +114,7 @@ export function createNativeRuntime(
110
114
  chats: new NativeChats(),
111
115
  mesh: getMeshService(),
112
116
  media: new Map(),
117
+ mediaIds: new Map(),
113
118
  uploads: new Map(),
114
119
  contextByChat: new Map(),
115
120
  liveTurns: new Map(),
@@ -79,7 +79,7 @@ function resolveAttachments(
79
79
  }
80
80
  }
81
81
  // A dropped reference is how "I attached a file and the agent never saw it"
82
- // happens, and it used to be entirely silent. The usual cause is a media id
82
+ // happens, so it must not be silent. The usual cause is a media id
83
83
  // minted by a previous daemon run — `runtime.uploads` is per-run, so a
84
84
  // client that staged a file before a restart and sent it after points at
85
85
  // nothing.
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Read-only on purpose: a bridge client can ask what Talon remembers and
7
7
  * why, and nothing more. Asserting, superseding and dropping stay with
8
- * the write path (rollout PR 6), so a paired phone can never quietly
8
+ * the write path, so a paired phone can never quietly
9
9
  * rewrite the operator's memory.
10
10
  *
11
11
  * The one piece of policy here is the limit cap: whatever a client asks
@@ -35,9 +35,9 @@ export async function listModels(
35
35
  ): Promise<{ active: string; models: ModelOption[] }> {
36
36
  const { config } = runtime;
37
37
  // Resolve the chat's *own* backend so the model list tracks whatever
38
- // backend the chat is currently bound to (fixes the list staying on the
39
- // previous backend's models after a switch). Fall back to the global
40
- // default backend when there's no chat / the pool isn't ready yet.
38
+ // backend the chat is currently bound to, not the one before a switch.
39
+ // Fall back to the global default backend when there's no chat / the
40
+ // pool isn't ready yet.
41
41
  let backendId: string = config.backend;
42
42
  let active = config.model;
43
43
  if (chatId) {
@@ -186,19 +186,31 @@ export function applyConfigUpdate(
186
186
  * Merge a partial update into talon.json on disk, preserving everything
187
187
  * else. Shared by every bridge surface that persists config changes
188
188
  * (settings sync here, plugin toggles in extensions.ts).
189
+ *
190
+ * An existing file that can't be read or isn't a JSON object throws and
191
+ * is left untouched: merging into `{}` would replace the operator's whole
192
+ * config (tokens included) with just this patch.
189
193
  */
190
194
  export function persistConfigPatch(update: Record<string, unknown>): void {
191
195
  const file = pathFiles.config;
192
196
  let current: Record<string, unknown> = {};
193
- try {
194
- if (existsSync(file)) {
195
- current = JSON.parse(readFileSync(file, "utf-8")) as Record<
196
- string,
197
- unknown
198
- >;
197
+ if (existsSync(file)) {
198
+ let parsed: unknown;
199
+ try {
200
+ parsed = JSON.parse(readFileSync(file, "utf-8"));
201
+ } catch (err) {
202
+ throw new Error(
203
+ `Refusing to write ${file}: cannot read the existing file ` +
204
+ `(${err instanceof Error ? err.message : err}). It was left untouched.`,
205
+ );
206
+ }
207
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
208
+ throw new Error(
209
+ `Refusing to write ${file}: the top level is not a JSON object. ` +
210
+ `It was left untouched.`,
211
+ );
199
212
  }
200
- } catch {
201
- /* corrupt/absent — start from empty */
213
+ current = parsed as Record<string, unknown>;
202
214
  }
203
215
  for (const [k, v] of Object.entries(update)) {
204
216
  if (v === undefined) delete current[k];
@@ -88,14 +88,12 @@ export async function refreshContext(
88
88
  /**
89
89
  * Fill the context cache for chats restored at startup.
90
90
  *
91
- * The readout is served from an in-memory map that was only ever written at
92
- * turn end, so after a restart every existing chat reported no context at
93
- * all — the header chip vanished until that chat ran another turn, which
94
- * read as "context usage doesn't save". The numbers themselves are
95
- * persisted with the session; only the cache was cold. This re-reads them
96
- * for the most recently active chats (bounded, and on the cheap path that
97
- * never touches the backend pool); anything it skips or can't resolve is
98
- * filled in the moment the chat is opened.
91
+ * The readout is served from an in-memory map otherwise written only at
92
+ * turn end, so after a restart every chat would show no context until its
93
+ * next turn, though the numbers are persisted with the session. This
94
+ * re-reads them for the most recently active chats (bounded, and on the
95
+ * cheap path that never touches the backend pool); anything it skips or
96
+ * can't resolve is filled in the moment the chat is opened.
99
97
  */
100
98
  const CONTEXT_WARM_LIMIT = 40;
101
99
 
@@ -2,11 +2,8 @@
2
2
  * Turn-meta sidecar — thin native-frontend façade over the shared
3
3
  * storage/turn-meta store. It owns the TurnMeta *shape* (tool calls,
4
4
  * duration, token usage — the companion app's tool timeline and stats
5
- * footer); the storage layer treats each record as opaque JSON.
6
- *
7
- * The data used to live in a debounced JSON file under the data dir;
8
- * it now rides the `turn_meta` SQLite table shared by every frontend.
9
- * SQLite commits on every write, so the old flush timer is gone.
5
+ * footer); the storage layer treats each record as opaque JSON in the
6
+ * `turn_meta` SQLite table shared by every frontend.
10
7
  */
11
8
 
12
9
  import {