talon-agent 5.14.0 → 5.18.2

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 (116) hide show
  1. package/LICENSE +202 -21
  2. package/LICENSE-MIT +21 -0
  3. package/NOTICE +16 -0
  4. package/README.md +14 -7
  5. package/package.json +6 -4
  6. package/prompts/system/heartbeat-agent.md +1 -1
  7. package/src/backend/agy/factory.ts +3 -0
  8. package/src/backend/agy/mcp/config.ts +14 -2
  9. package/src/backend/claude-sdk/factory.ts +3 -0
  10. package/src/backend/claude-sdk/options.ts +24 -3
  11. package/src/backend/codex/factory.ts +3 -0
  12. package/src/backend/codex/init.ts +4 -0
  13. package/src/backend/codex/mcp-config.ts +10 -0
  14. package/src/backend/codex/oauth-incompat.ts +1 -1
  15. package/src/backend/codex/token-usage.ts +2 -2
  16. package/src/backend/openai-agents/factory.ts +3 -0
  17. package/src/backend/openai-agents/mcp-pool.ts +4 -0
  18. package/src/backend/remote-server/factory.ts +3 -0
  19. package/src/backend/remote-server/mcp.ts +3 -0
  20. package/src/backend/runtime/prompt/prompt-format.ts +3 -3
  21. package/src/bootstrap.ts +8 -0
  22. package/src/cli/commands/backup.ts +61 -5
  23. package/src/cli/commands/mesh.ts +133 -0
  24. package/src/cli/config.ts +3 -1
  25. package/src/cli/daemon-api.ts +22 -0
  26. package/src/cli/index.ts +6 -0
  27. package/src/cli/install-sources.ts +40 -5
  28. package/src/cli/plugin.ts +10 -0
  29. package/src/cli/setup.ts +45 -4
  30. package/src/cli/skill.ts +3 -0
  31. package/src/core/agent-runtime/backend-registry.ts +16 -0
  32. package/src/core/backup/archive/crypt.ts +429 -0
  33. package/src/core/backup/archive/manifest-auth.ts +98 -0
  34. package/src/core/backup/passphrase.ts +130 -0
  35. package/src/core/backup/plan.ts +66 -13
  36. package/src/core/backup/restore-guard.ts +101 -0
  37. package/src/core/backup/restore.ts +249 -32
  38. package/src/core/backup/snapshot.ts +418 -60
  39. package/src/core/backup/sources/plugins.ts +223 -0
  40. package/src/core/backup/sources/relocate.ts +136 -0
  41. package/src/core/backup/sources/sessions.ts +198 -0
  42. package/src/core/backup/store.ts +3 -1
  43. package/src/core/backup/types.ts +66 -0
  44. package/src/core/backup/upload.ts +66 -6
  45. package/src/core/config/index.ts +140 -8
  46. package/src/core/daemon/control.ts +9 -0
  47. package/src/core/daemon/discovery.ts +7 -0
  48. package/src/core/engine/backend-router/headroom.ts +29 -5
  49. package/src/core/engine/backend-router/usage.ts +6 -2
  50. package/src/core/engine/gateway-actions/fetch-url/guard.ts +201 -0
  51. package/src/core/engine/gateway-actions/{fetch-url.ts → fetch-url/index.ts} +60 -32
  52. package/src/core/engine/gateway-actions/index.ts +4 -2
  53. package/src/core/engine/gateway-actions/native/index.ts +24 -0
  54. package/src/core/engine/gateway-actions/whatsapp-account.ts +1 -1
  55. package/src/core/engine/gateway-auth.ts +164 -0
  56. package/src/core/engine/gateway-routes.ts +100 -5
  57. package/src/core/engine/gateway.ts +12 -4
  58. package/src/core/mcp-hub/guest-scope.ts +170 -29
  59. package/src/core/mcp-hub/index.ts +33 -16
  60. package/src/core/mcp-hub/talon-server.ts +71 -14
  61. package/src/core/mesh/credentials/admin.ts +146 -0
  62. package/src/core/mesh/credentials/index.ts +19 -0
  63. package/src/core/mesh/credentials/store.ts +443 -0
  64. package/src/core/mesh/credentials/token.ts +45 -0
  65. package/src/core/mesh/credentials/types.ts +83 -0
  66. package/src/core/mesh/devices/service.ts +58 -6
  67. package/src/core/mesh/links/bridge-links.ts +46 -4
  68. package/src/core/mesh/links/node-binaries.ts +1 -1
  69. package/src/core/mesh/links/node-provision.ts +8 -1
  70. package/src/core/models/active-model.ts +1 -1
  71. package/src/core/plugin/loader.ts +4 -0
  72. package/src/core/plugin/mcp.ts +4 -0
  73. package/src/core/tools/bridge.ts +2 -1
  74. package/src/core/types.ts +13 -0
  75. package/src/core/weaver/weaver.ts +47 -15
  76. package/src/frontend/discord/callbacks/components/agent-buttons.ts +1 -0
  77. package/src/frontend/discord/handlers/delivery.ts +3 -0
  78. package/src/frontend/discord/handlers/queue.ts +1 -0
  79. package/src/frontend/native/bridge/auth-guard.ts +277 -0
  80. package/src/frontend/native/bridge/auth.ts +84 -1
  81. package/src/frontend/native/bridge/credentials/claims.ts +51 -0
  82. package/src/frontend/native/bridge/credentials/principal.ts +200 -0
  83. package/src/frontend/native/bridge/credentials/upgrade.ts +129 -0
  84. package/src/frontend/native/bridge/routes/auth.ts +37 -0
  85. package/src/frontend/native/bridge/routes/chats.ts +20 -2
  86. package/src/frontend/native/bridge/routes/host.ts +11 -1
  87. package/src/frontend/native/bridge/routes/index.ts +2 -0
  88. package/src/frontend/native/bridge/routes/mesh.ts +72 -15
  89. package/src/frontend/native/bridge/routes/table.ts +73 -51
  90. package/src/frontend/native/bridge/server.ts +266 -83
  91. package/src/frontend/native/index.ts +54 -3
  92. package/src/frontend/native/turn/turn.ts +2 -0
  93. package/src/frontend/teams/turn.ts +1 -0
  94. package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
  95. package/src/frontend/telegram/actions/send.ts +4 -0
  96. package/src/frontend/telegram/admin.ts +20 -0
  97. package/src/frontend/telegram/commands/admin.ts +1 -1
  98. package/src/frontend/telegram/commands/state.ts +7 -9
  99. package/src/frontend/telegram/handlers/access.ts +31 -10
  100. package/src/frontend/telegram/handlers/delivery.ts +14 -1
  101. package/src/frontend/telegram/handlers/group-access.ts +50 -0
  102. package/src/frontend/telegram/handlers/messages.ts +1 -0
  103. package/src/frontend/telegram/handlers/queue.ts +14 -0
  104. package/src/frontend/telegram/handlers/state.ts +2 -2
  105. package/src/frontend/telegram/index.ts +32 -9
  106. package/src/frontend/telegram/middleware.ts +2 -2
  107. package/src/frontend/telegram/polling/poll-deadline.ts +52 -0
  108. package/src/frontend/telegram/{stale-command.ts → polling/stale-command.ts} +1 -1
  109. package/src/frontend/telegram/{update-offset.ts → polling/update-offset.ts} +1 -1
  110. package/src/frontend/telegram/userbot.ts +103 -10
  111. package/src/frontend/terminal/index.ts +8 -1
  112. package/src/frontend/whatsapp/commands.ts +3 -3
  113. package/src/frontend/whatsapp/messages/inbound.ts +1 -0
  114. package/src/plugins/playwright/index.ts +1 -1
  115. package/src/storage/backup/index.ts +1 -1
  116. package/src/storage/db.ts +23 -0
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { TelegramClient, Api } from "telegram";
12
12
  import { StringSession } from "telegram/sessions/index.js";
