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.
- package/package.json +4 -2
- package/src/backend/claude-sdk/handler.ts +428 -415
- package/src/backend/codex/handler/message.ts +275 -366
- package/src/backend/codex/handler/rollout-accounting.ts +137 -0
- package/src/backend/openai-agents/handler/message.ts +282 -356
- package/src/backend/remote-server/chat-turn.ts +66 -122
- package/src/backend/remote-server/index.ts +4 -0
- package/src/backend/remote-server/mcp.ts +73 -9
- package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
- package/src/backend/remote-server/sessions.ts +2 -2
- package/src/backend/shared/cache-telemetry.ts +17 -1
- package/src/backend/shared/handler-to-events.ts +11 -16
- package/src/backend/shared/index.ts +14 -15
- package/src/backend/shared/result-events.ts +30 -0
- package/src/backend/shared/turn-phases.ts +277 -0
- package/src/core/background/triggers/exit.ts +19 -2
- package/src/core/background/triggers/resume.ts +14 -7
- package/src/core/background/triggers/state.ts +9 -0
- package/src/core/mcp-hub/child-transport.ts +215 -0
- package/src/core/mcp-hub/children.ts +72 -13
- package/src/core/mcp-hub/index.ts +32 -13
- package/src/core/models/active-model.ts +29 -6
- package/src/core/weaver/shuttle.ts +4 -0
- package/src/frontend/telegram/admin.ts +75 -52
- package/src/storage/trigger-store.ts +8 -0
- package/src/util/watchdog.ts +32 -7
|
@@ -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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
221
|
-
|
|
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({
|