talon-agent 1.10.0 → 1.11.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 (65) hide show
  1. package/package.json +24 -6
  2. package/src/backend/claude-sdk/handler.ts +26 -22
  3. package/src/backend/claude-sdk/options.ts +78 -10
  4. package/src/core/gateway.ts +10 -3
  5. package/src/core/tools/index.ts +25 -2
  6. package/src/frontend/telegram/handlers.ts +120 -7
  7. package/src/__tests__/chat-id.test.ts +0 -91
  8. package/src/__tests__/chat-settings.test.ts +0 -471
  9. package/src/__tests__/claude-sdk-models.test.ts +0 -146
  10. package/src/__tests__/claude-sdk-options.test.ts +0 -110
  11. package/src/__tests__/cleanup-registry.test.ts +0 -58
  12. package/src/__tests__/compose-tools.test.ts +0 -216
  13. package/src/__tests__/config.test.ts +0 -716
  14. package/src/__tests__/cron-store-extended.test.ts +0 -661
  15. package/src/__tests__/cron-store.test.ts +0 -574
  16. package/src/__tests__/daily-log.test.ts +0 -357
  17. package/src/__tests__/disallowed-tools.test.ts +0 -64
  18. package/src/__tests__/dispatcher.test.ts +0 -784
  19. package/src/__tests__/dream.test.ts +0 -1145
  20. package/src/__tests__/end-turn.test.ts +0 -189
  21. package/src/__tests__/errors-extended.test.ts +0 -428
  22. package/src/__tests__/errors.test.ts +0 -332
  23. package/src/__tests__/fixtures/test-mcp-server.ts +0 -37
  24. package/src/__tests__/fuzz.test.ts +0 -375
  25. package/src/__tests__/gateway-actions.test.ts +0 -1772
  26. package/src/__tests__/gateway-context.test.ts +0 -102
  27. package/src/__tests__/gateway-http.test.ts +0 -436
  28. package/src/__tests__/gateway-retry.test.ts +0 -355
  29. package/src/__tests__/gateway-withRetry-extended.test.ts +0 -343
  30. package/src/__tests__/graph.test.ts +0 -830
  31. package/src/__tests__/handlers-stream.test.ts +0 -203
  32. package/src/__tests__/handlers.test.ts +0 -2914
  33. package/src/__tests__/heartbeat.test.ts +0 -388
  34. package/src/__tests__/history-extended.test.ts +0 -775
  35. package/src/__tests__/history-persistence.test.ts +0 -227
  36. package/src/__tests__/history.test.ts +0 -693
  37. package/src/__tests__/integration.test.ts +0 -224
  38. package/src/__tests__/log-init.test.ts +0 -129
  39. package/src/__tests__/log.test.ts +0 -129
  40. package/src/__tests__/mcp-launcher-functional.test.ts +0 -334
  41. package/src/__tests__/mcp-launcher.test.ts +0 -139
  42. package/src/__tests__/mcp-lifecycle.test.ts +0 -165
  43. package/src/__tests__/media-index.test.ts +0 -559
  44. package/src/__tests__/mempalace-plugin.test.ts +0 -350
  45. package/src/__tests__/metrics.test.ts +0 -76
  46. package/src/__tests__/opencode-models.test.ts +0 -117
  47. package/src/__tests__/opencode-summary.test.ts +0 -105
  48. package/src/__tests__/opencode-ui.test.ts +0 -94
  49. package/src/__tests__/plugin.test.ts +0 -962
  50. package/src/__tests__/reload-plugins.test.ts +0 -342
  51. package/src/__tests__/sessions.test.ts +0 -877
  52. package/src/__tests__/storage-save-errors.test.ts +0 -342
  53. package/src/__tests__/teams-frontend.test.ts +0 -762
  54. package/src/__tests__/telegram-formatting.test.ts +0 -86
  55. package/src/__tests__/telegram-helpers.test.ts +0 -151
  56. package/src/__tests__/telegram.test.ts +0 -176
  57. package/src/__tests__/terminal-commands.test.ts +0 -666
  58. package/src/__tests__/terminal-frontend.test.ts +0 -141
  59. package/src/__tests__/terminal-renderer.test.ts +0 -501
  60. package/src/__tests__/time.test.ts +0 -107
  61. package/src/__tests__/tool-functional.test.ts +0 -615
  62. package/src/__tests__/tool-id-coercion.test.ts +0 -136
  63. package/src/__tests__/watchdog.test.ts +0 -285
  64. package/src/__tests__/workspace-migrate.test.ts +0 -256
  65. package/src/__tests__/workspace.test.ts +0 -284
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.10.0",
3
+ "version": "1.11.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",
@@ -30,7 +30,16 @@
30
30
  },
