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
|
@@ -22,9 +22,14 @@ import {
|
|
|
22
22
|
} from "node:http";
|
|
23
23
|
import { createServer as createTlsServer } from "node:https";
|
|
24
24
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
25
|
-
import { createReadStream } from "node:fs";
|
|
25
|
+
import { createReadStream, type ReadStream } from "node:fs";
|
|
26
26
|
import { stat } from "node:fs/promises";
|
|
27
|
-
import { log, logError, logDebug } from "../../../util/log.js";
|
|
27
|
+
import { log, logError, logDebug, logWarn } from "../../../util/log.js";
|
|
28
|
+
import {
|
|
29
|
+
raiseAlert,
|
|
30
|
+
resolveAlert,
|
|
31
|
+
} from "../../../core/frontend-runtime/alerts.js";
|
|
32
|
+
import { errorText } from "../../health/outage.js";
|
|
28
33
|
import {
|
|
29
34
|
formatFingerprint,
|
|
30
35
|
isLoopbackHost,
|
|
@@ -63,6 +68,14 @@ type StreamSession = {
|
|
|
63
68
|
};
|
|
64
69
|
|
|
65
70
|
const SSE_PING_MS = 25_000;
|
|
71
|
+
/**
|
|
72
|
+
* Unsent bytes a stream may hold before it counts as dead. A client that
|
|
73
|
+
* stops reading without closing (phone asleep, network switch) otherwise
|
|
74
|
+
* buffers every broadcast in memory until TCP gives up on it, minutes later.
|
|
75
|
+
* Far past anything a live client falls behind by; evicted, it reconnects
|
|
76
|
+
* and gets a fresh `hello`.
|
|
77
|
+
*/
|
|
78
|
+
const SSE_MAX_BACKLOG_BYTES = 16 * 1024 * 1024;
|
|
66
79
|
const MAX_BODY_BYTES = 256 * 1024;
|
|
67
80
|
const PORT_FALLBACKS = 5;
|
|
68
81
|
|
|
@@ -94,6 +107,15 @@ export const DEFAULT_BRIDGE_TIMEOUTS: BridgeTimeouts = {
|
|
|
94
107
|
checkIntervalMs: 30_000,
|
|
95
108
|
};
|
|
96
109
|
|
|
110
|
+
/**
|
|
111
|
+
* `pipe` never closes its source when the destination goes away, so a
|
|
112
|
+
* client that hangs up mid-download (app backgrounded, image scrolled
|
|
113
|
+
* away) would leave the paused read stream holding its fd forever.
|
|
114
|
+
*/
|
|
115
|
+
function releaseOnClose(res: ServerResponse, stream: ReadStream): void {
|
|
116
|
+
res.once("close", () => stream.destroy());
|
|
117
|
+
}
|
|
118
|
+
|
|
97
119
|
export class BridgeServer {
|
|
98
120
|
private server: Server | null = null;
|
|
99
121
|
/**
|
|
@@ -225,14 +247,14 @@ export class BridgeServer {
|
|
|
225
247
|
*
|
|
226
248
|
* Device commands are not public: their params carry one-time transfer
|
|
227
249
|
* tokens, exec command lines, remote paths, and — on the chunked fallback —
|
|
228
|
-
* whole base64 file bodies. Broadcasting them
|
|
229
|
-
* another device's secrets and
|
|
230
|
-
* addressed to it, which is courtesy, not enforcement.
|
|
250
|
+
* whole base64 file bodies. Broadcasting them would hand every connected
|
|
251
|
+
* client another device's secrets and rely on each client discarding what
|
|
252
|
+
* isn't addressed to it, which is courtesy, not enforcement.
|
|
231
253
|
*
|
|
232
254
|
* A claim is an ADDRESS, not a credential: any client holding the bridge
|
|
233
255
|
* token could claim any id, and the bridge token is (still) the only trust
|
|
234
|
-
* boundary here. What this buys is that a device
|
|
235
|
-
*
|
|
256
|
+
* boundary here. What this buys is that a device does not passively
|
|
257
|
+
* receive traffic meant for its peers.
|
|
236
258
|
*
|
|
237
259
|
* Clients that claimed nothing are the fallback audience, and only when the
|
|
238
260
|
* target claimed nothing either: a companion build that predates the claim
|
|
@@ -265,12 +287,24 @@ export class BridgeServer {
|
|
|
265
287
|
|
|
266
288
|
private write(targets: Iterable<ServerResponse>, event: BridgeEvent): void {
|
|
267
289
|
const payload = `data: ${JSON.stringify(event)}\n\n`;
|
|
268
|
-
for (const res of targets)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
290
|
+
for (const res of targets) this.send(res, payload);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Write one frame to a stream, evicting it if its backlog never drains. */
|
|
294
|
+
private send(res: ServerResponse, frame: string): void {
|
|
295
|
+
if (res.writableLength > SSE_MAX_BACKLOG_BYTES) {
|
|
296
|
+
this.clients.delete(res);
|
|
297
|
+
logWarn(
|
|
298
|
+
"native",
|
|
299
|
+
`Dropped an SSE client that stopped reading (${res.writableLength} bytes unsent)`,
|
|
300
|
+
);
|
|
301
|
+
res.destroy();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
res.write(frame);
|
|
306
|
+
} catch {
|
|
307
|
+
// Write on a half-closed socket — the 'close' handler will evict it.
|
|
274
308
|
}
|
|
275
309
|
}
|
|
276
310
|
|
|
@@ -281,7 +315,7 @@ export class BridgeServer {
|
|
|
281
315
|
loopback: isLoopbackHost(this.opts.host),
|
|
282
316
|
allowWeakToken: this.opts.allowWeakToken,
|
|
283
317
|
});
|
|
284
|
-
this.tlsIdentity = this.opts.tls ? await this.opts.tls
|
|
318
|
+
this.tlsIdentity = this.opts.tls ? await this.loadTls(this.opts.tls) : null;
|
|
285
319
|
const onRequest = (req: IncomingMessage, res: ServerResponse): void => {
|
|
286
320
|
this.handle(req, res).catch((err) => {
|
|
287
321
|
logError("native", "Bridge request handler threw", err);
|
|
@@ -312,29 +346,70 @@ export class BridgeServer {
|
|
|
312
346
|
: createServer(serverOpts, onRequest);
|
|
313
347
|
|
|
314
348
|
this.pingTimer = setInterval(() => {
|
|
315
|
-
for (const res of this.clients.keys())
|
|
316
|
-
try {
|
|
317
|
-
res.write(": ping\n\n");
|
|
318
|
-
} catch {
|
|
319
|
-
/* evicted on close */
|
|
320
|
-
}
|
|
321
|
-
}
|
|
349
|
+
for (const res of this.clients.keys()) this.send(res, ": ping\n\n");
|
|
322
350
|
}, SSE_PING_MS);
|
|
323
351
|
this.pingTimer.unref?.();
|
|
324
352
|
this.unsubscribeRevocations = this.opts.credentials?.authority.onRevoked(
|
|
325
353
|
(ids) => this.dropCredentialSessions(ids),
|
|
326
354
|
);
|
|
327
355
|
|
|
356
|
+
return this.bind(server);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The TLS identity, or a thrown boot failure the operator hears about:
|
|
361
|
+
* without it no companion app can connect.
|
|
362
|
+
*/
|
|
363
|
+
private async loadTls(
|
|
364
|
+
load: () => Promise<BridgeTlsIdentity>,
|
|
365
|
+
): Promise<BridgeTlsIdentity> {
|
|
366
|
+
try {
|
|
367
|
+
const identity = await load();
|
|
368
|
+
resolveAlert(
|
|
369
|
+
"bridge.tls",
|
|
370
|
+
"The client bridge TLS certificate loads again.",
|
|
371
|
+
);
|
|
372
|
+
return identity;
|
|
373
|
+
} catch (err) {
|
|
374
|
+
logError("native", `bridge.tls.fail err=${errorText(err)}`, err);
|
|
375
|
+
raiseAlert(
|
|
376
|
+
"bridge.tls",
|
|
377
|
+
`The client bridge could not load its TLS certificate: ${errorText(err)}. Companion apps cannot connect.`,
|
|
378
|
+
{ severity: "critical" },
|
|
379
|
+
);
|
|
380
|
+
throw err;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Listen on the configured port, stepping up to PORT_FALLBACKS ports past
|
|
386
|
+
* it when one is taken. A bind that fails for good raises `bridge.listen`
|
|
387
|
+
* — the frontend has no surface at all without it.
|
|
388
|
+
*/
|
|
389
|
+
private bind(server: Server): Promise<number> {
|
|
328
390
|
return new Promise<number>((resolve, reject) => {
|
|
329
391
|
let attempt = 0;
|
|
330
392
|
const tryPort = (p: number): void => {
|
|
331
393
|
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
332
394
|
if (err.code === "EADDRINUSE" && attempt < PORT_FALLBACKS) {
|
|
333
395
|
attempt++;
|
|
396
|
+
logWarn(
|
|
397
|
+
"native",
|
|
398
|
+
`bridge.listen port=${p} in use — trying port=${p + 1} attempt=${attempt}/${PORT_FALLBACKS}`,
|
|
399
|
+
);
|
|
334
400
|
server.removeAllListeners("error");
|
|
335
401
|
server.removeAllListeners("listening");
|
|
336
402
|
tryPort(p + 1);
|
|
337
403
|
} else {
|
|
404
|
+
logError(
|
|
405
|
+
"native",
|
|
406
|
+
`bridge.listen.fail host=${this.opts.host} port=${p} attempt=${attempt} err=${errorText(err)}`,
|
|
407
|
+
);
|
|
408
|
+
raiseAlert(
|
|
409
|
+
"bridge.listen",
|
|
410
|
+
`The client bridge could not listen on ${this.opts.host}:${p}: ${errorText(err)}. Companion apps cannot connect.`,
|
|
411
|
+
{ severity: "critical" },
|
|
412
|
+
);
|
|
338
413
|
reject(err);
|
|
339
414
|
}
|
|
340
415
|
});
|
|
@@ -362,6 +437,10 @@ export class BridgeServer {
|
|
|
362
437
|
`Bridge certificate fingerprint ${formatFingerprint(this.tlsIdentity.fingerprint)}`,
|
|
363
438
|
);
|
|
364
439
|
}
|
|
440
|
+
resolveAlert(
|
|
441
|
+
"bridge.listen",
|
|
442
|
+
"The client bridge is listening again.",
|
|
443
|
+
);
|
|
365
444
|
resolve(this.port);
|
|
366
445
|
});
|
|
367
446
|
};
|
|
@@ -507,6 +586,7 @@ export class BridgeServer {
|
|
|
507
586
|
});
|
|
508
587
|
const stream = createReadStream(file.path);
|
|
509
588
|
stream.on("error", () => res.destroy());
|
|
589
|
+
releaseOnClose(res, stream);
|
|
510
590
|
stream.pipe(res);
|
|
511
591
|
}
|
|
512
592
|
|
|
@@ -532,6 +612,7 @@ export class BridgeServer {
|
|
|
532
612
|
if (!res.headersSent) res.writeHead(500);
|
|
533
613
|
res.end();
|
|
534
614
|
});
|
|
615
|
+
releaseOnClose(res, stream);
|
|
535
616
|
stream.pipe(res);
|
|
536
617
|
} catch {
|
|
537
618
|
return this.json(res, 404, { ok: false, error: "No such media" });
|
|
@@ -599,6 +680,10 @@ export class BridgeServer {
|
|
|
599
680
|
const ms = Math.round(max * (0.9 + Math.random() * 0.2));
|
|
600
681
|
const timer = setTimeout(() => {
|
|
601
682
|
logDebug("native", "bridge.sse event=max_lifetime reason=expired");
|
|
683
|
+
// Out of the fan-out before end(): a stream still flushing a backlog
|
|
684
|
+
// stays open until it drains, and a broadcast in that window is a
|
|
685
|
+
// write after end — an unhandled 'error' that takes the daemon down.
|
|
686
|
+
this.clients.delete(res);
|
|
602
687
|
res.end();
|
|
603
688
|
}, ms);
|
|
604
689
|
timer.unref?.();
|
|
@@ -191,7 +191,7 @@ export function createNativeFrontend(
|
|
|
191
191
|
await mesh.load();
|
|
192
192
|
unregisterMeshTransport = registerMeshTransport(runtime, server);
|
|
193
193
|
// Mesh tool actions (list_devices / get_device_location) are shared
|
|
194
|
-
// gateway actions
|
|
194
|
+
// gateway actions — no native-only cases here.
|
|
195
195
|
gateway.registerFrontendHandler(
|
|
196
196
|
"native",
|
|
197
197
|
createNativeActionHandler({
|
|
@@ -20,13 +20,17 @@ import type { NativeRuntime } from "../runtime.js";
|
|
|
20
20
|
*/
|
|
21
21
|
export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
|
|
22
22
|
|
|
23
|
-
/** Register a file for serving and return its short media id
|
|
23
|
+
/** Register a file for serving and return its short media id (stable per
|
|
24
|
+
* path for the life of the daemon). */
|
|
24
25
|
export function registerMedia(
|
|
25
26
|
runtime: NativeRuntime,
|
|
26
27
|
filePath: string,
|
|
27
28
|
): string {
|
|
29
|
+
const known = runtime.mediaIds.get(filePath);
|
|
30
|
+
if (known) return known;
|
|
28
31
|
const id = `m${runtime.nextId().toString(36)}`;
|
|
29
32
|
runtime.media.set(id, filePath);
|
|
33
|
+
runtime.mediaIds.set(filePath, id);
|
|
30
34
|
return id;
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Native frontend runtime — the state every bridge module shares
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* first parameter. The runtime carries state plus two primitives — the
|
|
8
|
-
* message-id minter and the broadcast sink; the modules own the behaviour.
|
|
2
|
+
* Native frontend runtime — the state every bridge module shares: one
|
|
3
|
+
* object, constructed once, that each module (chat-wire, context, emit,
|
|
4
|
+
* turn, …) takes as its first parameter. It carries state plus two
|
|
5
|
+
* primitives — the message-id minter and the broadcast sink; the modules
|
|
6
|
+
* own the behaviour.
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
9
|
import type { TalonConfig } from "../../core/config/index.js";
|
|
@@ -55,6 +53,12 @@ export type NativeRuntime = {
|
|
|
55
53
|
* frontends behave).
|
|
56
54
|
*/
|
|
57
55
|
readonly media: Map<string, string>;
|
|
56
|
+
/**
|
|
57
|
+
* `media` reversed (path → id). Every history page re-registers its
|
|
58
|
+
* attachments, so without this each fetch — and every client reconnect
|
|
59
|
+
* re-fetches — grew `media` by one entry per attachment, forever.
|
|
60
|
+
*/
|
|
61
|
+
readonly mediaIds: Map<string, string>;
|
|
58
62
|
/**
|
|
59
63
|
* Uploads this daemon run has accepted, keyed by their media id. `/send`
|
|
60
64
|
* resolves a client's attachment references through here rather than
|
|
@@ -110,6 +114,7 @@ export function createNativeRuntime(
|
|
|
110
114
|
chats: new NativeChats(),
|
|
111
115
|
mesh: getMeshService(),
|
|
112
116
|
media: new Map(),
|
|
117
|
+
mediaIds: new Map(),
|
|
113
118
|
uploads: new Map(),
|
|
114
119
|
contextByChat: new Map(),
|
|
115
120
|
liveTurns: new Map(),
|
|
@@ -79,7 +79,7 @@ function resolveAttachments(
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
// A dropped reference is how "I attached a file and the agent never saw it"
|
|
82
|
-
// happens,
|
|
82
|
+
// happens, so it must not be silent. The usual cause is a media id
|
|
83
83
|
// minted by a previous daemon run — `runtime.uploads` is per-run, so a
|
|
84
84
|
// client that staged a file before a restart and sent it after points at
|
|
85
85
|
// nothing.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Read-only on purpose: a bridge client can ask what Talon remembers and
|
|
7
7
|
* why, and nothing more. Asserting, superseding and dropping stay with
|
|
8
|
-
* the write path
|
|
8
|
+
* the write path, so a paired phone can never quietly
|
|
9
9
|
* rewrite the operator's memory.
|
|
10
10
|
*
|
|
11
11
|
* The one piece of policy here is the limit cap: whatever a client asks
|
|
@@ -35,9 +35,9 @@ export async function listModels(
|
|
|
35
35
|
): Promise<{ active: string; models: ModelOption[] }> {
|
|
36
36
|
const { config } = runtime;
|
|
37
37
|
// Resolve the chat's *own* backend so the model list tracks whatever
|
|
38
|
-
// backend the chat is currently bound to
|
|
39
|
-
//
|
|
40
|
-
//
|
|
38
|
+
// backend the chat is currently bound to, not the one before a switch.
|
|
39
|
+
// Fall back to the global default backend when there's no chat / the
|
|
40
|
+
// pool isn't ready yet.
|
|
41
41
|
let backendId: string = config.backend;
|
|
42
42
|
let active = config.model;
|
|
43
43
|
if (chatId) {
|
|
@@ -186,19 +186,31 @@ export function applyConfigUpdate(
|
|
|
186
186
|
* Merge a partial update into talon.json on disk, preserving everything
|
|
187
187
|
* else. Shared by every bridge surface that persists config changes
|
|
188
188
|
* (settings sync here, plugin toggles in extensions.ts).
|
|
189
|
+
*
|
|
190
|
+
* An existing file that can't be read or isn't a JSON object throws and
|
|
191
|
+
* is left untouched: merging into `{}` would replace the operator's whole
|
|
192
|
+
* config (tokens included) with just this patch.
|
|
189
193
|
*/
|
|
190
194
|
export function persistConfigPatch(update: Record<string, unknown>): void {
|
|
191
195
|
const file = pathFiles.config;
|
|
192
196
|
let current: Record<string, unknown> = {};
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
if (existsSync(file)) {
|
|
198
|
+
let parsed: unknown;
|
|
199
|
+
try {
|
|
200
|
+
parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
201
|
+
} catch (err) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Refusing to write ${file}: cannot read the existing file ` +
|
|
204
|
+
`(${err instanceof Error ? err.message : err}). It was left untouched.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Refusing to write ${file}: the top level is not a JSON object. ` +
|
|
210
|
+
`It was left untouched.`,
|
|
211
|
+
);
|
|
199
212
|
}
|
|
200
|
-
|
|
201
|
-
/* corrupt/absent — start from empty */
|
|
213
|
+
current = parsed as Record<string, unknown>;
|
|
202
214
|
}
|
|
203
215
|
for (const [k, v] of Object.entries(update)) {
|
|
204
216
|
if (v === undefined) delete current[k];
|
|
@@ -88,14 +88,12 @@ export async function refreshContext(
|
|
|
88
88
|
/**
|
|
89
89
|
* Fill the context cache for chats restored at startup.
|
|
90
90
|
*
|
|
91
|
-
* The readout is served from an in-memory map
|
|
92
|
-
* turn end, so after a restart every
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* never touches the backend pool); anything it skips or can't resolve is
|
|
98
|
-
* filled in the moment the chat is opened.
|
|
91
|
+
* The readout is served from an in-memory map otherwise written only at
|
|
92
|
+
* turn end, so after a restart every chat would show no context until its
|
|
93
|
+
* next turn, though the numbers are persisted with the session. This
|
|
94
|
+
* re-reads them for the most recently active chats (bounded, and on the
|
|
95
|
+
* cheap path that never touches the backend pool); anything it skips or
|
|
96
|
+
* can't resolve is filled in the moment the chat is opened.
|
|
99
97
|
*/
|
|
100
98
|
const CONTEXT_WARM_LIMIT = 40;
|
|
101
99
|
|
|
@@ -2,11 +2,8 @@
|
|
|
2
2
|
* Turn-meta sidecar — thin native-frontend façade over the shared
|
|
3
3
|
* storage/turn-meta store. It owns the TurnMeta *shape* (tool calls,
|
|
4
4
|
* duration, token usage — the companion app's tool timeline and stats
|
|
5
|
-
* footer); the storage layer treats each record as opaque JSON
|
|
6
|
-
*
|
|
7
|
-
* The data used to live in a debounced JSON file under the data dir;
|
|
8
|
-
* it now rides the `turn_meta` SQLite table shared by every frontend.
|
|
9
|
-
* SQLite commits on every write, so the old flush timer is gone.
|
|
5
|
+
* footer); the storage layer treats each record as opaque JSON in the
|
|
6
|
+
* `turn_meta` SQLite table shared by every frontend.
|
|
10
7
|
*/
|
|
11
8
|
|
|
12
9
|
import {
|
|
@@ -102,10 +102,8 @@ export function liveTurnEvents(runtime: NativeRuntime): BridgeEvent[] {
|
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
104
|
* Safety net: any tool the backend announced but never resolved (a crash can
|
|
105
|
-
* eat a tool_result
|
|
106
|
-
*
|
|
107
|
-
* result) gets a synthetic result at turn end — a spinner the app opened on
|
|
108
|
-
* phase:"call" must always see a phase:"result".
|
|
105
|
+
* eat a tool_result) gets a synthetic result at turn end — a spinner the app
|
|
106
|
+
* opened on phase:"call" must always see a phase:"result".
|
|
109
107
|
*/
|
|
110
108
|
function flushOpenTools(
|
|
111
109
|
runtime: NativeRuntime,
|
|
@@ -312,14 +310,14 @@ async function runTurn(
|
|
|
312
310
|
// clients. Any late tool spinner has already been flushed above.
|
|
313
311
|
runtime.liveTurns.delete(entry.id);
|
|
314
312
|
// Flush a queued follow-up as a fresh turn: "once it's done it will
|
|
315
|
-
// send".
|
|
313
|
+
// send". Started synchronously so the chat never looks idle in between —
|
|
314
|
+
// a /send landing in a deferred gap would start its own turn and run
|
|
315
|
+
// ahead of the message that was queued first.
|
|
316
316
|
const queued = takeQueued(runtime, entry);
|
|
317
317
|
if (queued) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}),
|
|
322
|
-
);
|
|
318
|
+
startTurn(runtime, entry, queued.text, {
|
|
319
|
+
attachments: queued.attachments,
|
|
320
|
+
});
|
|
323
321
|
}
|
|
324
322
|
}
|
|
325
323
|
}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Frontend-agnostic display formatters shared by all chat frontends.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* parseInterval accepted days on Discord but not Telegram. One definition
|
|
7
|
-
* here keeps them in lockstep; frontends import this module directly.
|
|
4
|
+
* One definition here keeps the frontends in lockstep — per-frontend copies
|
|
5
|
+
* drift (parseInterval once accepted days on Discord but not Telegram).
|
|
8
6
|
*/
|
|
9
7
|
|
|
10
8
|
import { resolveModel } from "../../core/models/catalog.js";
|
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared /reset and /status logic for chat frontends.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* drifted (Discord's /status no longer re-fetched the context window for the
|
|
7
|
-
* model that actually served the session). All the frontend-agnostic work
|
|
8
|
-
* lives here now; frontends only render `SessionStatusData` in their native
|
|
9
|
-
* markup.
|
|
4
|
+
* All the frontend-agnostic work lives here so per-frontend copies can't
|
|
5
|
+
* drift; frontends only render `SessionStatusData` in their native markup.
|
|
10
6
|
*/
|
|
11
7
|
|
|
12
8
|
import type { TalonConfig } from "../../core/config/index.js";
|
|
@@ -7,6 +7,9 @@ import type { Gateway } from "../../core/engine/gateway.js";
|
|
|
7
7
|
import { buildAdaptiveCard, splitTeamsMessage } from "./formatting.js";
|
|
8
8
|
import { log, logError } from "../../util/log.js";
|
|
9
9
|
import { proxyFetch } from "./proxy-fetch.js";
|
|
10
|
+
import { createDeliveryTracker, trackDeliveries } from "../health/delivery.js";
|
|
11
|
+
|
|
12
|
+
const delivery = createDeliveryTracker("teams", "Teams", "teams");
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* POST an Adaptive Card to the Power Automate workflow webhook URL.
|
|
@@ -32,7 +35,10 @@ export function createTeamsActionHandler(
|
|
|
32
35
|
webhookUrl: string,
|
|
33
36
|
gateway: Gateway,
|
|
34
37
|
): FrontendActionHandler {
|
|
35
|
-
|
|
38
|
+
const dispatch: FrontendActionHandler = async (
|
|
39
|
+
body,
|
|
40
|
+
chatId,
|
|
41
|
+
): Promise<ActionResult | null> => {
|
|
36
42
|
const action = body.action as string;
|
|
37
43
|
|
|
38
44
|
switch (action) {
|
|
@@ -100,6 +106,7 @@ export function createTeamsActionHandler(
|
|
|
100
106
|
return null;
|
|
101
107
|
}
|
|
102
108
|
};
|
|
109
|
+
return trackDeliveries(delivery, dispatch);
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
export { postToTeams };
|
|
@@ -302,7 +302,6 @@ export class GraphClient {
|
|
|
302
302
|
* Initialize the Graph client — loads stored tokens or runs device code flow.
|
|
303
303
|
*/
|
|
304
304
|
export async function initGraphClient(): Promise<GraphClient> {
|
|
305
|
-
// Clear old tokens that used channel scopes
|
|
306
305
|
const stored = loadTokens();
|
|
307
306
|
|
|
308
307
|
if (stored && stored.refreshToken) {
|
|
@@ -76,9 +76,7 @@ export function createTeamsFrontend(
|
|
|
76
76
|
const graphClient = await initGraphClient();
|
|
77
77
|
runtime.graphClient = graphClient;
|
|
78
78
|
|
|
79
|
-
// Get our own user ID (to filter out our own messages)
|
|
80
79
|
const me = await graphClient.getMe();
|
|
81
|
-
runtime.myUserId = me.id;
|
|
82
80
|
log("teams", `Authenticated as: ${me.displayName} (${me.id})`);
|
|
83
81
|
|
|
84
82
|
const chatId = await resolveChatId(runtime, graphClient, me.id);
|
|
@@ -99,8 +97,7 @@ export function createTeamsFrontend(
|
|
|
99
97
|
|
|
100
98
|
// The receive side is a timer on the runtime, not a loop to sit in:
|
|
101
99
|
// once the first poll is done the frontend is listening, and
|
|
102
|
-
// start() is finished.
|
|
103
|
-
// resolved, which made the boot end at shutdown.)
|
|
100
|
+
// start() is finished.
|
|
104
101
|
await startPolling(runtime, chatId);
|
|
105
102
|
},
|
|
106
103
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { log, logError } from "../../util/log.js";
|
|
9
|
+
import { errorText } from "../health/outage.js";
|
|
9
10
|
import { handleSlashCommand } from "./commands.js";
|
|
10
11
|
import type { ChatMessage } from "./graph.js";
|
|
11
12
|
import type { TeamsRuntime } from "./runtime.js";
|
|
@@ -51,13 +52,47 @@ async function handleMessage(
|
|
|
51
52
|
runTurn(runtime, msg, talonChatId);
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Fetch the newest messages, feeding the poll outage: failures are logged
|
|
57
|
+
* with their attempt number and time down, and the first success after
|
|
58
|
+
* them logs the recovery. Returns null when the fetch failed.
|
|
59
|
+
*/
|
|
60
|
+
async function fetchMessages(
|
|
61
|
+
runtime: TeamsRuntime,
|
|
62
|
+
graph: NonNullable<TeamsRuntime["graphClient"]>,
|
|
63
|
+
chatId: string,
|
|
64
|
+
): Promise<ChatMessage[] | null> {
|
|
65
|
+
let messages: ChatMessage[];
|
|
66
|
+
try {
|
|
67
|
+
messages = await graph.getChatMessages(chatId, 20);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
const { attempt, downMs } = runtime.pollOutage.fail(err);
|
|
70
|
+
logError(
|
|
71
|
+
"teams",
|
|
72
|
+
`Poll error: poll.fail chat=${chatId} attempt=${attempt} down_ms=${downMs} ` +
|
|
73
|
+
`next_poll_ms=${runtime.pollIntervalMs} err=${errorText(err)}`,
|
|
74
|
+
);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const ended = runtime.pollOutage.ok();
|
|
78
|
+
if (ended) {
|
|
79
|
+
log(
|
|
80
|
+
"teams",
|
|
81
|
+
`poll.recovered chat=${chatId} failed_attempts=${ended.attempts} down_ms=${ended.downMs}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return messages;
|
|
85
|
+
}
|
|
86
|
+
|
|
54
87
|
async function poll(runtime: TeamsRuntime, chatId: string): Promise<void> {
|
|
55
88
|
if (runtime.polling) return;
|
|
56
89
|
runtime.polling = true;
|
|
90
|
+
let current: ChatMessage | null = null;
|
|
57
91
|
|
|
58
92
|
try {
|
|
59
93
|
if (!runtime.graphClient) return;
|
|
60
|
-
const messages = await runtime.graphClient
|
|
94
|
+
const messages = await fetchMessages(runtime, runtime.graphClient, chatId);
|
|
95
|
+
if (!messages) return;
|
|
61
96
|
const newMessages = selectNewMessages(messages, runtime.lastSeenMessageId);
|
|
62
97
|
|
|
63
98
|
if (newMessages.length > 0) {
|
|
@@ -69,12 +104,14 @@ async function poll(runtime: TeamsRuntime, chatId: string): Promise<void> {
|
|
|
69
104
|
if (!msg.text.trim()) continue;
|
|
70
105
|
if (msg.edited) continue;
|
|
71
106
|
if (isBotEcho(runtime, msg)) continue;
|
|
107
|
+
current = msg;
|
|
72
108
|
await handleMessage(runtime, msg);
|
|
73
109
|
}
|
|
74
110
|
} catch (err) {
|
|
75
111
|
logError(
|
|
76
112
|
"teams",
|
|
77
|
-
`Poll error:
|
|
113
|
+
`Poll error: message=${current?.id ?? "?"} chat=${current?.chatId ?? chatId} ` +
|
|
114
|
+
`${err instanceof Error ? err.message : err}`,
|
|
78
115
|
);
|
|
79
116
|
} finally {
|
|
80
117
|
runtime.polling = false;
|
|
@@ -94,6 +131,7 @@ export async function startPolling(
|
|
|
94
131
|
}
|
|
95
132
|
|
|
96
133
|
export function stopPolling(runtime: TeamsRuntime): void {
|
|
134
|
+
runtime.pollOutage.dispose();
|
|
97
135
|
if (runtime.pollTimer) {
|
|
98
136
|
clearInterval(runtime.pollTimer);
|
|
99
137
|
runtime.pollTimer = null;
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Teams frontend runtime — the state every module shares.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* the poll loop and the slash commands nested inside `.start`. It is now one
|
|
6
|
-
* explicit object, constructed once, that each module (chat-discovery,
|
|
4
|
+
* One explicit object, constructed once, that each module (chat-discovery,
|
|
7
5
|
* poll, commands, turn, outbound) takes as its first parameter.
|
|
8
6
|
*/
|
|
9
7
|
|
|
10
8
|
import type { TalonConfig } from "../../core/config/index.js";
|
|
11
9
|
import type { Gateway } from "../../core/engine/gateway.js";
|
|
12
10
|
import type { GraphClient } from "./graph.js";
|
|
11
|
+
import { createOutage, type Outage } from "../health/outage.js";
|
|
12
|
+
|
|
13
|
+
/** Graph polling must fail this long before `teams.poll` is raised. */
|
|
14
|
+
const POLL_OUTAGE_MS = 10 * 60_000;
|
|
13
15
|
|
|
14
16
|
export type TeamsRuntime = {
|
|
15
17
|
readonly config: TalonConfig;
|
|
@@ -25,9 +27,10 @@ export type TeamsRuntime = {
|
|
|
25
27
|
pollTimer: ReturnType<typeof setInterval> | null;
|
|
26
28
|
/** Newest message id already handled — the poll loop cuts at it. */
|
|
27
29
|
lastSeenMessageId: string | null;
|
|
28
|
-
myUserId: string | null;
|
|
29
30
|
/** Re-entrancy guard: a slow poll never overlaps the next tick. */
|
|
30
31
|
polling: boolean;
|
|
32
|
+
/** Failed Graph fetches, and the `teams.poll` alert they raise. */
|
|
33
|
+
readonly pollOutage: Outage;
|
|
31
34
|
};
|
|
32
35
|
|
|
33
36
|
export function createTeamsRuntime(
|
|
@@ -45,7 +48,13 @@ export function createTeamsRuntime(
|
|
|
45
48
|
graphClient: null,
|
|
46
49
|
pollTimer: null,
|
|
47
50
|
lastSeenMessageId: null,
|
|
48
|
-
myUserId: null,
|
|
49
51
|
polling: false,
|
|
52
|
+
pollOutage: createOutage({
|
|
53
|
+
key: "teams.poll",
|
|
54
|
+
thresholdMs: POLL_OUTAGE_MS,
|
|
55
|
+
describe: (err, mins) =>
|
|
56
|
+
`Teams polling has failed for ${mins} min: ${err}. Messages are not being received.`,
|
|
57
|
+
recovered: "Teams polling is working again.",
|
|
58
|
+
}),
|
|
50
59
|
};
|
|
51
60
|
}
|