talon-agent 5.18.2 → 5.19.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/README.md +2 -1
- package/package.json +2 -2
- package/prompts/system/agent-brief.md +20 -3
- package/src/app.ts +13 -0
- package/src/backend/claude-sdk/handler.ts +4 -4
- package/src/backend/claude-sdk/mcp-ready.ts +16 -2
- package/src/backend/claude-sdk/one-shot.ts +32 -2
- package/src/backend/claude-sdk/stream.ts +2 -2
- package/src/backend/codex/auth.ts +1 -1
- package/src/backend/codex/handler/message.ts +11 -11
- package/src/backend/codex/init.ts +4 -9
- package/src/backend/codex/mcp-config.ts +1 -2
- package/src/backend/codex/oauth-incompat.ts +8 -4
- package/src/backend/codex/one-shot.ts +1 -1
- package/src/backend/openai-agents/builtins.ts +55 -27
- package/src/backend/openai-agents/factory.ts +3 -3
- package/src/backend/openai-agents/handler/message.ts +3 -5
- package/src/backend/openai-agents/mcp-pool.ts +10 -27
- package/src/backend/remote-server/chat-turn.ts +6 -6
- package/src/backend/remote-server/events.ts +1 -5
- package/src/backend/remote-server/index.ts +0 -1
- package/src/backend/remote-server/messages.ts +3 -7
- package/src/backend/remote-server/one-shot.ts +1 -3
- package/src/backend/remote-server/session-helpers.ts +1 -4
- package/src/backend/remote-server/sse-stream.ts +8 -9
- package/src/backend/runtime/metrics.ts +7 -13
- package/src/backend/runtime/sleep.ts +1 -2
- package/src/backend/runtime/turn/handle-retry.ts +48 -2
- package/src/backend/runtime/turn/handler-to-events.ts +3 -4
- package/src/bootstrap.ts +9 -1
- package/src/cli/doctor.ts +3 -0
- package/src/cli/index.ts +8 -10
- package/src/cli/logs.ts +148 -9
- package/src/cli/setup.ts +9 -11
- package/src/cli/status.ts +29 -0
- package/src/core/agent-runtime/README.md +5 -19
- package/src/core/agent-runtime/events.ts +3 -39
- package/src/core/agent-runtime/model-ref.ts +0 -8
- package/src/core/agents/registry.ts +65 -2
- package/src/core/auth/expiry-monitor.ts +9 -1
- package/src/core/auth/login-flow.ts +9 -1
- package/src/core/auth/status.ts +31 -3
- package/src/core/background/cron/scheduler.ts +25 -5
- package/src/core/background/dream/index.ts +29 -10
- package/src/core/background/failure-backoff.ts +30 -0
- package/src/core/background/heartbeat/agent.ts +2 -27
- package/src/core/background/heartbeat/index.ts +0 -2
- package/src/core/background/heartbeat/scheduler.ts +21 -13
- package/src/core/background/heartbeat/state.ts +10 -2
- package/src/core/background/isolated-agent.ts +6 -2
- package/src/core/background/pulse/pulse.ts +9 -0
- package/src/core/background/triggers/exit.ts +54 -0
- package/src/core/background/triggers/index.ts +1 -3
- package/src/core/background/triggers/resume.ts +2 -4
- package/src/core/backup/archive/tar.ts +14 -4
- package/src/core/backup/plan.ts +1 -0
- package/src/core/backup/restore.ts +21 -13
- package/src/core/backup/scheduler.ts +28 -9
- package/src/core/backup/snapshot.ts +43 -10
- package/src/core/backup/store.ts +7 -19
- package/src/core/backup/targets.ts +69 -17
- package/src/core/config/index.ts +14 -0
- package/src/core/daemon/crash-marker.ts +141 -0
- package/src/core/daemon/crash.ts +9 -2
- package/src/core/daemon/handoff.ts +15 -0
- package/src/core/daemon/health-alerts.ts +297 -0
- package/src/core/daemon/log-reader.ts +289 -0
- package/src/core/doctor/index.ts +18 -2
- package/src/core/doctor/logs.ts +124 -0
- package/src/core/doctor/types.ts +1 -1
- package/src/core/engine/backend-controller/index.ts +1 -13
- package/src/core/engine/backend-router/router.ts +1 -1
- package/src/core/engine/dispatcher.ts +55 -2
- package/src/core/engine/fault-text.ts +40 -0
- package/src/core/engine/gateway-actions/agents/index.ts +3 -2
- package/src/core/engine/gateway-actions/agents/report.ts +62 -0
- package/src/core/engine/gateway-actions/history.ts +2 -4
- package/src/core/engine/gateway-actions/mesh.ts +26 -14
- package/src/core/engine/gateway-actions/native/exec.ts +13 -16
- package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
- package/src/core/engine/gateway.ts +60 -1
- package/src/core/engine/turn-health.ts +222 -0
- package/src/core/errors.ts +2 -2
- package/src/core/frontend-runtime/admin-notify.ts +1 -1
- package/src/core/frontend-runtime/alerts.ts +130 -0
- package/src/core/mcp-hub/children.ts +78 -29
- package/src/core/mcp-hub/index.ts +21 -18
- package/src/core/mcp-hub/proxy-server.ts +8 -4
- package/src/core/mcp-hub/talon-server.ts +5 -12
- package/src/core/mesh/credentials/store.ts +16 -1
- package/src/core/mesh/devices/registry.ts +1 -17
- package/src/core/mesh/devices/service.ts +49 -17
- package/src/core/mesh/devices/teleport.ts +14 -2
- package/src/core/mesh/links/node-binaries.ts +13 -6
- package/src/core/mesh/persist.ts +22 -10
- package/src/core/mesh/transfers/device-files.ts +5 -23
- package/src/core/mesh/transfers/transfers.ts +16 -3
- package/src/core/models/active-model.ts +2 -55
- package/src/core/plugin/actions.ts +19 -20
- package/src/core/plugin/builtins.ts +80 -90
- package/src/core/plugin/index.ts +1 -4
- package/src/core/plugin/loader.ts +25 -33
- package/src/core/plugin/mcp.ts +3 -5
- package/src/core/plugin/registry.ts +19 -35
- package/src/core/plugin/types.ts +2 -5
- package/src/core/prompt/assemble.ts +15 -3
- package/src/core/scripts/lua.ts +6 -2
- package/src/core/tasks/table.ts +8 -2
- package/src/core/tools/bridge.ts +2 -4
- package/src/core/tools/chat/cross-send.ts +1 -1
- package/src/core/tools/chat/messaging.ts +1 -1
- package/src/core/tools/index.ts +2 -2
- package/src/core/tools/mcp-env.ts +2 -59
- package/src/core/tools/ops/agents.ts +22 -1
- package/src/core/tools/schemas.ts +4 -9
- package/src/core/vfs/fusefs.ts +0 -5
- package/src/core/vfs/index.ts +9 -2
- package/src/core/vfs/mounts/diagnostics.ts +109 -0
- package/src/core/vfs/mounts/proc.ts +17 -1
- package/src/core/vfs/workspace.ts +7 -3
- package/src/core/weaver/shuttle.ts +8 -1
- package/src/core/weaver/turn-log.ts +320 -0
- package/src/core/weaver/weaver.ts +48 -5
- package/src/frontend/discord/actions/index.ts +8 -1
- package/src/frontend/discord/diagnostics.ts +82 -6
- package/src/frontend/discord/handlers/index.ts +0 -2
- package/src/frontend/discord/middleware.ts +7 -13
- package/src/frontend/discord/runtime.ts +1 -3
- package/src/frontend/health/delivery.ts +115 -0
- package/src/frontend/health/outage.ts +116 -0
- package/src/frontend/native/bridge/routes/chats.ts +3 -5
- package/src/frontend/native/bridge/server.ts +106 -21
- package/src/frontend/native/index.ts +1 -1
- package/src/frontend/native/media/media.ts +5 -1
- package/src/frontend/native/runtime.ts +12 -7
- package/src/frontend/native/surface/handlers.ts +1 -1
- package/src/frontend/native/surface/memory.ts +1 -1
- package/src/frontend/native/surface/models.ts +3 -3
- package/src/frontend/native/surface/settings.ts +20 -8
- package/src/frontend/native/turn/context.ts +6 -8
- package/src/frontend/native/turn/turn-meta.ts +2 -5
- package/src/frontend/native/turn/turn.ts +8 -10
- package/src/frontend/presentation/format.ts +2 -4
- package/src/frontend/presentation/session-status.ts +2 -6
- package/src/frontend/teams/actions.ts +8 -1
- package/src/frontend/teams/graph.ts +0 -1
- package/src/frontend/teams/index.ts +1 -4
- package/src/frontend/teams/poll.ts +40 -2
- package/src/frontend/teams/runtime.ts +14 -5
- package/src/frontend/telegram/actions/index.ts +4 -1
- package/src/frontend/telegram/actions/send.ts +8 -0
- package/src/frontend/telegram/handlers/context.ts +13 -2
- package/src/frontend/telegram/handlers/delivery.ts +12 -9
- package/src/frontend/telegram/handlers/index.ts +0 -2
- package/src/frontend/telegram/index.ts +35 -9
- package/src/frontend/telegram/polling/poll-health.ts +110 -0
- package/src/frontend/telegram/userbot.ts +100 -36
- package/src/frontend/terminal/builtins/session.ts +2 -2
- package/src/frontend/terminal/index.ts +1 -3
- package/src/frontend/terminal/renderer.ts +2 -18
- package/src/frontend/whatsapp/actions/index.ts +12 -1
- package/src/frontend/whatsapp/actions/messaging.ts +7 -2
- package/src/frontend/whatsapp/connection/connection.ts +29 -9
- package/src/frontend/whatsapp/connection/health.ts +89 -0
- package/src/frontend/whatsapp/connection/identity.ts +4 -4
- package/src/frontend/whatsapp/runtime.ts +11 -5
- package/src/native/blake3.ts +28 -2
- package/src/native/fusefs.ts +23 -5
- package/src/native/registry.ts +1 -1
- package/src/native/warden.ts +33 -5
- package/src/plugins/github/index.ts +0 -1
- package/src/plugins/mempalace/index.ts +9 -3
- package/src/plugins/playwright/index.ts +2 -4
- package/src/plugins/playwright/provision.ts +12 -4
- package/src/storage/chat-settings.ts +6 -1
- package/src/storage/cron.ts +29 -4
- package/src/storage/daily-log.ts +43 -47
- package/src/storage/db.ts +61 -33
- package/src/storage/history.ts +6 -1
- package/src/storage/journal.ts +9 -2
- package/src/storage/kv.ts +19 -6
- package/src/storage/media-index.ts +28 -6
- package/src/storage/repositories/chat-settings-repo.ts +8 -2
- package/src/storage/repositories/sessions-repo.ts +10 -3
- package/src/storage/scripts.ts +24 -13
- package/src/storage/sessions.ts +11 -2
- package/src/storage/skills.ts +21 -2
- package/src/storage/stickers.ts +17 -3
- package/src/storage/triggers.ts +8 -3
- package/src/storage/turn-meta.ts +25 -7
- package/src/util/log.ts +189 -6
- package/src/util/logging/turn-scope.ts +85 -0
- package/src/util/time.ts +3 -3
- package/src/util/watchdog.ts +30 -0
- package/src/core/engine/backend-controller/legacy.ts +0 -111
|
@@ -2,11 +2,8 @@
|
|
|
2
2
|
* Hub child manager — the single owner of external MCP server
|
|
3
3
|
* subprocesses (plugins, brave-search).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* plugin MCP server — memory grew linearly with chats. The hub instead
|
|
8
|
-
* keeps one child per key and reaps it after an idle TTL, so resident
|
|
9
|
-
* cost tracks *recently active* keys, not every chat ever seen.
|
|
5
|
+
* One child per key, reaped after an idle TTL, so resident cost tracks
|
|
6
|
+
* *recently active* keys, not every chat ever seen.
|
|
10
7
|
*
|
|
11
8
|
* Keys: chat-scoped plugins get `name + chatId` (they read
|
|
12
9
|
* `TALON_CHAT_ID` at boot, so instances cannot be shared across chats
|
|
@@ -14,8 +11,8 @@
|
|
|
14
11
|
* use a shared key. Either way the spec factory decides — this module
|
|
15
12
|
* only manages lifecycles.
|
|
16
13
|
*
|
|
17
|
-
* Each child is spawned through the
|
|
18
|
-
*
|
|
14
|
+
* Each child is spawned through the supervisor wrap (stdout JSON
|
|
15
|
+
* filtering + orphan cleanup if the daemon is SIGKILLed),
|
|
19
16
|
* connected once over stdio, and shared by every hub session that
|
|
20
17
|
* proxies to it. The tools list is cached per child lifetime — plugin
|
|
21
18
|
* reload restarts children, which naturally invalidates the cache.
|
|
@@ -30,6 +27,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
30
27
|
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
31
28
|
import { log, logError, logWarn } from "../../util/log.js";
|
|
32
29
|
import { HubChildTransport, type ChildExit } from "./child-transport.js";
|
|
30
|
+
import { raiseAlert, resolveAlert } from "../frontend-runtime/alerts.js";
|
|
31
|
+
import { faultText } from "../engine/fault-text.js";
|
|
33
32
|
|
|
34
33
|
export type ChildSpec = {
|
|
35
34
|
command: string;
|
|
@@ -40,13 +39,15 @@ export type ChildSpec = {
|
|
|
40
39
|
export type ChildHandle = {
|
|
41
40
|
/** Cached tools/list result — fetched once per child lifetime. */
|
|
42
41
|
listTools(): Promise<Tool[]>;
|
|
43
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Forward one tool call; tracked so retirement can drain in-flight work.
|
|
44
|
+
* Aborting `signal` cancels the call on the child too.
|
|
45
|
+
*/
|
|
44
46
|
callTool(
|
|
45
47
|
name: string,
|
|
46
48
|
args: Record<string, unknown>,
|
|
49
|
+
signal?: AbortSignal,
|
|
47
50
|
): Promise<CallToolResult>;
|
|
48
|
-
/** Mark activity so the idle reaper skips this child. */
|
|
49
|
-
touch(): void;
|
|
50
51
|
};
|
|
51
52
|
|
|
52
53
|
type ChildEntry = {
|
|
@@ -151,6 +152,8 @@ type SpawnFailure = { at: number; count: number; error: unknown };
|
|
|
151
152
|
const spawnFailures = new Map<string, SpawnFailure>();
|
|
152
153
|
const FAILURE_BACKOFF_BASE_MS = 30_000;
|
|
153
154
|
const FAILURE_BACKOFF_MAX_MS = 10 * 60_000;
|
|
155
|
+
/** Consecutive spawn failures of one child before the operator hears. */
|
|
156
|
+
const SPAWN_FAILURE_ALERT_THRESHOLD = 3;
|
|
154
157
|
|
|
155
158
|
function failureBackoffMs(count: number): number {
|
|
156
159
|
return Math.min(
|
|
@@ -159,6 +162,42 @@ function failureBackoffMs(count: number): number {
|
|
|
159
162
|
);
|
|
160
163
|
}
|
|
161
164
|
|
|
165
|
+
/** The MCP server name of a child key (keys are `server\0chat`). */
|
|
166
|
+
function serverOf(key: string): string {
|
|
167
|
+
const nul = key.indexOf("\u0000");
|
|
168
|
+
return nul === -1 ? key : key.slice(0, nul);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Count a failed spawn into the negative cache, log it with the child's
|
|
173
|
+
* last words, and alert once the same child has failed
|
|
174
|
+
* SPAWN_FAILURE_ALERT_THRESHOLD times running — its tools are gone for
|
|
175
|
+
* every turn until whatever backs it comes back.
|
|
176
|
+
*/
|
|
177
|
+
function noteSpawnFailure(key: string, err: unknown): void {
|
|
178
|
+
const count = (spawnFailures.get(key)?.count ?? 0) + 1;
|
|
179
|
+
spawnFailures.set(key, { at: Date.now(), count, error: err });
|
|
180
|
+
const backoffMs = failureBackoffMs(count);
|
|
181
|
+
// The exit record is this attempt's only when it is fresh — an older
|
|
182
|
+
// one belongs to a child that ran and was reaped long ago.
|
|
183
|
+
const exit = lastExits.get(key);
|
|
184
|
+
const stderr =
|
|
185
|
+
exit && Date.now() - exit.at < 60_000 ? exit.stderr.at(-1) : undefined;
|
|
186
|
+
const detail = faultText(err);
|
|
187
|
+
logWarn(
|
|
188
|
+
"gateway",
|
|
189
|
+
`hub child spawn failed ${describeKey(key)} attempt=${count} backoff_ms=${backoffMs} ` +
|
|
190
|
+
`error="${detail}"${stderr ? ` stderr="${faultText(stderr)}"` : ""}`,
|
|
191
|
+
);
|
|
192
|
+
if (count < SPAWN_FAILURE_ALERT_THRESHOLD) return;
|
|
193
|
+
const server = serverOf(key);
|
|
194
|
+
raiseAlert(
|
|
195
|
+
`mcp.child.${server}`,
|
|
196
|
+
`MCP server "${server}" has failed to start ${count} times in a row: ${detail}` +
|
|
197
|
+
`${stderr ? ` (stderr: ${faultText(stderr, 120)})` : ""}. Its tools are unavailable until it starts.`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
162
201
|
/** Idle TTL for hub children; tunable for tests / tight deployments. */
|
|
163
202
|
function idleTtlMs(): number {
|
|
164
203
|
const raw = Number(process.env.TALON_MCP_HUB_IDLE_MS);
|
|
@@ -166,6 +205,14 @@ function idleTtlMs(): number {
|
|
|
166
205
|
}
|
|
167
206
|
|
|
168
207
|
const REAP_INTERVAL_MS = 60_000;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Backstop for one forwarded tool call. The SDK's default (60s) would cut
|
|
211
|
+
* any longer plugin call, though the upstream client allows far more —
|
|
212
|
+
* its own timeout or cancel reaches the child through the forwarded
|
|
213
|
+
* signal. This only bounds a call nobody is waiting on any more.
|
|
214
|
+
*/
|
|
215
|
+
const CHILD_CALL_TIMEOUT_MS = 65 * 60_000;
|
|
169
216
|
let reaper: ReturnType<typeof setInterval> | null = null;
|
|
170
217
|
|
|
171
218
|
async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
|
|
@@ -217,9 +264,6 @@ async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
|
|
|
217
264
|
})());
|
|
218
265
|
})(),
|
|
219
266
|
handle: {
|
|
220
|
-
touch: () => {
|
|
221
|
-
entry.lastActivity = Date.now();
|
|
222
|
-
},
|
|
223
267
|
listTools: () =>
|
|
224
268
|
track(async () => {
|
|
225
269
|
if (toolsCache) return toolsCache;
|
|
@@ -227,12 +271,12 @@ async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
|
|
|
227
271
|
toolsCache = result.tools;
|
|
228
272
|
return toolsCache;
|
|
229
273
|
}),
|
|
230
|
-
callTool: (name, args) =>
|
|
274
|
+
callTool: (name, args, signal) =>
|
|
231
275
|
track(
|
|
232
276
|
() =>
|
|
233
|
-
client.callTool({
|
|
234
|
-
|
|
235
|
-
|
|
277
|
+
client.callTool({ name, arguments: args }, undefined, {
|
|
278
|
+
signal,
|
|
279
|
+
timeout: CHILD_CALL_TIMEOUT_MS,
|
|
236
280
|
}) as Promise<CallToolResult>,
|
|
237
281
|
),
|
|
238
282
|
},
|
|
@@ -272,23 +316,26 @@ export function acquireChild(
|
|
|
272
316
|
);
|
|
273
317
|
}
|
|
274
318
|
|
|
275
|
-
const
|
|
319
|
+
const spawn = async (): Promise<ChildHandle> => {
|
|
276
320
|
try {
|
|
277
321
|
const handle = await spawnChild(key, spec());
|
|
278
|
-
spawnFailures.delete(key)
|
|
322
|
+
if (spawnFailures.delete(key)) {
|
|
323
|
+
const server = serverOf(key);
|
|
324
|
+
resolveAlert(
|
|
325
|
+
`mcp.child.${server}`,
|
|
326
|
+
`MCP server "${server}" is starting normally again.`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
279
329
|
return handle;
|
|
280
330
|
} catch (err) {
|
|
281
|
-
|
|
282
|
-
spawnFailures.set(key, {
|
|
283
|
-
at: Date.now(),
|
|
284
|
-
count: (prior?.count ?? 0) + 1,
|
|
285
|
-
error: err,
|
|
286
|
-
});
|
|
331
|
+
noteSpawnFailure(key, err);
|
|
287
332
|
throw err;
|
|
288
|
-
} finally {
|
|
289
|
-
inflight.delete(key);
|
|
290
333
|
}
|
|
291
|
-
}
|
|
334
|
+
};
|
|
335
|
+
// `.finally` always runs after the `set` below. A `finally` block inside
|
|
336
|
+
// `spawn` would not: when `spec()` throws synchronously it runs before
|
|
337
|
+
// the set, leaving the rejected promise in `inflight` for good.
|
|
338
|
+
const promise = spawn().finally(() => inflight.delete(key));
|
|
292
339
|
inflight.set(key, promise);
|
|
293
340
|
return promise;
|
|
294
341
|
}
|
|
@@ -340,7 +387,9 @@ export function retireAllChildren(): void {
|
|
|
340
387
|
function reapIdle(): void {
|
|
341
388
|
const cutoff = Date.now() - idleTtlMs();
|
|
342
389
|
for (const [key, entry] of children) {
|
|
343
|
-
|
|
390
|
+
// A call outliving the TTL is work, not idleness — closing the child
|
|
391
|
+
// would fail it mid-flight. The first sweep after it drains reaps.
|
|
392
|
+
if (entry.lastActivity >= cutoff || entry.pending > 0) continue;
|
|
344
393
|
children.delete(key);
|
|
345
394
|
entry.close().catch((err) => {
|
|
346
395
|
logError("gateway", `hub reap of ${describeKey(key)} failed`, err);
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP hub — daemon-hosted MCP over streamable HTTP, one endpoint for
|
|
3
|
-
* every backend.
|
|
4
|
-
*
|
|
5
|
-
* Motivation: every backend used to spawn its own stdio copy of every
|
|
6
|
-
* MCP server per chat (claude-sdk/codex per turn; openai-agents and
|
|
7
|
-
* kilo/opencode per chat, held indefinitely) — two processes per server
|
|
8
|
-
* per chat once the supervisor wrap is counted. Memory grew linearly
|
|
9
|
-
* with chats. The hub inverts the ownership: the daemon hosts
|
|
3
|
+
* every backend. The daemon hosts
|
|
10
4
|
*
|
|
11
5
|
* /mcp/talon/<frontend>/<chatId> Talon's own tools, IN-PROCESS
|
|
12
6
|
* (zero subprocesses; chat binding
|
|
@@ -20,13 +14,13 @@
|
|
|
20
14
|
* (claude-sdk `type:"http"`, openai-agents `MCPServerStreamableHttp`,
|
|
21
15
|
* codex `mcp_servers.<name>.url`, kilo/opencode `type:"remote"`).
|
|
22
16
|
*
|
|
23
|
-
*
|
|
17
|
+
* Invariants:
|
|
24
18
|
* - per-chat tool isolation (binding is per-session from the URL)
|
|
25
|
-
* -
|
|
19
|
+
* - chat-scoped plugin children keep TALON_CHAT_ID in their env
|
|
26
20
|
* - tool-surface trimming (disabledTools / disabledToolTags)
|
|
27
|
-
* - plugin
|
|
28
|
-
*
|
|
29
|
-
* -
|
|
21
|
+
* - plugin reload retires children; the next request respawns from
|
|
22
|
+
* the current registry (see reloadHubChildren)
|
|
23
|
+
* - children run under the supervisor wrap (orphan cleanup)
|
|
30
24
|
*
|
|
31
25
|
* Endpoints live on the gateway HTTP server (127.0.0.1-bound, behind the
|
|
32
26
|
* same token and Host/Origin guard as /action — see engine/gateway-auth.ts;
|
|
@@ -112,8 +106,7 @@ export function pluginHubUrl(
|
|
|
112
106
|
* Names of every hub-served plugin server — what backends enumerate to
|
|
113
107
|
* build their per-chat URL maps. Registry-backed, so it reflects plugin
|
|
114
108
|
* reloads immediately. (brave-search is served by the hub too, but each
|
|
115
|
-
* backend adds it explicitly alongside its frontend tools
|
|
116
|
-
* pre-hub structure.)
|
|
109
|
+
* backend adds it explicitly alongside its frontend tools.)
|
|
117
110
|
*/
|
|
118
111
|
export function hubPluginServerNames(only?: string[]): string[] {
|
|
119
112
|
// Specs are built with placeholder identity — only the names matter.
|
|
@@ -134,7 +127,6 @@ export async function listHubPluginToolNames(
|
|
|
134
127
|
const child = await acquireChild(childKey(serverName, chatId), () =>
|
|
135
128
|
pluginSpec(serverName, chatId, bridgeUrl),
|
|
136
129
|
);
|
|
137
|
-
child.touch();
|
|
138
130
|
return (await child.listTools()).map((tool) => tool.name);
|
|
139
131
|
}
|
|
140
132
|
|
|
@@ -253,6 +245,8 @@ function guestPluginDenied(target: { serverName: string; chatId: string }) {
|
|
|
253
245
|
type SessionEntry = {
|
|
254
246
|
transport: StreamableHTTPServerTransport;
|
|
255
247
|
lastSeen: number;
|
|
248
|
+
/** Requests still open on this session, SSE streams included. */
|
|
249
|
+
open: number;
|
|
256
250
|
};
|
|
257
251
|
|
|
258
252
|
const sessions = new Map<string, SessionEntry>();
|
|
@@ -260,7 +254,11 @@ const sessions = new Map<string, SessionEntry>();
|
|
|
260
254
|
/**
|
|
261
255
|
* Sessions whose client vanished without a DELETE (crashed subprocess,
|
|
262
256
|
* kill -9) are closed after this idle window. Every well-behaved client
|
|
263
|
-
* terminates explicitly, so this only catches stragglers.
|
|
257
|
+
* terminates explicitly, so this only catches stragglers. A session with
|
|
258
|
+
* a request still open is never idle: a client that holds its event
|
|
259
|
+
* stream between turns (openai-agents keeps one per chat for good) is
|
|
260
|
+
* alive however quiet the chat, and it does not re-initialize on a 404.
|
|
261
|
+
* The idle clock restarts when its last open request ends.
|
|
264
262
|
*/
|
|
265
263
|
const SESSION_IDLE_MS = 30 * 60_000;
|
|
266
264
|
const SESSION_REAP_INTERVAL_MS = 5 * 60_000;
|
|
@@ -271,7 +269,7 @@ function startSessionReaper(): void {
|
|
|
271
269
|
sessionReaper = setInterval(() => {
|
|
272
270
|
const cutoff = Date.now() - SESSION_IDLE_MS;
|
|
273
271
|
for (const [id, entry] of sessions) {
|
|
274
|
-
if (entry.lastSeen >= cutoff) continue;
|
|
272
|
+
if (entry.open > 0 || entry.lastSeen >= cutoff) continue;
|
|
275
273
|
sessions.delete(id);
|
|
276
274
|
entry.transport.close().catch(() => {});
|
|
277
275
|
log("gateway", `hub session reaped (idle): ${id.slice(0, 8)}…`);
|
|
@@ -330,6 +328,11 @@ export async function handleHubRequest(
|
|
|
330
328
|
return;
|
|
331
329
|
}
|
|
332
330
|
entry.lastSeen = Date.now();
|
|
331
|
+
entry.open++;
|
|
332
|
+
res.once("close", () => {
|
|
333
|
+
entry.open--;
|
|
334
|
+
entry.lastSeen = Date.now();
|
|
335
|
+
});
|
|
333
336
|
await entry.transport.handleRequest(req, res);
|
|
334
337
|
return;
|
|
335
338
|
}
|
|
@@ -369,7 +372,7 @@ export async function handleHubRequest(
|
|
|
369
372
|
enableDnsRebindingProtection: true,
|
|
370
373
|
allowedHosts: loopbackHosts(bridgeUrl),
|
|
371
374
|
onsessioninitialized: (id) => {
|
|
372
|
-
sessions.set(id, { transport, lastSeen: Date.now() });
|
|
375
|
+
sessions.set(id, { transport, lastSeen: Date.now(), open: 0 });
|
|
373
376
|
},
|
|
374
377
|
});
|
|
375
378
|
transport.onclose = () => {
|
|
@@ -31,14 +31,18 @@ export function buildProxyServer(
|
|
|
31
31
|
|
|
32
32
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
33
33
|
const child = await getChild();
|
|
34
|
-
child.touch();
|
|
35
34
|
return { tools: await child.listTools() };
|
|
36
35
|
});
|
|
37
36
|
|
|
38
|
-
|
|
37
|
+
// `extra.signal` fires when the upstream client cancels or its session
|
|
38
|
+
// closes; passing it on stops the child's work instead of orphaning it.
|
|
39
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
39
40
|
const child = await getChild();
|
|
40
|
-
child.
|
|
41
|
-
|
|
41
|
+
return child.callTool(
|
|
42
|
+
request.params.name,
|
|
43
|
+
request.params.arguments ?? {},
|
|
44
|
+
extra.signal,
|
|
45
|
+
);
|
|
42
46
|
});
|
|
43
47
|
|
|
44
48
|
return server;
|
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* In-process Talon tool server
|
|
3
|
-
*
|
|
2
|
+
* In-process Talon tool server: composeTools + createBridge + textResult,
|
|
3
|
+
* bound to one (frontend, chatId) that arrives per hub session from the
|
|
4
|
+
* request URL. One `McpServer` instance per session, zero processes.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* dedicated subprocess, the binding arrives per hub session from the
|
|
8
|
-
* request URL. One `McpServer` instance per session, zero processes —
|
|
9
|
-
* this replaces the two processes (supervisor + server) that every
|
|
10
|
-
* chat previously paid per configured frontend.
|
|
11
|
-
*
|
|
12
|
-
* Tool-surface trimming (`disabledTools` / `disabledToolTags`) applies
|
|
13
|
-
* exactly as in the subprocess version, including the `end_turn`
|
|
14
|
-
* exemption — tool-only backends need it to close every turn.
|
|
6
|
+
* Tool-surface trimming (`disabledTools` / `disabledToolTags`) never
|
|
7
|
+
* removes `end_turn` — tool-only backends need it to close every turn.
|
|
15
8
|
*/
|
|
16
9
|
|
|
17
10
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -24,6 +24,8 @@ import { dirs } from "../../../util/paths.js";
|
|
|
24
24
|
import { log, logWarn } from "../../../util/log.js";
|
|
25
25
|
import { TalonError } from "../../errors.js";
|
|
26
26
|
import { readArray, writePrivateJson } from "../persist.js";
|
|
27
|
+
import { faultText } from "../../engine/fault-text.js";
|
|
28
|
+
import { raiseAlert, resolveAlert } from "../../frontend-runtime/alerts.js";
|
|
27
29
|
import {
|
|
28
30
|
credentialIdOf,
|
|
29
31
|
digestsEqual,
|
|
@@ -65,6 +67,8 @@ export type MintInput = {
|
|
|
65
67
|
export type BindResult =
|
|
66
68
|
{ ok: true; credential: DeviceCredential } | { ok: false; error: string };
|
|
67
69
|
|
|
70
|
+
const PERSIST_ALERT = "mesh.credentials.persist";
|
|
71
|
+
|
|
68
72
|
export class DeviceCredentialStore {
|
|
69
73
|
private records = new Map<string, DeviceCredentialRecord>();
|
|
70
74
|
private loading: Promise<void> | null = null;
|
|
@@ -418,7 +422,18 @@ export class DeviceCredentialStore {
|
|
|
418
422
|
|
|
419
423
|
private async persist(): Promise<void> {
|
|
420
424
|
this.lastPersist = this.now();
|
|
421
|
-
|
|
425
|
+
try {
|
|
426
|
+
await writePrivateJson(this.file, [...this.records.values()]);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
// A credential that never reached disk is gone after a restart —
|
|
429
|
+
// the device holding it is locked out. Disk faults need a human.
|
|
430
|
+
raiseAlert(
|
|
431
|
+
PERSIST_ALERT,
|
|
432
|
+
`Could not save mesh device credentials: ${faultText(err)}. Newly paired or rotated devices will lose access after a restart until this is fixed.`,
|
|
433
|
+
);
|
|
434
|
+
throw err;
|
|
435
|
+
}
|
|
436
|
+
resolveAlert(PERSIST_ALERT, "Mesh device credentials are saving again.");
|
|
422
437
|
}
|
|
423
438
|
}
|
|
424
439
|
|
|
@@ -125,23 +125,7 @@ export class MeshRegistry {
|
|
|
125
125
|
now,
|
|
126
126
|
);
|
|
127
127
|
this.devices.set(device.id, device);
|
|
128
|
-
|
|
129
|
-
// own persisted device id, so the same physical machine re-registers
|
|
130
|
-
// under a fresh identity and the old entry lingers forever as an offline
|
|
131
|
-
// ghost. Same name + platform under a different id, currently OFFLINE →
|
|
132
|
-
// superseded install, drop it. An online doppelganger is kept: two live
|
|
133
|
-
// devices can legitimately share a name.
|
|
134
|
-
let evicted = false;
|
|
135
|
-
for (const [id, d] of this.devices) {
|
|
136
|
-
if (id === device.id) continue;
|
|
137
|
-
if (d.name !== device.name || d.platform !== device.platform) continue;
|
|
138
|
-
if (toDeviceInfo(d, now, PRESENCE_TIMEOUT_MS).online) continue;
|
|
139
|
-
this.devices.delete(id);
|
|
140
|
-
this.locations.delete(id);
|
|
141
|
-
this.history.delete(id);
|
|
142
|
-
evicted = true;
|
|
143
|
-
}
|
|
144
|
-
if (this.enforceDeviceCap()) evicted = true;
|
|
128
|
+
const evicted = this.enforceDeviceCap();
|
|
145
129
|
await this.persistDevices();
|
|
146
130
|
if (evicted) {
|
|
147
131
|
await this.persistLocations();
|
|
@@ -69,8 +69,6 @@ export type MeshTransport = {
|
|
|
69
69
|
command(command: DeviceCommand): void;
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
-
export type { MeshToolResult } from "../tool-surface.js";
|
|
73
|
-
|
|
74
72
|
/** Outcome of pinging one device (see {@link MeshService.pingAll}). */
|
|
75
73
|
export type MeshPingResult = {
|
|
76
74
|
device: DeviceInfo;
|
|
@@ -101,8 +99,6 @@ export type MeshServiceOptions = {
|
|
|
101
99
|
credentials?: DeviceCredentialStore;
|
|
102
100
|
};
|
|
103
101
|
|
|
104
|
-
export type { MeshBridgeInfo } from "../links/bridge-links.js";
|
|
105
|
-
|
|
106
102
|
const DEFAULT_FRESH_FIX_TIMEOUT_MS = 8_000;
|
|
107
103
|
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
108
104
|
const DEFAULT_COMMAND_TIMEOUT_MS = 12_000;
|
|
@@ -281,17 +277,14 @@ export class MeshService {
|
|
|
281
277
|
* whose deviceId names a DIFFERENT device is dropped (a confused or
|
|
282
278
|
* misbehaving companion must not be able to answer for its peers).
|
|
283
279
|
*
|
|
284
|
-
* An ABSENT deviceId is dropped too
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
* only
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
* asserted by daemon, node and companion alike) and both shipped clients
|
|
293
|
-
* send it. An unattributable reply now leaves the command to time out with
|
|
294
|
-
* the honest "did not answer" rather than resolving with someone's data.
|
|
280
|
+
* An ABSENT deviceId is dropped too: omitting the field would skip the
|
|
281
|
+
* ownership check, which is exactly what a spoofer would do to feed the
|
|
282
|
+
* model fabricated exec stdout or a fake install success. Accepting it
|
|
283
|
+
* when only one command is pending still cannot attribute the reply — it
|
|
284
|
+
* only narrows the window to whenever the mesh is idle. `deviceId` is part
|
|
285
|
+
* of the command-result wire contract (protocol/fixtures/mesh_v1.json) and
|
|
286
|
+
* every shipped client sends it, so an unattributable reply is left to
|
|
287
|
+
* time out with "did not answer" rather than resolving with someone's data.
|
|
295
288
|
*/
|
|
296
289
|
completeCommand(body: Record<string, unknown>): boolean {
|
|
297
290
|
const commandId = typeof body.commandId === "string" ? body.commandId : "";
|
|
@@ -317,6 +310,15 @@ export class MeshService {
|
|
|
317
310
|
return true;
|
|
318
311
|
}
|
|
319
312
|
|
|
313
|
+
/** Resolve every command still waiting on `deviceId` as failed. */
|
|
314
|
+
private failPendingCommands(deviceId: string, message: string): void {
|
|
315
|
+
for (const [commandId, pending] of this.pendingCommands) {
|
|
316
|
+
if (pending.deviceId !== deviceId) continue;
|
|
317
|
+
this.pendingCommands.delete(commandId);
|
|
318
|
+
pending.resolve({ commandId, deviceId, ok: false, message });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
320
322
|
/**
|
|
321
323
|
* Fail a pending command early when its device stops heartbeating.
|
|
322
324
|
*
|
|
@@ -511,6 +513,15 @@ export class MeshService {
|
|
|
511
513
|
const revoked =
|
|
512
514
|
(await this.credentials?.revokeDevice(removed.id, "device removed")) ??
|
|
513
515
|
[];
|
|
516
|
+
// With its credential gone it can no longer post a result, and presence
|
|
517
|
+
// can't fail these for it (the registry entry is gone too) — without
|
|
518
|
+
// this, a command in flight waits out its whole budget.
|
|
519
|
+
if (revoked.length > 0) {
|
|
520
|
+
this.failPendingCommands(
|
|
521
|
+
removed.id,
|
|
522
|
+
`${removed.name} was removed from the mesh before it answered.`,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
514
525
|
return {
|
|
515
526
|
ok: true,
|
|
516
527
|
text:
|
|
@@ -866,6 +877,7 @@ export class MeshService {
|
|
|
866
877
|
const q = raw.toLowerCase();
|
|
867
878
|
const byId = devices.find((d) => d.id.toLowerCase() === q);
|
|
868
879
|
if (byId) return { target: byId };
|
|
880
|
+
|
|
869
881
|
const fold = (s: string): string =>
|
|
870
882
|
s.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
871
883
|
const folded = fold(raw);
|
|
@@ -874,6 +886,7 @@ export class MeshService {
|
|
|
874
886
|
): { target: DeviceInfo } | { error: string } | undefined => {
|
|
875
887
|
if (candidates.length === 1) return { target: candidates[0]! };
|
|
876
888
|
if (candidates.length > 1) {
|
|
889
|
+
const exactNames = new Set(candidates.map((d) => fold(d.name)));
|
|
877
890
|
const online = candidates.filter((d) => d.online);
|
|
878
891
|
if (online.length === 1) return { target: online[0]! };
|
|
879
892
|
return {
|
|
@@ -882,13 +895,32 @@ export class MeshService {
|
|
|
882
895
|
(d) =>
|
|
883
896
|
`${d.name} [id: ${d.id}, ${d.online ? "online" : "offline"}]`,
|
|
884
897
|
)
|
|
885
|
-
.join(
|
|
898
|
+
.join(
|
|
899
|
+
", ",
|
|
900
|
+
)}. Use the device id${exactNames.size > 1 ? " or full name" : ""}.`,
|
|
886
901
|
};
|
|
887
902
|
}
|
|
888
903
|
return undefined;
|
|
889
904
|
};
|
|
905
|
+
|
|
906
|
+
const exactMatch = pick(devices.filter((d) => fold(d.name) === folded));
|
|
907
|
+
if (exactMatch) return exactMatch;
|
|
908
|
+
|
|
909
|
+
if (q.length >= 6) {
|
|
910
|
+
const byPrefix = devices.filter((d) =>
|
|
911
|
+
d.id.toLowerCase().startsWith(q),
|
|
912
|
+
);
|
|
913
|
+
if (byPrefix.length === 1) return { target: byPrefix[0]! };
|
|
914
|
+
if (byPrefix.length > 1) {
|
|
915
|
+
return {
|
|
916
|
+
error: `Device id prefix "${raw}" matches ${byPrefix.length} devices: ${byPrefix
|
|
917
|
+
.map((d) => `${d.name} [id: ${d.id}]`)
|
|
918
|
+
.join(", ")}. Provide more characters of the id.`,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
890
923
|
return (
|
|
891
|
-
pick(devices.filter((d) => fold(d.name) === folded)) ??
|
|
892
924
|
(folded
|
|
893
925
|
? pick(devices.filter((d) => fold(d.name).includes(folded)))
|
|
894
926
|
: undefined) ?? { error: this.noSuchDevice(query).text }
|
|
@@ -44,6 +44,12 @@ function stateFile(): string {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
let cache: TeleportStore | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* The in-flight first read. Shared so concurrent cold callers all get the
|
|
49
|
+
* one store: separate reads would each build their own, and the last to
|
|
50
|
+
* finish would replace the cache — dropping any teleport set on another.
|
|
51
|
+
*/
|
|
52
|
+
let loading: Promise<TeleportStore> | undefined;
|
|
47
53
|
|
|
48
54
|
function emptyStore(): TeleportStore {
|
|
49
55
|
return { chats: Object.create(null) as Record<string, TeleportState> };
|
|
@@ -69,8 +75,13 @@ function parseState(value: unknown): TeleportState | null {
|
|
|
69
75
|
};
|
|
70
76
|
}
|
|
71
77
|
|
|
72
|
-
|
|
73
|
-
if (cache !== undefined) return cache;
|
|
78
|
+
function readStore(): Promise<TeleportStore> {
|
|
79
|
+
if (cache !== undefined) return Promise.resolve(cache);
|
|
80
|
+
loading ??= loadStore();
|
|
81
|
+
return loading;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function loadStore(): Promise<TeleportStore> {
|
|
74
85
|
try {
|
|
75
86
|
const raw = await readFile(stateFile(), "utf8");
|
|
76
87
|
const parsed = JSON.parse(raw) as unknown;
|
|
@@ -155,4 +166,5 @@ export async function clearTeleport(
|
|
|
155
166
|
/** Test seam — drop the in-memory cache so the next read hits disk. */
|
|
156
167
|
export function resetTeleportCache(): void {
|
|
157
168
|
cache = undefined;
|
|
169
|
+
loading = undefined;
|
|
158
170
|
}
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { execFile } from "node:child_process";
|
|
25
25
|
import { createHash } from "node:crypto";
|
|
26
|
+
import { createReadStream } from "node:fs";
|
|
26
27
|
import {
|
|
27
28
|
chmod,
|
|
28
29
|
mkdir,
|
|
@@ -382,12 +383,18 @@ async function pruneStaleVersions(
|
|
|
382
383
|
}
|
|
383
384
|
}
|
|
384
385
|
|
|
385
|
-
|
|
386
|
+
/** Stream a file through SHA-256 without loading it into memory (APKs are big
|
|
387
|
+
* and Buffer has a hard ceiling). Returns the hex digest and byte size. */
|
|
388
|
+
export async function hashFile(
|
|
386
389
|
path: string,
|
|
387
390
|
): Promise<{ sha256: string; size: number }> {
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
391
|
+
const { size } = await stat(path);
|
|
392
|
+
const hash = createHash("sha256");
|
|
393
|
+
await new Promise<void>((resolve, reject) => {
|
|
394
|
+
createReadStream(path)
|
|
395
|
+
.on("data", (chunk) => hash.update(chunk))
|
|
396
|
+
.on("end", () => resolve())
|
|
397
|
+
.on("error", reject);
|
|
398
|
+
});
|
|
399
|
+
return { sha256: hash.digest("hex"), size };
|
|
393
400
|
}
|
package/src/core/mesh/persist.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
|
-
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
11
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
12
12
|
import { dirname } from "node:path";
|
|
13
13
|
|
|
14
14
|
/** Per-path write chain: new writes queue onto the tail promise. */
|
|
@@ -31,19 +31,31 @@ export async function writePrivateJson(
|
|
|
31
31
|
value: unknown,
|
|
32
32
|
): Promise<void> {
|
|
33
33
|
const prior = writeQueues.get(path) ?? Promise.resolve();
|
|
34
|
-
const next = prior.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
})
|
|
40
|
-
|
|
34
|
+
const next = prior.then(() => atomicWriteJson(path, value));
|
|
35
|
+
// The queued tail must never reject: a failed write is reported to its
|
|
36
|
+
// caller through `next`; a rejecting tail that no later writer happens to
|
|
37
|
+
// chain onto surfaces as a process-level unhandled rejection.
|
|
38
|
+
const tail: Promise<void> = next
|
|
39
|
+
.catch(() => {})
|
|
40
|
+
.then(() => {
|
|
41
|
+
if (writeQueues.get(path) === tail) writeQueues.delete(path);
|
|
42
|
+
});
|
|
43
|
+
writeQueues.set(path, tail);
|
|
41
44
|
return next;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
async function atomicWriteJson(path: string, value: unknown): Promise<void> {
|
|
45
48
|
await mkdir(dirname(path), { recursive: true });
|
|
46
49
|
const tmp = `${path}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
try {
|
|
51
|
+
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, {
|
|
52
|
+
mode: 0o600,
|
|
53
|
+
});
|
|
54
|
+
await rename(tmp, path);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// Each attempt names a fresh temp file, so one left behind is never
|
|
57
|
+
// reused — on a full disk every heartbeat would strand another.
|
|
58
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
49
61
|
}
|