talon-agent 3.34.0 → 3.35.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 (70) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +0 -10
  3. package/package.json +6 -4
  4. package/src/backend/claude-sdk/handler.ts +428 -415
  5. package/src/backend/codex/auth.ts +40 -1
  6. package/src/backend/codex/handler/message.ts +290 -366
  7. package/src/backend/codex/handler/rollout-accounting.ts +137 -0
  8. package/src/backend/codex/plan-usage.ts +27 -2
  9. package/src/backend/kilo/handler/message.ts +2 -0
  10. package/src/backend/kilo/server.ts +1 -0
  11. package/src/backend/openai-agents/handler/message.ts +282 -356
  12. package/src/backend/opencode/handler/message.ts +2 -0
  13. package/src/backend/opencode/server.ts +1 -0
  14. package/src/backend/remote-server/chat-turn.ts +76 -122
  15. package/src/backend/remote-server/index.ts +4 -0
  16. package/src/backend/remote-server/lifecycle.ts +37 -0
  17. package/src/backend/remote-server/mcp.ts +73 -9
  18. package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
  19. package/src/backend/remote-server/server-bindings.ts +12 -0
  20. package/src/backend/remote-server/sessions.ts +2 -2
  21. package/src/backend/remote-server/state.ts +8 -0
  22. package/src/backend/remote-server/turn.ts +93 -23
  23. package/src/backend/shared/cache-telemetry.ts +17 -1
  24. package/src/backend/shared/handler-to-events.ts +11 -16
  25. package/src/backend/shared/index.ts +14 -15
  26. package/src/backend/shared/result-events.ts +30 -0
  27. package/src/backend/shared/turn-phases.ts +277 -0
  28. package/src/bootstrap.ts +24 -10
  29. package/src/core/auth/expiry-monitor.ts +89 -0
  30. package/src/core/auth/login-flow.ts +247 -0
  31. package/src/core/auth/status.ts +193 -0
  32. package/src/core/background/triggers/exit.ts +19 -2
  33. package/src/core/background/triggers/resume.ts +14 -7
  34. package/src/core/background/triggers/state.ts +9 -0
  35. package/src/core/engine/gateway-actions/history.ts +12 -1
  36. package/src/core/errors.ts +1 -1
  37. package/src/core/mcp-hub/child-transport.ts +215 -0
  38. package/src/core/mcp-hub/children.ts +72 -13
  39. package/src/core/mcp-hub/index.ts +32 -13
  40. package/src/core/models/active-model.ts +29 -6
  41. package/src/core/tools/history.ts +10 -1
  42. package/src/core/weaver/shuttle.ts +4 -0
  43. package/src/frontend/shared/model-commands.ts +400 -0
  44. package/src/frontend/telegram/admin.ts +75 -52
  45. package/src/frontend/telegram/auth-panel.ts +205 -0
  46. package/src/frontend/telegram/callbacks/auth.ts +70 -0
  47. package/src/frontend/telegram/callbacks/index.ts +16 -0
  48. package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
  49. package/src/frontend/telegram/commands/auth.ts +58 -0
  50. package/src/frontend/telegram/commands/definitions.ts +4 -0
  51. package/src/frontend/telegram/commands/index.ts +10 -4
  52. package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
  53. package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
  54. package/src/frontend/whatsapp/actions/history.ts +100 -0
  55. package/src/frontend/whatsapp/actions/index.ts +4 -1
  56. package/src/frontend/whatsapp/commands.ts +371 -0
  57. package/src/frontend/whatsapp/inbound.ts +24 -46
  58. package/src/frontend/whatsapp/index.ts +12 -1
  59. package/src/frontend/whatsapp/media-store.ts +20 -1
  60. package/src/frontend/whatsapp/message-store.ts +101 -24
  61. package/src/storage/history.ts +22 -1
  62. package/src/storage/repositories/history-repo.ts +14 -0
  63. package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
  64. package/src/storage/sql/history.sql +12 -0
  65. package/src/storage/sql/schema.sql +21 -0
  66. package/src/storage/sql/statements.generated.ts +50 -1
  67. package/src/storage/sql/whatsapp-messages.sql +29 -0
  68. package/src/storage/trigger-store.ts +8 -0
  69. package/src/storage/whatsapp-messages.ts +51 -0
  70. package/src/util/watchdog.ts +32 -7