13
+ import { outgoingText } from "./actions/outgoing-log.js";
13
14
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
14
15
  import { resolve, dirname } from "node:path";
15
16
  import writeFileAtomic from "write-file-atomic";
@@ -127,6 +128,29 @@ export async function disconnectUserClient(): Promise<void> {
127
128
  // ── Connection monitoring ────────────────────────────────────────────────────
128
129
 
129
130
  const CHECK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
131
+ /**
132
+ * Upper bound for any single network step in the monitor. GramJS's
133
+ * `connect()` can hang forever on a half-open socket; without a bound the
134
+ * `reconnecting` latch never clears and every later tick bails out, leaving
135
+ * the userbot dead until the daemon restarts.
136
+ */
137
+ const RECONNECT_STEP_TIMEOUT_MS = 30_000;
138
+
139
+ /** Reject if `p` doesn't settle within `ms`. Exported for tests. */
140
+ export function withTimeout<T>(
141
+ p: Promise<T>,
142
+ ms: number,
143
+ what: string,
144
+ ): Promise<T> {
145
+ let timer: ReturnType<typeof setTimeout> | undefined;
146
+ const timeout = new Promise<never>((_, reject) => {
147
+ timer = setTimeout(
148
+ () => reject(new Error(`${what} timed out after ${ms}ms`)),
149
+ ms,
150
+ );
151
+ });
152
+ return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
153
+ }
130
154
 
131
155
  let reconnecting = false;
132
156
  // Bumped by stopConnectionMonitor so a tick whose awaits straddle a shutdown
@@ -150,7 +174,11 @@ function startConnectionMonitor(): void {
150
174
  if (client.connected) {
151
175
  try {
152
176
  // A full API round-trip; throws if the socket is silently dead.
153
- await client.getMe();
177
+ await withTimeout(
178
+ client.getMe(),
179
+ RECONNECT_STEP_TIMEOUT_MS,
180
+ "liveness probe",
181
+ );
154
182
  return; // genuinely alive
155
183
  } catch {
156
184
  if (gen !== monitorGeneration) return; // monitor stopped mid-probe
@@ -162,8 +190,18 @@ function startConnectionMonitor(): void {
162
190
  reconnecting = true;
163
191
  logWarn("userbot", "Connection lost, attempting reconnect...");
164
192
  try {
165
- await client.connect();
166
- if (await client.isUserAuthorized()) {
193
+ await withTimeout(
194
+ client.connect(),
195
+ RECONNECT_STEP_TIMEOUT_MS,
196
+ "reconnect",
197
+ );
198
+ if (
199
+ await withTimeout(
200
+ client.isUserAuthorized(),
201
+ RECONNECT_STEP_TIMEOUT_MS,
202
+ "auth check",
203
+ )
204
+ ) {
167
205
  log("userbot", "Reconnected successfully.");
168
206
  } else {
169
207
  logWarn("userbot", "Reconnected but not authorized.");
@@ -174,7 +212,11 @@ function startConnectionMonitor(): void {
174
212
  // Try a full re-init on next check
175
213
  if (storedApiId && storedApiHash) {
176
214
  try {
215
+ // Drop the wedged client without awaiting it — its socket may be
216
+ // the thing that's hung.
217
+ const stale = client;
177
218
  client = null;
219
+ void stale?.disconnect().catch(() => {});
178
220
  let sessionString = "";
179
221
  if (existsSync(SESSION_FILE)) {
180
222
  sessionString = readFileSync(SESSION_FILE, "utf-8").trim();
@@ -183,8 +225,18 @@ function startConnectionMonitor(): void {
183
225
  client = new TelegramClient(session, storedApiId, storedApiHash, {
184
226
  connectionRetries: 5,
185
227
  });
186
- await client.connect();
187
- if (await client.isUserAuthorized()) {
228
+ await withTimeout(
229
+ client.connect(),
230
+ RECONNECT_STEP_TIMEOUT_MS,
231
+ "re-init connect",
232
+ );
233
+ if (
234
+ await withTimeout(
235
+ client.isUserAuthorized(),
236
+ RECONNECT_STEP_TIMEOUT_MS,
237
+ "re-init auth check",
238
+ )
239
+ ) {
188
240
  log("userbot", "Full re-init reconnect succeeded.");
189
241
  }
190
242
  } catch (retryErr) {
@@ -230,7 +282,7 @@ export async function searchMessages(params: {
230
282
  m.sender && "firstName" in m.sender
231
283
  ? [m.sender.firstName, m.sender.lastName].filter(Boolean).join(" ")
232
284
  : "Unknown";
233
- return `[msg:${m.id} ${date}] ${sender}: ${m.text || "(media)"}`;
285
+ return `[msg:${m.id} ${date}] ${sender}: ${messageBody(m)}`;
234
286
  })
235
287
  .join("\n");
236
288
  } catch (err) {
@@ -238,6 +290,47 @@ export async function searchMessages(params: {
238
290
  }
239
291
  }
240
292
 
293
+ /**
294
+ * Render one message's body for the history readers.
295
+ *
296
+ * Rich Messages (Bot API 10.2) reach this client as `messageMediaUnsupported`
297
+ * with no text, because gramjs negotiates an older MTProto layer — so every
298
+ * message the bot itself sent reads back as "(media)" and the agent cannot see
299
+ * its own half of the conversation. When the text is one of ours, splice it
300
+ * back in from the outgoing log; when it is not, say what happened rather than
301
+ * calling it media.
302
+ */
303
+ export function messageBody(m: {
304
+ id: number;
305
+ text?: string;
306
+ media?: { className: string };
307
+ }): string {
308
+ if (m.text) return m.text;
309
+ if (m.media?.className === "MessageMediaUnsupported") {
310
+ const own = outgoingText(m.id);
311
+ if (own) return own;
312
+ return "(rich message — not renderable by this client)";
313
+ }
314
+ return "(media)";
315
+ }
316
+
317
+ /** Media tag for the history line, suppressed for rich messages we resolved. */
318
+ export function mediaTagFor(m: {
319
+ id: number;
320
+ text?: string;
321
+ media?: { className: string };
322
+ }): string {
323
+ if (!m.media) return "";
324
+ if (
325
+ m.media.className === "MessageMediaUnsupported" &&
326
+ !m.text &&
327
+ outgoingText(m.id)
328
+ ) {
329
+ return "";
330
+ }
331
+ return ` [${m.media.className}]`;
332
+ }
333
+
241
334
  /** Get message history from a chat. Supports going back in time via offsetDate or offsetId. */
242
335
  export async function getHistory(params: {
243
336
  chatId: number | string;
@@ -278,8 +371,8 @@ export async function getHistory(params: {
278
371
  const replyTag = m.replyTo?.replyToMsgId
279
372
  ? ` (reply to msg:${m.replyTo.replyToMsgId})`
280
373
  : "";
281
- const mediaTag = m.media ? ` [${m.media.className}]` : "";
282
- return `[msg:${m.id} ${date}] ${sender}${replyTag}${mediaTag}: ${m.text || "(media)"}`;
374
+ const mediaTag = mediaTagFor(m);
375
+ return `[msg:${m.id} ${date}] ${sender}${replyTag}${mediaTag}: ${messageBody(m)}`;
283
376
  })
284
377
  .join("\n");
285
378
  } catch (err) {
@@ -415,8 +508,8 @@ export async function getMessage(params: {
415
508
  const replyTag = m.replyTo?.replyToMsgId
416
509
  ? `\nReply to: msg:${m.replyTo.replyToMsgId}`
417
510
  : "";
418
- const mediaTag = m.media ? `\nMedia: ${m.media.className}` : "";
419
- return `[msg:${m.id} ${date}] ${sender}${replyTag}${mediaTag}\n${m.text || "(no text)"}`;
511
+ const mediaTag = mediaTagFor(m) ? `\nMedia: ${m.media?.className}` : "";
512
+ return `[msg:${m.id} ${date}] ${sender}${replyTag}${mediaTag}\n${messageBody(m)}`;
420
513
  } catch (err) {
421
514
  return `Failed: ${err instanceof Error ? err.message : err}`;
422
515
  }
@@ -33,9 +33,16 @@ import {
33
33
  type CommandContext,
34
34
  } from "./commands.js";
35
35
  import { buildCacheDisplay } from "../presentation/status-context.js";
36
+ import { LOCAL_OPERATOR_SENDER } from "../../core/mcp-hub/guest-scope.js";
36
37
 
37
38
  // ── State ────────────────────────────────────────────────────────────────────
38
39
 
40
+ /** The terminal is driven by the operator at the keyboard. */
41
+ const TERMINAL_SENDER = {
42
+ senderName: "User",
43
+ senderKeys: [LOCAL_OPERATOR_SENDER],
44
+ } as const;
45
+
39
46
  let terminalChatId = "";
40
47
  let terminalNumericId = 0;
41
48
 
@@ -224,7 +231,7 @@ export function createTerminalFrontend(
224
231
  chatId: terminalChatId,
225
232
  numericChatId: terminalNumericId,
226
233
  prompt: text,
227
- senderName: "User",
234
+ ...TERMINAL_SENDER,
228
235
  isGroup: false,
229
236
  source: "message",
230
237
  onEvent: async (event) => {
@@ -101,14 +101,14 @@ function isMutating(cmd: WhatsAppCommand): boolean {
101
101
  * May this sender change the chat's settings? A DM sender already passed
102
102
  * the allowlist. In a group, the same allowlist is the admin rule — the
103
103
  * people in `allowedJids` are the operator's, everyone else may read
104
- * settings but not change them. With no allowlist at all there is no
105
- * admin to distinguish, the way Telegram opens up with no adminUserId.
104
+ * settings but not change them. With no allowlist at all nobody in a group
105
+ * may change them: an empty list never means everyone.
106
106
  */
107
107
  export function canChangeSettings(
108
108
  runtime: WhatsAppRuntime,
109
109
  inbound: Pick<CommandInbound, "isGroup" | "identity">,
110
110
  ): boolean {
111
- if (!inbound.isGroup || runtime.allowedDms.size === 0) return true;
111
+ if (!inbound.isGroup) return true;
112
112
  return identityAllowed(inbound.identity, runtime.allowedDms);
113
113
  }
114
114
 
@@ -273,6 +273,7 @@ async function runInboundTurn(
273
273
  numericChatId: chat.numericChatId,
274
274
  prompt,
275
275
  senderName,
276
+ senderKeys: identity.ids.map((id) => `wa_dm_${id}`),
276
277
  isGroup,
277
278
  source: "message",
278
279
  onEvent: (event) => onTurnEvent(runtime, chat, event),
@@ -15,7 +15,7 @@
15
15
  * "playwright": {
16
16
  * "enabled": true,
17
17
  * "browser": "firefox",
18
- * "endpointFile": "/home/dylan/camoufox-endpoint.txt"
18
+ * "endpointFile": "/home/ada/camoufox-endpoint.txt"
19
19
  * }
20
20
  *
21
21
  * VERSION COUPLING (endpoint mode): the WebSocket handshake requires the
@@ -22,7 +22,7 @@ import * as repo from "./repo.js";
22
22
  * handle stays inside storage/ (`db-handle-stays-in-storage`), and a
23
23
  * snapshot of the database is a storage concern with a storage API.
24
24
  */
25
- export { snapshotDatabase } from "../db.js";
25
+ export { snapshotDatabase, snapshotSqliteFile } from "../db.js";
26
26
 
27
27
  export type { BackupRecord, BackupRemoteRecord } from "./repo.js";
28
28
  import type { BackupRecord, BackupRemoteRecord } from "./repo.js";
package/src/storage/db.ts CHANGED
@@ -58,6 +58,11 @@ const sqliteModule = (await import(
58
58
  IS_BUN ? "bun:sqlite" : "node:sqlite"
59
59
  )) as Record<string, new (path: string) => SqlDatabase>;
60
60
  const Database = IS_BUN ? sqliteModule.Database : sqliteModule.DatabaseSync;
61
+ /** The same constructor, with the options bag both runtimes accept. */
62
+ const ReadOnlyDatabase = Database as unknown as new (
63
+ path: string,
64
+ options: Record<string, boolean>,
65
+ ) => SqlDatabase;
61
66
 
62
67
  let db: SqlDatabase | null = null;
63
68
 
@@ -236,6 +241,24 @@ export function snapshotDatabase(destPath: string): void {
236
241
  database.prepare(dbSql.vacuumInto).run(destPath);
237
242
  }
238
243
 
244
+ /**
245
+ * Consistent copy of ANOTHER program's SQLite file (a backend's session
246
+ * store) — the same `VACUUM INTO` as {@link snapshotDatabase}, through a
247
+ * read-only handle so the owner's database and WAL are never written.
248
+ */
249
+ export function snapshotSqliteFile(sourcePath: string, destPath: string): void {
250
+ const foreign = new ReadOnlyDatabase(
251
+ sourcePath,
252
+ IS_BUN ? { readonly: true } : { readOnly: true },
253
+ );
254
+ try {
255
+ mkdirSync(dirname(destPath), { recursive: true });
256
+ foreign.prepare(dbSql.vacuumInto).run(destPath);
257
+ } finally {
258
+ foreign.close();
259
+ }
260
+ }
261
+
239
262
  export function closeDatabase(): void {
240
263
  if (!db) return;
241
264
  try {