talon-agent 5.25.1 → 5.26.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 +1 -0
- package/package.json +1 -1
- package/src/backend/agy/one-shot.ts +34 -3
- package/src/backend/claude-sdk/model-provider.ts +59 -12
- package/src/backend/codex/auth.ts +31 -2
- package/src/backend/codex/constants.ts +21 -7
- package/src/backend/codex/discovery.ts +32 -1
- package/src/backend/codex/factory.ts +3 -5
- package/src/backend/codex/handler/message.ts +7 -7
- package/src/backend/codex/models.ts +53 -11
- package/src/backend/codex/one-shot.ts +139 -37
- package/src/backend/codex/state.ts +11 -0
- package/src/core/agents/abort-reason.ts +46 -0
- package/src/core/agents/registry.ts +3 -2
- package/src/core/background/dream/index.ts +2 -2
- package/src/core/background/heartbeat/agent.ts +1 -1
- package/src/core/background/isolated-agent.ts +1 -1
- package/src/core/backup/index.ts +1 -0
- package/src/core/backup/status.ts +30 -1
- package/src/core/config/index.ts +8 -0
- package/src/core/engine/gateway.ts +5 -0
- package/src/core/frontend-runtime/admin-notify.ts +46 -6
- package/src/core/frontend-runtime/alerts.ts +10 -6
- package/src/core/mesh/credentials/store.ts +60 -9
- package/src/core/models/active-model.ts +57 -48
- package/src/core/plugin/loader.ts +47 -12
- package/src/core/tools/bridge.ts +58 -4
- package/src/frontend/discord/callbacks/components/effort.ts +4 -4
- package/src/frontend/discord/callbacks/components/index.ts +2 -0
- package/src/frontend/discord/commands/backup-panel.ts +219 -0
- package/src/frontend/discord/commands/backup.ts +25 -34
- package/src/frontend/discord/commands/info.ts +17 -5
- package/src/frontend/discord/commands/settings.ts +12 -9
- package/src/frontend/discord/render.ts +1 -1
- package/src/frontend/native/bridge/credentials/claims.ts +5 -5
- package/src/frontend/native/bridge/routes/chats.ts +40 -20
- package/src/frontend/native/bridge/routes/host.ts +15 -2
- package/src/frontend/native/bridge/routes/table.ts +4 -0
- package/src/frontend/native/commands/admin.ts +64 -0
- package/src/frontend/native/commands/backup.ts +191 -0
- package/src/frontend/native/commands/definitions.ts +113 -0
- package/src/frontend/native/commands/format.ts +24 -0
- package/src/frontend/native/commands/index.ts +106 -0
- package/src/frontend/native/commands/info.ts +97 -0
- package/src/frontend/native/commands/session.ts +130 -0
- package/src/frontend/native/commands/types.ts +26 -0
- package/src/frontend/native/protocol.ts +18 -0
- package/src/frontend/native/surface/handlers.ts +14 -1
- package/src/frontend/native/surface/status.ts +9 -1
- package/src/frontend/native/turn/emit.ts +32 -1
- package/src/frontend/presentation/backup-panel.ts +425 -0
- package/src/frontend/presentation/memory-report.ts +109 -0
- package/src/frontend/presentation/text-commands.ts +243 -0
- package/src/frontend/telegram/actions/media.ts +60 -19
- package/src/frontend/telegram/actions/messaging.ts +9 -0
- package/src/frontend/telegram/admin/sessions.ts +20 -5
- package/src/frontend/telegram/admin.ts +9 -2
- package/src/frontend/telegram/callbacks/backup.ts +153 -22
- package/src/frontend/telegram/callbacks/effort.ts +5 -5
- package/src/frontend/telegram/callbacks/index.ts +2 -2
- package/src/frontend/telegram/callbacks/settings.ts +3 -40
- package/src/frontend/telegram/commands/admin.ts +8 -7
- package/src/frontend/telegram/commands/backup.ts +56 -46
- package/src/frontend/telegram/commands/index.ts +3 -2
- package/src/frontend/telegram/commands/info.ts +45 -26
- package/src/frontend/telegram/commands/memory.ts +8 -92
- package/src/frontend/telegram/commands/settings.ts +13 -45
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +19 -15
- package/src/frontend/telegram/render/backup-panel.ts +47 -0
- package/src/frontend/telegram/render/menu.ts +21 -30
- package/src/frontend/terminal/builtins/model.ts +20 -11
- package/src/frontend/whatsapp/commands.ts +16 -216
- package/src/frontend/whatsapp/messages/inbound.ts +33 -2
|
@@ -69,6 +69,12 @@ export type BindResult =
|
|
|
69
69
|
|
|
70
70
|
const PERSIST_ALERT = "mesh.credentials.persist";
|
|
71
71
|
|
|
72
|
+
/** Operator-minted, bind-on-first-use origins allowed to re-pair a device. */
|
|
73
|
+
const REPAIR_ORIGINS: ReadonlySet<CredentialOrigin> = new Set([
|
|
74
|
+
"pair",
|
|
75
|
+
"install",
|
|
76
|
+
]);
|
|
77
|
+
|
|
72
78
|
export class DeviceCredentialStore {
|
|
73
79
|
private records = new Map<string, DeviceCredentialRecord>();
|
|
74
80
|
private loading: Promise<void> | null = null;
|
|
@@ -146,9 +152,21 @@ export class DeviceCredentialStore {
|
|
|
146
152
|
}
|
|
147
153
|
|
|
148
154
|
/**
|
|
149
|
-
* Bind an unbound credential to the device id it just named.
|
|
150
|
-
*
|
|
151
|
-
*
|
|
155
|
+
* Bind an unbound credential to the device id it just named.
|
|
156
|
+
*
|
|
157
|
+
* When that id already holds live credentials, an operator-minted pairing
|
|
158
|
+
* or installer credential takes the device over (a re-pair): the old
|
|
159
|
+
* credentials are revoked and their live sessions dropped. This is the
|
|
160
|
+
* "the Mac's app was reset but kept its device id" case, which otherwise
|
|
161
|
+
* needs a `talon mesh revoke` before the new link can work.
|
|
162
|
+
*
|
|
163
|
+
* Trust: every unbound credential is operator-minted (`pair`/`install`,
|
|
164
|
+
* see MeshService → BridgeLinks) and binds exactly once — after this call
|
|
165
|
+
* it is bound, so a replayed link cannot bind again. Takeover is further
|
|
166
|
+
* limited to devices whose live credentials carry no scope the new one
|
|
167
|
+
* lacks: a device-only installer link can re-provision a node, but can
|
|
168
|
+
* never evict (and then impersonate) a companion holding client/operator.
|
|
169
|
+
* A credential of any other origin keeps the old rule — refused.
|
|
152
170
|
*/
|
|
153
171
|
bind(credentialId: string, deviceId: string): BindResult {
|
|
154
172
|
const record = this.records.get(credentialId);
|
|
@@ -164,19 +182,52 @@ export class DeviceCredentialStore {
|
|
|
164
182
|
if (!id || id.length > MAX_DEVICE_ID_CHARS) {
|
|
165
183
|
return { ok: false, error: "Invalid device id" };
|
|
166
184
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
};
|
|
185
|
+
const existing = this.activeRecordsFor(id);
|
|
186
|
+
if (existing.length > 0) {
|
|
187
|
+
const refusal = this.takeoverRefusal(record, id, existing);
|
|
188
|
+
if (refusal) return { ok: false, error: refusal };
|
|
172
189
|
}
|
|
173
190
|
record.deviceId = id;
|
|
174
191
|
delete record.expiresAt;
|
|
175
192
|
log("mesh", `Credential ${record.id} bound to device ${id}`);
|
|
176
|
-
|
|
193
|
+
if (existing.length > 0) {
|
|
194
|
+
logWarn(
|
|
195
|
+
"mesh",
|
|
196
|
+
`Credential ${record.id} (${record.origin}) re-paired device ${id}; revoking ${existing.map((r) => r.id).join(", ")}`,
|
|
197
|
+
);
|
|
198
|
+
// Mark revoked synchronously (authenticate must reject them from this
|
|
199
|
+
// instant); revokeRecords drops their sessions and persists.
|
|
200
|
+
void this.revokeRecords(existing, `re-paired by ${record.id}`).catch(
|
|
201
|
+
(err: unknown) =>
|
|
202
|
+
logWarn("mesh", `Could not persist re-pair revocation: ${err}`),
|
|
203
|
+
);
|
|
204
|
+
} else {
|
|
205
|
+
this.persistSoon();
|
|
206
|
+
}
|
|
177
207
|
return { ok: true, credential: publicView(record) };
|
|
178
208
|
}
|
|
179
209
|
|
|
210
|
+
/** Why `record` may not take over `deviceId` from `existing`, if it may not. */
|
|
211
|
+
private takeoverRefusal(
|
|
212
|
+
record: DeviceCredentialRecord,
|
|
213
|
+
deviceId: string,
|
|
214
|
+
existing: readonly DeviceCredentialRecord[],
|
|
215
|
+
): string | null {
|
|
216
|
+
const revokeHint = `revoke it first (talon mesh revoke ${deviceId})`;
|
|
217
|
+
if (!REPAIR_ORIGINS.has(record.origin)) {
|
|
218
|
+
return `Device ${deviceId} already has a credential — ${revokeHint}`;
|
|
219
|
+
}
|
|
220
|
+
const held = new Set(record.scopes);
|
|
221
|
+
const wider = existing.flatMap((r) => r.scopes).filter((s) => !held.has(s));
|
|
222
|
+
if (wider.length > 0) {
|
|
223
|
+
return (
|
|
224
|
+
`Device ${deviceId} already has a credential with scopes this link does not carry ` +
|
|
225
|
+
`(${[...new Set(wider)].join(", ")}) — ${revokeHint}`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
180
231
|
/** True when the operator asked for this credential to be replaced. */
|
|
181
232
|
rotationDue(credentialId: string): boolean {
|
|
182
233
|
const record = this.records.get(credentialId);
|
|
@@ -36,19 +36,20 @@
|
|
|
36
36
|
* 1. `modelByBackend[B]` — per-chat-per-backend pick, if it still
|
|
37
37
|
* validates against B's catalog. Cross-backend orphans surface
|
|
38
38
|
* as `kind: "missing"` and fall through.
|
|
39
|
-
* 2. `
|
|
39
|
+
* 2. `config.backendDefaults[B]` — operator-configured per-backend
|
|
40
|
+
* default in `talon.json`.
|
|
41
|
+
* 3. `config.model` — only when B is the global chat-role backend
|
|
42
|
+
* (`config.backend === B`).
|
|
43
|
+
* Operator picks (2/3) must validate against B's catalog when B
|
|
44
|
+
* has a canonical default to fall back to; an unknown pin falls
|
|
45
|
+
* through to step 4 (what the boot-time model audit warns about).
|
|
46
|
+
* 4. `backend.models?.getDefaultModelId()` — backend's canonical default.
|
|
40
47
|
* Codex picks auth-aware (`gpt-5-codex` on API key, `gpt-5.5`
|
|
41
48
|
* on ChatGPT OAuth). Claude SDK returns the `"default"` alias.
|
|
42
49
|
* Stock OpenAI Agents returns a constant.
|
|
43
50
|
* Catalog-driven backends without a canonical (Kilo, OpenCode,
|
|
44
51
|
* OpenAI Agents on OpenRouter / custom OpenAI-compatible) do
|
|
45
|
-
* NOT implement this
|
|
46
|
-
* 3. `config.backendDefaults[B]` — operator-configured per-backend
|
|
47
|
-
* default in `talon.json`. Escape hatch for "no canonical"
|
|
48
|
-
* backends.
|
|
49
|
-
* 4. `config.model` — only when B is the global chat-role backend
|
|
50
|
-
* (`config.backend === B`). Back-compat for installs that
|
|
51
|
-
* predate `backendDefaults`.
|
|
52
|
+
* NOT implement this.
|
|
52
53
|
* 5. `null` → UI renders "No model selected", send guard refuses
|
|
53
54
|
* with a "use /model to pick one" reply.
|
|
54
55
|
*
|
|
@@ -56,6 +57,10 @@
|
|
|
56
57
|
* is called. Only `kind: "exact"` with `selectable: true` honours the
|
|
57
58
|
* stored override. Anything else falls through to step 2.
|
|
58
59
|
*
|
|
60
|
+
* Operator config ranks above the canonical so a pinned `config.model`
|
|
61
|
+
* is actually honoured (it used to lose to Claude's `"default"`, so a
|
|
62
|
+
* claude install pinned to any model silently ran the default).
|
|
63
|
+
*
|
|
59
64
|
* Backends with no `resolveModel` (rare — defensive fallback only)
|
|
60
65
|
* have their stored override returned verbatim — no way to validate.
|
|
61
66
|
*/
|
|
@@ -157,8 +162,8 @@ async function runChain(
|
|
|
157
162
|
}
|
|
158
163
|
}
|
|
159
164
|
|
|
160
|
-
// ──
|
|
161
|
-
//
|
|
165
|
+
// ── Steps 2-5: backendDefaults → config.model (chat-role only) →
|
|
166
|
+
// backend canonical → null
|
|
162
167
|
return stepsTwoThroughFive(backend, backendId, config, null);
|
|
163
168
|
}
|
|
164
169
|
|
|
@@ -196,57 +201,61 @@ async function stepsTwoThroughFive(
|
|
|
196
201
|
config: TalonConfig,
|
|
197
202
|
fallbackSourceOverride: "override-invalid-fallback" | null,
|
|
198
203
|
): Promise<{ model: string | null; source: ActiveModelSource }> {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
204
|
+
const withSource = (model: string, source: ActiveModelSource) => ({
|
|
205
|
+
model,
|
|
206
|
+
source: fallbackSourceOverride ?? source,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const operatorPick = pickOperatorDefault(backendId, config);
|
|
210
|
+
const canonical = backend?.models ? await safeBackendDefault(backend) : null;
|
|
211
|
+
|
|
212
|
+
// Operator config (steps 2/3) beats the backend canonical (step 4): a
|
|
213
|
+
// pinned `config.model` is an explicit choice, and letting the canonical
|
|
214
|
+
// win meant a claude install pinned to e.g. "opus[1m]" silently ran
|
|
215
|
+
// "default" on every turn. The pin must still validate — a withdrawn id
|
|
216
|
+
// falls back to the canonical, exactly what the boot-time model audit
|
|
217
|
+
// warns about. Without a canonical to fall back to, the pin is returned
|
|
218
|
+
// unvalidated (catalog-driven backends with no default, unchanged).
|
|
219
|
+
if (operatorPick) {
|
|
220
|
+
if (!canonical) return withSource(operatorPick.model, operatorPick.source);
|
|
221
|
+
if (await validateModelOnBackend(backend, operatorPick.model)) {
|
|
222
|
+
return withSource(operatorPick.model, operatorPick.source);
|
|
207
223
|
}
|
|
208
224
|
}
|
|
209
225
|
|
|
210
|
-
// Step
|
|
226
|
+
// Step 4: backend.models.getDefaultModelId()
|
|
227
|
+
if (canonical) return withSource(canonical, "backend-canonical");
|
|
228
|
+
|
|
229
|
+
// Step 5: null. Callers must render "No model selected" / refuse send.
|
|
230
|
+
return { model: null, source: "none" };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The operator-configured default for a backend, if any:
|
|
235
|
+
* - `config.backendDefaults[B]`;
|
|
236
|
+
* - else `config.model` — only when B is the global chat-role backend
|
|
237
|
+
* (`config.backend === B`), or when no backend id is known at all
|
|
238
|
+
* (pre-bootstrap callers passing null).
|
|
239
|
+
*/
|
|
240
|
+
function pickOperatorDefault(
|
|
241
|
+
backendId: string | null,
|
|
242
|
+
config: TalonConfig,
|
|
243
|
+
): { model: string; source: ActiveModelSource } | null {
|
|
211
244
|
if (backendId && config.backendDefaults) {
|
|
212
245
|
const operatorDefault = config.backendDefaults[backendId];
|
|
213
246
|
if (operatorDefault && operatorDefault.length > 0) {
|
|
214
|
-
return {
|
|
215
|
-
model: operatorDefault,
|
|
216
|
-
source: fallbackSourceOverride ?? "config-backend-defaults",
|
|
217
|
-
};
|
|
247
|
+
return { model: operatorDefault, source: "config-backend-defaults" };
|
|
218
248
|
}
|
|
219
249
|
}
|
|
220
|
-
|
|
221
|
-
// Step 4: legacy config.model — only for the global chat-role backend
|
|
250
|
+
const modelAppliesHere = !backendId || backendId === config.backend;
|
|
222
251
|
if (
|
|
223
|
-
|
|
224
|
-
backendId === config.backend &&
|
|
252
|
+
modelAppliesHere &&
|
|
225
253
|
typeof config.model === "string" &&
|
|
226
254
|
config.model.length > 0
|
|
227
255
|
) {
|
|
228
|
-
return {
|
|
229
|
-
model: config.model,
|
|
230
|
-
source: fallbackSourceOverride ?? "config-legacy-global",
|
|
231
|
-
};
|
|
256
|
+
return { model: config.model, source: "config-legacy-global" };
|
|
232
257
|
}
|
|
233
|
-
|
|
234
|
-
// Step 4b: even without a backendId, honour config.model if no
|
|
235
|
-
// backend is bound at all (callers passing null for both — rare,
|
|
236
|
-
// typically pre-bootstrap code paths).
|
|
237
|
-
if (
|
|
238
|
-
!backendId &&
|
|
239
|
-
typeof config.model === "string" &&
|
|
240
|
-
config.model.length > 0
|
|
241
|
-
) {
|
|
242
|
-
return {
|
|
243
|
-
model: config.model,
|
|
244
|
-
source: fallbackSourceOverride ?? "config-legacy-global",
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Step 5: null. Callers must render "No model selected" / refuse send.
|
|
249
|
-
return { model: null, source: "none" };
|
|
258
|
+
return null;
|
|
250
259
|
}
|
|
251
260
|
|
|
252
261
|
async function validateModelOnBackend(
|
|
@@ -98,6 +98,15 @@ function registerPluginInstance(
|
|
|
98
98
|
return loaded;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Run a plugin's init, waiting at most `timeoutMs` for it before boot moves
|
|
103
|
+
* on. The deadline bounds how long boot waits, not the init itself: a
|
|
104
|
+
* plugin stays registered either way (its tools are served from
|
|
105
|
+
* `mcpServer` whether or not init finished), so an init that outlives the
|
|
106
|
+
* deadline keeps running and, when it does finish, clears the alert the
|
|
107
|
+
* timeout raised — a slow handshake on a busy boot is a delay, not a
|
|
108
|
+
* failure that lingers until the next restart.
|
|
109
|
+
*/
|
|
101
110
|
export async function initPluginWithTimeout(
|
|
102
111
|
plugin: TalonPlugin,
|
|
103
112
|
config: Record<string, unknown>,
|
|
@@ -108,28 +117,54 @@ export async function initPluginWithTimeout(
|
|
|
108
117
|
if (!plugin.init) return;
|
|
109
118
|
|
|
110
119
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
111
|
-
|
|
112
120
|
const alertKey = `plugin.${plugin.name}`;
|
|
121
|
+
const startedAt = Date.now();
|
|
122
|
+
// Started (and timed) only here, when this plugin's own init begins.
|
|
123
|
+
const init = Promise.resolve().then(() => plugin.init!(config));
|
|
124
|
+
const TIMED_OUT = Symbol("timed out");
|
|
125
|
+
|
|
113
126
|
try {
|
|
114
|
-
await Promise.race([
|
|
115
|
-
|
|
116
|
-
new Promise<
|
|
117
|
-
timer = setTimeout(() =>
|
|
118
|
-
reject(
|
|
119
|
-
new Error(`${timeoutLabel} timed out after ${timeoutMs / 1000}s`),
|
|
120
|
-
);
|
|
121
|
-
}, timeoutMs);
|
|
127
|
+
const outcome = await Promise.race([
|
|
128
|
+
init,
|
|
129
|
+
new Promise<typeof TIMED_OUT>((settle) => {
|
|
130
|
+
timer = setTimeout(() => settle(TIMED_OUT), timeoutMs);
|
|
122
131
|
timer.unref?.();
|
|
123
132
|
}),
|
|
124
133
|
]);
|
|
125
|
-
|
|
134
|
+
if (outcome !== TIMED_OUT) {
|
|
135
|
+
resolveAlert(alertKey, `Plugin "${plugin.name}" initialised normally.`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const message = `${timeoutLabel} timed out after ${timeoutMs / 1000}s`;
|
|
139
|
+
logError("plugin", `${errorPrefix}: ${message}; still waiting for it`);
|
|
140
|
+
raiseAlert(
|
|
141
|
+
alertKey,
|
|
142
|
+
`Plugin "${plugin.name}" failed to initialise: ${message}. Its tools stay registered; this clears itself if init finishes late.`,
|
|
143
|
+
);
|
|
144
|
+
void init.then(
|
|
145
|
+
() => {
|
|
146
|
+
// Reloaded or unloaded meanwhile: this instance's verdict is moot.
|
|
147
|
+
if (registry.getByName(plugin.name)?.plugin !== plugin) return;
|
|
148
|
+
const took = Math.round((Date.now() - startedAt) / 1000);
|
|
149
|
+
log("plugin", `${plugin.name} init finished late (${took}s)`);
|
|
150
|
+
resolveAlert(
|
|
151
|
+
alertKey,
|
|
152
|
+
`Plugin "${plugin.name}" finished initialising late (${took}s).`,
|
|
153
|
+
);
|
|
154
|
+
},
|
|
155
|
+
(err: unknown) =>
|
|
156
|
+
logError(
|
|
157
|
+
"plugin",
|
|
158
|
+
`${errorPrefix} (after timing out): ${err instanceof Error ? err.message : err}`,
|
|
159
|
+
),
|
|
160
|
+
);
|
|
126
161
|
} catch (err) {
|
|
127
162
|
logError(
|
|
128
163
|
"plugin",
|
|
129
164
|
`${errorPrefix}: ${err instanceof Error ? err.message : err}`,
|
|
130
165
|
);
|
|
131
|
-
//
|
|
132
|
-
//
|
|
166
|
+
// An init that threw won't retry on its own: it needs a reload or
|
|
167
|
+
// restart, so this needs no threshold.
|
|
133
168
|
raiseAlert(
|
|
134
169
|
alertKey,
|
|
135
170
|
`Plugin "${plugin.name}" failed to initialise: ${faultText(err)}. Its tools may not work until the next reload or restart.`,
|
package/src/core/tools/bridge.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import { Agent, fetch as undiciFetch } from "undici";
|
|
12
12
|
import { isBunRuntime } from "../../util/runtime.js";
|
|
13
13
|
import { gatewayAuthHeaders } from "../engine/gateway-auth.js";
|
|
14
|
+
import { logWarn } from "../../util/log.js";
|
|
15
|
+
import { TalonError } from "../errors.js";
|
|
14
16
|
import type { BridgeFunction } from "./types.js";
|
|
15
17
|
|
|
16
18
|
/** Default wall-clock budget for a bridge action. */
|
|
@@ -64,6 +66,26 @@ function dispatcher(): Agent {
|
|
|
64
66
|
/** Built-in fetch's default dispatcher fails headers slower than this. */
|
|
65
67
|
const BUILTIN_FETCH_HEADERS_CEILING_MS = 300_000;
|
|
66
68
|
|
|
69
|
+
/** Best-effort one-line description of an abort's reason / error cause. */
|
|
70
|
+
function describeAbort(err: Error, signal: AbortSignal): string {
|
|
71
|
+
const parts: string[] = [];
|
|
72
|
+
if (err.message) parts.push(err.message);
|
|
73
|
+
const cause = (err as { cause?: unknown }).cause;
|
|
74
|
+
if (cause instanceof Error && cause.message)
|
|
75
|
+
parts.push(`cause: ${cause.name}: ${cause.message}`);
|
|
76
|
+
else if (cause !== undefined && cause !== null)
|
|
77
|
+
parts.push(`cause: ${String(cause)}`);
|
|
78
|
+
if (signal.aborted && signal.reason !== undefined) {
|
|
79
|
+
const r = signal.reason as { name?: string; message?: string };
|
|
80
|
+
const reason =
|
|
81
|
+
r instanceof Error || (r && typeof r === "object" && "name" in r)
|
|
82
|
+
? `${r.name}: ${r.message ?? ""}`
|
|
83
|
+
: String(signal.reason);
|
|
84
|
+
if (!parts.some((p) => p.includes(reason))) parts.push(`reason: ${reason}`);
|
|
85
|
+
}
|
|
86
|
+
return parts.join("; ") || err.name || "unknown abort";
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
/**
|
|
68
90
|
* Create a bridge caller bound to a default URL and chat.
|
|
69
91
|
*
|
|
@@ -93,13 +115,15 @@ export function createBridge(
|
|
|
93
115
|
: null;
|
|
94
116
|
const effectiveChatId = explicitChatId ?? chatId;
|
|
95
117
|
const timeoutMs = LONG_ACTION_TIMEOUTS_MS[action] ?? DEFAULT_TIMEOUT_MS;
|
|
118
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
119
|
+
const startedAt = Date.now();
|
|
96
120
|
const init = {
|
|
97
121
|
method: "POST",
|
|
98
122
|
headers: { "Content-Type": "application/json", ...gatewayAuthHeaders() },
|
|
99
123
|
// chat_id stays in body when set — gateway uses its presence as the
|
|
100
124
|
// "explicit routing" signal. _chatId is the routing key either way.
|
|
101
125
|
body: JSON.stringify({ action, ...params, _chatId: effectiveChatId }),
|
|
102
|
-
signal
|
|
126
|
+
signal,
|
|
103
127
|
};
|
|
104
128
|
// Minimal common surface of DOM Response and undici's Response.
|
|
105
129
|
let resp: {
|
|
@@ -123,11 +147,41 @@ export function createBridge(
|
|
|
123
147
|
: await fetch(`${bridgeUrl}/action`, init);
|
|
124
148
|
} catch (err) {
|
|
125
149
|
const cause = err as Error & { name?: string };
|
|
150
|
+
const elapsedMs = Date.now() - startedAt;
|
|
126
151
|
if (cause.name === "TimeoutError" || cause.name === "AbortError") {
|
|
127
|
-
|
|
128
|
-
|
|
152
|
+
// Only call it a timeout when the budget actually ran out: the
|
|
153
|
+
// deadline signal fired (a TimeoutError, thrown or as the signal's
|
|
154
|
+
// reason) or the full budget elapsed. Anything earlier — a dropped socket, a daemon
|
|
155
|
+
// restart, a cancelled request — is an abort with its own cause,
|
|
156
|
+
// and mislabelling it as "did not complete within 3600s" after
|
|
157
|
+
// 780s sends the diagnosis in the wrong direction.
|
|
158
|
+
const reasonName = (signal.reason as { name?: string } | undefined)
|
|
159
|
+
?.name;
|
|
160
|
+
const timedOut =
|
|
161
|
+
cause.name === "TimeoutError" ||
|
|
162
|
+
(signal.aborted && reasonName === "TimeoutError") ||
|
|
163
|
+
elapsedMs >= timeoutMs;
|
|
164
|
+
const detail = describeAbort(cause, signal);
|
|
165
|
+
logWarn(
|
|
166
|
+
"bridge",
|
|
167
|
+
`tool bridge "${action}" ${timedOut ? "timed out" : "aborted"} after ${elapsedMs}ms (budget ${timeoutMs}ms): ${detail}`,
|
|
168
|
+
);
|
|
169
|
+
if (timedOut) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`"${action}" did not complete within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
172
|
+
`The operation may still be running on the daemon — check its ` +
|
|
173
|
+
`outcome (e.g. list the target directory) before retrying.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
// Classified as a non-retryable network fault: the transport
|
|
177
|
+
// dropped mid-call, but the daemon may already have acted, so a
|
|
178
|
+
// blind retry could run the action twice.
|
|
179
|
+
throw new TalonError(
|
|
180
|
+
`"${action}" was aborted after ${Math.round(elapsedMs / 1000)}s ` +
|
|
181
|
+
`(${elapsedMs}ms; before its ${Math.round(timeoutMs / 1000)}s budget): ${detail}. ` +
|
|
129
182
|
`The operation may still be running on the daemon — check its ` +
|
|
130
|
-
`outcome
|
|
183
|
+
`outcome before retrying.`,
|
|
184
|
+
{ reason: "network", retryable: false, cause },
|
|
131
185
|
);
|
|
132
186
|
}
|
|
133
187
|
throw new Error(
|
|
@@ -30,15 +30,15 @@ async function selectEffort(
|
|
|
30
30
|
backendId: beId,
|
|
31
31
|
config,
|
|
32
32
|
});
|
|
33
|
-
|
|
33
|
+
// Adaptive needs no model levels — reachable even when the model has none.
|
|
34
|
+
if (level === "adaptive") setChatEffort(chatId, undefined);
|
|
35
|
+
else if (reasoning.levels.length === 0) {
|
|
34
36
|
await interaction.update({
|
|
35
37
|
content: "No valid reasoning levels found for this model.",
|
|
36
38
|
components: [],
|
|
37
39
|
});
|
|
38
40
|
return;
|
|
39
|
-
}
|
|
40
|
-
if (level === "adaptive") setChatEffort(chatId, undefined);
|
|
41
|
-
else if (supportsReasoningLevel(level, reasoning.levels))
|
|
41
|
+
} else if (supportsReasoningLevel(level, reasoning.levels))
|
|
42
42
|
setChatEffort(chatId, level as EffortLevel);
|
|
43
43
|
else {
|
|
44
44
|
await interaction.update({
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* model:nav:* (pager)
|
|
19
19
|
* model:backend-select (select menu)
|
|
20
20
|
* metrics:today | metrics:all
|
|
21
|
+
* backup:panel|now|guide|list:<p>|pin:<p>:<id>|unpin:<p>:<id>|ask:<p>:<id>
|
|
22
|
+
* (the /backup panel, admin only)
|
|
21
23
|
* backup:restore:<id> | backup:cancel (restore confirmation, admin only)
|
|
22
24
|
* ai:<id> (AI-generated buttons — forwarded to agent)
|
|
23
25
|
*/
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /backup panel on Discord — the same views and custom-id grammar as
|
|
3
|
+
* Telegram (frontend/presentation/backup-panel.ts), spelled in Discord
|
|
4
|
+
* markdown and mapped onto button rows.
|
|
5
|
+
*
|
|
6
|
+
* Discord allows five action rows per message, so snapshot pages hold
|
|
7
|
+
* three snapshots (three rows, navigation, Back). The staged restore
|
|
8
|
+
* (`backup:restore:<id>`) and the confirmation's Cancel stay in
|
|
9
|
+
* backup.ts; this module owns every other `backup:*` button, and every
|
|
10
|
+
* one of them re-checks the admin before it runs (see
|
|
11
|
+
* `handleBackupComponent`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
ActionRowBuilder,
|
|
16
|
+
ButtonBuilder,
|
|
17
|
+
ButtonStyle,
|
|
18
|
+
MessageFlags,
|
|
19
|
+
type ChatInputCommandInteraction,
|
|
20
|
+
} from "discord.js";
|
|
21
|
+
import {
|
|
22
|
+
collectBackupStatus,
|
|
23
|
+
listSnapshots,
|
|
24
|
+
readManifest,
|
|
25
|
+
runBackup,
|
|
26
|
+
setSnapshotPinned,
|
|
27
|
+
} from "../../../core/backup/index.js";
|
|
28
|
+
import { logError } from "../../../util/log.js";
|
|
29
|
+
import {
|
|
30
|
+
GUIDE_BUTTONS,
|
|
31
|
+
PANEL_BUTTONS,
|
|
32
|
+
backupPanelView,
|
|
33
|
+
renderRestoreGuide,
|
|
34
|
+
restoreConfirmView,
|
|
35
|
+
snapshotPageView,
|
|
36
|
+
type BackupAction,
|
|
37
|
+
type PanelButton,
|
|
38
|
+
type PanelView,
|
|
39
|
+
} from "../../presentation/backup-panel.js";
|
|
40
|
+
import { DISCORD_REPORTS } from "../render.js";
|
|
41
|
+
import type { ComponentInteraction } from "../callbacks/components/types.js";
|
|
42
|
+
|
|
43
|
+
const DISCORD_PAGE_SIZE = 3;
|
|
44
|
+
|
|
45
|
+
function buttonRows(
|
|
46
|
+
rows: readonly PanelButton[][],
|
|
47
|
+
): ActionRowBuilder<ButtonBuilder>[] {
|
|
48
|
+
return rows.map((row) =>
|
|
49
|
+
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
50
|
+
row.map((button) =>
|
|
51
|
+
new ButtonBuilder()
|
|
52
|
+
.setCustomId(button.data)
|
|
53
|
+
.setLabel(button.label)
|
|
54
|
+
.setStyle(
|
|
55
|
+
button.data.startsWith("backup:restore:")
|
|
56
|
+
? ButtonStyle.Danger
|
|
57
|
+
: ButtonStyle.Secondary,
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function payload(view: PanelView) {
|
|
65
|
+
return {
|
|
66
|
+
content: view.text,
|
|
67
|
+
components: buttonRows(view.buttons).map((row) => row.toJSON()),
|
|
68
|
+
allowedMentions: { parse: [] as never[] },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run one snapshot; the outcome as one markdown line. Never throws. Shared
|
|
74
|
+
* by `/backup now|checkpoint` and the panel's 📸 button.
|
|
75
|
+
*/
|
|
76
|
+
export async function runSnapshotLine(label?: string): Promise<string> {
|
|
77
|
+
try {
|
|
78
|
+
const manifest = await runBackup({
|
|
79
|
+
kind: label ? "checkpoint" : "backup",
|
|
80
|
+
label,
|
|
81
|
+
pinned: Boolean(label),
|
|
82
|
+
trigger: "command",
|
|
83
|
+
});
|
|
84
|
+
return (
|
|
85
|
+
`✅ \`${manifest.id}\` — ${manifest.parts.length} part(s), ` +
|
|
86
|
+
`${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB` +
|
|
87
|
+
(label ? " (pinned)" : "")
|
|
88
|
+
);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
logError("backup", "/backup now failed", err);
|
|
91
|
+
return `⚠️ Backup failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function statusView(notice?: string): Promise<PanelView> {
|
|
96
|
+
return backupPanelView(
|
|
97
|
+
DISCORD_REPORTS,
|
|
98
|
+
await collectBackupStatus(),
|
|
99
|
+
Date.now(),
|
|
100
|
+
notice,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function pageView(page: number): Promise<PanelView> {
|
|
105
|
+
return snapshotPageView(
|
|
106
|
+
DISCORD_REPORTS,
|
|
107
|
+
await listSnapshots(),
|
|
108
|
+
page,
|
|
109
|
+
Date.now(),
|
|
110
|
+
DISCORD_PAGE_SIZE,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Bare `/backup`: the panel, ephemeral, with its buttons. */
|
|
115
|
+
export async function replyWithPanel(
|
|
116
|
+
i: ChatInputCommandInteraction,
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
await i.reply({
|
|
119
|
+
...payload(await statusView()),
|
|
120
|
+
flags: MessageFlags.Ephemeral,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** One panel snapshot at a time: a double tap must not take two. */
|
|
125
|
+
let panelSnapshotRunning = false;
|
|
126
|
+
|
|
127
|
+
/** Tests only. */
|
|
128
|
+
export function _resetDiscordBackupPanel(): void {
|
|
129
|
+
panelSnapshotRunning = false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function backUpNow(interaction: ComponentInteraction): Promise<void> {
|
|
133
|
+
if (panelSnapshotRunning) {
|
|
134
|
+
await interaction.reply({
|
|
135
|
+
content: "A snapshot is already running.",
|
|
136
|
+
flags: MessageFlags.Ephemeral,
|
|
137
|
+
});
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
panelSnapshotRunning = true;
|
|
141
|
+
try {
|
|
142
|
+
await interaction.update({
|
|
143
|
+
content:
|
|
144
|
+
"📸 **Taking a snapshot…**\nBuilding it, then pruning and uploading to any " +
|
|
145
|
+
"targets. This can take a few minutes — this message updates when it is done.",
|
|
146
|
+
components: [],
|
|
147
|
+
});
|
|
148
|
+
const line = await runSnapshotLine();
|
|
149
|
+
const view = await statusView(line).catch((err: unknown) => {
|
|
150
|
+
logError("backup", "Backup panel status read failed", err);
|
|
151
|
+
return { text: line, buttons: PANEL_BUTTONS };
|
|
152
|
+
});
|
|
153
|
+
await interaction.editReply(payload(view));
|
|
154
|
+
} finally {
|
|
155
|
+
panelSnapshotRunning = false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function viewFor(
|
|
160
|
+
action: Exclude<BackupAction, { kind: "now" | "restore" | "cancel" }>,
|
|
161
|
+
): Promise<PanelView | null> {
|
|
162
|
+
switch (action.kind) {
|
|
163
|
+
case "panel":
|
|
164
|
+
return statusView();
|
|
165
|
+
case "list":
|
|
166
|
+
return pageView(action.page);
|
|
167
|
+
case "guide":
|
|
168
|
+
return {
|
|
169
|
+
text: renderRestoreGuide(DISCORD_REPORTS),
|
|
170
|
+
buttons: GUIDE_BUTTONS,
|
|
171
|
+
};
|
|
172
|
+
case "pin":
|
|
173
|
+
case "unpin":
|
|
174
|
+
await setSnapshotPinned(action.id, action.kind === "pin");
|
|
175
|
+
return pageView(action.page);
|
|
176
|
+
case "ask": {
|
|
177
|
+
// The confirmation only — the restore is its own, second tap.
|
|
178
|
+
const manifest = await readManifest(action.id);
|
|
179
|
+
return manifest
|
|
180
|
+
? restoreConfirmView(DISCORD_REPORTS, manifest, action.page)
|
|
181
|
+
: null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Every panel button except the staged restore and Cancel. The caller has
|
|
188
|
+
* already checked the admin.
|
|
189
|
+
*/
|
|
190
|
+
export async function handleBackupPanelAction(
|
|
191
|
+
interaction: ComponentInteraction,
|
|
192
|
+
action: Exclude<BackupAction, { kind: "restore" | "cancel" }>,
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
try {
|
|
195
|
+
if (action.kind === "now") {
|
|
196
|
+
await backUpNow(interaction);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const view = await viewFor(action);
|
|
200
|
+
if (!view) {
|
|
201
|
+
await interaction.reply({
|
|
202
|
+
content: "That snapshot has no copy on this machine.",
|
|
203
|
+
flags: MessageFlags.Ephemeral,
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
await interaction.update(payload(view));
|
|
208
|
+
} catch (err) {
|
|
209
|
+
logError("backup", `Backup panel action ${action.kind} failed`, err);
|
|
210
|
+
if (!interaction.replied && !interaction.deferred) {
|
|
211
|
+
await interaction
|
|
212
|
+
.reply({
|
|
213
|
+
content: "⚠️ That did not work — see the log.",
|
|
214
|
+
flags: MessageFlags.Ephemeral,
|
|
215
|
+
})
|
|
216
|
+
.catch(() => {});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|