@@ -7,6 +7,7 @@ import type { QueryParams, QueryResult } from "../../shared/handler-types.js";
7
7
  import { runRemoteChatTurn } from "../../remote-server/chat-turn.js";
8
8
  import {
9
9
  ensureServer,
10
+ trackActiveTurn,
10
11
  ensureSession,
11
12
  ensureChatMcpServer,
12
13
  ensurePluginMcpServers,
@@ -26,6 +27,7 @@ export function handleMessage(params: QueryParams): Promise<QueryResult> {
26
27
  label: "OpenCode",
27
28
  getConfig,
28
29
  ensureServer,
30
+ trackActiveTurn,
29
31
  parseModelSelection: parseStoredOpenCodeModelSelection,
30
32
  resolveProviderID,
31
33
  ensureSession,
@@ -64,6 +64,7 @@ export const stopOpenCodeServer = opencode.stop;
64
64
  export const {
65
65
  onServerStop,
66
66
  ensureServer,
67
+ trackActiveTurn,
67
68
  ensureChatMcpServer,
68
69
  ensurePluginMcpServers,
69
70
  buildToolOverrides,
@@ -4,9 +4,9 @@
4
4
  *
5
5
  * Resolves the active model against the server's catalog, makes sure the
6
6
  * server, session, and this chat's MCP servers exist, builds the prompt
7
- * pair, drives the turn (`./turn.ts`), then does the post-turn accounting:
8
- * usage fallback from the session summary, metrics, session id/name
9
- * persistence, and the shared delivery decision.
7
+ * pair, drives the turn (`./turn.ts`), then runs the shared post-turn
8
+ * phases (`backend/shared/turn-phases.ts`) with the one family-specific
9
+ * step in between: the usage fallback from the session summary.
10
10
  *
11
11
  * Kilo and OpenCode ran byte-for-byte copies of this (modulo the backend
12
12
  * name in log lines). The copies are gone; `RemoteChatBindings` is the
@@ -14,13 +14,7 @@
14
14
  * already returns.
15
15
  */
16
16
 
17
- import {
18
- getSession,
19
- incrementTurns,
20
- recordUsage,
21
- setSessionId,
22
- setSessionName,
23
- } from "../../storage/sessions.js";
17
+ import { getSession, incrementTurns } from "../../storage/sessions.js";
24
18
  import { getChatSettings } from "../../storage/chat-settings.js";
25
19
  import { log, logError } from "../../util/log.js";
26
20
  import { traceMessage } from "../../util/trace.js";
@@ -32,13 +26,14 @@ import {
32
26
  finalizeResponseText,
33
27
  formatUserPrompt,
34
28
  prepareSystemPrompt,
35
- extractSessionName,
36
- summarizeUsage,
37
29
  routeDelivery,
38
30
  applyRetryDecision,
39
- recordTurnMetrics,
40
- recordFailedTurnAccounting,
41
31
  registerTurnInterrupt,
32
+ accountTurn,
33
+ accountFailedTurn,
34
+ nameSessionFromFirstMessage,
35
+ finishCallbackTurn,
36
+ type StreamState,
42
37
  } from "../shared/index.js";
43
38
  import type { RemoteAgentClient } from "./client.js";
44
39
  import type { RemoteServerBindings } from "./server-bindings.js";
@@ -50,6 +45,7 @@ export type RemoteChatBindings<TClient extends RemoteAgentClient> = Pick<
50
45
  RemoteServerBindings<TClient>,
51
46
  | "getConfig"
52
47
  | "ensureServer"
48
+ | "trackActiveTurn"
53
49
  | "parseModelSelection"
54
50
  | "resolveProviderID"
55
51
  | "ensureSession"
@@ -150,10 +146,12 @@ export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
150
146
  state.turnTerminated = true;
151
147
  await turnClient.session.abort({ sessionID: sessionId });
152
148
  });
149
+ // Stopping the backend mid-turn (a `/model` swap, shutdown) aborts this
150
+ // controller, which rejects the turn promptly instead of leaving it to
151
+ // the deadline.
152
+ const stopController = new AbortController();
153
+ const untrackTurn = bindings.trackActiveTurn(stopController);
153
154
  const promptStartedAt = Date.now();
154
- const seenQuestionIds = new Set<string>();
155
- const seenPermissionIds = new Set<string>();
156
- const seenToolCallIds = new Set<string>();
157
155
 
158
156
  const setupMs = Date.now() - t0;
159
157
  let promptMs = 0;
@@ -176,13 +174,14 @@ export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
176
174
  modelID,
177
175
  state,
178
176
  chatId,
179
- seenQuestionIds,
180
- seenPermissionIds,
181
- seenToolCallIds,
177
+ seenQuestionIds: new Set<string>(),
178
+ seenPermissionIds: new Set<string>(),
179
+ seenToolCallIds: new Set<string>(),
182
180
  toolOverrides,
183
181
  onStreamDelta: undefined,
184
182
  onTextBlock,
185
183
  onToolUse,
184
+ stopSignal: stopController.signal,
186
185
  });
