talon-agent 3.8.1 → 3.9.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 (55) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -2
  3. package/src/backend/claude-sdk/handler.ts +1 -1
  4. package/src/backend/codex/handler/message.ts +1 -1
  5. package/src/backend/kilo/handler/index.ts +1 -2
  6. package/src/backend/kilo/handler/state.ts +3 -8
  7. package/src/backend/kilo/index.ts +2 -28
  8. package/src/backend/openai-agents/handler/message.ts +1 -1
  9. package/src/backend/openai-agents/session.ts +1 -1
  10. package/src/backend/opencode/handler/index.ts +1 -2
  11. package/src/backend/opencode/handler/state.ts +3 -8
  12. package/src/backend/opencode/index.ts +0 -7
  13. package/src/backend/shared/delivery.ts +1 -1
  14. package/src/backend/shared/handle-retry.ts +1 -1
  15. package/src/backend/shared/index.ts +3 -2
  16. package/src/backend/shared/turn-interrupt.ts +1 -1
  17. package/src/cli/index.ts +0 -8
  18. package/src/core/bus/index.ts +1 -9
  19. package/src/core/mesh/registry.ts +106 -4
  20. package/src/core/mesh/service.ts +22 -7
  21. package/src/core/mesh/transfers.ts +33 -4
  22. package/src/core/plugin/index.ts +0 -9
  23. package/src/core/prompt/index.ts +1 -16
  24. package/src/core/vfs/index.ts +2 -21
  25. package/src/core/weaver/index.ts +5 -9
  26. package/src/frontend/discord/actions/messaging.ts +73 -13
  27. package/src/frontend/discord/actions/shared.ts +21 -3
  28. package/src/frontend/discord/commands/admin.ts +1 -1
  29. package/src/frontend/discord/errors.ts +16 -5
  30. package/src/frontend/discord/formatting.ts +19 -4
  31. package/src/frontend/native/index.ts +22 -8
  32. package/src/frontend/native/protocol.ts +6 -0
  33. package/src/frontend/native/server.ts +171 -12
  34. package/src/frontend/shared/format.ts +6 -0
  35. package/src/frontend/telegram/actions/messaging.ts +120 -24
  36. package/src/frontend/telegram/callbacks/metrics.ts +1 -1
  37. package/src/frontend/telegram/commands/admin.ts +1 -1
  38. package/src/frontend/telegram/formatting.ts +64 -19
  39. package/src/frontend/terminal/commands.ts +46 -8
  40. package/src/frontend/terminal/input.ts +110 -12
  41. package/src/frontend/terminal/renderer.ts +2 -0
  42. package/src/{util → storage}/metrics.ts +1 -1
  43. package/src/storage/scheduled-store.ts +6 -3
  44. package/src/util/config.ts +8 -0
  45. package/src/util/log.ts +1 -0
  46. package/src/backend/codex/index.ts +0 -33
  47. package/src/backend/openai-agents/index.ts +0 -48
  48. package/src/cli/plugin-entries.ts +0 -104
  49. package/src/core/agent-runtime/index.ts +0 -62
  50. package/src/core/background/index.ts +0 -21
  51. package/src/core/engine/index.ts +0 -20
  52. package/src/core/models/index.ts +0 -17
  53. package/src/core/soul/index.ts +0 -140
  54. package/src/core/tools/mcp-server.ts +0 -111
  55. /package/src/{backend/shared → util}/session-name.ts +0 -0
@@ -35,27 +35,8 @@ import { createProcMount } from "./mounts/proc.js";
35
35
  import { Vfs } from "./vfs.js";
36
36
 
37
37
  export { Vfs } from "./vfs.js";
