talon-agent 3.34.0 → 3.34.1

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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Hub child transport — the SDK's stdio client transport with the child
3
+ * process kept in view.
4
+ *
5
+ * `StdioClientTransport` spawns the process itself and hides it: its
6
+ * `onclose` carries no exit code or signal, and stderr is either
7
+ * inherited (lost in the daemon's own stderr, never in talon.log) or
8
+ * piped raw. When a child died during the initialize handshake —
9
+ * playwright-tools with its browser endpoint down, say — the hub saw
10
+ * only "MCP error -32000: Connection closed" and nothing about why.
11
+ *
12
+ * This transport owns the spawn so the hub can record the cause: exit
13
+ * code + signal, and the last few stderr lines in a bounded ring
14
+ * buffer. Wire behaviour matches the SDK transport exactly —
15
+ * newline-delimited JSON-RPC over stdin/stdout; `close()` ends stdin,
16
+ * then escalates SIGTERM → SIGKILL. Children still run under the
17
+ * supervisor wrap (the spec's command IS the supervisor), so orphan
18
+ * cleanup and stdout filtering are untouched.
19
+ */
20
+
21
+ import crossSpawn from "cross-spawn";
22
+ import type { ChildProcess } from "node:child_process";
23
+ import {
24
+ ReadBuffer,
25
+ serializeMessage,
26
+ } from "@modelcontextprotocol/sdk/shared/stdio.js";
27
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
28
+ import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
29
+
30
+ /** How many trailing stderr lines a child keeps for its exit report. */
31
+ export const STDERR_TAIL_LINES = 20;
32
+ /** Per-line cap so one runaway stack trace can't bloat a log entry. */
33
+ const STDERR_LINE_MAX_CHARS = 400;
34
+
35
+ function clipLine(raw: string): string {
36
+ const line = raw.replace(/\r$/, "");
37
+ return line.length > STDERR_LINE_MAX_CHARS
38
+ ? `${line.slice(0, STDERR_LINE_MAX_CHARS)}…`
39
+ : line;
40
+ }
41
+
42
+ /**
43
+ * Bounded ring buffer over a line stream: keeps the last `capacity`
44
+ * non-blank lines. Chunks may split lines arbitrarily; an unterminated
45
+ * trailing fragment is included in snapshots so a crash mid-line (no
46
+ * final newline before exit) still shows its last words.
47
+ */
48
+ export class StderrTail {
49
+ private lines: string[] = [];
50
+ private partial = "";
51
+
52
+ constructor(private readonly capacity = STDERR_TAIL_LINES) {}
53
+
54
+ push(chunk: string): void {
55
+ const parts = (this.partial + chunk).split("\n");
56
+ this.partial = parts.pop() ?? "";
57
+ for (const line of parts) {
58
+ if (line.trim().length === 0) continue;
59
+ this.lines.push(clipLine(line));
60
+ }
61
+ if (this.lines.length > this.capacity) {
62
+ this.lines.splice(0, this.lines.length - this.capacity);
63
+ }
64
+ }
65
+
66
+ snapshot(): string[] {
67
+ const tail = this.partial.trim().length > 0 ? [clipLine(this.partial)] : [];
68
+ return [...this.lines, ...tail].slice(-this.capacity);
69
+ }
70
+ }
71
+
72
+ export type ChildExit = {
73
+ code: number | null;
74
+ signal: NodeJS.Signals | null;
75
+ };
76
+
77
+ export type HubChildTransportOptions = {
78
+ command: string;
79
+ args: string[];
80
+ env: Record<string, string>;
81
+ };
82
+
83
+ const CLOSE_GRACE_MS = 2_000;
84
+
85
+ function delay(ms: number): Promise<void> {
86
+ return new Promise((resolve) => setTimeout(resolve, ms).unref());
87
+ }
88
+
89
+ export class HubChildTransport implements Transport {
90
+ onclose?: () => void;
91
+ onerror?: (error: Error) => void;
92
+ onmessage?: (message: JSONRPCMessage) => void;
93
+
94
+ private child: ChildProcess | null = null;
95
+ /** Kept past exit so the exit report can name the process. */
96
+ private spawnedPid: number | null = null;
97
+ private readonly readBuffer = new ReadBuffer();
98
+ private readonly stderrTail = new StderrTail();
99
+ private exitInfo: ChildExit | null = null;
100
+ private closeRequested = false;
101
+
102
+ constructor(private readonly opts: HubChildTransportOptions) {}
103
+
104
+ get pid(): number | null {
105
+ return this.spawnedPid;
106
+ }
107
+
108
+ /** Exit code + signal once the process has gone; null while alive. */
109
+ get exit(): ChildExit | null {
110
+ return this.exitInfo;
111
+ }
112
+
113
+ /** True when the hub itself asked for the close (reap/retire/shutdown). */
114
+ get closedByHub(): boolean {
115
+ return this.closeRequested;
116
+ }
117
+
118
+ /** Last stderr lines seen so far (bounded, see STDERR_TAIL_LINES). */
119
+ get stderrLines(): string[] {
120
+ return this.stderrTail.snapshot();
121
+ }
122
+
123
+ start(): Promise<void> {
124
+ if (this.child) {
125
+ return Promise.reject(new Error("HubChildTransport already started"));
126
+ }
127
+ return new Promise((resolve, reject) => {
128
+ const child = crossSpawn(this.opts.command, this.opts.args, {
129
+ env: this.opts.env,
130
+ stdio: ["pipe", "pipe", "pipe"],
131
+ shell: false,
132
+ windowsHide: process.platform === "win32",
133
+ });
134
+ this.child = child;
135
+ this.spawnedPid = child.pid ?? null;
136
+ child.once("spawn", () => resolve());
137
+ child.on("error", (err) => {
138
+ reject(err);
139
+ this.onerror?.(err);
140
+ });
141
+ // `close` (not `exit`) so the stderr tail is complete when the
142
+ // exit report is read.
143
+ child.on("close", (code, signal) => {
144
+ this.exitInfo = { code, signal };
145
+ this.child = null;
146
+ this.onclose?.();
147
+ });
148
+ child.stdin?.on("error", (err) => this.onerror?.(err));
149
+ child.stdout?.on("error", (err) => this.onerror?.(err));
150
+ child.stdout?.on("data", (chunk: Buffer) => {
151
+ try {
152
+ this.readBuffer.append(chunk);
153
+ this.drainMessages();
154
+ } catch (err) {
155
+ this.onerror?.(err as Error);
156
+ void this.close();
157
+ }
158
+ });
159
+ child.stderr?.setEncoding("utf-8");
160
+ child.stderr?.on("data", (chunk: string) => this.stderrTail.push(chunk));
161
+ child.stderr?.on("error", () => {});
162
+ });
163
+ }
164
+
165
+ private drainMessages(): void {
166
+ for (;;) {
167
+ let message: JSONRPCMessage | null;
168
+ try {
169
+ // readMessage consumes the line before parsing, so a malformed
170
+ // line is skipped rather than re-read forever.
171
+ message = this.readBuffer.readMessage();
172
+ } catch (err) {
173
+ this.onerror?.(err as Error);
174
+ continue;
175
+ }
176
+ if (message === null) return;
177
+ this.onmessage?.(message);
178
+ }
179
+ }
180
+
181
+ async close(): Promise<void> {
182
+ this.closeRequested = true;
183
+ const child = this.child;
184
+ this.child = null;
185
+ if (child) {
186
+ const closed = new Promise<void>((resolve) => {
187
+ child.once("close", () => resolve());
188
+ });
189
+ const alive = () => child.exitCode === null && child.signalCode === null;
190
+ child.stdin?.end();
191
+ await Promise.race([closed, delay(CLOSE_GRACE_MS)]);
192
+ if (alive()) {
193
+ child.kill("SIGTERM");
194
+ await Promise.race([closed, delay(CLOSE_GRACE_MS)]);
195
+ }
196
+ if (alive()) child.kill("SIGKILL");
197
+ }
198
+ this.readBuffer.clear();
199
+ }
200
+
201
+ send(message: JSONRPCMessage): Promise<void> {
202
+ return new Promise((resolve, reject) => {
203
+ const stdin = this.child?.stdin;
204
+ if (!stdin) {
205
+ reject(new Error("Not connected"));
206
+ return;
207
+ }
208
+ if (stdin.write(serializeMessage(message))) {
209
+ resolve();
210
+ } else {
211
+ stdin.once("drain", resolve);
212
+ }
213
+ });
214
+ }
215
+ }
@@ -19,12 +19,17 @@
19
19
  * connected once over stdio, and shared by every hub session that
20
20
  * proxies to it. The tools list is cached per child lifetime — plugin
21
21
  * reload restarts children, which naturally invalidates the cache.
22
+ *
23
+ * Every exit is accounted for: the child's exit code, signal and
24
+ * stderr tail are logged when it goes away unasked (handshake death,
25
+ * crash) and kept per key so a registration failure upstream can name
26
+ * the cause instead of just "Connection closed" (see getLastChildExit).
22
27
  */
23
28
 
24
29
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
25
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
26
30
  import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
27
31
  import { log, logError, logWarn } from "../../util/log.js";
32
+ import { HubChildTransport, type ChildExit } from "./child-transport.js";
28
33
 
29
34
  export type ChildSpec = {
30
35
  command: string;
@@ -56,12 +61,74 @@ type ChildEntry = {
56
61
  * retire grace timer, whichever comes first).
57
62
  */
58
63
  retired: boolean;
64
+ transport: HubChildTransport;
59
65
  close: () => Promise<void>;
60
66
  };
61
67
 
62
68
  const children = new Map<string, ChildEntry>();
63
69
  const inflight = new Map<string, Promise<ChildHandle>>();
64
70
 
71
+ /**
72
+ * Most recent exit per key, whether the child died during the
73
+ * handshake (never registered) or later. Consulted by the backends'
74
+ * registration path to explain a failure that surfaced upstream as a
75
+ * bare "Connection closed".
76
+ */
77
+ export type ChildExitRecord = ChildExit & {
78
+ at: number;
79
+ /** Last stderr lines before exit (bounded ring buffer). */
80
+ stderr: string[];
81
+ };
82
+ const lastExits = new Map<string, ChildExitRecord>();
83
+
84
+ /** Last recorded exit for `key`, or null if it never exited. */
85
+ export function getLastChildExit(key: string): ChildExitRecord | null {
86
+ return lastExits.get(key) ?? null;
87
+ }
88
+
89
+ /**
90
+ * One-line exit summary: `code=1 signal=null 3s ago; stderr: a | b`.
91
+ * `maxStderrLines` bounds the stderr part for compact callers.
92
+ */
93
+ export function formatChildExit(
94
+ exit: ChildExitRecord,
95
+ maxStderrLines = exit.stderr.length,
96
+ ): string {
97
+ const age = Math.round((Date.now() - exit.at) / 1000);
98
+ const head = `code=${exit.code} signal=${exit.signal} ${age}s ago`;
99
+ const lines = exit.stderr.slice(-maxStderrLines);
100
+ return lines.length > 0 ? `${head}; stderr: ${lines.join(" | ")}` : head;
101
+ }
102
+
103
+ /**
104
+ * Bookkeeping for a child process that has gone away, asked or not.
105
+ * Wired as the transport's `onclose` BEFORE `client.connect` so the SDK
106
+ * chains it ahead of its own close handling — set afterwards it would
107
+ * replace the SDK's handler and every in-flight request on a dead
108
+ * child would hang instead of rejecting with "Connection closed".
109
+ */
110
+ function onChildClosed(key: string, transport: HubChildTransport): void {
111
+ const exit = transport.exit ?? { code: null, signal: null };
112
+ const record: ChildExitRecord = {
113
+ ...exit,
114
+ at: Date.now(),
115
+ stderr: transport.stderrLines,
116
+ };
117
+ lastExits.set(key, record);
118
+ const entry = children.get(key);
119
+ if (entry?.transport === transport) children.delete(key);
120
+ // Hub-initiated closes (reap/retire/shutdown) are logged by their
121
+ // callers; only an unasked exit is news.
122
+ if (transport.closedByHub) return;
123
+ const phase = entry
124
+ ? "exited — will respawn on demand"
125
+ : "died before registration";
126
+ logWarn(
127
+ "gateway",
128
+ `hub child ${key} ${phase} (pid ${transport.pid ?? "?"}): ${formatChildExit(record)}`,
129
+ );
130
+ }
131
+
65
132
  /**
66
133
  * Negative cache for spawn failures. A child whose backing service is down
67
134
  * (e.g. playwright-tools with its browser endpoint offline) dies at the
@@ -92,14 +159,14 @@ const REAP_INTERVAL_MS = 60_000;
92
159
  let reaper: ReturnType<typeof setInterval> | null = null;
93
160
 
94
161
  async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
95
- const transport = new StdioClientTransport({
162
+ const transport = new HubChildTransport({
96
163
  command: spec.command,
97
164
  args: spec.args,
98
165
  // Merge over the daemon env — same visibility the SDK-spawned
99
166
  // subprocesses had (PATH, HOME, proxy vars, …).
100
167
  env: { ...(process.env as Record<string, string>), ...spec.env },
101
- stderr: "inherit",
102
168
  });
169
+ transport.onclose = () => onChildClosed(key, transport);
103
170
  const client = new Client(
104
171
  { name: "talon-mcp-hub", version: "1.0.0" },
105
172
  { capabilities: {} },
@@ -123,6 +190,7 @@ async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
123
190
  lastActivity: Date.now(),
124
191
  pending: 0,
125
192
  retired: false,
193
+ transport,
126
194
  // Idempotent: retirement can race its own grace timer.
127
195
  close: (() => {
128
196
  let closing: Promise<void> | null = null;
@@ -157,17 +225,8 @@ async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
157
225
  },
158
226
  };
159
227
 
160
- // If the child dies on its own (crash, plugin bug), drop the entry so
161
- // the next request respawns instead of hitting a dead pipe forever.
162
- transport.onclose = () => {
163
- if (children.get(key) === entry) {
164
- children.delete(key);
165
- log("gateway", `hub child ${key} exited — will respawn on demand`);
166
- }
167
- };
168
-
169
228
  children.set(key, entry);
170
- log("gateway", `hub child started: ${key}`);
229
+ log("gateway", `hub child started: ${key} (pid ${transport.pid ?? "?"})`);
171
230
  return entry.handle;
172
231
  }
173
232
 
@@ -45,6 +45,8 @@ import { buildProxyServer } from "./proxy-server.js";
45
45
  import {
46
46
  acquireChild,
47
47
  closeAllChildren,
48
+ formatChildExit,
49
+ getLastChildExit,
48
50
  retireAllChildren,
49
51
  startChildReaper,
50
52
  stopChildReaper,
@@ -115,17 +117,41 @@ export async function listHubPluginToolNames(
115
117
  chatId: string,
116
118
  bridgeUrl: string,
117
119
  ): Promise<string[]> {
118
- const key =
119
- serverName === "brave-search"
120
- ? "brave-search"
121
- : `${serverName}\u0000${chatId}`;
122
- const child = await acquireChild(key, () =>
120
+ const child = await acquireChild(childKey(serverName, chatId), () =>
123
121
  pluginSpec(serverName, chatId, bridgeUrl),
124
122
  );
125
123
  child.touch();
126
124
  return (await child.listTools()).map((tool) => tool.name);
127
125
  }
128
126
 
127
+ /** Stderr lines quoted in a registration-failure warning (full tail is in the exit log line). */
128
+ const EXIT_SUMMARY_STDERR_LINES = 5;
129
+
130
+ /**
131
+ * Why the hub child behind (serverName, chatId) last went away —
132
+ * `code=1 signal=null 2s ago; stderr: …` — or null if it never exited.
133
+ * Lets a backend's "registration failed: Connection closed" warning
134
+ * carry the cause.
135
+ */
136
+ export function describeHubChildExit(
137
+ serverName: string,
138
+ chatId: string,
139
+ ): string | null {
140
+ const exit = getLastChildExit(childKey(serverName, chatId));
141
+ return exit ? formatChildExit(exit, EXIT_SUMMARY_STDERR_LINES) : null;
142
+ }
143
+
144
+ /**
145
+ * brave-search is chat-agnostic (one shared child); plugins read
146
+ * TALON_CHAT_ID at boot, so their children stay chat-scoped and the
147
+ * idle reaper bounds the fleet.
148
+ */
149
+ function childKey(serverName: string, chatId: string): string {
150
+ return serverName === "brave-search"
151
+ ? "brave-search"
152
+ : `${serverName}\u0000${chatId}`;
153
+ }
154
+
129
155
  // ── Server construction per session ─────────────────────────────────────────
130
156
 
131
157
  type HubTarget =
@@ -191,15 +217,8 @@ function buildServerFor(target: HubTarget, bridgeUrl: string) {
191
217
  includeNativeTools: hubConfig.nativeTools,
192
218
  });
193
219
  }
