talon-agent 5.18.2 → 5.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +1 -1
- 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/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 +49 -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/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/native/exec.ts +13 -16
- 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/service.ts +26 -15
- 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
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reply delivery health — the worst silent failure is a turn that ran but
|
|
3
|
+
* whose answer never reached the chat. The model sees a failed tool call;
|
|
4
|
+
* the person who asked sees nothing, and so does the operator.
|
|
5
|
+
*
|
|
6
|
+
* Each frontend wraps its action handler with `trackDeliveries`. The
|
|
7
|
+
* reply actions (`send_message`, `send_message_with_buttons`, `reply_to`)
|
|
8
|
+
* are counted per chat: `DELIVERY_ALERT_AFTER` failures in a row for one
|
|
9
|
+
* chat raise `delivery.<frontend>` with that chat and the error, and the
|
|
10
|
+
* next success to it resolves the alert once no chat is still failing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ActionResult } from "../../core/types.js";
|
|
14
|
+
import {
|
|
15
|
+
raiseAlert,
|
|
16
|
+
resolveAlert,
|
|
17
|
+
} from "../../core/frontend-runtime/alerts.js";
|
|
18
|
+
import { log, logWarn, type LogComponent } from "../../util/log.js";
|
|
19
|
+
import { errorText } from "./outage.js";
|
|
20
|
+
|
|
21
|
+
/** Consecutive failed replies to one chat before the operator hears of it. */
|
|
22
|
+
const DELIVERY_ALERT_AFTER = 3;
|
|
23
|
+
|
|
24
|
+
const REPLY_ACTIONS = new Set([
|
|
25
|
+
"send_message",
|
|
26
|
+
"send_message_with_buttons",
|
|
27
|
+
"reply_to",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export type DeliveryTracker = {
|
|
31
|
+
failed(chat: string | number, err: unknown): void;
|
|
32
|
+
delivered(chat: string | number): void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `label` is the operator-facing platform name ("Telegram"); the alert key
|
|
37
|
+
* is `delivery.<frontend>`.
|
|
38
|
+
*/
|
|
39
|
+
export function createDeliveryTracker(
|
|
40
|
+
frontend: string,
|
|
41
|
+
label: string,
|
|
42
|
+
component: LogComponent,
|
|
43
|
+
): DeliveryTracker {
|
|
44
|
+
const key = `delivery.${frontend}`;
|
|
45
|
+
const streaks = new Map<string, number>();
|
|
46
|
+
/** Chats that crossed the threshold and have not delivered since. */
|
|
47
|
+
const alerted = new Set<string>();
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
failed(chat, err) {
|
|
51
|
+
const id = String(chat);
|
|
52
|
+
const streak = (streaks.get(id) ?? 0) + 1;
|
|
53
|
+
streaks.set(id, streak);
|
|
54
|
+
const error = errorText(err);
|
|
55
|
+
logWarn(
|
|
56
|
+
component,
|
|
57
|
+
`delivery.fail frontend=${frontend} chat=${id} streak=${streak} err=${error}`,
|
|
58
|
+
);
|
|
59
|
+
if (streak < DELIVERY_ALERT_AFTER) return;
|
|
60
|
+
alerted.add(id);
|
|
61
|
+
raiseAlert(
|
|
62
|
+
key,
|
|
63
|
+
`${label} replies to chat ${id} have failed ${streak} times in a row: ${error}. ` +
|
|
64
|
+
"Answers are not reaching that chat.",
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
delivered(chat) {
|
|
68
|
+
const id = String(chat);
|
|
69
|
+
const streak = streaks.get(id);
|
|
70
|
+
if (streak === undefined) return;
|
|
71
|
+
streaks.delete(id);
|
|
72
|
+
log(
|
|
73
|
+
component,
|
|
74
|
+
`delivery.recovered frontend=${frontend} chat=${id} after_failures=${streak}`,
|
|
75
|
+
);
|
|
76
|
+
if (alerted.delete(id) && alerted.size === 0) {
|
|
77
|
+
resolveAlert(key, `${label} replies are being delivered again.`);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type ActionHandler = (
|
|
84
|
+
body: Record<string, unknown>,
|
|
85
|
+
chatId: number,
|
|
86
|
+
) => Promise<ActionResult | null>;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Wrap a frontend action handler so its reply actions feed `tracker`.
|
|
90
|
+
* Results and thrown errors pass through untouched. `chatOf` names the
|
|
91
|
+
* destination when an action can target a chat other than `chatId`.
|
|
92
|
+
*/
|
|
93
|
+
export function trackDeliveries(
|
|
94
|
+
tracker: DeliveryTracker,
|
|
95
|
+
handler: ActionHandler,
|
|
96
|
+
chatOf: (body: Record<string, unknown>, chatId: number) => string | number = (
|
|
97
|
+
_body,
|
|
98
|
+
chatId,
|
|
99
|
+
) => chatId,
|
|
100
|
+
): ActionHandler {
|
|
101
|
+
return async (body, chatId) => {
|
|
102
|
+
if (!REPLY_ACTIONS.has(body.action as string)) return handler(body, chatId);
|
|
103
|
+
const chat = chatOf(body, chatId);
|
|
104
|
+
let result: ActionResult | null;
|
|
105
|
+
try {
|
|
106
|
+
result = await handler(body, chatId);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
tracker.failed(chat, err);
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
if (result?.ok) tracker.delivered(chat);
|
|
112
|
+
else if (result) tracker.failed(chat, result.error ?? "unknown error");
|
|
113
|
+
return result;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection outages — the line between "a reconnect failed" (log it) and
|
|
3
|
+
* "this chat channel is down" (tell the operator).
|
|
4
|
+
*
|
|
5
|
+
* A frontend reports every failed poll / reconnect with `fail` and every
|
|
6
|
+
* healthy one with `ok`. The first failure arms a timer; if nothing healthy
|
|
7
|
+
* arrives before `thresholdMs`, the alert is raised with the latest error.
|
|
8
|
+
* The timer — not the next failure — decides, because a link that dies
|
|
9
|
+
* silently (a gateway that never reconnects) produces no further events.
|
|
10
|
+
* `ok` ends the outage and, when an alert went out, sends the recovery.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
raiseAlert,
|
|
15
|
+
resolveAlert,
|
|
16
|
+
type AlertSeverity,
|
|
17
|
+
} from "../../core/frontend-runtime/alerts.js";
|
|
18
|
+
|
|
19
|
+
export type OutageOptions = {
|
|
20
|
+
/** Stable alert key, e.g. "telegram.polling". */
|
|
21
|
+
key: string;
|
|
22
|
+
/** How long failures must persist before the operator hears of it. */
|
|
23
|
+
thresholdMs: number;
|
|
24
|
+
severity?: AlertSeverity;
|
|
25
|
+
/** Operator text for the raise, given the latest error and minutes down. */
|
|
26
|
+
describe: (lastError: string, downMin: number) => string;
|
|
27
|
+
/** Operator text for the recovery notice. */
|
|
28
|
+
recovered: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type Outage = {
|
|
32
|
+
/** Record a failure. Returns the streak so the caller can log it. */
|
|
33
|
+
fail(err: unknown): { attempt: number; downMs: number };
|
|
34
|
+
/** Record a healthy round-trip. Returns the outage it ended, if any. */
|
|
35
|
+
ok(): { attempts: number; downMs: number } | null;
|
|
36
|
+
/** Raise immediately — for failures that will not heal on their own. */
|
|
37
|
+
raiseNow(message: string, severity?: AlertSeverity): void;
|
|
38
|
+
/** True between the first failure and the next `ok`. */
|
|
39
|
+
readonly down: boolean;
|
|
40
|
+
/** Forget the outage without resolving it — shutdown. */
|
|
41
|
+
dispose(): void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const MAX_ERROR_CHARS = 200;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One-line error text fit for an alert or a log line: the message only,
|
|
48
|
+
* bounded, with bot tokens and webhook signatures masked.
|
|
49
|
+
*/
|
|
50
|
+
export function errorText(err: unknown): string {
|
|
51
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
52
|
+
const text = raw
|
|
53
|
+
.replace(/bot\d+:[\w-]+/g, "bot<redacted>")
|
|
54
|
+
.replace(/([?&]sig=)[^&\s]+/g, "$1<redacted>")
|
|
55
|
+
.replace(/\s+/g, " ")
|
|
56
|
+
.trim();
|
|
57
|
+
return text.length > MAX_ERROR_CHARS
|
|
58
|
+
? `${text.slice(0, MAX_ERROR_CHARS)}…`
|
|
59
|
+
: text;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createOutage(opts: OutageOptions): Outage {
|
|
63
|
+
let since = 0;
|
|
64
|
+
let attempts = 0;
|
|
65
|
+
let lastError = "";
|
|
66
|
+
let raised = false;
|
|
67
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
68
|
+
|
|
69
|
+
const clearTimer = (): void => {
|
|
70
|
+
if (timer) clearTimeout(timer);
|
|
71
|
+
timer = null;
|
|
72
|
+
};
|
|
73
|
+
const raise = (message: string, severity?: AlertSeverity): void => {
|
|
74
|
+
raised = true;
|
|
75
|
+
raiseAlert(opts.key, message, { severity: severity ?? opts.severity });
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
get down() {
|
|
80
|
+
return attempts > 0;
|
|
81
|
+
},
|
|
82
|
+
fail(err) {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
if (attempts === 0) {
|
|
85
|
+
since = now;
|
|
86
|
+
timer = setTimeout(() => {
|
|
87
|
+
timer = null;
|
|
88
|
+
const mins = Math.max(1, Math.round((Date.now() - since) / 60_000));
|
|
89
|
+
raise(opts.describe(lastError, mins));
|
|
90
|
+
}, opts.thresholdMs);
|
|
91
|
+
timer.unref?.();
|
|
92
|
+
}
|
|
93
|
+
attempts++;
|
|
94
|
+
lastError = errorText(err);
|
|
95
|
+
return { attempt: attempts, downMs: now - since };
|
|
96
|
+
},
|
|
97
|
+
ok() {
|
|
98
|
+
if (attempts === 0 && !raised) return null;
|
|
99
|
+
const ended = { attempts, downMs: attempts ? Date.now() - since : 0 };
|
|
100
|
+
clearTimer();
|
|
101
|
+
attempts = 0;
|
|
102
|
+
if (raised) resolveAlert(opts.key, opts.recovered);
|
|
103
|
+
raised = false;
|
|
104
|
+
return ended;
|
|
105
|
+
},
|
|
106
|
+
raiseNow(message, severity) {
|
|
107
|
+
clearTimer();
|
|
108
|
+
raise(message, severity);
|
|
109
|
+
},
|
|
110
|
+
dispose() {
|
|
111
|
+
clearTimer();
|
|
112
|
+
attempts = 0;
|
|
113
|
+
raised = false;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -132,11 +132,9 @@ export function chatRoutes(
|
|
|
132
132
|
const startedAt = Date.now();
|
|
133
133
|
try {
|
|
134
134
|
const attachment = await h.upload(filename, contentType, req);
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
// read. Both outcomes are logged now, with the size and duration
|
|
139
|
-
// that distinguish a rejected upload from a dropped connection.
|
|
135
|
+
// Log both outcomes, with the size and duration that tell a rejected
|
|
136
|
+
// upload from a dropped connection — the JSON answer is otherwise
|
|
137
|
+
// only visible in the app.
|
|
140
138
|
log(
|
|
141
139
|
"native",
|
|
142
140
|
`upload ok: ${attachment.name} (${attachment.size} bytes, ${attachment.mimeType}) in ${Date.now() - startedAt}ms`,
|
|
@@ -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 {
|