38
- export { createFileMount } from "./mounts/files.js";
39
- export { createProcMount, type ProcMountDeps } from "./mounts/proc.js";
40
- export { createPluginsMount, type PluginView } from "./mounts/plugins.js";
41
- export { syncNamespaceDir, type NsDirSync } from "./nsdir.js";
42
- export {
43
- isNamespaceFsMounted,
44
- mountNamespaceFs,
45
- namespaceFsStatus,
46
- serveNamespaceRequest,
47
- unmountNamespaceFs,
48
- type FuseStatus,
49
- type MountOptions,
50
- } from "./fusefs.js";
51
- export type {
52
- VfsErrorCode,
53
- VfsMount,
54
- VfsNodeKind,
55
- VfsResult,
56
- VfsStat,
57
- } from "./types.js";
58
- export { VFS_MAX_READ_BYTES, vfsError, vfsOk } from "./types.js";
38
+
39
+ export { mountNamespaceFs, unmountNamespaceFs } from "./fusefs.js";
59
40
 
60
41
  function pluginViews(): PluginView[] {
61
42
  const modules: PluginView[] = registry.all.map(({ plugin, path }) => ({
@@ -1,13 +1,9 @@
1
- export { Thread, type Warp, type ThreadSnapshot } from "./thread.js";
2
- export { ThreadSession, type SessionSummary } from "./thread-session.js";
1
+ export { Thread, type ThreadSnapshot } from "./thread.js";
2
+
3
3
  export { Loom, type ContextRegistry } from "./loom.js";
4
- export { carryTurnEvents, type EventSink } from "./shuttle.js";
5
- export { startTypingLoop, TYPING_REFRESH_MS } from "./typing-loop.js";
6
- export {
7
- resolveWarp,
8
- type WarpResolution,
9
- type WarpResolverDeps,
10
- } from "./warp-resolver.js";
4
+ export { carryTurnEvents } from "./shuttle.js";
5
+ export { startTypingLoop } from "./typing-loop.js";
6
+ export { resolveWarp } from "./warp-resolver.js";
11
7
  export {
12
8
  Weaver,
13
9
  getActiveLoom,
@@ -6,7 +6,11 @@
6
6
  import type { Client } from "discord.js";
7
7
  import { withRetry } from "../../../core/engine/gateway.js";
8
8
  import { sendChunked } from "../handlers/index.js";
9
- import { suppressMentions, DISCORD_MAX_TEXT } from "../formatting.js";
9
+ import {
10
+ suppressMentions,
11
+ splitMessage,
12
+ DISCORD_MAX_TEXT,
13
+ } from "../formatting.js";
10
14
  import { log, logError } from "../../../util/log.js";
11
15
  import {
12
16
  saveScheduled,
@@ -24,6 +28,41 @@ import type { DiscordActionHandlers } from "./types.js";
24
28
  /** Longest schedulable delay: 24h. Timers re-arm from the store on boot. */
25
29
  const MAX_DELAY_SEC = 24 * 60 * 60;
26
30
 
31
+ /**
32
+ * Deliver one scheduled entry, replaying whatever it was scheduled with.
33
+ *
34
+ * The `send` tool documents that "buttons and reply threading survive the
35
+ * delay — the schedule handler replays them at fire time". Discord's path
36
+ * used to call `sendChunked(c, entry.text)` and drop both, so a scheduled
37
+ * message with buttons arrived bare while the caller had been told ok:true.
38
+ */
39
+ async function fireScheduled(
40
+ channel: Awaited<ReturnType<typeof resolveChannel>>,
41
+ entry: ScheduledMessage,
42
+ ): Promise<void> {
43
+ if (!channel) return;
44
+ const replyTo =
45
+ entry.replyTo !== undefined ? String(entry.replyTo) : undefined;
46
+ if (!entry.rows || entry.rows.length === 0) {
47
+ await sendChunked(channel, entry.text, replyTo);
48
+ return;
49
+ }
50
+ const components = buildButtonRows(entry.rows);
51
+ const chunks = splitMessage(suppressMentions(entry.text), DISCORD_MAX_TEXT);
52
+ for (let i = 0; i < chunks.length; i++) {
53
+ if (!channel.isSendable()) return;
54
+ const last = i === chunks.length - 1;
55
+ await channel.send({
56
+ content: chunks[i],
57
+ ...(last ? { components } : {}),
58
+ allowedMentions: { parse: [] },
59
+ ...(i === 0 && replyTo
60
+ ? { reply: { messageReference: replyTo, failIfNotExists: false } }
61
+ : {}),
62
+ });
63
+ }
64
+ }
65
+
27
66
  /** Arm a timer for a stored entry; fires, then cleans up store + map. */
28
67
  function armScheduled(
29
68
  client: Client,
@@ -34,7 +73,7 @@ function armScheduled(
34
73
  const timer = setTimeout(async () => {
35
74
  try {
36
75
  const c = await resolveChannel(client, Number(entry.chatId));
37
- if (c) await sendChunked(c, entry.text);
76
+ if (c) await fireScheduled(c, entry);
38
77
  } catch (err) {
39
78
  logError(
40
79
  "discord",
@@ -189,25 +228,44 @@ export const messagingHandlers: DiscordActionHandlers = {
189
228
  }>
190
229
  >;
191
230
  gateway.incrementMessages(chatId);
192
- const components = buildButtonRows(rows);
193
- const safe = suppressMentions(text).slice(0, DISCORD_MAX_TEXT);
194
231
  if (!channel!.isSendable())
195
232
  return { ok: false, error: "Channel not sendable" };
196
233
  return tryAction("send_message_with_buttons", async () => {
197
- const sent = await withRetry(
198
- () =>
199
- channel!.send({
200
- content: safe,
201
- components,
202
- allowedMentions: { parse: [] },
203
- }) as Promise<{ id: string }>,
204
- );
205
- return { ok: true, message_id: sent.id };
234
+ // buildButtonRows is inside tryAction on purpose: it can still reject a
235
+ // row, and escaping here would bypass the action's error mapping and
236
+ // lose the text as well as the buttons.
237
+ const components = buildButtonRows(rows);
238
+ // Chunk rather than slice. `.slice(0, 2000)` dropped everything past
239
+ // the limit mid-word and still answered ok:true, so the model never
240
+ // learned its reply had been cut — while the SAME text without buttons
241
+ // went through sendChunked and arrived whole.
242
+ const chunks = splitMessage(suppressMentions(text), DISCORD_MAX_TEXT);
243
+ const ids: string[] = [];
244
+ for (let i = 0; i < chunks.length; i++) {
245
+ const last = i === chunks.length - 1;
246
+ const sent = await withRetry(
247
+ () =>
248
+ channel!.send({
249
+ content: chunks[i],
250
+ // Buttons belong on the final chunk: they act on the whole
251
+ // message, and Discord would render a set per chunk otherwise.
252
+ ...(last ? { components } : {}),
253
+ allowedMentions: { parse: [] },
254
+ }) as Promise<{ id: string }>,
255
+ );
256
+ ids.push(sent.id);
257
+ }
258
+ return { ok: true, message_id: ids[0], message_ids: ids };
206
259
  });
207
260
  },
208
261
 
209
262
  schedule_message: (body, chatId, { client, scheduledMessages }) => {
210
263
  const text = String(body.text ?? "");
264
+ const rows = body.rows as ScheduledMessage["rows"];
265
+ const replyTo =
266
+ typeof body.reply_to_message_id === "string"
267
+ ? body.reply_to_message_id
268
+ : undefined;
211
269
  // NaN (e.g. delay_seconds: "5m") must fall back to the default, not
212
270
  // propagate: setTimeout(fn, NaN) fires immediately.
213
271
  const requested = Number(body.delay_seconds ?? 60);
@@ -222,6 +280,8 @@ export const messagingHandlers: DiscordActionHandlers = {
222
280
  text,
223
281
  fireAt: Date.now() + delaySec * 1000,
224
282
  createdAt: Date.now(),
283
+ replyTo,
284
+ rows,
225
285
  };
226
286
  // Persist before arming: if we crash between the two, the restore
227
287
  // path delivers it; the reverse order could lose it forever.
@@ -70,11 +70,21 @@ export function buildButtonRows(
70
70
  >,
71
71
  ): ActionRowBuilder<ButtonBuilder>[] {
72
72
  const out: ActionRowBuilder<ButtonBuilder>[] = [];
73
+ // custom_id must be unique within a message or Discord rejects the send.
74
+ // Two buttons with the same label — or none at all, which both collapse to
75
+ // the bare `ai:` prefix — used to produce a duplicate pair.
76
+ const usedIds = new Set<string>();
73
77
  for (const row of rows.slice(0, 5)) {
74
78
  const arb = new ActionRowBuilder<ButtonBuilder>();
75
79
  for (const btn of row.slice(0, 5)) {
76
80
  const b = new ButtonBuilder().setLabel(safeSlice(btn.text || "•", 80));
77
- if (btn.url) {
81
+ // Only http(s) can be a Link button. discord.js throws on anything
82
+ // else — a bare domain ("docs.example.com", a common model output) or
83
+ // a javascript: URL — and the throw escapes before the send, so ONE
84
+ // malformed URL discarded the reply text and every other button too.
85
+ // Downgrade to a plain non-link button instead: the label still says
86
+ // what it meant, and the rest of the message survives.
87
+ if (btn.url !== undefined && /^https?:\/\//i.test(btn.url)) {
78
88
  b.setStyle(ButtonStyle.Link).setURL(btn.url);
79
89
  } else {
80
90
  const styleMap: Record<string, ButtonStyle> = {
@@ -88,13 +98,21 @@ export function buildButtonRows(
88
98
  // Namespace AI-generated custom_ids under `ai:` so the callback router
89
99
  // never confuses them with system custom_ids like `settings:done`.
90
100
  // The router strips the prefix before forwarding to the agent.
101
+ //
102
+ // Budget is 100 UTF-16 units total (Discord's cap), so the raw part
103
+ // gets 100 minus the 3-unit prefix minus room for a de-dupe suffix.
91
104
  const raw = btn.callback_data || btn.text || "";
92
- const id = `ai:${safeSlice(raw, 96)}`;
105
+ let id = `ai:${safeSlice(raw, 93)}`;
106
+ for (let n = 2; usedIds.has(id); n++)
107
+ id = `ai:${safeSlice(raw, 93)}#${n}`;
108
+ usedIds.add(id);
93
109
  b.setStyle(style).setCustomId(id);
94
110
  }
95
111
  arb.addComponents(b);
96
112
  }
97
- out.push(arb);
113
+ // Discord requires at least one component per action row; an empty row
114
+ // (from an empty inner array) fails the whole message.
115
+ if (arb.components.length > 0) out.push(arb);
98
116
  }
99
117
  return out;
100
118
  }
@@ -9,7 +9,7 @@ import type { Gateway } from "../../../core/engine/gateway.js";
9
9
  import { respawnSelf } from "../../../util/respawn.js";
10
10
  import { forceDream } from "../../../core/background/dream.js";
11
11
  import { formatDuration, renderMetricsMessages } from "../helpers.js";
12
- import { getMetrics, getTodayMetrics } from "../../../util/metrics.js";
12
+ import { getMetrics, getTodayMetrics } from "../../../storage/metrics.js";
13
13
  import { handleAdminSubcommand } from "../admin.js";
14
14
  import { isAdmin } from "../handlers/index.js";
15
15
  import { suppressMentions, DISCORD_MAX_TEXT } from "../formatting.js";
@@ -18,13 +18,24 @@ type DiscordAPIErrorLike = {
18
18
  rawError?: { code?: number };
19
19
  };
20
20
 
21
- /** Extract numeric JSON error code, if this looks like a DiscordAPIError. */
22
- function discordErrorCode(err: unknown): number | null {
23
- if (!err || typeof err !== "object") return null;
24
- const e = err as DiscordAPIErrorLike;
21
+ /**
22
+ * Extract numeric JSON error code, if this looks like a DiscordAPIError.
23
+ *
24
+ * Walks the `cause` chain, which is what makes this work at all on the
25
+ * primary send paths. Those run inside `withRetry`, which rethrows
26
+ * `classify(err)` — a TalonError carrying only the message, with the
27
+ * DiscordAPIError demoted to `cause`. A shallow look therefore found no
28
+ * code and every curated message below was dead on `send_message`,
29
+ * `reply_to`, `send_message_with_buttons`, the media sends, and
30
+ * forward/copy: the agent got a bare "Missing Permissions" instead of
31
+ * being told which permission and on what.
32
+ */
33
+ function discordErrorCode(err: unknown, depth = 0): number | null {
34
+ if (depth > 5 || !err || typeof err !== "object") return null;
35
+ const e = err as DiscordAPIErrorLike & { cause?: unknown };
25
36
  if (typeof e.code === "number") return e.code;
26
37
  if (typeof e.rawError?.code === "number") return e.rawError.code;
27
- return null;
38
+ return discordErrorCode(e.cause, depth + 1);
28
39
  }
29
40
 
30
41
  /**
@@ -58,12 +58,27 @@ export function suppressMentions(text: string): string {
58
58
  }
59
59
 
60
60
  /**
61
- * Slice a string to `max` user-visible characters without splitting multi-byte
62
- * code points (emoji, CJK) on a UTF-16 surrogate boundary. Used for Discord
63
- * `customId`, `label`, `value`, etc. where Discord measures char count.
61
+ * Slice a string so it satisfies a Discord length limit, without splitting a
62
+ * surrogate pair. Used for `customId`, `label`, `value`, etc.
63
+ *
64
+ * `max` counts UTF-16 code units, because that is what Discord (and
65
+ * discord.js's validators, which call `String.length`) measure. Counting
66
+ * code points instead — `Array.from(text).slice(0, max)` — silently
67
+ * overshoots for any astral character: 80 code points of emoji is 160 code
68
+ * units, so the guard passed and discord.js then threw
69
+ * `ExpectedConstraintError: Invalid string length`, taking the whole message
70
+ * with it. The guard worked precisely when it wasn't needed and failed when
71
+ * it was.
64
72
  */
65
73
  export function safeSlice(text: string, max: number): string {
66
- return Array.from(text).slice(0, max).join("");
74
+ if (text.length <= max) return text;
75
+ let out = "";
76
+ // Iterating a string yields whole code points, so a pair is never split.
77
+ for (const char of text) {
78
+ if (out.length + char.length > max) break;
79
+ out += char;
80
+ }
81
+ return out;
67
82
  }
68
83
 
69
84
  /** Simple HTML-style escape used when displaying raw text inside code blocks. */
@@ -69,7 +69,7 @@ import {
69
69
  } from "../../core/engine/backend-controller/index.js";
70
70
  import { getActiveReasoningLevels } from "../shared/reasoning-levels.js";
71
71
  import { NativeChats, DEFAULT_CHAT_TITLE, type ChatEntry } from "./chats.js";
72
- import { extractSessionName } from "../../backend/shared/session-name.js";
72
+ import { extractSessionName } from "../../util/session-name.js";
73
73
  import { BridgeServer, type BridgeServerHandlers } from "./server.js";
74
74
  import { createNativeActionHandler } from "./actions.js";
75
75
  import { getMeshService } from "../../core/mesh/index.js";
@@ -1192,8 +1192,10 @@ export function createNativeFrontend(
1192
1192
  storeLocation: (body) => mesh.storeLocation(body),
1193
1193
  listDevices: () => mesh.list(),
1194
1194
  completeCommand: (body) => mesh.completeCommand(body),
1195
- acceptFileUpload: (token, body) => mesh.acceptFileUpload(token, body),
1196
- openFileDownload: (token) => mesh.openFileDownload(token),
1195
+ acceptFileUpload: (token, body, fromDeviceId) =>
1196
+ mesh.acceptFileUpload(token, body, fromDeviceId),
1197
+ openFileDownload: (token, fromDeviceId) =>
1198
+ mesh.openFileDownload(token, fromDeviceId),
1197
1199
  openNodeInstall: (token) => mesh.openNodeInstall(token),
1198
1200
  openNodeBinary: (token) => mesh.openNodeBinary(token),
1199
1201
  };
@@ -1205,14 +1207,22 @@ export function createNativeFrontend(
1205
1207
  const bridgeTls = nativeCfg.tls ?? !isLoopbackHost(bridgeHost);
1206
1208
  // Never serve the agent API to the network unauthenticated: a non-loopback
1207
1209
  // bind with no configured token gets a persistent auto-minted one instead.
1210
+ // `?? ` alone would treat an empty string as a configured token and skip
1211
+ // the mint, serving the LAN unauthenticated while looking configured —
1212
+ // easy to hit from `"token": "${BRIDGE_TOKEN}"` with the var unset.
1213
+ const configuredToken =
1214
+ nativeCfg.token !== undefined && nativeCfg.token !== ""
1215
+ ? nativeCfg.token
1216
+ : undefined;
1208
1217
  const bridgeToken =
1209
- nativeCfg.token ??
1218
+ configuredToken ??
1210
1219
  (isLoopbackHost(bridgeHost) ? undefined : loadOrCreateBridgeToken());
1211
1220
  const server = new BridgeServer(
1212
1221
  {
1213
1222
  host: bridgeHost,
1214
1223
  port: nativeCfg.port ?? 19880,
1215
1224
  token: bridgeToken,
1225
+ allowedOrigins: nativeCfg.allowedOrigins,
1216
1226
  startedAt,
1217
1227
  ...(bridgeTls ? { tls: () => loadOrCreateBridgeTlsIdentity() } : {}),
1218
1228
  },
@@ -1249,13 +1259,17 @@ export function createNativeFrontend(
1249
1259
  async init() {
1250
1260
  await mesh.load();
1251
1261
  // Plug this bridge in as the mesh's transport: locates and device
1252
- // commands (from ANY frontend's mesh tool calls) fan out to every
1253
- // connected companion client as SSE events; each client filters by
1254
- // its own device id.
1262
+ // commands (from ANY frontend's mesh tool calls) leave as SSE events.
1263
+ //
1264
+ // A locate is a bare "who's there?" — no secret in the frame, and
1265
+ // pre-command app builds rely on receiving it — so it still fans out.
1266
+ // A command is the opposite: its params carry transfer tokens, exec
1267
+ // command lines and (on the chunked fallback) file bodies, so it goes
1268
+ // to the target device's own client(s) only.
1255
1269
  unregisterMeshTransport = mesh.registerTransport({
1256
1270
  locate: (deviceId) => broadcast({ kind: "locate", deviceId }),
1257
1271
  command: (command) =>
1258
- broadcast({
1272
+ server.sendToDevice(command.deviceId, {
1259
1273
  kind: "device_command",
1260
1274
  id: command.id,
1261
1275
  deviceId: command.deviceId,
@@ -286,6 +286,12 @@ export type BridgeEvent =
286
286
  * target device executes it and answers via POST /devices/command-result
287
287
  * with the same `id` as `commandId`. Additive in v1 — app builds that
288
288
  * predate the command channel simply ignore the event.
289
+ *
290
+ * Addressed, not broadcast: params carry one-time transfer tokens, exec
291
+ * command lines and (on the chunked fallback) file bodies, so the frame
292
+ * goes only to the SSE client(s) that claimed this `deviceId` via
293
+ * `GET /events?deviceId=…`. Clients that claim no id still receive
294
+ * commands for devices nobody claimed, so pre-claim builds keep working.
289
295
  */
290
296
  | {
291
297
  kind: "device_command";