187
186
  promptMs = Date.now() - turnStart;
188
187
  } catch (err) {
@@ -198,31 +197,22 @@ export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
198
197
  if (outcome.retry) return outcome.retry;
199
198
 
200
199
  // Terminal failure — account for whatever the turn consumed before
201
- // dying and drop the live overlay (the retry path above did its own
202
- // accounting inside the recursive attempt).
203
- recordFailedTurnAccounting({
200
+ // dying (the retry path above did its own accounting inside the
201
+ // recursive attempt).
202
+ accountFailedTurn({
204
203
  backend: id,
205
204
  chatId,
205
+ state,
206
206
  durationMs: Date.now() - t0,
207
- toolCalls: state.toolCalls,
208
- apiCalls: state.numApiCalls,
209
207
  model: activeModel,
210
- usage: {
211
- inputTokens: state.sdkInputTokens,
212
- outputTokens: state.sdkOutputTokens,
213
- cacheRead: state.sdkCacheRead,
214
- cacheWrite: state.sdkCacheWrite,
215
- },
216
- contextTokens: state.contextTokens,
217
- contextWindow: state.contextWindow,
218
208
  });
219
-
220
209
  logError(
221
210
  "agent",
222
211
  `[${chatId}] ${label} error: ${outcome.classified.message}`,
223
212
  );
224
213
  throw outcome.classified;
225
214
  } finally {
215
+ untrackTurn();
226
216
  unregisterInterrupt();
227
217
  // Note: we deliberately do NOT disconnect the chat MCP server here.
228
218
  // The server is named per-chat so it's safe to keep across turns;
@@ -232,71 +222,25 @@ export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
232
222
 
233
223
  // ── Post-loop accounting ──────────────────────────────────────────────────
234
224
 
235
- // If the SSE loop missed any usage info, fall back to the session
236
- // summary endpoint (which always reflects the final server state).
237
- if (
238
- state.sdkInputTokens === 0 &&
239
- state.sdkOutputTokens === 0 &&
240
- state.sdkCacheRead === 0
241
- ) {
242
- try {
243
- const summary = await getTurnSummary(
244
- oc as unknown as RemoteSessionClient,
245
- sessionId,
246
- promptStartedAt,
247
- );
248
- if (summary.usage.assistantMessages > 0) {
249
- recordTokens(state, {
250
- inputTokens: summary.usage.inputTokens,
251
- outputTokens: summary.usage.outputTokens,
252
- cacheRead: summary.usage.cacheRead,
253
- cacheWrite: summary.usage.cacheWrite,
254
- });
255
- }
256
- } catch {
257
- // best-effort — session summaries can race on cancellation
258
- }
259
- }
225
+ await fillUsageFromSummary(
226
+ oc as unknown as RemoteSessionClient,
227
+ sessionId,
228
+ promptStartedAt,
229
+ state,
230
+ );
260
231
 
261
232
  const responseText = finalizeResponseText(state);
262
233
  const durationMs = Date.now() - t0;
263
- recordTurnMetrics({
234
+ accountTurn({
264
235
  chatId,
265
236
  backend: id,
266
- durationMs,
267
- toolCalls: state.toolCalls,
268
- apiCalls: state.numApiCalls,
269
- usage: {
270
- inputTokens: state.sdkInputTokens,
271
- outputTokens: state.sdkOutputTokens,
272
- cacheRead: state.sdkCacheRead,
273
- cacheWrite: state.sdkCacheWrite,
274
- },
275
- });
276
-
277
- if (state.newSessionId) {
278
- // setSessionId tolerates "same id" calls — keep it simple.
279
- const stored = getSession(chatId).sessionId;
280
- if (stored !== state.newSessionId) {
281
- setSessionId(chatId, state.newSessionId);
282
- }
283
- }
284
-
285
- incrementTurns(chatId);
286
- recordUsage(chatId, {
287
- inputTokens: state.sdkInputTokens,
288
- outputTokens: state.sdkOutputTokens,
289
- cacheRead: state.sdkCacheRead,
290
- cacheWrite: state.sdkCacheWrite,
237
+ state,
291
238
  durationMs,
292
239
  model: activeModel,
240
+ sessionId: state.newSessionId,
293
241
  });
294
-
295
- // Set a descriptive session name from the user's first message.
296
- if (previousTurns === 0) {
297
- const name = extractSessionName(text);
298
- if (name) setSessionName(chatId, name);
299
- }
242
+ incrementTurns(chatId);
243
+ nameSessionFromFirstMessage({ chatId, text, previousTurns });
300
244
 
301
245
  // ── Delivery — the decision tree shared by every backend ──────────────────
302
246
  const delivery = await routeDelivery({
@@ -307,40 +251,50 @@ export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
307
251
  onTextBlock,
308
252
  });
309
253
 
310
- log(
311
- "agent",
312
- `[${chatId}] delivery: ${delivery.route} (${delivery.chars} chars)`,
313
- );
314
-
315
- log(
316
- "agent",
317
- `[${chatId}] -> (${summarizeUsage(
318
- {
319
- inputTokens: state.sdkInputTokens,
320
- outputTokens: state.sdkOutputTokens,
321
- cacheRead: state.sdkCacheRead,
322
- cacheWrite: state.sdkCacheWrite,
323
- },
324
- { durationMs, toolCalls: state.toolCalls },
325
- )} terminator=${state.turnTerminated ? "yes" : "no"} ` +
326
- `delivered=${state.deliveredTextNorms.length} ` +
327
- `respLen=${responseText.length} ` +
328
- `setup=${setupMs}ms turn=${promptMs}ms ` +
329
- `events=${formatEventCounts(state.eventCounts)})`,
330
- );
331
- traceMessage(chatId, "out", responseText, {
254
+ return finishCallbackTurn({
255
+ chatId,
256
+ state,
257
+ responseText,
332
258
  durationMs,
333
- toolCalls: state.toolCalls,
259
+ setupMs,
260
+ turnMs: promptMs,
261
+ delivery,
262
+ detail: `events=${formatEventCounts(state.eventCounts)}`,
334
263
  });
264
+ }
335
265
 
336
- return {
337
- text: responseText,
338
- durationMs,
339
- inputTokens: state.sdkInputTokens,
340
- outputTokens: state.sdkOutputTokens,
341
- cacheRead: state.sdkCacheRead,
342
- cacheWrite: state.sdkCacheWrite,
343
- };
266
+ /**
267
+ * If the SSE loop missed the usage info, fall back to the session summary
268
+ * endpoint (which always reflects the final server state). Best-effort:
269
+ * session summaries can race on cancellation, so a failure leaves the
270
+ * counts at zero.
271
+ */
272
+ async function fillUsageFromSummary(
273
+ oc: RemoteSessionClient,
274
+ sessionId: string,
275
+ promptStartedAt: number,
276
+ state: ReturnType<typeof createStreamState>,
277
+ ): Promise<void> {
278
+ if (
279
+ state.sdkInputTokens !== 0 ||
280
+ state.sdkOutputTokens !== 0 ||
281
+ state.sdkCacheRead !== 0
282
+ ) {
283
+ return;
284
+ }
285
+ try {
286
+ const summary = await getTurnSummary(oc, sessionId, promptStartedAt);
287
+ if (summary.usage.assistantMessages > 0) {
288
+ recordTokens(state, {
289
+ inputTokens: summary.usage.inputTokens,
290
+ outputTokens: summary.usage.outputTokens,
291
+ cacheRead: summary.usage.cacheRead,
292
+ cacheWrite: summary.usage.cacheWrite,
293
+ });
294
+ }
295
+ } catch {
296
+ // best-effort — session summaries can race on cancellation
297
+ }
344
298
  }
345
299
 
346
300
  /**
@@ -42,7 +42,11 @@ export { stopRemoteServer } from "./lifecycle.js";
42
42
 
43
43
  export {
44
44
  TALON_MCP_SERVER_NAME,
45
+ TALON_PLUGIN_MCP_SERVER_NAME,
46
+ PLUGIN_MCP_SERVER_NAME_MAX_LENGTH,
45
47
  getChatMcpServerName,
48
+ getPluginMcpServerName,
49
+ getPluginMcpServerPrefix,
46
50
  isTalonToolID,
47
51
  ensureChatMcpServer,
48
52
  ensurePluginMcpServers,
@@ -13,11 +13,29 @@
13
13
  * reuse machinery.
14
14
  */
15
15
 
16
+ import { TalonError } from "../../core/errors.js";
16
17
  import { log, logWarn } from "../../util/log.js";
17
18
  import type { RemoteAgentClient } from "./client.js";
18
19
  import type { RemoteServerState } from "./state.js";
19
20
  import { errMsg } from "./state.js";
20
21
 
22
+ /**
23
+ * Rejection for a turn whose server was stopped underneath it (a backend
24
+ * hot-swap or shutdown). Non-retryable on purpose: the chat no longer
25
+ * points at this backend, so a fallback-model retry against it would be
26
+ * wrong — the turn fails cleanly and the next message runs on the new
27
+ * backend.
28
+ */
29
+ export class RemoteServerStoppedError extends TalonError {
30
+ constructor(label: string) {
31
+ super(`${label} server stopped while the turn was in flight`, {
32
+ reason: "unknown",
33
+ retryable: false,
34
+ });
35
+ this.name = "RemoteServerStoppedError";
36
+ }
37
+ }
38
+
21
39
  /**
22
40
  * Inputs passed to {@link ensureRemoteServer}. The backend supplies its
23
41
  * SDK's client/server factories; the shared helper handles the
@@ -180,6 +198,7 @@ export function stopRemoteServer<TClient extends RemoteAgentClient>(
180
198
  extraCleanup?: () => void,
181
199
  ): void {
182
200
  state.clientPromise = null;
201
+ abortActiveTurns(state);
183
202
  state.modelProviderCache.clear();
184
203
  state.registeredMcpServers.clear();
185
204
  state.registeredMcpTools.clear();
@@ -201,3 +220,21 @@ export function stopRemoteServer<TClient extends RemoteAgentClient>(
201
220
  // reused and is intentionally left running for its external owner.
202
221
  state.client = null;
203
222
  }
223
+
224
+ /**
225
+ * Reject every in-flight turn with {@link RemoteServerStoppedError} before
226
+ * the server goes away. The turn's own cleanup unregisters its controller;
227
+ * clearing here only covers turns that never reach their `finally`.
228
+ */
229
+ function abortActiveTurns<TClient extends RemoteAgentClient>(
230
+ state: RemoteServerState<TClient>,
231
+ ): void {
232
+ if (state.activeTurns.size === 0) return;
233
+ log(
234
+ "agent",
235
+ `${state.label} stopping with ${state.activeTurns.size} turn(s) in flight; aborting them`,
236
+ );
237
+ const reason = new RemoteServerStoppedError(state.label);
238
+ for (const controller of state.activeTurns) controller.abort(reason);
239
+ state.activeTurns.clear();
240
+ }
@@ -14,7 +14,9 @@
14
14
  * `talon-tools-<chatId>` MCP server. Returns the registered name so
15
15
  * callers can scope tool overrides to this chat alone.
16
16
  * - {@link ensurePluginMcpServers} — register chat-namespaced plugin MCP
17
- * servers (`mempalace-tools`, `brave-search`, `github-tools`, …).
17
+ * servers (`mempalace-tools`, `brave-search`, `github-tools`, …) under
18
+ * short `tp-<chat hash>-<plugin>` names (see
19
+ * {@link getPluginMcpServerName} for the length budget).
18
20
  * - {@link buildToolOverrides} — produce a `tools` map that whitelists
19
21
  * this chat's Talon tools and blacklists every other chat's. Used
20
22
  * as the `tools` field on the prompt payload to constrain the model's
@@ -29,12 +31,15 @@
29
31
  * registrations run concurrently through the hub; later turns skip them.
30
32
  */
31
33
 
34
+ import { createHash } from "node:crypto";
35
+
32
36
  import { log, logDebug, logWarn } from "../../util/log.js";
33
37
  import {
34
38
  talonHubUrl,
35
39
  pluginHubUrl,
36
40
  hubPluginServerNames,
37
41
  listHubPluginToolNames,
42
+ describeHubChildExit,
38
43
  } from "../../core/mcp-hub/index.js";
39
44
  import type { RemoteAgentClient } from "./client.js";
40
45
  import type { RemoteServerState } from "./state.js";
@@ -51,8 +56,28 @@ import {
51
56
 
52
57
  /** Stable name prefix for Talon's per-chat MCP servers. */
53
58
  export const TALON_MCP_SERVER_NAME = "talon-tools";
54
- /** Stable prefix for chat-scoped plugin MCP registrations. */
55
- export const TALON_PLUGIN_MCP_SERVER_NAME = "talon-plugin";
59
+ /**
60
+ * Stable prefix for chat-scoped plugin MCP registrations. Deliberately
61
+ * terse: the upstream agent server composes tool ids as
62
+ * `<server>_<tool>`, and Anthropic rejects tool names over 64 characters
63
+ * (`\`name\` must be at most 64 characters`). The old
64
+ * `talon-plugin-<chatId>-<plugin>` form alone reached ~45 characters and
65
+ * hard-failed the first call of any long-named plugin tool.
66
+ */
67
+ export const TALON_PLUGIN_MCP_SERVER_NAME = "tp";
68
+
69
+ /**
70
+ * Upper bound on a generated plugin MCP server name. The 64-character
71
+ * Anthropic tool-name limit must cover `<server>_<tool>`, so this leaves
72
+ * 31 characters for the plugin's own tool name — comfortably above the
73
+ * longest ones in the wild (`browser_take_screenshot`, 23).
74
+ */
75
+ export const PLUGIN_MCP_SERVER_NAME_MAX_LENGTH = 32;
76
+
77
+ /** Hex characters of the chat-id hash embedded in a plugin server name. */
78
+ const PLUGIN_SERVER_CHAT_HASH_LENGTH = 8;
79
+ /** Hex characters of the plugin-name hash appended when the name is cut. */
80
+ const PLUGIN_SERVER_PLUGIN_HASH_LENGTH = 6;
56
81
 
57
82
  /**
58
83
  * MCP add() calls slower than this get a `[slow]` annotation in the log
@@ -87,13 +112,48 @@ export function getChatMcpServerName(chatId: string): string {
87
112
  }
88
113
 
89
114
  /** Sanitize a component embedded in an upstream MCP registration name. */
90
- export function safeMcpNamePart(value: string, fallback: string): string {
115
+ function safeMcpNamePart(value: string, fallback: string): string {
91
116
  return value.replace(/[^a-zA-Z0-9_-]+/g, "_") || fallback;
92
117
  }
93
118
 
94
- /** Per-chat registration name for a plugin-provided MCP server. */
95
- function getPluginMcpServerName(pluginName: string, chatId: string): string {
96
- return `${TALON_PLUGIN_MCP_SERVER_NAME}-${safeMcpNamePart(chatId, "chat")}-${safeMcpNamePart(pluginName, "plugin")}`;
119
+ /** Leading `length` hex characters of the SHA-256 of `value`. */
120
+ function shortHash(value: string, length: number): string {
121
+ return createHash("sha256").update(value).digest("hex").slice(0, length);
122
+ }
123
+
124
+ /**
125
+ * Name prefix shared by every plugin MCP server registered for one chat
126
+ * (`tp-<8-hex chat hash>-`). The permission ruleset allows this chat's
127
+ * prefix and denies `tp-*` for everything else, so the prefix must be a
128
+ * pure function of the chat id — never of the plugin.
129
+ */
130
+ export function getPluginMcpServerPrefix(chatId: string): string {
131
+ return `${TALON_PLUGIN_MCP_SERVER_NAME}-${shortHash(chatId, PLUGIN_SERVER_CHAT_HASH_LENGTH)}-`;
132
+ }
133
+
134
+ /**
135
+ * Per-chat registration name for a plugin-provided MCP server.
136
+ *
137
+ * Deterministic for a given (plugin, chat) pair and never longer than
138
+ * {@link PLUGIN_MCP_SERVER_NAME_MAX_LENGTH}. The plugin name is kept
139
+ * verbatim while it fits; an overlong one is cut and suffixed with a short
140
+ * hash of the full name so two long plugins sharing a head stay distinct.
141
+ * Nothing parses the name back — `state.pluginMcpServersByChat` is the
142
+ * reverse map from chat + plugin to registered name.
143
+ */
144
+ export function getPluginMcpServerName(
145
+ pluginName: string,
146
+ chatId: string,
147
+ ): string {
148
+ const prefix = getPluginMcpServerPrefix(chatId);
149
+ const budget = PLUGIN_MCP_SERVER_NAME_MAX_LENGTH - prefix.length;
150
+ const safePlugin = safeMcpNamePart(pluginName, "plugin");
151
+ if (safePlugin.length <= budget) return `${prefix}${safePlugin}`;
152
+ const head = safePlugin.slice(
153
+ 0,
154
+ budget - PLUGIN_SERVER_PLUGIN_HASH_LENGTH - 1,
155
+ );
156
+ return `${prefix}${head}-${shortHash(pluginName, PLUGIN_SERVER_PLUGIN_HASH_LENGTH)}`;
97
157
  }
98
158
 
99
159
  /** Whether a tool id belongs to one of Talon's MCP servers. */
@@ -257,7 +317,7 @@ export async function ensurePluginMcpServers<TClient extends RemoteAgentClient>(
257
317
  const ms = Date.now() - startedAt;
258
318
  log(
259
319
  "agent",
260
- `Registered plugin MCP server: ${serverName} (${ms}ms)` +
320
+ `Registered plugin MCP server: ${serverName} (${name} for chat ${chatId}, ${ms}ms)` +
261
321
  (ms > SLOW_MCP_REGISTRATION_MS ? " [slow]" : ""),
262
322
  );
263
323
  return serverName;
@@ -271,9 +331,13 @@ export async function ensurePluginMcpServers<TClient extends RemoteAgentClient>(
271
331
  ? logDebug
272
332
  : logWarn;
273
333
  warnedMcpRegistrationFailures.add(serverName);
334
+ // "Connection closed" alone says nothing; the hub knows how the
335
+ // child behind this server last died.
336
+ const exit = describeHubChildExit(name, chatId);
274
337
  level(
275
338
  "agent",
276
- `Plugin MCP registration failed for ${serverName}: ${errMsg(err)}`,
339
+ `Plugin MCP registration failed for ${serverName}: ${errMsg(err)}` +
340
+ (exit ? ` (hub child ${exit})` : ""),
277
341
  );
278
342
  return null;
279
343
  }