talon-agent 5.25.1 → 5.26.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 +1 -0
- package/package.json +1 -1
- package/src/backend/agy/one-shot.ts +34 -3
- 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/mesh/credentials/store.ts +60 -9
- 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/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
|
@@ -48,6 +48,15 @@ export interface CodexState {
|
|
|
48
48
|
* when synthesising entries for ids absent from the curated table.
|
|
49
49
|
*/
|
|
50
50
|
discoveredModelMetadata: Map<string, DiscoveredModelMetadata>;
|
|
51
|
+
/**
|
|
52
|
+
* The Codex CLI's own default model for the signed-in ChatGPT account:
|
|
53
|
+
* the listed, API-callable entry of `~/.codex/models_cache.json` with
|
|
54
|
+
* the lowest `priority` (the CLI sorts its picker the same way and
|
|
55
|
+
* treats the first entry as the default). `null` until the cache has
|
|
56
|
+
* been read, or when it lists nothing usable. Consumed by
|
|
57
|
+
* `getCodexChatGptDefaultModel()`.
|
|
58
|
+
*/
|
|
59
|
+
discoveredDefaultModel: string | null;
|
|
51
60
|
/**
|
|
52
61
|
* In-flight discovery promise. Non-null while a fetch is pending;
|
|
53
62
|
* cleared once the fetch settles (success or failure). Callers that
|
|
@@ -70,6 +79,7 @@ const state: CodexState = {
|
|
|
70
79
|
frontendName: "telegram",
|
|
71
80
|
discoveredModels: new Set<string>(),
|
|
72
81
|
discoveredModelMetadata: new Map<string, DiscoveredModelMetadata>(),
|
|
82
|
+
discoveredDefaultModel: null,
|
|
73
83
|
discoveryPromise: null,
|
|
74
84
|
discoveryAt: null,
|
|
75
85
|
};
|
|
@@ -87,6 +97,7 @@ export function resetState(): void {
|
|
|
87
97
|
state.frontendName = "telegram";
|
|
88
98
|
state.discoveredModels.clear();
|
|
89
99
|
state.discoveredModelMetadata.clear();
|
|
100
|
+
state.discoveredDefaultModel = null;
|
|
90
101
|
state.discoveryPromise = null;
|
|
91
102
|
state.discoveryAt = null;
|
|
92
103
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a run's AbortSignal fired — so logs and results can say "killed" for
|
|
3
|
+
* a deliberate kill_agent instead of blaming a timeout that never happened.
|
|
4
|
+
*
|
|
5
|
+
* Abort sites pass a typed reason (`controller.abort(reason)`); consumers
|
|
6
|
+
* read it back with `abortKind(signal)`. A bare `abort()` carries the
|
|
7
|
+
* runtime's default AbortError reason and reads as a plain "aborted".
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Reason passed when an operator/agent deliberately kills a run. */
|
|
11
|
+
export class RunKilledError extends Error {
|
|
12
|
+
constructor(message = "run killed on request") {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "RunKilledError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type AbortKind = "killed" | "timeout" | "aborted";
|
|
19
|
+
|
|
20
|
+
/** Classify an abort reason (typically `signal.reason`). */
|
|
21
|
+
export function abortKindOf(reason: unknown): AbortKind {
|
|
22
|
+
if (reason instanceof RunKilledError) return "killed";
|
|
23
|
+
const r = reason as { name?: unknown; message?: unknown } | null;
|
|
24
|
+
const name = typeof r?.name === "string" ? r.name : "";
|
|
25
|
+
const message = typeof r?.message === "string" ? r.message : "";
|
|
26
|
+
if (name === "RunKilledError") return "killed";
|
|
27
|
+
if (/timeout/i.test(name) || /timed out/i.test(message)) return "timeout";
|
|
28
|
+
return "aborted";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Classify why `signal` fired ("aborted" when it has not, or gave no reason). */
|
|
32
|
+
export function abortKind(signal: AbortSignal): AbortKind {
|
|
33
|
+
return signal.aborted ? abortKindOf(signal.reason) : "aborted";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One-line run-log sentence for an aborted run. */
|
|
37
|
+
export function abortLogLine(signal: AbortSignal): string {
|
|
38
|
+
switch (abortKind(signal)) {
|
|
39
|
+
case "killed":
|
|
40
|
+
return "Run killed on request.";
|
|
41
|
+
case "timeout":
|
|
42
|
+
return "Run aborted by timeout.";
|
|
43
|
+
default:
|
|
44
|
+
return "Run aborted.";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -26,6 +26,7 @@ import type { AgentSettledEvent, AgentSpawnedEvent } from "../bus/events.js";
|
|
|
26
26
|
import type { ReasoningEffortLevel } from "../types.js";
|
|
27
27
|
import { bus } from "../bus/index.js";
|
|
28
28
|
import { logWarn } from "../../util/log.js";
|
|
29
|
+
import { RunKilledError } from "./abort-reason.js";
|
|
29
30
|
|
|
30
31
|
/** Settled agents kept for status queries after they leave the live map. */
|
|
31
32
|
const DEFAULT_HISTORY_LIMIT = 100;
|
|
@@ -224,7 +225,7 @@ export class AgentRegistry {
|
|
|
224
225
|
// right after start() and settles the run as "killed".
|
|
225
226
|
if (entry.killRequested) {
|
|
226
227
|
try {
|
|
227
|
-
binding.abort.abort();
|
|
228
|
+
binding.abort.abort(new RunKilledError(`agent ${id} killed`));
|
|
228
229
|
} catch (err) {
|
|
229
230
|
logWarn(
|
|
230
231
|
"agents",
|
|
@@ -312,7 +313,7 @@ export class AgentRegistry {
|
|
|
312
313
|
if (!entry.killRequested) {
|
|
313
314
|
entry.killRequested = true;
|
|
314
315
|
try {
|
|
315
|
-
entry.abort?.abort();
|
|
316
|
+
entry.abort?.abort(new RunKilledError(`agent ${id} killed`));
|
|
316
317
|
} catch (err) {
|
|
317
318
|
// An abort hook must not be able to break the kill path — but a
|
|
318
319
|
// throwing one may leave the agent running, so say so.
|
|
@@ -320,11 +320,11 @@ If commands fail, log the error and continue — this stage is optional.`
|
|
|
320
320
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
321
321
|
const t = setTimeout(() => {
|
|
322
322
|
try {
|
|
323
|
-
abortController.abort();
|
|
323
|
+
abortController.abort(new Error("Dream agent timed out"));
|
|
324
324
|
} catch {
|
|
325
325
|
/* ignore */
|
|
326
326
|
}
|
|
327
|
-
reject(
|
|
327
|
+
reject(abortController.signal.reason as Error);
|
|
328
328
|
}, DREAM_TIMEOUT_MS);
|
|
329
329
|
t.unref(); // Don't prevent Node.js from exiting cleanly during shutdown
|
|
330
330
|
timeoutHandle = t;
|
package/src/core/backup/index.ts
CHANGED
|
@@ -16,7 +16,8 @@ import {
|
|
|
16
16
|
type BackupTarget,
|
|
17
17
|
} from "./targets.js";
|
|
18
18
|
import { backupSettings, schedulerStatus } from "./scheduler.js";
|
|
19
|
-
import
|
|
19
|
+
import { PASSPHRASE_ENV } from "./passphrase.js";
|
|
20
|
+
import type { BackupSettings, SnapshotSummary } from "./types.js";
|
|
20
21
|
|
|
21
22
|
type TargetStatus = {
|
|
22
23
|
id: string;
|
|
@@ -38,8 +39,35 @@ export type BackupStatus = {
|
|
|
38
39
|
};
|
|
39
40
|
targets: TargetStatus[];
|
|
40
41
|
snapshots: SnapshotSummary[];
|
|
42
|
+
/** Retention and encryption, when the subsystem is initialised. */
|
|
43
|
+
policy?: BackupPolicy;
|
|
41
44
|
};
|
|
42
45
|
|
|
46
|
+
/** The settings a status panel renders alongside the numbers. */
|
|
47
|
+
type BackupPolicy = {
|
|
48
|
+
keepLocal: number;
|
|
49
|
+
keepRemote: number;
|
|
50
|
+
/**
|
|
51
|
+
* Snapshots are written encrypted: `backup.encryption` is configured or
|
|
52
|
+
* the passphrase comes from the environment (see passphrase.ts). Off
|
|
53
|
+
* means they stay on this machine — upload refuses plaintext.
|
|
54
|
+
*/
|
|
55
|
+
encrypted: boolean;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function describePolicy(
|
|
59
|
+
settings: BackupSettings | null,
|
|
60
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
61
|
+
): BackupPolicy | undefined {
|
|
62
|
+
if (!settings) return undefined;
|
|
63
|
+
return {
|
|
64
|
+
keepLocal: settings.keepLocal,
|
|
65
|
+
keepRemote: settings.keepRemote,
|
|
66
|
+
encrypted:
|
|
67
|
+
settings.encryption !== undefined || Boolean(env[PASSPHRASE_ENV]?.trim()),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
43
71
|
export function formatBytes(bytes: number): string {
|
|
44
72
|
if (bytes < 1024) return `${bytes} B`;
|
|
45
73
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -119,6 +147,7 @@ export async function collectBackupStatus(
|
|
|
119
147
|
},
|
|
120
148
|
targets,
|
|
121
149
|
snapshots,
|
|
150
|
+
policy: describePolicy(settings),
|
|
122
151
|
};
|
|
123
152
|
}
|
|
124
153
|
|
package/src/core/config/index.ts
CHANGED
|
@@ -779,6 +779,14 @@ const configSchema = z.object({
|
|
|
779
779
|
* Codex.
|
|
780
780
|
*/
|
|
781
781
|
codexApiKey: z.string().optional(),
|
|
782
|
+
/**
|
|
783
|
+
* Model a ChatGPT-OAuth Codex session falls back to (pre-emptive swaps,
|
|
784
|
+
* mismatch retries, the default when no model is configured). Unset =
|
|
785
|
+
* the Codex CLI's own default for the signed-in account (first listed
|
|
786
|
+
* model in `~/.codex/models_cache.json`), else a bundled default. The
|
|
787
|
+
* TALON_CODEX_CHATGPT_MODEL env var overrides this.
|
|
788
|
+
*/
|
|
789
|
+
codexChatGptDefaultModel: z.string().min(1).optional(),
|
|
782
790
|
/**
|
|
783
791
|
* OpenAI API key — used by the OpenAI Agents backend and accepted by
|
|
784
792
|
* Codex only as a last-resort legacy fallback when no Codex-specific
|
|
@@ -275,6 +275,11 @@ export class Gateway {
|
|
|
275
275
|
return this.port;
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
/** True while the tool bridge's HTTP server is bound and accepting. */
|
|
279
|
+
isListening(): boolean {
|
|
280
|
+
return this.server?.listening === true;
|
|
281
|
+
}
|
|
282
|
+
|
|
278
283
|
getActiveChats(): number {
|
|
279
284
|
return this.loom.activeContextCount();
|
|
280
285
|
}
|
|
@@ -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);
|
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
|
+
}
|