194
- // brave-search is chat-agnostic (one shared child); plugins read
195
- // TALON_CHAT_ID at boot, so their children stay chat-scoped and the
196
- // idle reaper bounds the fleet.
197
- const key =
198
- target.serverName === "brave-search"
199
- ? "brave-search"
200
- : `${target.serverName}\u0000${target.chatId}`;
201
220
  return buildProxyServer(target.serverName, () =>
202
- acquireChild(key, () =>
221
+ acquireChild(childKey(target.serverName, target.chatId), () =>
203
222
  pluginSpec(target.serverName, target.chatId, bridgeUrl),
204
223
  ),
205
224
  );
@@ -150,12 +150,7 @@ async function runChain(
150
150
  if (validated) {
151
151
  return { model: override, source: "override-valid" };
152
152
  }
153
- logWarn(
154
- "settings",
155
- `chat=${chatId} backend=${backendId}: per-chat override ` +
156
- `"${override}" is not a selectable model on this backend. ` +
157
- `Falling through to backend default.`,
158
- );
153
+ warnInvalidOverrideOnce(chatId, backendId, override);
159
154
  return stepsTwoThroughFive(
160
155
  backend,
161
156
  backendId,
@@ -170,6 +165,34 @@ async function runChain(
170
165
  return stepsTwoThroughFive(backend, backendId, config, null);
171
166
  }
172
167
 
168
+ /**
169
+ * chat id → the invalid override it was last warned about. A single turn
170
+ * resolves the active model several times (status line, send guard,
171
+ * menu), so an unchanged bad override warns once; picking a different
172
+ * value re-arms the warning for that chat.
173
+ */
174
+ const warnedInvalidOverrides = new Map<string, string>();
175
+
176
+ /** Test seam. */
177
+ export function resetInvalidOverrideWarnings(): void {
178
+ warnedInvalidOverrides.clear();
179
+ }
180
+
181
+ function warnInvalidOverrideOnce(
182
+ chatId: string,
183
+ backendId: string,
184
+ override: string,
185
+ ): void {
186
+ if (warnedInvalidOverrides.get(chatId) === override) return;
187
+ warnedInvalidOverrides.set(chatId, override);
188
+ logWarn(
189
+ "settings",
190
+ `chat=${chatId} backend=${backendId}: per-chat override ` +
191
+ `"${override}" is not a selectable model on this backend. ` +
192
+ `Falling through to backend default.`,
193
+ );
194
+ }
195
+
173
196
  async function stepsTwoThroughFive(
174
197
  backend: Backend | null,
175
198
  backendId: string | null,
@@ -26,6 +26,7 @@ import {
26
26
  type AgentEvent,
27
27
  type AgentResult,
28
28
  } from "../agent-runtime/events.js";
29
+ import { recordTurnActivity } from "../../util/watchdog.js";
29
30
 
30
31
  export type EventSink = (event: AgentEvent) => void | Promise<void>;
31
32
 
@@ -64,6 +65,9 @@ export async function carryTurnEvents(
64
65
  }
65
66
  : onEvent;
66
67
  for await (const event of stream) {
68
+ // Every backend's events cross this loop exactly once, so this is the
69
+ // one place the watchdog learns a turn is alive (vs. wedged).
70
+ recordTurnActivity();
67
71
  if (timing && timing.firstEventAt === undefined) {
68
72
  timing.firstEventAt = Date.now();
69
73
  }
@@ -7,7 +7,8 @@ import { readFileSync } from "node:fs";
7
7
  import type { TalonConfig } from "../../util/config.js";
8
8
  import { files, dirs } from "../../util/paths.js";
9
9
  import { tailFile } from "../../util/tail-file.js";
10
- import { escapeHtml } from "./formatting.js";
10
+ import { escapeHtml, splitMessage } from "./formatting.js";
11
+ import { TELEGRAM_MAX_TEXT } from "./actions/types.js";
11
12
  import { resetSession, getAllSessions } from "../../storage/sessions.js";
12
13
  import { clearHistory } from "../../storage/history.js";
13
14
  import { todayLogDate } from "../../storage/daily-log.js";
@@ -22,6 +23,72 @@ import { getPulseStatus } from "../../core/background/pulse.js";
22
23
  import { getHealthStatus, getRecentErrors } from "../../util/watchdog.js";
23
24
  import { formatDuration, formatModelLabel } from "./helpers/index.js";
24
25
 
26
+ /**
27
+ * Reply with an HTML listing, split across messages when it outgrows
28
+ * Telegram's 4096-char cap. The per-item listings below (chats, cron,
29
+ * pulse) scale with the daemon's chat count, and a single oversized
30
+ * `ctx.reply` fails the whole command with 400 "message is too long".
31
+ * Entries are `\n\n`-separated and each carries balanced tags, so the
32
+ * paragraph-first splitter never cuts through markup.
33
+ */
34
+ async function replyHtmlChunked(ctx: Context, text: string): Promise<void> {
35
+ for (const chunk of splitMessage(text, TELEGRAM_MAX_TEXT)) {
36
+ await ctx.reply(chunk, { parse_mode: "HTML" });
37
+ }
38
+ }
39
+
40
+ /** `/admin chats` — every active session, newest first, titled via getChat. */
41
+ async function replyActiveChats(
42
+ ctx: Context,
43
+ bot: Bot,
44
+ config: TalonConfig,
45
+ ): Promise<void> {
46
+ const sessions = getAllSessions();
47
+ if (sessions.length === 0) {
48
+ await ctx.reply("No active sessions.");
49
+ return;
50
+ }
51
+ sessions.sort((a, b) => (b.info.lastActive || 0) - (a.info.lastActive || 0));
52
+
53
+ const titles = new Map<string, string>();
54
+ await Promise.all(
55
+ sessions.map(async (s) => {
56
+ try {
57
+ const id = parseInt(s.chatId, 10);
58
+ if (isNaN(id)) return;
59
+ const chat = await bot.api.getChat(id);
60
+ titles.set(
61
+ s.chatId,
62
+ "title" in chat
63
+ ? (chat.title ?? "DM")
64
+ : "first_name" in chat
65
+ ? (chat.first_name ?? "DM")
66
+ : "DM",
67
+ );
68
+ } catch {
69
+ /* inaccessible */
70
+ }
71
+ }),
72
+ );
73
+
74
+ const lines = sessions.map((s) => {
75
+ const age = s.info.lastActive
76
+ ? `${Math.round((Date.now() - s.info.lastActive) / 60000)}m ago`
77
+ : "?";
78
+ const title = titles.get(s.chatId) ?? s.chatId;
79
+ const model = formatModelLabel(
80
+ getChatSettings(s.chatId).model ?? config.model,
81
+ );
82
+ // `model` is a catalog id (OpenRouter/Kilo ids are free-form), so
83
+ // it gets the same escaping the title already had.
84
+ return `<b>${escapeHtml(title)}</b> <code>${s.chatId}</code>\n ${s.info.turns} turns | ${age} | ${escapeHtml(model)}`;
85
+ });
86
+ await replyHtmlChunked(
87
+ ctx,
88
+ `<b>Active chats (${sessions.length})</b>\n\n` + lines.join("\n\n"),
89
+ );
90
+ }
91
+
25
92
  export async function handleAdminCommand(
26
93
  ctx: Context,
27
94
  bot: Bot,
@@ -32,52 +99,7 @@ export async function handleAdminCommand(
32
99
 
33
100
  switch (subcommand) {
34
101
  case "chats": {
35
- const sessions = getAllSessions();
36
- if (sessions.length === 0) {
37
- await ctx.reply("No active sessions.");
38
- return;
39
- }
40
- sessions.sort(
41
- (a, b) => (b.info.lastActive || 0) - (a.info.lastActive || 0),
42
- );
43
-
44
- const titles = new Map<string, string>();
45
- await Promise.all(
46
- sessions.map(async (s) => {
47
- try {
48
- const id = parseInt(s.chatId, 10);
49
- if (isNaN(id)) return;
50
- const chat = await bot.api.getChat(id);
51
- titles.set(
52
- s.chatId,
53
- "title" in chat
54
- ? (chat.title ?? "DM")
55
- : "first_name" in chat
56
- ? (chat.first_name ?? "DM")
57
- : "DM",
58
- );
59
- } catch {
60
- /* inaccessible */
61
- }
62
- }),
63
- );
64
-
65
- const lines = sessions.map((s) => {
66
- const age = s.info.lastActive
67
- ? `${Math.round((Date.now() - s.info.lastActive) / 60000)}m ago`
68
- : "?";
69
- const title = titles.get(s.chatId) ?? s.chatId;
70
- const model = formatModelLabel(
71
- getChatSettings(s.chatId).model ?? config.model,
72
- );
73
- // `model` is a catalog id (OpenRouter/Kilo ids are free-form), so
74
- // it gets the same escaping the title already had.
75
- return `<b>${escapeHtml(title)}</b> <code>${s.chatId}</code>\n ${s.info.turns} turns | ${age} | ${escapeHtml(model)}`;
76
- });
77
- await ctx.reply(
78
- `<b>Active chats (${sessions.length})</b>\n\n` + lines.join("\n\n"),
79
- { parse_mode: "HTML" },
80
- );
102
+ await replyActiveChats(ctx, bot, config);
81
103
  return;
82
104
  }
83
105
 
@@ -189,9 +211,9 @@ export async function handleAdminCommand(
189
211
  : "?";
190
212
  return `${j.enabled ? "\u2713" : "\u2717"} <b>${escapeHtml(j.name)}</b>\n <code>${escapeHtml(describeSchedule(j))}</code> | ${j.type} | runs: ${j.runCount} | last: ${last} | next: ${next}`;
191
213
  });
192
- await ctx.reply(
214
+ await replyHtmlChunked(
215
+ ctx,
193
216
  `<b>Cron Jobs (${jobs.length})</b>\n\n` + lines.join("\n\n"),
194
- { parse_mode: "HTML" },
195
217
  );
196
218
  return;
197
219
  }
@@ -217,9 +239,10 @@ export async function handleAdminCommand(
217
239
  return `${p.enabled ? "\u2713" : "\u2717"} ${escapeHtml(title)}`;
218
240
  }),
219
241
  );
220
- await ctx.reply(`<b>Pulse (${chats.length})</b>\n\n` + lines.join("\n"), {
221
- parse_mode: "HTML",
222
- });
242
+ await replyHtmlChunked(
243
+ ctx,
244
+ `<b>Pulse (${chats.length})</b>\n\n` + lines.join("\n"),
245
+ );
223
246
  return;
224
247
  }
225
248
 
@@ -73,6 +73,14 @@ function isTrigger(value: unknown): value is Trigger {
73
73
  */
74
74
  export const RESTART_KILL_ERROR = "Talon restarted while trigger was running";
75
75
 
76
+ /**
77
+ * The lastError shutdownTriggers stamps on non-persistent triggers it kills
78
+ * during a clean shutdown. Their terminal wake is deliberately not dispatched
79
+ * while the daemon is tearing down — resume.ts keys the next boot's late-wake
80
+ * decision on this marker, exactly as it does for RESTART_KILL_ERROR.
81
+ */
82
+ export const SHUTDOWN_KILL_ERROR = "Killed by Talon shutdown";
83
+
76
84
  export function loadTriggers(): void {
77
85
  try {
78
86
  importLegacyJson({