talon-agent 3.35.0 → 3.37.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.35.0",
3
+ "version": "3.37.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { resolve } from "node:path";
8
+ import { logWarn } from "../../util/log.js";
8
9
  import { wrapMcpServer } from "../../util/mcp-launcher.js";
9
10
  import { isBunRuntime } from "../../util/runtime.js";
10
11
  import { registry, reloadState } from "./registry.js";
@@ -51,6 +52,18 @@ export function getPluginMcpServers(
51
52
  for (const { plugin, envVars } of registry.all) {
52
53
  // Skip plugins not in the allow-list when filtering
53
54
  if (only !== undefined && !only.includes(plugin.name)) continue;
55
+
56
+ // Let the plugin re-materialize on-disk state the child will read. Runs
57
+ // on every build so a deleted file heals within a turn; never fatal.
58
+ try {
59
+ if (plugin.mcpServer || plugin.mcpServerPath) plugin.prepareMcpSpawn?.();
60
+ } catch (err) {
61
+ logWarn(
62
+ "plugin",
63
+ `${plugin.name}: prepareMcpSpawn failed: ${err instanceof Error ? err.message : String(err)}`,
64
+ );
65
+ }
66
+
54
67
  const baseEnv = buildBridgeEnv(bridgeUrl, chatId, envVars);
55
68
 
56
69
  if (plugin.mcpServer) {
@@ -119,6 +119,17 @@ export const _deps = {
119
119
  // dynamic import() rejects bare drive-letter paths (e.g. C:\...).
120
120
  // Leave node: specifiers, relative paths, and URLs unchanged.
121
121
  const isAbsFilePath = /^[a-zA-Z]:[/\\]/.test(path) || path.startsWith("/");
122
- return import(isAbsFilePath ? pathToFileURL(path).href : path);
122
+ if (!isAbsFilePath) return import(path);
123
+
124
+ // Cache-bust with the reload timestamp. ESM caches modules by resolved
125
+ // URL forever, so without a changing query a hot reload re-imports the
126
+ // *old* module object and the plugin's own source edits are invisible
127
+ // until a full restart — silently, since the stale module still loads
128
+ // fine (observed 2026-09-16: a rewritten plugin kept reporting its
129
+ // previous version through three reloads). The timestamp only moves per
130
+ // reload, so every module in one load cycle shares a key.
131
+ const url = pathToFileURL(path);
132
+ url.searchParams.set("talonReloadAt", reloadState.lastReloadAt);
133
+ return import(url.href);
123
134
  },
124
135
  };
@@ -85,6 +85,18 @@ export interface TalonPlugin {
85
85
  readonly args: readonly string[];
86
86
  };
87
87
 
88
+ /**
89
+ * Materialize anything the MCP child needs on disk before it is spawned.
90
+ *
91
+ * MCP children are spawned lazily and respawned on demand for the lifetime
92
+ * of the daemon, so state written once at load time can go missing long
93
+ * before the last spawn. This hook runs every time the MCP server map is
94
+ * built, making such state self-healing instead of broken-until-restart.
95
+ * Must be synchronous, cheap, and idempotent; throwing is swallowed and
96
+ * logged (a failed prepare must not take the whole server map down).
97
+ */
98
+ prepareMcpSpawn?(): void;
99
+
88
100
  /**
89
101
  * Map plugin config to env vars for the MCP subprocess and action handlers.
90
102
  * Called once at load time. Values are set on process.env for the main
@@ -10,6 +10,16 @@ import { logWarn } from "../../util/log.js";
10
10
  /** Platforms expire typing indicators after ~5s; refresh under that. */
11
11
  const TYPING_REFRESH_MS = 4000;
12
12
 
13
+ /**
14
+ * Consecutive failures after which the loop gives up for the rest of the
15
+ * turn. A chat that has gone away (deleted, kicked, migrated) fails every
16
+ * single refresh, and a long turn then logs one warning every 4 seconds for
17
+ * a cosmetic indicator — 55 of them in 30 seconds on 2026-09-15, all
18
+ * "sendChatAction failed (400: chat not found)". Three strikes is enough to
19
+ * tell a transient blip from a chat that is simply gone.
20
+ */
21
+ const MAX_CONSECUTIVE_FAILURES = 3;
22
+
13
23
  export type SendTyping = (
14
24
  numericChatId: number,
15
25
  stringId?: string,
@@ -22,15 +32,36 @@ export function startTypingLoop(
22
32
  stringId: string,
23
33
  intervalMs = TYPING_REFRESH_MS,
24
34
  ): () => void {
35
+ let timer: ReturnType<typeof setInterval> | undefined;
36
+ let consecutiveFailures = 0;
37
+
38
+ const stop = () => {
39
+ if (timer !== undefined) clearInterval(timer);
40
+ timer = undefined;
41
+ };
42
+
25
43
  const send = (label: string) => {
26
- sendTyping(numericChatId, stringId).catch((err: unknown) => {
27
- logWarn(
28
- "dispatcher",
29
- `${label} failed: ${err instanceof Error ? err.message : String(err)}`,
30
- );
31
- });
44
+ sendTyping(numericChatId, stringId).then(
45
+ () => {
46
+ consecutiveFailures = 0;
47
+ },
48
+ (err: unknown) => {
49
+ consecutiveFailures++;
50
+ const reason = err instanceof Error ? err.message : String(err);
51
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
52
+ stop();
53
+ logWarn(
54
+ "dispatcher",
55
+ `${label} failed ${consecutiveFailures}x in a row — no typing indicator for the rest of this turn: ${reason}`,
56
+ );
57
+ return;
58
+ }
59
+ logWarn("dispatcher", `${label} failed: ${reason}`);
60
+ },
61
+ );
32
62
  };
63
+
33
64
  send("sendTyping");
34
- const timer = setInterval(() => send("sendTyping interval"), intervalMs);
35
- return () => clearInterval(timer);
65
+ timer = setInterval(() => send("sendTyping interval"), intervalMs);
66
+ return stop;
36
67
  }
@@ -14,14 +14,18 @@ import type { ChatEntry } from "./chats.js";
14
14
  import type { ClientChat, QueuedMessage } from "./protocol.js";
15
15
  import type { NativeRuntime } from "./runtime.js";
16
16
 
17
- /** Project the stored queue entry to its wire shape (text + attachment flag). */
17
+ /** Project the stored queue entry to its wire shape (text + attachments). */
18
18
  function toQueued(
19
19
  runtime: NativeRuntime,
20
20
  chatId: string,
21
21
  ): QueuedMessage | undefined {
22
22
  const q = runtime.queuedByChat.get(chatId);
23
23
  if (!q) return undefined;
24
- return { text: q.text, hasAttachment: Boolean(q.attachmentPath) };
24
+ return {
25
+ text: q.text,
26
+ hasAttachment: q.attachments.length > 0,
27
+ attachmentCount: q.attachments.length,
28
+ };
25
29
  }
26
30
 
27
31
  export function toClientChat(
@@ -12,6 +12,7 @@ import {
12
12
  BOT_SENDER_ID,
13
13
  USER_SENDER_ID,
14
14
  type ClientButton,
15
+ type ClientAttachment,
15
16
  type ClientMessage,
16
17
  } from "./protocol.js";
17
18
  import type { NativeRuntime } from "./runtime.js";
@@ -104,43 +105,61 @@ export function emitPhoto(
104
105
  return id;
105
106
  }
106
107
 
108
+ /** The sidebar preview for a message whose real content is its files. */
109
+ function attachmentPreview(attachments: ClientAttachment[]): string {
110
+ if (attachments.length === 1) {
111
+ const only = attachments[0]!;
112
+ return only.image ? "[photo]" : `[${only.name}]`;
113
+ }
114
+ return `[${attachments.length} files]`;
115
+ }
116
+
107
117
  /** Persist + broadcast a user message; returns its numeric id so the turn
108
118
  * hands the model that same id (as `[msg_id:N]`) to react/reply to.
109
- * An optional imagePath renders an attached image inline. */
119
+ * Attached files ride along on the message and are persisted with it. */
110
120
  export function emitUser(
111
121
  runtime: NativeRuntime,
112
122
  entry: ChatEntry,
113
123
  text: string,
114
- imagePath?: string,
115
- attachmentPath?: string,
124
+ attachments: ClientAttachment[] = [],
116
125
  ): number {
117
126
  const id = runtime.nextId();
118
127
  const ts = Date.now();
128
+ // `imagePath` stays in sync with the first image so clients written against
129
+ // the single-image shape keep rendering it.
130
+ const firstImage = attachments.find((a) => a.image);
119
131
  const message: ClientMessage = {
120
132
  id: String(id),
121
133
  chatId: entry.id,
122
134
  role: "user",
123
135
  text,
124
136
  ts,
125
- ...(imagePath ? { imagePath } : {}),
137
+ ...(firstImage ? { imagePath: firstImage.url } : {}),
138
+ ...(attachments.length ? { attachments } : {}),
126
139
  };
127
- // For an attached image, persist the on-disk path + a `photo` mediaType so
128
- // it re-renders on history reload (rehydrated in the `history` handler)
129
- // instead of vanishing to a text-only placeholder. Caption stays as text.
140
+ // Persist the attachment records so the files re-render on history reload
141
+ // (rehydrated in `history.ts`) instead of vanishing to a text-only
142
+ // placeholder. mediaType/filePath keep the pre-multi-file row shape
143
+ // populated from the first attachment. Caption stays as text.
144
+ const first = attachments[0];
130
145
  pushMessage(entry.id, {
131
146
  msgId: id,
132
147
  senderId: USER_SENDER_ID,
133
148
  senderName: "User",
134
149
  text,
135
150
  timestamp: ts,
136
- ...(imagePath
151
+ ...(first
137
152
  ? {
138
- mediaType: "photo" as const,
139
- ...(attachmentPath ? { filePath: attachmentPath } : {}),
153
+ mediaType: first.image ? ("photo" as const) : ("document" as const),
154
+ filePath: first.path,
155
+ attachments,
140
156
  }
141
157
  : {}),
142
158
  });
143
- runtime.chats.touch(entry.id, imagePath ? text || "[photo]" : text);
159
+ runtime.chats.touch(
160
+ entry.id,
161
+ attachments.length ? text || attachmentPreview(attachments) : text,
162
+ );
144
163
  maybeAutoTitle(runtime, entry, text);
145
164
  runtime.broadcast({ kind: "message", chatId: entry.id, message });
146
165
  broadcastChatUpdated(runtime, entry);
@@ -18,7 +18,12 @@ import {
18
18
  } from "./extensions.js";
19
19
  import { historyPage, searchHistory } from "./history.js";
20
20
  import { readLogEntries } from "./logs.js";
21
- import { mediaUrl, registerMedia, saveUpload } from "./media.js";
21
+ import {
22
+ describeAttachment,
23
+ MAX_UPLOAD_BYTES,
24
+ resolveUpload,
25
+ saveUploadStream,
26
+ } from "./media.js";
22
27
  import {
23
28
  effortLevels,
24
29
  listBackends,
@@ -30,11 +35,57 @@ import {
30
35
  import { setQueued } from "./queue.js";
31
36
  import { resetChat } from "./reset.js";
32
37
  import type { NativeRuntime } from "./runtime.js";
33
- import type { BridgeServerHandlers } from "./server.js";
38
+ import type { BridgeServerHandlers, SendOptions } from "./server.js";
39
+ import type { ClientAttachment } from "./protocol.js";
34
40
  import { configSnapshot, applyConfigUpdate } from "./settings.js";
35
41
  import { bridgeStatus, broadcastStatus } from "./status.js";
42
+ import { logWarn } from "../../util/log.js";
36
43
  import { interruptTurn, isBusy, liveTurnEvents, startTurn } from "./turn.js";
37
44
 
45
+ /**
46
+ * Turn a `/send` body's attachment references into the records this daemon
47
+ * minted at upload time. Both wire shapes land here: the `attachments` list
48
+ * multi-file clients send, and the `imagePath`/`attachmentPath` pair older
49
+ * single-image clients send. References the daemon cannot account for are
50
+ * dropped — a message can only ever point the model at a file the daemon
51
+ * itself wrote to its uploads dir.
52
+ */
53
+ function resolveAttachments(
54
+ runtime: NativeRuntime,
55
+ opts: SendOptions | undefined,
56
+ ): ClientAttachment[] {
57
+ const refs = [...(opts?.attachments ?? [])];
58
+ if (!refs.length && (opts?.imagePath || opts?.attachmentPath)) {
59
+ refs.push({ url: opts.imagePath, path: opts.attachmentPath });
60
+ }
61
+ const resolved: ClientAttachment[] = [];
62
+ const dropped: string[] = [];
63
+ for (const ref of refs) {
64
+ const found = resolveUpload(runtime, ref);
65
+ if (!found) {
66
+ dropped.push(ref.url ?? ref.path ?? "(empty reference)");
67
+ continue;
68
+ }
69
+ // Same file referenced twice (a re-send of a queued message, say) stays
70
+ // one attachment.
71
+ if (!resolved.some((a) => a.path === found.path)) {
72
+ resolved.push(found);
73
+ }
74
+ }
75
+ // A dropped reference is how "I attached a file and the agent never saw it"
76
+ // happens, and it used to be entirely silent. The usual cause is a media id
77
+ // minted by a previous daemon run — `runtime.uploads` is per-run, so a
78
+ // client that staged a file before a restart and sent it after points at
79
+ // nothing.
80
+ if (dropped.length) {
81
+ logWarn(
82
+ "native",
83
+ `dropped ${dropped.length} unknown attachment reference(s) from /send: ${dropped.join(", ")} — re-upload the file (ids do not survive a daemon restart)`,
84
+ );
85
+ }
86
+ return resolved;
87
+ }
88
+
38
89
  export function buildBridgeHandlers(
39
90
  runtime: NativeRuntime,
40
91
  ): BridgeServerHandlers {
@@ -49,29 +100,44 @@ export function buildBridgeHandlers(
49
100
  search: (query, chatId) => searchHistory(runtime, query, chatId),
50
101
  send: (id, text, opts) => {
51
102
  const entry = chats.get(id) ?? chats.ensure(id);
103
+ // Resolve the client's references into the records this daemon minted
104
+ // at upload time — dropping anything it can't account for.
105
+ const attachments = resolveAttachments(runtime, opts);
52
106
  // A turn is already running for this chat — don't interrupt it. Park the
53
107
  // message as the single queued follow-up (synced to every client); it
54
108
  // auto-sends when the running turn ends. `isBusy` reads `liveTurns`,
55
109
  // which `runTurn` sets synchronously, so even a rapid second /send from
56
110
  // any client is caught here rather than starting a concurrent turn.
57
111
  if (isBusy(runtime, entry.id)) {
58
- setQueued(runtime, entry.id, {
59
- text,
60
- imagePath: opts?.imagePath,
61
- attachmentPath: opts?.attachmentPath,
62
- });
112
+ setQueued(runtime, entry.id, { text, attachments });
63
113
  return;
64
114
  }
65
- startTurn(runtime, entry, text, opts);
115
+ startTurn(runtime, entry, text, { attachments });
66
116
  },
67
117
  queueMessage: (id, text) => {
68
- // Edit/replace the queued follow-up (text-only). Empty clears it.
118
+ // Edit/replace the queued follow-up's text, keeping whatever files were
119
+ // queued with it. Empty text cancels the whole follow-up, attachments
120
+ // included — the client's queue editor offers no other way to drop it.
69
121
  const entry = chats.get(id);
70
- if (entry) setQueued(runtime, entry.id, { text });
122
+ if (!entry) return;
123
+ const attachments = text.trim()
124
+ ? (runtime.queuedByChat.get(entry.id)?.attachments ?? [])
125
+ : [];
126
+ setQueued(runtime, entry.id, { text, attachments });
71
127
  },
72
- upload: async (filename, _contentType, bytes) => {
73
- const path = await saveUpload(runtime, filename, bytes);
74
- return { imagePath: mediaUrl(registerMedia(runtime, path)), path };
128
+ upload: async (filename, contentType, body) => {
129
+ const saved = await saveUploadStream(
130
+ runtime,
131
+ filename,
132
+ body,
133
+ MAX_UPLOAD_BYTES,
134
+ );
135
+ return describeAttachment(runtime, {
136
+ path: saved.path,
137
+ name: filename,
138
+ size: saved.size,
139
+ contentType,
140
+ });
75
141
  },
76
142
  listModels: (chatId) => listModels(runtime, chatId),
77
143
  setModel: (id, model) => setModel(runtime, id, model),
@@ -3,18 +3,21 @@
3
3
  * (attached images get a fresh /media URL, assistant rows their turn meta).
4
4
  */
5
5
 
6
+ import { basename } from "node:path";
6
7
  import {
7
8
  getRecentHistory,
8
9
  getHistoryBefore,
9
10
  searchHistoryMessages,
10
11
  type HistoryMessage,
12
+ type MessageAttachment,
11
13
  } from "../../storage/history.js";
12
14
  import { isDeliveryTool } from "../../core/tools/index.js";
13
15
  import type { ChatEntry } from "./chats.js";
14
16
  import { refreshContext } from "./context.js";
15
- import { mediaUrl, registerMedia } from "./media.js";
17
+ import { contentTypeFor, rehydrateAttachment } from "./media.js";
16
18
  import {
17
19
  historyToClientMessage,
20
+ type ClientAttachment,
18
21
  type ClientMessage,
19
22
  type SearchResult,
20
23
  } from "./protocol.js";
@@ -29,11 +32,32 @@ function hydrateHistoryRow(
29
32
  row: HistoryMessage,
30
33
  ): ClientMessage {
31
34
  const msg = historyToClientMessage(row, chatId);
32
- // Re-hydrate an attached image: re-register its on-disk path into
33
- // the media map and hand back a fresh /media URL so the image shows
34
- // in reloaded history (survives restarts as long as the file exists).
35
- if (row.mediaType === "photo" && row.filePath) {
36
- msg.imagePath = mediaUrl(registerMedia(runtime, row.filePath));
35
+ // Re-hydrate attached files: media ids are per-daemon-run, so each stored
36
+ // attachment is re-registered and handed a fresh /media URL. Rows written
37
+ // before multi-file attachments carry only mediaType + filePath, which is
38
+ // projected into the same one-element list so every client renders history
39
+ // the same way regardless of when it was recorded.
40
+ const stored: ClientAttachment[] = row.attachments?.length
41
+ ? row.attachments.map((a: MessageAttachment) =>
42
+ rehydrateAttachment(runtime, { ...a, url: "" }),
43
+ )
44
+ : row.filePath &&
45
+ (row.mediaType === "photo" || row.mediaType === "document")
46
+ ? [
47
+ rehydrateAttachment(runtime, {
48
+ path: row.filePath,
49
+ name: basename(row.filePath),
50
+ size: 0,
51
+ mimeType: contentTypeFor(row.filePath),
52
+ image: row.mediaType === "photo",
53
+ url: "",
54
+ }),
55
+ ]
56
+ : [];
57
+ if (stored.length) {
58
+ msg.attachments = stored;
59
+ const firstImage = stored.find((a) => a.image);
60
+ if (firstImage) msg.imagePath = firstImage.url;
37
61
  }
38
62
  // Re-hydrate turn meta (tool timeline + stats) for assistant rows.
39
63
  if (msg.role === "assistant") {