31
31
  "files": [
32
32
  "bin/",
33
- "src/",
33
+ "src/backend/",
34
+ "src/core/",
35
+ "src/frontend/",
36
+ "src/plugins/",
37
+ "src/storage/",
38
+ "src/util/",
39
+ "src/bootstrap.ts",
40
+ "src/cli.ts",
41
+ "src/index.ts",
42
+ "src/login.ts",
34
43
  "prompts/",
35
44
  "README.md",
36
45
  "tsconfig.json"
@@ -41,13 +50,20 @@
41
50
  "setup": "tsx src/cli.ts setup",
42
51
  "dev": "tsx --watch src/index.ts",
43
52
  "test": "vitest run",
53
+ "test:ci": "vitest run --reporter=verbose --reporter=json --outputFile=test-results.json",
54
+ "test:functional": "vitest run --reporter=verbose --reporter=json --outputFile=functional-results.json src/__tests__/package.functional.test.ts src/__tests__/tool-functional.test.ts src/__tests__/mcp-launcher.test.ts src/__tests__/mcp-launcher-functional.test.ts src/__tests__/integration/sdk-stub.test.ts src/__tests__/integration/talon-functional.test.ts",
55
+ "test:integration": "vitest run --reporter=verbose --reporter=json --outputFile=integration-results.json src/__tests__/integration/talon-mcp-functional.test.ts",
56
+ "test:integration:all": "vitest run --reporter=verbose src/__tests__/integration/",
57
+ "tarball:check": "node .github/scripts/tarball-check.mjs",
58
+ "build:stub-sea": "node src/__tests__/integration/stub-claude/build-sea.mjs",
44
59
  "test:watch": "vitest",
45
60
  "test:coverage": "vitest run --coverage",
46
61
  "typecheck": "tsc --noEmit",
47
62
  "lint": "oxlint src/",
48
63
  "knip": "knip",
49
64
  "format": "prettier --write src/ prompts/",
50
- "format:check": "prettier --check src/ prompts/"
65
+ "format:check": "prettier --check src/ prompts/",
66
+ "ci:protect": "node .github/scripts/enforce-ci-gate.mjs"
51
67
  },
52
68
  "dependencies": {
53
69
  "@anthropic-ai/claude-agent-sdk": "^0.2.108",
@@ -58,7 +74,7 @@
58
74
  "@grammyjs/transformer-throttler": "^1.2.1",
59
75
  "@modelcontextprotocol/sdk": "^1.29.0",
60
76
  "@opencode-ai/sdk": "^1.4.0",
61
- "@playwright/mcp": "^0.0.74",
77
+ "@playwright/mcp": "^0.0.75",
62
78
  "big-integer": "^1.6.52",
63
79
  "cheerio": "^1.2.0",
64
80
  "croner": "^10.0.1",
@@ -71,7 +87,7 @@
71
87
  "telegram": "^2.26.22",
72
88
  "tsx": "^4.21.0",
73
89
  "undici": "^8.0.2",
74
- "write-file-atomic": "^7.0.1",
90
+ "write-file-atomic": "^8.0.0",
75
91
  "zod": "^4.3.6"
76
92
  },
77
93
  "devDependencies": {
@@ -86,6 +102,8 @@
86
102
  "vitest": "^4.1.3"
87
103
  },
