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
|
@@ -24,13 +24,17 @@ import { emitAssistantText } from "../runtime/one-shot-hooks.js";
|
|
|
24
24
|
import { ensureCodex, getCodexAuthInfo } from "./init.js";
|
|
25
25
|
import {
|
|
26
26
|
CODEX_SYSTEM_PROMPT_SUFFIX,
|
|
27
|
-
CODEX_CHATGPT_DEFAULT_MODEL,
|
|
28
27
|
CODEX_THREAD_PERMISSIONS,
|
|
29
28
|
} from "./constants.js";
|
|
30
29
|
import { isChatGptModelMismatchError } from "./auth.js";
|
|
31
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
chatGptFallbackFor,
|
|
32
|
+
getCodexChatGptDefaultModel,
|
|
33
|
+
isCodexOAuthIncompat,
|
|
34
|
+
} from "./models.js";
|
|
32
35
|
import { markOAuthIncompat } from "./oauth-incompat.js";
|
|
33
36
|
import { toCodexReasoningEffort } from "./effort.js";
|
|
37
|
+
import { abortLogLine } from "../../core/agents/abort-reason.js";
|
|
34
38
|
|
|
35
39
|
/**
|
|
36
40
|
* Resolve the effective model for a one-shot run, applying the same
|
|
@@ -39,7 +43,8 @@ import { toCodexReasoningEffort } from "./effort.js";
|
|
|
39
43
|
* Heartbeats and dream calls pass `params.model` straight through from
|
|
40
44
|
* `config.heartbeatModel ?? config.model`. If that's an OAuth-incompat
|
|
41
45
|
* id (curated `apiKeyOnly: true` or runtime-learned) AND the active
|
|
42
|
-
* Codex credential is ChatGPT OAuth, swap to
|
|
46
|
+
* Codex credential is ChatGPT OAuth, swap to the resolved ChatGPT default
|
|
47
|
+
* (`getCodexChatGptDefaultModel`) to avoid the
|
|
43
48
|
* silent exit-1 failure mode that hit a group chat on 2026-05-20 23:13Z.
|
|
44
49
|
*
|
|
45
50
|
* Returns the resolved model id, whether a swap occurred, and an
|
|
@@ -56,7 +61,8 @@ function resolveOneShotModel(requested: string): {
|
|
|
56
61
|
return { model: requested, swapped: false };
|
|
57
62
|
}
|
|
58
63
|
|
|
59
|
-
const fallback =
|
|
64
|
+
const fallback =
|
|
65
|
+
chatGptFallbackFor(requested) ?? getCodexChatGptDefaultModel();
|
|
60
66
|
if (fallback === requested) return { model: requested, swapped: false };
|
|
61
67
|
|
|
62
68
|
return {
|
|
@@ -131,6 +137,14 @@ export async function runOneShotAgent(
|
|
|
131
137
|
...CODEX_THREAD_PERMISSIONS,
|
|
132
138
|
});
|
|
133
139
|
|
|
140
|
+
// The real reason a run failed lives in the stream, not in whatever the
|
|
141
|
+
// SDK throws afterwards: a model the account can't use yields
|
|
142
|
+
// `turn.failed` ("404 … The model `gpt-5.5` does not exist …") and the
|
|
143
|
+
// SDK then throws only "Codex Exec exited with code 1: Reading prompt
|
|
144
|
+
// from stdin...". Capture both so the failure the caller records is the
|
|
145
|
+
// one that explains it.
|
|
146
|
+
const failure = new StreamFailure();
|
|
147
|
+
|
|
134
148
|
try {
|
|
135
149
|
if (abortController.signal.aborted) {
|
|
136
150
|
throw new Error("Aborted before prompt was sent");
|
|
@@ -146,6 +160,7 @@ export async function runOneShotAgent(
|
|
|
146
160
|
for await (const event of events) {
|
|
147
161
|
if (abortController.signal.aborted) break;
|
|
148
162
|
await appendCodexEvent(appendLog, event, onAssistantText);
|
|
163
|
+
failure.observe(event);
|
|
149
164
|
if (event.type === "turn.completed") {
|
|
150
165
|
const u = (event as { usage?: Record<string, number> }).usage;
|
|
151
166
|
if (u) {
|
|
@@ -158,49 +173,136 @@ export async function runOneShotAgent(
|
|
|
158
173
|
}
|
|
159
174
|
}
|
|
160
175
|
}
|
|
176
|
+
// A stream that ends cleanly after `turn.failed` is still a failed run
|
|
177
|
+
// — returning here is how 26/26 Codex cron runs were stored as "ok".
|
|
178
|
+
if (!abortController.signal.aborted && failure.message) {
|
|
179
|
+
throw new CodexOneShotError(failure.message);
|
|
180
|
+
}
|
|
161
181
|
return usage;
|
|
162
182
|
} catch (err) {
|
|
183
|
+
const thrown = err instanceof Error ? err.message : String(err);
|
|
163
184
|
if (
|
|
164
|
-
|
|
165
|
-
/abort/i.test(
|
|
185
|
+
!(err instanceof CodexOneShotError) &&
|
|
186
|
+
(abortController.signal.aborted || /abort/i.test(thrown))
|
|
166
187
|
) {
|
|
167
188
|
const ts = new Date().toISOString().slice(11, 19);
|
|
168
|
-
await appendLog(
|
|
189
|
+
await appendLog(
|
|
190
|
+
`\n### [${ts}] Aborted\n${abortLogLine(abortController.signal)}\n`,
|
|
191
|
+
);
|
|
169
192
|
return;
|
|
170
193
|
}
|
|
171
|
-
const msg =
|
|
172
|
-
|
|
173
|
-
// Learn only from EXPLICIT mismatches in one-shot context.
|
|
174
|
-
// Silent-exit failures are ambiguous (transient outage vs real
|
|
175
|
-
// model-incompat) and persisting them would over-poison the
|
|
176
|
-
// learning store with the result that one bad heartbeat
|
|
177
|
-
// permanently downgrades the model. Explicit mismatches carry the
|
|
178
|
-
// unambiguous server message so they're safe to mark.
|
|
179
|
-
//
|
|
180
|
-
// Unlike the interactive handler, heartbeat/dream can't recurse for
|
|
181
|
-
// a retry (would mess with the timing contract and lock
|
|
182
|
-
// semantics), so silent-exit failures here simply surface to the
|
|
183
|
-
// run log; the next scheduled run takes a fresh swing.
|
|
184
|
-
const authInfo = getCodexAuthInfo();
|
|
185
|
-
if (
|
|
186
|
-
authInfo?.mode === "chatgpt" &&
|
|
187
|
-
activeModel !== CODEX_CHATGPT_DEFAULT_MODEL &&
|
|
188
|
-
isChatGptModelMismatchError(msg)
|
|
189
|
-
) {
|
|
190
|
-
const recorded = await markOAuthIncompat(activeModel);
|
|
191
|
-
if (recorded) {
|
|
192
|
-
logWarn(
|
|
193
|
-
"agent",
|
|
194
|
-
`[${contextLabel}] Codex one-shot: recorded ${activeModel} as ` +
|
|
195
|
-
`OAuth-incompat (explicit mismatch) — next ${contextLabel} run ` +
|
|
196
|
-
`will pre-emptively swap to ${CODEX_CHATGPT_DEFAULT_MODEL}`,
|
|
197
|
-
);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
194
|
+
const msg = failure.describe(thrown);
|
|
195
|
+
await learnFromMismatch(activeModel, msg, contextLabel);
|
|
201
196
|
logWarn("agent", `Codex one-shot run failed: ${msg}`);
|
|
202
197
|
const ts = new Date().toISOString().slice(11, 19);
|
|
203
198
|
await appendLog(`\n### [${ts}] Error\n${msg}\n`);
|
|
199
|
+
throw err instanceof CodexOneShotError
|
|
200
|
+
? err
|
|
201
|
+
: new CodexOneShotError(msg, { cause: err });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Record a model as OAuth-incompat when a one-shot failed on it with an
|
|
207
|
+
* explicit server mismatch, so the next run pre-emptively swaps.
|
|
208
|
+
*/
|
|
209
|
+
async function learnFromMismatch(
|
|
210
|
+
activeModel: string,
|
|
211
|
+
msg: string,
|
|
212
|
+
contextLabel: string,
|
|
213
|
+
): Promise<void> {
|
|
214
|
+
// Learn only from EXPLICIT mismatches in one-shot context.
|
|
215
|
+
// Silent-exit failures are ambiguous (transient outage vs real
|
|
216
|
+
// model-incompat) and persisting them would over-poison the
|
|
217
|
+
// learning store with the result that one bad heartbeat
|
|
218
|
+
// permanently downgrades the model. Explicit mismatches (the 400
|
|
219
|
+
// "not supported … ChatGPT account" and the 404 "model … does not
|
|
220
|
+
// exist") carry the unambiguous server message so they're safe to
|
|
221
|
+
// mark.
|
|
222
|
+
//
|
|
223
|
+
// Unlike the interactive handler, heartbeat/dream can't recurse for
|
|
224
|
+
// a retry (would mess with the timing contract and lock
|
|
225
|
+
// semantics), so the failure is surfaced to the caller — the task
|
|
226
|
+
// settles as failed — and the next scheduled run takes a fresh
|
|
227
|
+
// swing on the learned fallback.
|
|
228
|
+
const authInfo = getCodexAuthInfo();
|
|
229
|
+
const fallback = getCodexChatGptDefaultModel();
|
|
230
|
+
if (
|
|
231
|
+
authInfo?.mode !== "chatgpt" ||
|
|
232
|
+
activeModel === fallback ||
|
|
233
|
+
!isChatGptModelMismatchError(msg)
|
|
234
|
+
) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const recorded = await markOAuthIncompat(activeModel);
|
|
238
|
+
if (recorded) {
|
|
239
|
+
logWarn(
|
|
240
|
+
"agent",
|
|
241
|
+
`[${contextLabel}] Codex one-shot: recorded ${activeModel} as ` +
|
|
242
|
+
`OAuth-incompat (explicit mismatch) — next ${contextLabel} run ` +
|
|
243
|
+
`will pre-emptively swap to ${fallback}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A Codex one-shot that failed upstream. The message is the most specific
|
|
250
|
+
* reason the stream carried (the `turn.failed` text when there was one),
|
|
251
|
+
* so task tables, cron run records and the backend router see the cause
|
|
252
|
+
* rather than the SDK's generic exit wrapper.
|
|
253
|
+
*/
|
|
254
|
+
class CodexOneShotError extends Error {
|
|
255
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
256
|
+
super(message, options);
|
|
257
|
+
this.name = "CodexOneShotError";
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Codex emits `error` events for transient retries it handles itself
|
|
263
|
+
* ("Reconnecting... 2/5 (…)", "Falling back from WebSockets to HTTPS
|
|
264
|
+
* transport. …"). Those are progress, not failure — only the terminal
|
|
265
|
+
* `turn.failed` / final `error` count.
|
|
266
|
+
*/
|
|
267
|
+
const TRANSIENT_ERROR_RE = /^\s*(reconnecting\.\.\.|falling back from)/i;
|
|
268
|
+
|
|
269
|
+
/** Tracks the terminal failure reported on a Codex event stream. */
|
|
270
|
+
class StreamFailure {
|
|
271
|
+
private turnFailed: string | undefined;
|
|
272
|
+
private lastError: string | undefined;
|
|
273
|
+
|
|
274
|
+
observe(event: { type: string } & Record<string, unknown>): void {
|
|
275
|
+
if (event.type === "turn.failed") {
|
|
276
|
+
const err = (event as { error?: { message?: unknown } }).error;
|
|
277
|
+
const text =
|
|
278
|
+
typeof err?.message === "string" && err.message.trim()
|
|
279
|
+
? err.message.trim()
|
|
280
|
+
: "turn failed (no message)";
|
|
281
|
+
this.turnFailed = text;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (event.type === "error" && typeof event.message === "string") {
|
|
285
|
+
const text = event.message.trim();
|
|
286
|
+
if (text && !TRANSIENT_ERROR_RE.test(text)) this.lastError = text;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The failure the stream reported, or undefined when it reported none. */
|
|
291
|
+
get message(): string | undefined {
|
|
292
|
+
return this.turnFailed ?? this.lastError;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Combine the stream's failure with whatever the SDK threw, most
|
|
297
|
+
* specific first, without repeating the same text twice.
|
|
298
|
+
*/
|
|
299
|
+
describe(thrown: string): string {
|
|
300
|
+
const streamed = this.message;
|
|
301
|
+
if (!streamed) return thrown;
|
|
302
|
+
if (!thrown || thrown === streamed || streamed.includes(thrown)) {
|
|
303
|
+
return streamed;
|
|
304
|
+
}
|
|
305
|
+
return `${streamed} (${thrown.trim()})`;
|
|
204
306
|
}
|
|
205
307
|
}
|
|
206
308
|
|
|
@@ -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
|
}
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* wiring, which does the same thing privately). Alerts raised before that
|
|
8
8
|
* — early boot is exactly when restore reports and security alerts fire —
|
|
9
9
|
* are held in a small bounded queue and flushed, oldest first, the moment
|
|
10
|
-
* a notifier is wired.
|
|
10
|
+
* a notifier is wired. An alert carrying a key (operator alerts do) is
|
|
11
|
+
* deduplicated while it waits: a re-raise replaces the queued copy instead
|
|
12
|
+
* of queueing another, and a key that resolves before anyone could hear it
|
|
13
|
+
* is withdrawn outright. With nothing ever wired (tests, terminal mode with
|
|
11
14
|
* no admin) they stay a log line; nothing throws.
|
|
12
15
|
*
|
|
13
16
|
* First consumer: WhatsApp pairing. When WhatsApp unlinks the device,
|
|
@@ -25,7 +28,14 @@ let deliver: Deliver | null = null;
|
|
|
25
28
|
/** Most alerts held while no notifier is wired; the oldest are dropped past it. */
|
|
26
29
|
export const ADMIN_NOTIFY_QUEUE_MAX = 20;
|
|
27
30
|
|
|
28
|
-
type Pending = {
|
|
31
|
+
type Pending = {
|
|
32
|
+
text: string;
|
|
33
|
+
at: number;
|
|
34
|
+
/** Dedup key (operator alert key); unkeyed alerts never coalesce. */
|
|
35
|
+
key?: string;
|
|
36
|
+
/** Re-raises folded into this entry while it waited. */
|
|
37
|
+
repeats: number;
|
|
38
|
+
};
|
|
29
39
|
const pending: Pending[] = [];
|
|
30
40
|
let droppedWhileUnwired = 0;
|
|
31
41
|
let flushing: Promise<void> | null = null;
|
|
@@ -68,6 +78,7 @@ async function flushPending(fn: Deliver): Promise<void> {
|
|
|
68
78
|
batch.unshift({
|
|
69
79
|
text: `${dropped} earlier admin alert(s) were dropped before a notifier was wired (queue holds ${ADMIN_NOTIFY_QUEUE_MAX}); see the daemon log.`,
|
|
70
80
|
at: Date.now(),
|
|
81
|
+
repeats: 0,
|
|
71
82
|
});
|
|
72
83
|
}
|
|
73
84
|
log("notify", `Flushing ${batch.length} queued admin alert(s)`);
|
|
@@ -79,7 +90,10 @@ async function flushPending(fn: Deliver): Promise<void> {
|
|
|
79
90
|
continue;
|
|
80
91
|
}
|
|
81
92
|
const ageS = Math.round((Date.now() - item.at) / 1000);
|
|
82
|
-
const
|
|
93
|
+
const repeated =
|
|
94
|
+
item.repeats > 0 ? `\n(raised ${item.repeats + 1}× while starting)` : "";
|
|
95
|
+
const text =
|
|
96
|
+
(ageS >= 5 ? `(delayed ${ageS}s) ${item.text}` : item.text) + repeated;
|
|
83
97
|
try {
|
|
84
98
|
await fn(text);
|
|
85
99
|
log("notify", `Admin notified (queued): ${preview(item.text)}`);
|
|
@@ -95,6 +109,17 @@ async function flushPending(fn: Deliver): Promise<void> {
|
|
|
95
109
|
}
|
|
96
110
|
|
|
97
111
|
function enqueue(item: Pending): void {
|
|
112
|
+
if (item.key !== undefined) {
|
|
113
|
+
const i = pending.findIndex((p) => p.key === item.key);
|
|
114
|
+
if (i >= 0) {
|
|
115
|
+
// Same fault raised again while waiting: keep one entry, newest text,
|
|
116
|
+
// original timestamp (so the "delayed" note stays honest).
|
|
117
|
+
const prior = pending[i];
|
|
118
|
+
prior.text = item.text;
|
|
119
|
+
prior.repeats += item.repeats + 1;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
98
123
|
pending.push(item);
|
|
99
124
|
while (pending.length > ADMIN_NOTIFY_QUEUE_MAX) {
|
|
100
125
|
const lost = pending.shift();
|
|
@@ -111,14 +136,29 @@ function preview(text: string): string {
|
|
|
111
136
|
return text.slice(0, 80).replace(/\n/g, " ");
|
|
112
137
|
}
|
|
113
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Withdraw a queued, not-yet-delivered alert by key (its fault cleared
|
|
141
|
+
* before a notifier was wired). Returns whether one was withdrawn.
|
|
142
|
+
*/
|
|
143
|
+
export function withdrawAdminNotification(key: string): boolean {
|
|
144
|
+
const i = pending.findIndex((p) => p.key === key);
|
|
145
|
+
if (i < 0) return false;
|
|
146
|
+
const [gone] = pending.splice(i, 1);
|
|
147
|
+
log("notify", `Withdrew queued admin alert ${key}: ${preview(gone.text)}`);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
114
151
|
/**
|
|
115
152
|
* Send `text` to the admin chat. Never throws; returns whether it was
|
|
116
153
|
* delivered now (false = failed, or queued because no notifier is wired
|
|
117
|
-
* yet — it is sent when one is).
|
|
154
|
+
* yet — it is sent when one is). `key` deduplicates while queued.
|
|
118
155
|
*/
|
|
119
|
-
export async function notifyAdmin(
|
|
156
|
+
export async function notifyAdmin(
|
|
157
|
+
text: string,
|
|
158
|
+
key?: string,
|
|
159
|
+
): Promise<boolean> {
|
|
120
160
|
if (!deliver) {
|
|
121
|
-
enqueue({ text, at: Date.now() });
|
|
161
|
+
enqueue({ text, at: Date.now(), key, repeats: 0 });
|
|
122
162
|
logWarn(
|
|
123
163
|
"notify",
|
|
124
164
|
`No admin notifier wired yet; queued (${pending.length}/${ADMIN_NOTIFY_QUEUE_MAX}): ${text.slice(0, 120)}`,
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { log, logWarn } from "../../util/log.js";
|
|
18
|
-
import { notifyAdmin } from "./admin-notify.js";
|
|
18
|
+
import { notifyAdmin, withdrawAdminNotification } from "./admin-notify.js";
|
|
19
19
|
|
|
20
20
|
export type AlertSeverity = "warn" | "error" | "critical";
|
|
21
21
|
|
|
@@ -39,7 +39,8 @@ const RANK: Record<AlertSeverity, number> = { warn: 0, error: 1, critical: 2 };
|
|
|
39
39
|
const active = new Map<string, ActiveAlert>();
|
|
40
40
|
let cooldownMs = DEFAULT_COOLDOWN_MS;
|
|
41
41
|
let enabled = true;
|
|
42
|
-
|
|
42
|
+
type Send = (text: string, key?: string) => Promise<unknown>;
|
|
43
|
+
let send: Send = notifyAdmin;
|
|
43
44
|
|
|
44
45
|
/** Apply operator settings (config `alerts`). */
|
|
45
46
|
export function configureAlerts(opts: {
|
|
@@ -86,7 +87,9 @@ export function raiseAlert(
|
|
|
86
87
|
// Nobody heard it: don't let the cooldown swallow the next raise.
|
|
87
88
|
if (active.get(key) === entry) entry.lastSentAt = 0;
|
|
88
89
|
};
|
|
89
|
-
|
|
90
|
+
// The key lets a still-queued copy (no notifier wired yet) be replaced by
|
|
91
|
+
// this raise rather than queued twice.
|
|
92
|
+
void send(`${ICON[severity]} ${message}${repeat}`, key).then((ok) => {
|
|
90
93
|
if (ok === false) undelivered();
|
|
91
94
|
}, undelivered);
|
|
92
95
|
}
|
|
@@ -99,6 +102,9 @@ export function resolveAlert(key: string, message?: string): void {
|
|
|
99
102
|
const mins = Math.max(1, Math.round((Date.now() - prior.firstAt) / 60_000));
|
|
100
103
|
log("alert", `resolved ${key} after ${mins} min`);
|
|
101
104
|
if (!enabled) return;
|
|
105
|
+
// Still queued for a notifier that never got to send it: withdraw it, and
|
|
106
|
+
// there is nothing to announce a recovery from.
|
|
107
|
+
if (withdrawAdminNotification(key)) return;
|
|
102
108
|
void send(`✅ ${message ?? `Recovered: ${key}`} (after ${mins} min)`).catch(
|
|
103
109
|
() => {},
|
|
104
110
|
);
|
|
@@ -120,9 +126,7 @@ export function activeAlerts(): ReadonlyArray<{
|
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
/** Test seam: reset state and swap the delivery function. */
|
|
123
|
-
export function resetAlertsForTest(
|
|
124
|
-
deliver: (text: string) => Promise<unknown> = notifyAdmin,
|
|
125
|
-
): void {
|
|
129
|
+
export function resetAlertsForTest(deliver: Send = notifyAdmin): void {
|
|
126
130
|
active.clear();
|
|
127
131
|
cooldownMs = DEFAULT_COOLDOWN_MS;
|
|
128
132
|
enabled = true;
|