88
104
  "overrides": {
89
- "@anthropic-ai/sdk": "^0.95.0"
105
+ "@anthropic-ai/sdk": "^0.95.0",
106
+ "ip-address": "^10.1.1",
107
+ "fast-uri": "^3.1.2"
90
108
  }
91
109
  }
@@ -24,7 +24,7 @@ import { log, logError, logWarn } from "../../util/log.js";
24
24
  import { traceMessage } from "../../util/trace.js";
25
25
  import { incrementCounter, recordHistogram } from "../../util/metrics.js";
26
26
  import { formatFullDatetime } from "../../util/time.js";
27
- import { isTurnTerminator } from "../../core/tools/index.js";
27
+ import { isTurnTerminator, stripMcpPrefix } from "../../core/tools/index.js";
28
28
 
29
29
  import type { Query } from "@anthropic-ai/claude-agent-sdk";
30
30
  import type { QueryParams, QueryResult } from "../../core/types.js";
@@ -99,15 +99,20 @@ export async function handleMessage(
99
99
  // so the end-of-turn trailing-text fallback can dedupe against content
100
100
  // already delivered. Without this, a model that writes prose AND calls a
101
101
  // delivery tool with similar text would surface twice in the chat.
102
+ //
103
+ // Tool names arrive MCP-prefixed (e.g. `mcp__telegram-tools__end_turn`)
104
+ // when routed through MCP — strip the prefix so equality checks match
105
+ // the registry's bare names.
102
106
  const captureDeliveredText = (
103
107
  toolName: string,
104
108
  input: Record<string, unknown>,
105
109
  ): void => {
110
+ const bareName = stripMcpPrefix(toolName);
106
111
  let deliveredText: string | undefined;
107
- if (toolName === "end_turn" && typeof input.text === "string") {
112
+ if (bareName === "end_turn" && typeof input.text === "string") {
108
113
  deliveredText = input.text;
109
114
  } else if (
110
- toolName === "send" &&
115
+ bareName === "send" &&
111
116
  input.type === "text" &&
112
117
  typeof input.text === "string"
113
118
  ) {
@@ -169,25 +174,24 @@ export async function handleMessage(
169
174
  }
170
175
  }
171
176
 
172
- // Turn-terminator tool was called (e.g. `end_turn`). Abort the SDK
173
- // loop cleanly so the model can't keep producing trailing scratchpad
174
- // after declaring "I'm done". Without this, the model is free to
175
- // think more, call more tools, or write more prose — and any prose
176
- // afterwards trips the flow-violation re-prompt path. Calling
177
- // qi.interrupt() lets the SDK yield its terminal result and exit
178
- // the for-await loop on the next iteration.
179
- if (state.turnTerminated) {
180
- try {
181
- await qi.interrupt();
182
- } catch (err) {
183
- // Non-fatal: interrupt failures shouldn't break the turn,
184
- // they just mean the natural end-of-stream path will run.
185
- logWarn(
186
- "agent",
187
- `[${chatId}] qi.interrupt() after turn terminator failed: ${(err as Error)?.message ?? err}`,
188
- );
189
- }
190
- }
177
+ // Note: we previously called `qi.interrupt()` here when a turn-
178
+ // terminator tool fired, intending to short-circuit the SDK's
179
+ // wasted "wrap up after end_turn tool_result" follow-up API call
180
+ // (~3s of phantom typing while the model says nothing useful).
181
+ // That interrupt races with in-flight MCP tool dispatches in the
182
+ // same assistant message `end_turn` itself is an MCP tool, and
183
+ // the model frequently emits sibling tool_use blocks in the same
184
+ // message. interrupt cancels their AbortController mid-flight,
185
+ // which surfaces as `MCP error -32001: AbortError` in the SDK
186
+ // result and bubbles up to the user as "Something went wrong".
187
+ //
188
+ // The natural-close path is fine: the SDK does one more API call
189
+ // after end_turn returns (the model has nothing to say so it
190
+ // returns a stop turn quickly, ~2-3s typing lag), then yields a
191
+ // result message and exits the iterator cleanly. We accept the
192
+ // typing lag in exchange for not breaking turns. `state.turnTerminated`
193
+ // is still tracked so the flow-violation re-prompt path below can
194
+ // skip its retry when the model explicitly ended its turn.
191
195
  continue;
192
196
  }
193
197
 
@@ -6,12 +6,20 @@
6
6
  */
7
7
 
8
8
  import { resolve } from "node:path";
9
- import type { Options } from "@anthropic-ai/claude-agent-sdk";
9
+ import { pathToFileURL } from "node:url";
10
+ import type {
11
+ Options,
12
+ PostToolBatchHookInput,
13
+ HookCallback,
14
+ HookJSONOutput,
15
+ } from "@anthropic-ai/claude-agent-sdk";
10
16
  import { getSession } from "../../storage/sessions.js";
11
17
  import { getChatSettings } from "../../storage/chat-settings.js";
12
18
  import { getPluginMcpServers } from "../../core/plugin.js";
13
19
  import { resolveModelId } from "../../core/models.js";
14
20
  import { wrapMcpServer } from "../../util/mcp-launcher.js";
21
+ import { isTurnTerminator } from "../../core/tools/index.js";
22
+ import { log } from "../../util/log.js";
15
23
  import { getConfig, getBridgePort } from "./state.js";
16
24
  import { DISALLOWED_TOOLS_CHAT, EFFORT_MAP } from "./constants.js";
17
25
 
@@ -38,10 +46,17 @@ export function buildMcpServers(
38
46
  const config = getConfig();
39
47
  const bridgeUrl = `http://127.0.0.1:${getBridgePort()}`;
40
48
 
41
- const tsxImport = resolve(
42
- import.meta.dirname ?? ".",
43
- "../../../node_modules/tsx/dist/esm/index.mjs",
44
- );
49
+ // tsx as a Node loader is passed via `--import <url>`. Node accepts URLs
50
+ // or absolute paths, but on Windows a raw backslash path (`D:\…\tsx`) is
51
+ // ambiguous between path and URL — the loader hook fails to register and
52
+ // every subsequent `import` of a .ts file throws. `pathToFileURL` produces
53
+ // a cross-platform `file://` URL that Node always treats as a loader URL.
54
+ const tsxImport = pathToFileURL(
55
+ resolve(
56
+ import.meta.dirname ?? ".",
57
+ "../../../node_modules/tsx/dist/esm/index.mjs",
58
+ ),
59
+ ).href;
45
60
  const mcpServerPath = resolve(
46
61
  import.meta.dirname ?? ".",
47
62
  "../../core/tools/mcp-server.ts",
@@ -65,12 +80,14 @@ export function buildMcpServers(
65
80
  TALON_CHAT_ID: chatId,
66
81
  TALON_FRONTEND: frontend,
67
82
  };
83
+ // `node --import <tsx-loader>` everywhere — tsx as a Node loader works
84
+ // identically on Windows and POSIX, and avoids spawning `npx.cmd` (which
85
+ // Node 20.19+ refuses to execute via child_process.spawn without
86
+ // shell:true; CVE-2024-27980 mitigation). The wrapping launcher would
87
+ // hit the same .cmd ban when calling its child.
68
88
  servers[serverName] = wrapMcpServer({
69
- command: process.platform === "win32" ? "npx" : "node",
70
- args:
71
- process.platform === "win32"
72
- ? ["tsx", mcpServerPath]
73
- : ["--import", tsxImport, mcpServerPath],
89
+ command: "node",
90
+ args: ["--import", tsxImport, mcpServerPath],
74
91
  env: mcpEnv,
75
92
  });
76
93
  }
@@ -90,6 +107,54 @@ export function buildMcpServers(
90
107
  return servers;
91
108
  }
92
109
 
110
+ // ── Hooks ───────────────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * PostToolBatch hook: terminate the SDK query loop the moment a turn-terminator
114
+ * tool (e.g. `end_turn`) resolves in the assistant's tool batch.
115
+ *
116
+ * Why PostToolBatch and not PostToolUse:
117
+ * - PostToolUse fires per-tool and may run concurrently for parallel tool
118
+ * calls. Returning `continue: false` from there can race with sibling MCP
119
+ * tools whose AbortControllers haven't yet completed — the same race that
120
+ * killed the previous `qi.interrupt()` approach (see handler.ts comment
121
+ * and commit `d5ce30f`).
122
+ * - PostToolBatch fires exactly ONCE after every tool in the batch has
123
+ * resolved. By definition there are no in-flight siblings to race with.
124
+ *
125
+ * What this saves:
126
+ * - The ~2-3s "phantom typing" round-trip the SDK makes after `end_turn`
127
+ * returns (the model has nothing to say, generates a stop_turn anyway).
128
+ * - Trailing prose that gets generated during that round-trip and was
129
+ * previously suppressed only at the delivery layer (real tokens spent).
130
+ *
131
+ * Returns `{ continue: false, stopReason: ... }` → SDK exits with TerminalReason
132
+ * `'hook_stopped'`, no further model generation.
133
+ */
134
+ const turnTerminatorHook: HookCallback = async (
135
+ input,
136
+ ): Promise<HookJSONOutput> => {
137
+ if (input.hook_event_name !== "PostToolBatch") {
138
+ return { continue: true };
139
+ }
140
+ const batch = input as PostToolBatchHookInput;
141
+ const terminator = batch.tool_calls.find((tc) =>
142
+ isTurnTerminator(tc.tool_name),
143
+ );
144
+ if (terminator) {
145
+ log(
146
+ "agent",
147
+ `PostToolBatch: terminating SDK loop on ${terminator.tool_name} ` +
148
+ `(batch size: ${batch.tool_calls.length})`,
149
+ );
150
+ return {
151
+ continue: false,
152
+ stopReason: "turn terminated by end_turn / send",
153
+ };
154
+ }
155
+ return { continue: true };
156
+ };
157
+
93
158
  // ── Options builder ─────────────────────────────────────────────────────────
94
159
 
95
160
  export function buildSdkOptions(chatId: string): BuildSdkOptionsResult {
@@ -120,6 +185,9 @@ export function buildSdkOptions(chatId: string): BuildSdkOptionsResult {
120
185
  ...buildMcpServers(chatId),
121
186
  ...getPluginMcpServers(`http://127.0.0.1:${getBridgePort()}`, chatId),
122
187
  },
188
+ hooks: {
189
+ PostToolBatch: [{ hooks: [turnTerminatorHook] }],
190
+ },
123
191
  ...(session.sessionId ? { resume: session.sessionId } : {}),
124
192
  };
125
193
 
@@ -298,9 +298,16 @@ export class Gateway {
298
298
  });
299
299
  httpServer.listen(p, "127.0.0.1", () => {
300
300
  this.server = httpServer;
301
- this.port = p;
302
- log("gateway", `Action gateway on :${p}`);
303
- resolve(p);
301
+ // When the caller asks for port 0 the OS assigns a random free
302
+ // port read the actual port off the listening socket instead
303
+ // of saving the requested 0.
304
+ const addr = httpServer.address();
305
+ this.port =
306
+ typeof addr === "object" && addr !== null
307
+ ? (addr as { port: number }).port
308
+ : p;
309
+ log("gateway", `Action gateway on :${this.port}`);
310
+ resolve(this.port);
304
311
  });
305
312
  };
306
313
  tryPort(port);
@@ -43,9 +43,32 @@ const TURN_TERMINATOR_NAMES: ReadonlySet<string> = new Set(
43
43
  ALL_TOOLS.filter((t) => t.endsTurn).map((t) => t.name),
44
44
  );
45
45
 
46
- /** Whether a tool call by this name should terminate the model's turn. */
46
+ /**
47
+ * Strip an MCP server prefix (`mcp__<server>__`) from a tool name.
48
+ *
49
+ * Tools served through MCP arrive at the SDK with the prefix attached
50
+ * (e.g. `mcp__telegram-tools__end_turn`), while the registry stores them
51
+ * by their bare name (`end_turn`). Callers that want to compare against
52
+ * the registry should normalize first.
53
+ *
54
+ * Returns the input unchanged if no prefix matches — safe to call on any
55
+ * tool name. The non-greedy `.+?` matches the FIRST `__` boundary after
56
+ * `mcp__`, which is the server-name terminator in MCP's naming scheme.
57
+ */
58
+ export function stripMcpPrefix(toolName: string): string {
59
+ return toolName.replace(/^mcp__.+?__/, "");
60
+ }
61
+
62
+ /**
63
+ * Whether a tool call by this name should terminate the model's turn.
64
+ *
65
+ * Accepts both bare names (`end_turn`) and MCP-prefixed names
66
+ * (`mcp__telegram-tools__end_turn`) — the prefix is stripped before
67
+ * comparing against the terminator set.
68
+ */
47
69
  export function isTurnTerminator(toolName: string): boolean {
48
- return TURN_TERMINATOR_NAMES.has(toolName);
70
+ if (TURN_TERMINATOR_NAMES.has(toolName)) return true;
71
+ return TURN_TERMINATOR_NAMES.has(stripMcpPrefix(toolName));
49
72
  }
50
73
 
51
74
  /** Filter options for composing a tool set. */
@@ -14,6 +14,7 @@ import {
14
14
  appendDailyLog,
15
15
  appendDailyLogResponse,
16
16
  } from "../../storage/daily-log.js";
17
+ import { stripMcpPrefix } from "../../core/tools/index.js";
17
18
  import { setMessageFilePath } from "../../storage/history.js";
18
19
  import { addMedia } from "../../storage/media-index.js";
19
20
  import { recordMessageProcessed, recordError } from "../../util/watchdog.js";
@@ -158,11 +159,117 @@ export async function isAccessAllowed(
158
159
  return false;
159
160
  }
160
161
 
162
+ /**
163
+ * Maximum length of an unauthorized message body to retain in logs.
164
+ * Keeps abusive payloads (large pastes, attachment captions etc.) bounded
165
+ * while still preserving enough context to understand what was sent.
166
+ */
167
+ const UNAUTHORIZED_BODY_MAX_LEN = 1024;
168
+
169
+ /**
170
+ * Best-effort preview of an unauthorized message for forensics.
171
+ *
172
+ * Returns the visible text payload (text or caption), a short tag for
173
+ * media-only messages (`[sticker: 🤖]`, `[photo]`, `[voice 14s]`, etc.),
174
+ * or `undefined` if there's nothing meaningful to capture (e.g. a service
175
+ * message or empty content).
176
+ *
177
+ * Truncated to UNAUTHORIZED_BODY_MAX_LEN to keep log lines bounded.
178
+ */
179
+ export function extractUnauthorizedPreview(
180
+ message:
181
+ | {
182
+ text?: string;
183
+ caption?: string;
184
+ sticker?: { emoji?: string; set_name?: string };
185
+ photo?: unknown;
186
+ voice?: { duration?: number };
187
+ video?: unknown;
188
+ video_note?: unknown;
189
+ audio?: unknown;
190
+ animation?: unknown;
191
+ document?: { file_name?: string };
192
+ contact?: unknown;
193
+ location?: unknown;
194
+ poll?: { question?: string };
195
+ dice?: { emoji?: string };
196
+ }
197
+ | undefined,
198
+ ): string | undefined {
199
+ if (!message) return undefined;
200
+
201
+ const text = message.text ?? message.caption;
202
+ if (typeof text === "string" && text.trim().length > 0) {
203
+ return text.length > UNAUTHORIZED_BODY_MAX_LEN
204
+ ? `${text.slice(0, UNAUTHORIZED_BODY_MAX_LEN)}… [truncated]`
205
+ : text;
206
+ }
207
+
208
+ if (message.sticker) {
209
+ const emoji = message.sticker.emoji ?? "?";
210
+ const set = message.sticker.set_name
211
+ ? ` from ${message.sticker.set_name}`
212
+ : "";
213
+ return `[sticker: ${emoji}${set}]`;
214
+ }
215
+ if (message.photo) return "[photo]";
216
+ if (message.voice) {
217
+ const dur = message.voice.duration;
218
+ return dur ? `[voice ${dur}s]` : "[voice]";
219
+ }
220
+ if (message.video_note) return "[video note]";
221
+ if (message.video) return "[video]";
222
+ if (message.audio) return "[audio]";
223
+ if (message.animation) return "[animation]";
224
+ if (message.document) {
225
+ return message.document.file_name
226
+ ? `[document: ${message.document.file_name}]`
227
+ : "[document]";
228
+ }
229
+ if (message.contact) return "[contact]";
230
+ if (message.location) return "[location]";
231
+ if (message.poll) {
232
+ return message.poll.question
233
+ ? `[poll: ${message.poll.question}]`
234
+ : "[poll]";
235
+ }
236
+ if (message.dice) return `[dice: ${message.dice.emoji ?? "🎲"}]`;
237
+
238
+ return undefined;
239
+ }
240
+
161
241
  async function notifyUnauthorized(
162
242
  bot: Bot,
163
243
  ctx: Context,
164
244
  type: "dm" | "group",
165
245
  ): Promise<void> {
246
+ const sender = getSenderName(ctx.from);
247
+ const username = ctx.from?.username ? ` (@${ctx.from.username})` : "";
248
+ const userId = ctx.from?.id ?? "unknown";
249
+
250
+ // Capture message body BEFORE the cooldown check — every unauthorized
251
+ // attempt should be recorded for forensics, even if the user-facing
252
+ // warning + admin notification are suppressed by cooldown. Without
253
+ // this, follow-up DMs from a known social-engineering account vanish
254
+ // entirely from logs.
255
+ const body = extractUnauthorizedPreview(
256
+ ctx.message as Parameters<typeof extractUnauthorizedPreview>[0],
257
+ );
258
+ if (body) {
259
+ try {
260
+ appendDailyLog(
261
+ `⚠️ UNAUTHORIZED ${sender}${username} [id:${userId}]`,
262
+ body,
263
+ );
264
+ } catch {
265
+ /* daily log unavailable — fall through to talon.log */
266
+ }
267
+ logWarn(
268
+ "access",
269
+ `Unauthorized ${type} body from ${sender}${username} [id:${userId}]: ${body.slice(0, 200)}`,
270
+ );
271
+ }
272
+
166
273
  const key = type === "dm" ? `dm:${ctx.from?.id}` : `group:${ctx.chat?.id}`;
167
274
  const now = Date.now();
168
275
  const lastWarned = unauthorizedCooldown.get(key);
@@ -172,10 +279,6 @@ async function notifyUnauthorized(
172
279
  }
173
280
  unauthorizedCooldown.set(key, now);
174
281
 
175
- const sender = getSenderName(ctx.from);
176
- const username = ctx.from?.username ? ` (@${ctx.from.username})` : "";
177
- const userId = ctx.from?.id ?? "unknown";
178
-
179
282
  // Warn the user
180
283
  try {
181
284
  await bot.api.sendMessage(
@@ -192,8 +295,9 @@ async function notifyUnauthorized(
192
295
  type === "dm"
193
296
  ? `🚨 Unauthorized DM from ${sender}${username} [id:${userId}]`
194
297
  : `🚨 Unauthorized group access: "${(ctx.chat as { title?: string })?.title ?? ctx.chat!.id}" [id:${ctx.chat!.id}] by ${sender}${username}`;
298
+ const detailWithBody = body ? `${detail}\n\n${body.slice(0, 400)}` : detail;
195
299
  try {
196
- await bot.api.sendMessage(adminId, detail);
300
+ await bot.api.sendMessage(adminId, detailWithBody);
197
301
  } catch {
198
302
  /* admin unreachable — ignore */
199
303
  }
@@ -771,8 +875,17 @@ async function processAndReply(params: ProcessAndReplyParams): Promise<void> {
771
875
  onStreamDelta,
772
876
  onTextBlock,
773
877
  onToolUse: (toolName, input) => {
774
- if (
775
- toolName === "send" &&
878
+ // Tool names arrive MCP-prefixed (e.g. `mcp__telegram-tools__end_turn`)
879
+ // when routed through MCP — strip the prefix so equality checks
880
+ // match the registry's bare names. Both `end_turn(text=...)` and
881
+ // `send(type="text")` are user-facing text deliveries; capture
882
+ // both so the daily log records bot responses regardless of which
883
+ // delivery tool the model used.
884
+ const bareName = stripMcpPrefix(toolName);
885
+ if (bareName === "end_turn" && typeof input.text === "string") {
886
+ appendDailyLogResponse("Talon", input.text, { chatTitle });
887
+ } else if (
888
+ bareName === "send" &&
776
889
  input.type === "text" &&
777
890
  typeof input.text === "string"
778
891
  ) {
@@ -1,91 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
-
3
- import {
4
- deriveNumericChatId,
5
- generateTerminalChatId,
6
- isTerminalChatId,
7
- } from "../util/chat-id.js";
8
-
9
- describe("deriveNumericChatId", () => {
10
- it("returns a positive number", () => {
11
- const id = deriveNumericChatId("test-chat");
12
- expect(id).toBeGreaterThan(0);
13
- expect(Number.isInteger(id)).toBe(true);
14
- });
15
-
16
- it("returns the same value for the same input", () => {
17
- const a = deriveNumericChatId("stable-id");
18
- const b = deriveNumericChatId("stable-id");
19
- expect(a).toBe(b);
20
- });
21
-
22
- it("returns different values for different inputs", () => {
23
- const a = deriveNumericChatId("chat-alpha");
24
- const b = deriveNumericChatId("chat-beta");
25
- expect(a).not.toBe(b);
26
- });
27
-
28
- it("handles terminal-style IDs", () => {
29
- const id = deriveNumericChatId("t_1711360000000");
30
- expect(id).toBeGreaterThan(0);
31
- });
32
-
33
- it("handles empty string", () => {
34
- const id = deriveNumericChatId("");
35
- expect(id).toBeGreaterThanOrEqual(0);
36
- expect(Number.isInteger(id)).toBe(true);
37
- });
38
- });
39
-
40
- describe("generateTerminalChatId", () => {
41
- it("returns a string starting with t_", () => {
42
- const id = generateTerminalChatId();
43
- expect(id).toMatch(/^t_\d+$/);
44
- });
45
-
46
- it("returns a string with numeric timestamp portion", () => {
47
- const id = generateTerminalChatId();
48
- const ts = Number(id.slice(2));
49
- expect(Number.isNaN(ts)).toBe(false);
50
- expect(ts).toBeGreaterThan(0);
51
- });
52
-
53
- it("uses a recent timestamp", () => {
54
- const before = Date.now();
55
- const id = generateTerminalChatId();
56
- const after = Date.now();
57
- const ts = Number(id.slice(2));
58
- expect(ts).toBeGreaterThanOrEqual(before);
59
- expect(ts).toBeLessThanOrEqual(after);
60
- });
61
- });
62
-
63
- describe("isTerminalChatId", () => {
64
- it('returns true for "1" (legacy ID)', () => {
65
- expect(isTerminalChatId("1")).toBe(true);
66
- });
67
-
68
- it("returns true for t_ prefixed IDs", () => {
69
- expect(isTerminalChatId("t_1711360000000")).toBe(true);
70
- expect(isTerminalChatId("t_0")).toBe(true);
71
- expect(isTerminalChatId("t_abc")).toBe(true);
72
- });
73
-
74
- it("returns false for Telegram numeric IDs", () => {
75
- expect(isTerminalChatId("123456789")).toBe(false);
76
- expect(isTerminalChatId("-100123456")).toBe(false);
77
- });
78
-
79
- it("returns false for Teams IDs", () => {
80
- expect(isTerminalChatId("teams_chat_abc123")).toBe(false);
81
- });
82
-
83
- it("returns false for empty string", () => {
84
- expect(isTerminalChatId("")).toBe(false);
85
- });
86
-
87
- it('returns false for "10" and other strings starting with 1', () => {
88
- expect(isTerminalChatId("10")).toBe(false);
89
- expect(isTerminalChatId("100")).toBe(false);
90
- });
91
- });