talon-agent 3.34.0 → 3.35.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.
Files changed (70) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +0 -10
  3. package/package.json +6 -4
  4. package/src/backend/claude-sdk/handler.ts +428 -415
  5. package/src/backend/codex/auth.ts +40 -1
  6. package/src/backend/codex/handler/message.ts +290 -366
  7. package/src/backend/codex/handler/rollout-accounting.ts +137 -0
  8. package/src/backend/codex/plan-usage.ts +27 -2
  9. package/src/backend/kilo/handler/message.ts +2 -0
  10. package/src/backend/kilo/server.ts +1 -0
  11. package/src/backend/openai-agents/handler/message.ts +282 -356
  12. package/src/backend/opencode/handler/message.ts +2 -0
  13. package/src/backend/opencode/server.ts +1 -0
  14. package/src/backend/remote-server/chat-turn.ts +76 -122
  15. package/src/backend/remote-server/index.ts +4 -0
  16. package/src/backend/remote-server/lifecycle.ts +37 -0
  17. package/src/backend/remote-server/mcp.ts +73 -9
  18. package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
  19. package/src/backend/remote-server/server-bindings.ts +12 -0
  20. package/src/backend/remote-server/sessions.ts +2 -2
  21. package/src/backend/remote-server/state.ts +8 -0
  22. package/src/backend/remote-server/turn.ts +93 -23
  23. package/src/backend/shared/cache-telemetry.ts +17 -1
  24. package/src/backend/shared/handler-to-events.ts +11 -16
  25. package/src/backend/shared/index.ts +14 -15
  26. package/src/backend/shared/result-events.ts +30 -0
  27. package/src/backend/shared/turn-phases.ts +277 -0
  28. package/src/bootstrap.ts +24 -10
  29. package/src/core/auth/expiry-monitor.ts +89 -0
  30. package/src/core/auth/login-flow.ts +247 -0
  31. package/src/core/auth/status.ts +193 -0
  32. package/src/core/background/triggers/exit.ts +19 -2
  33. package/src/core/background/triggers/resume.ts +14 -7
  34. package/src/core/background/triggers/state.ts +9 -0
  35. package/src/core/engine/gateway-actions/history.ts +12 -1
  36. package/src/core/errors.ts +1 -1
  37. package/src/core/mcp-hub/child-transport.ts +215 -0
  38. package/src/core/mcp-hub/children.ts +72 -13
  39. package/src/core/mcp-hub/index.ts +32 -13
  40. package/src/core/models/active-model.ts +29 -6
  41. package/src/core/tools/history.ts +10 -1
  42. package/src/core/weaver/shuttle.ts +4 -0
  43. package/src/frontend/shared/model-commands.ts +400 -0
  44. package/src/frontend/telegram/admin.ts +75 -52
  45. package/src/frontend/telegram/auth-panel.ts +205 -0
  46. package/src/frontend/telegram/callbacks/auth.ts +70 -0
  47. package/src/frontend/telegram/callbacks/index.ts +16 -0
  48. package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
  49. package/src/frontend/telegram/commands/auth.ts +58 -0
  50. package/src/frontend/telegram/commands/definitions.ts +4 -0
  51. package/src/frontend/telegram/commands/index.ts +10 -4
  52. package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
  53. package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
  54. package/src/frontend/whatsapp/actions/history.ts +100 -0
  55. package/src/frontend/whatsapp/actions/index.ts +4 -1
  56. package/src/frontend/whatsapp/commands.ts +371 -0
  57. package/src/frontend/whatsapp/inbound.ts +24 -46
  58. package/src/frontend/whatsapp/index.ts +12 -1
  59. package/src/frontend/whatsapp/media-store.ts +20 -1
  60. package/src/frontend/whatsapp/message-store.ts +101 -24
  61. package/src/storage/history.ts +22 -1
  62. package/src/storage/repositories/history-repo.ts +14 -0
  63. package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
  64. package/src/storage/sql/history.sql +12 -0
  65. package/src/storage/sql/schema.sql +21 -0
  66. package/src/storage/sql/statements.generated.ts +50 -1
  67. package/src/storage/sql/whatsapp-messages.sql +29 -0
  68. package/src/storage/trigger-store.ts +8 -0
  69. package/src/storage/whatsapp-messages.ts +51 -0
  70. package/src/util/watchdog.ts +32 -7
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Login-expiry monitor — the daemon-side version of the CLI's "you have
3
+ * N days to log in again" banner, delivered to the admin chat.
4
+ *
5
+ * Checks every provider's credential file on an interval and alerts the
6
+ * admin (via the core notify seam) when a login is within
7
+ * {@link WARN_DAYS} of expiring, has expired, or is missing. Each distinct
8
+ * state is announced once; the latch re-arms when the state changes (a
9
+ * fresh login clears it, so the next expiry warns again).
10
+ */
11
+
12
+ import { notifyAdmin } from "../notify.js";
13
+ import {
14
+ daysUntil,
15
+ PROVIDER_LABELS,
16
+ readAllProviderStatus,
17
+ type ProviderAuthStatus,
18
+ } from "./status.js";
19
+
20
+ const WARN_DAYS = 7;
21
+ const CHECK_INTERVAL_MS = 6 * 60 * 60_000;
22
+
23
+ /** The alert key for a status, or undefined when nothing needs saying. */
24
+ export function alertKeyFor(
25
+ s: ProviderAuthStatus,
26
+ now = Date.now(),
27
+ ): string | undefined {
28
+ if (!s.loggedIn) return `${s.provider}:missing`;
29
+ if (s.expired) return `${s.provider}:expired`;
30
+ if (s.loginExpiresAt === undefined) return undefined;
31
+ const days = daysUntil(s.loginExpiresAt, now);
32
+ if (days > WARN_DAYS) return undefined;
33
+ return `${s.provider}:expiring:${Math.max(days, 0)}`;
34
+ }
35
+
36
+ export function alertTextFor(s: ProviderAuthStatus, now = Date.now()): string {
37
+ const label = PROVIDER_LABELS[s.provider];
38
+ if (!s.loggedIn)
39
+ return `🔑 ${label} is not signed in — send /auth to sign in from here.`;
40
+ if (s.expired)
41
+ return `🔑 ${label} login has expired — send /auth to sign in again.`;
42
+ const days =
43
+ s.loginExpiresAt === undefined ? 0 : daysUntil(s.loginExpiresAt, now);
44
+ const when = days <= 0 ? "today" : `in ${days} day${days === 1 ? "" : "s"}`;
45
+ return `⏳ ${label} login expires ${when} — send /auth to renew it before it lapses.`;
46
+ }
47
+
48
+ const announced = new Map<string, string>();
49
+
50
+ /** One check pass; exported for tests and for the panel's refresh button. */
51
+ export async function runAuthExpiryCheck(
52
+ now = Date.now(),
53
+ notify: (text: string) => Promise<unknown> = notifyAdmin,
54
+ ): Promise<string[]> {
55
+ const sent: string[] = [];
56
+ for (const status of await readAllProviderStatus()) {
57
+ const key = alertKeyFor(status, now);
58
+ const prev = announced.get(status.provider);
59
+ if (key === undefined) {
60
+ announced.delete(status.provider);
61
+ continue;
62
+ }
63
+ if (prev === key) continue;
64
+ announced.set(status.provider, key);
65
+ const text = alertTextFor(status, now);
66
+ sent.push(text);
67
+ await notify(text);
68
+ }
69
+ return sent;
70
+ }
71
+
72
+ export function resetAuthExpiryAnnouncements(): void {
73
+ announced.clear();
74
+ }
75
+
76
+ export function startAuthExpiryMonitor(): () => void {
77
+ const tick = (): void => {
78
+ void runAuthExpiryCheck().catch(() => {});
79
+ };
80
+ // First check shortly after boot so a lapsed login is surfaced right away.
81
+ const first = setTimeout(tick, 30_000);
82
+ const interval = setInterval(tick, CHECK_INTERVAL_MS);
83
+ first.unref();
84
+ interval.unref();
85
+ return () => {
86
+ clearTimeout(first);
87
+ clearInterval(interval);
88
+ };
89
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Interactive CLI logins driven from a chat.
3
+ *
4
+ * Both CLIs can only log in interactively — `codex login --device-auth`
5
+ * prints a URL plus a one-time code and waits for the browser side to
6
+ * finish; `claude auth login` prints a URL and waits for the code the
7
+ * browser hands back to be pasted on stdin. Each flow here spawns the CLI
8
+ * against a THROWAWAY home (`CODEX_HOME` / `CLAUDE_CONFIG_DIR`), so a
9
+ * cancelled or failed attempt never touches the credentials the daemon is
10
+ * currently using; only a successful exit installs the new file over the
11
+ * real one (atomic rename). One flow per provider at a time — starting a
12
+ * new one cancels the old.
13
+ */
14
+
15
+ import { spawn, type ChildProcess } from "node:child_process";
16
+ import { copyFile, mkdtemp, rename, rm } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+ import { log, logWarn } from "../../util/log.js";
20
+ import {
21
+ claudeCredentialsPath,
22
+ clearProviderExpired,
23
+ codexAuthPath,
24
+ type AuthProvider,
25
+ } from "./status.js";
26
+
27
+ const LOGIN_TIMEOUT_MS = 15 * 60_000;
28
+
29
+ export interface LoginPrompt {
30
+ /** Page the human must open. */
31
+ url: string;
32
+ /** One-time code shown to the human (Codex device auth). */
33
+ code?: string;
34
+ /** True when the human must send a code back to us (Claude). */
35
+ needsCode: boolean;
36
+ }
37
+
38
+ type LoginOutcome =
39
+ | { ok: true }
40
+ | { ok: false; reason: "cancelled" | "timeout" | "failed"; detail?: string };
41
+
42
+ export interface LoginFlow {
43
+ provider: AuthProvider;
44
+ /** Resolves once the CLI has printed enough to instruct the human. */
45
+ prompt: Promise<LoginPrompt>;
46
+ /** Resolves when the CLI exits (or is cancelled / times out). */
47
+ done: Promise<LoginOutcome>;
48
+ /** Feed the pasted code to the CLI (Claude). */
49
+ submitCode(code: string): void;
50
+ cancel(): void;
51
+ }
52
+
53
+ export interface LoginBinaries {
54
+ claude?: string;
55
+ codex?: string;
56
+ }
57
+
58
+ // oxlint-disable-next-line no-control-regex
59
+ const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g;
60
+
61
+ export function stripAnsi(text: string): string {
62
+ return text.replace(ANSI, "");
63
+ }
64
+
65
+ /** Parse `codex login --device-auth` output into URL + code, once both are present. */
66
+ export function parseCodexDevicePrompt(
67
+ output: string,
68
+ ): LoginPrompt | undefined {
69
+ const text = stripAnsi(output);
70
+ const url = /https:\/\/\S+\/device\S*/.exec(text)?.[0];
71
+ const code = /\b([A-Z0-9]{4}-[A-Z0-9]{5,6})\b/.exec(text)?.[1];
72
+ if (!url || !code) return undefined;
73
+ return { url, code, needsCode: false };
74
+ }
75
+
76
+ /** Parse `claude auth login` output into the sign-in URL, once printed. */
77
+ export function parseClaudeLoginPrompt(
78
+ output: string,
79
+ ): LoginPrompt | undefined {
80
+ const text = stripAnsi(output);
81
+ const url = /https:\/\/\S+oauth\/authorize\S*/.exec(text)?.[0];
82
+ if (!url) return undefined;
83
+ return { url, needsCode: true };
84
+ }
85
+
86
+ const active = new Map<AuthProvider, LoginFlow>();
87
+
88
+ export function activeLoginFlow(provider: AuthProvider): LoginFlow | undefined {
89
+ return active.get(provider);
90
+ }
91
+
92
+ interface Spec {
93
+ bin: string;
94
+ args: string[];
95
+ homeVar: "CODEX_HOME" | "CLAUDE_CONFIG_DIR";
96
+ parse: (out: string) => LoginPrompt | undefined;
97
+ /** Credential file inside the throwaway home → real destination. */
98
+ install: (tmpHome: string) => { from: string; to: string };
99
+ }
100
+
101
+ function specFor(provider: AuthProvider, bins: LoginBinaries): Spec {
102
+ if (provider === "codex") {
103
+ return {
104
+ bin: bins.codex || "codex",
105
+ args: ["login", "--device-auth"],
106
+ homeVar: "CODEX_HOME",
107
+ parse: parseCodexDevicePrompt,
108
+ install: (tmp) => ({ from: join(tmp, "auth.json"), to: codexAuthPath() }),
109
+ };
110
+ }
111
+ return {
112
+ bin: bins.claude || "claude",
113
+ args: ["auth", "login"],
114
+ homeVar: "CLAUDE_CONFIG_DIR",
115
+ parse: parseClaudeLoginPrompt,
116
+ install: (tmp) => ({
117
+ from: join(tmp, ".credentials.json"),
118
+ to: claudeCredentialsPath(),
119
+ }),
120
+ };
121
+ }
122
+
123
+ async function installCredentials(from: string, to: string): Promise<void> {
124
+ const staging = join(dirname(to), `.${Date.now()}.login.tmp`);
125
+ await copyFile(from, staging);
126
+ await rename(staging, to);
127
+ }
128
+
129
+ export function startLogin(
130
+ provider: AuthProvider,
131
+ bins: LoginBinaries = {},
132
+ ): LoginFlow {
133
+ active.get(provider)?.cancel();
134
+ const spec = specFor(provider, bins);
135
+
136
+ let child: ChildProcess | undefined;
137
+ let tmpHome: string | undefined;
138
+ let output = "";
139
+ let settled = false;
140
+ let cancelled = false;
141
+ let resolvePrompt!: (p: LoginPrompt) => void;
142
+ let rejectPrompt!: (e: Error) => void;
143
+ let resolveDone!: (o: LoginOutcome) => void;
144
+ const prompt = new Promise<LoginPrompt>((res, rej) => {
145
+ resolvePrompt = res;
146
+ rejectPrompt = rej;
147
+ });
148
+ const done = new Promise<LoginOutcome>((res) => {
149
+ resolveDone = res;
150
+ });
151
+ let promptSent = false;
152
+
153
+ const finish = async (outcome: LoginOutcome): Promise<void> => {
154
+ if (settled) return;
155
+ settled = true;
156
+ clearTimeout(timer);
157
+ if (active.get(provider) === flow) active.delete(provider);
158
+ if (!promptSent)
159
+ rejectPrompt(
160
+ new Error(outcome.ok ? "no prompt" : outcome.detail || outcome.reason),
161
+ );
162
+ if (tmpHome)
163
+ await rm(tmpHome, { recursive: true, force: true }).catch(() => {});
164
+ resolveDone(outcome);
165
+ };
166
+
167
+ const timer = setTimeout(() => {
168
+ child?.kill("SIGTERM");
169
+ void finish({ ok: false, reason: "timeout" });
170
+ }, LOGIN_TIMEOUT_MS);
171
+
172
+ const flow: LoginFlow = {
173
+ provider,
174
+ prompt,
175
+ done,
176
+ submitCode(code) {
177
+ child?.stdin?.write(`${code.trim()}\n`);
178
+ },
179
+ cancel() {
180
+ cancelled = true;
181
+ child?.kill("SIGTERM");
182
+ void finish({ ok: false, reason: "cancelled" });
183
+ },
184
+ };
185
+ active.set(provider, flow);
186
+
187
+ void (async () => {
188
+ tmpHome = await mkdtemp(join(tmpdir(), `talon-${provider}-login-`));
189
+ if (settled) return;
190
+ child = spawn(spec.bin, spec.args, {
191
+ env: { ...process.env, [spec.homeVar]: tmpHome },
192
+ stdio: ["pipe", "pipe", "pipe"],
193
+ });
194
+ const onData = (chunk: Buffer): void => {
195
+ output += chunk.toString("utf8");
196
+ if (promptSent) return;
197
+ const parsed = spec.parse(output);
198
+ if (parsed) {
199
+ promptSent = true;
200
+ resolvePrompt(parsed);
201
+ }
202
+ };
203
+ child.stdout?.on("data", onData);
204
+ child.stderr?.on("data", onData);
205
+ child.on("error", (err) => {
206
+ void finish({ ok: false, reason: "failed", detail: err.message });
207
+ });
208
+ child.on("exit", async (code, signal) => {
209
+ if (settled || cancelled) return;
210
+ if (code === 0 && tmpHome) {
211
+ const { from, to } = spec.install(tmpHome);
212
+ try {
213
+ await installCredentials(from, to);
214
+ clearProviderExpired(provider);
215
+ log("notify", `${provider} login installed at ${to}`);
216
+ await finish({ ok: true });
217
+ return;
218
+ } catch (err) {
219
+ const detail = err instanceof Error ? err.message : String(err);
220
+ logWarn(
221
+ "notify",
222
+ `${provider} login succeeded but install failed: ${detail}`,
223
+ );
224
+ await finish({
225
+ ok: false,
226
+ reason: "failed",
227
+ detail: `could not install credentials: ${detail}`,
228
+ });
229
+ return;
230
+ }
231
+ }
232
+ const tail = stripAnsi(output)
233
+ .trim()
234
+ .split("\n")
235
+ .slice(-3)
236
+ .join(" ")
237
+ .slice(0, 300);
238
+ await finish({
239
+ ok: false,
240
+ reason: "failed",
241
+ detail: `${spec.bin} exited with ${signal ?? `code ${code}`}${tail ? `: ${tail}` : ""}`,
242
+ });
243
+ });
244
+ })();
245
+
246
+ return flow;
247
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Provider login status — what the CLIs know about their own credentials.
3
+ *
4
+ * Talon's Claude and Codex backends borrow the login state of the
5
+ * `claude` and `codex` CLIs (`~/.claude/.credentials.json` and
6
+ * `~/.codex/auth.json`). Both files are plain JSON, so status can be read
7
+ * without spawning anything. Claude's file carries an explicit refresh-token
8
+ * expiry (the "you have N days to log in again" the CLI prints); Codex's
9
+ * carries the last refresh time and
10
+ * only reveals a dead refresh token by failing — so "expired" for Codex
11
+ * means the file is missing, unparsable, or was reported dead by the
12
+ * backend (see {@link markProviderExpired}).
13
+ */
14
+
15
+ import { readFile, stat } from "node:fs/promises";
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ export type AuthProvider = "claude" | "codex";
20
+
21
+ export const AUTH_PROVIDERS: readonly AuthProvider[] = ["claude", "codex"];
22
+
23
+ export const PROVIDER_LABELS: Record<AuthProvider, string> = {
24
+ claude: "Claude",
25
+ codex: "Codex",
26
+ };
27
+
28
+ export interface ProviderAuthStatus {
29
+ provider: AuthProvider;
30
+ loggedIn: boolean;
31
+ /** Human-readable account descriptor ("max plan", "ChatGPT"), when known. */
32
+ account?: string;
33
+ /** Epoch ms after which a fresh login is required, when the file says. */
34
+ loginExpiresAt?: number;
35
+ /** Epoch ms of the last token refresh, when the file says. */
36
+ lastRefreshAt?: number;
37
+ /** True when the credentials are known to be unusable. */
38
+ expired: boolean;
39
+ }
40
+
41
+ export function claudeCredentialsPath(
42
+ env: NodeJS.ProcessEnv = process.env,
43
+ ): string {
44
+ const configDir = env.CLAUDE_CONFIG_DIR?.trim();
45
+ return join(
46
+ configDir ? configDir : join(homedir(), ".claude"),
47
+ ".credentials.json",
48
+ );
49
+ }
50
+
51
+ export function codexAuthPath(env: NodeJS.ProcessEnv = process.env): string {
52
+ const home = env.CODEX_HOME?.trim();
53
+ return join(home ? home : join(homedir(), ".codex"), "auth.json");
54
+ }
55
+
56
+ /** Providers the backends reported as dead since boot (cleared on login). */
57
+ const reportedExpired = new Set<AuthProvider>();
58
+
59
+ export function markProviderExpired(provider: AuthProvider): void {
60
+ reportedExpired.add(provider);
61
+ }
62
+
63
+ export function clearProviderExpired(provider: AuthProvider): void {
64
+ reportedExpired.delete(provider);
65
+ }
66
+
67
+ export function parseClaudeCredentials(raw: string): ProviderAuthStatus {
68
+ const base: ProviderAuthStatus = {
69
+ provider: "claude",
70
+ loggedIn: false,
71
+ expired: true,
72
+ };
73
+ let parsed: {
74
+ claudeAiOauth?: {
75
+ accessToken?: string;
76
+ expiresAt?: number;
77
+ refreshTokenExpiresAt?: number;
78
+ subscriptionType?: string;
79
+ };
80
+ };
81
+ try {
82
+ parsed = JSON.parse(raw);
83
+ } catch {
84
+ return base;
85
+ }
86
+ const oauth = parsed.claudeAiOauth;
87
+ if (!oauth?.accessToken) return base;
88
+ const loginExpiresAt =
89
+ typeof oauth.refreshTokenExpiresAt === "number"
90
+ ? oauth.refreshTokenExpiresAt
91
+ : undefined;
92
+ return {
93
+ provider: "claude",
94
+ loggedIn: true,
95
+ account: oauth.subscriptionType
96
+ ? `${oauth.subscriptionType} plan`
97
+ : undefined,
98
+ loginExpiresAt,
99
+ expired: loginExpiresAt !== undefined && loginExpiresAt <= Date.now(),
100
+ };
101
+ }
102
+
103
+ export function parseCodexAuth(raw: string): ProviderAuthStatus {
104
+ const base: ProviderAuthStatus = {
105
+ provider: "codex",
106
+ loggedIn: false,
107
+ expired: true,
108
+ };
109
+ let parsed: {
110
+ auth_mode?: string;
111
+ OPENAI_API_KEY?: string | null;
112
+ tokens?: { access_token?: string; refresh_token?: string };
113
+ last_refresh?: string;
114
+ };
115
+ try {
116
+ parsed = JSON.parse(raw);
117
+ } catch {
118
+ return base;
119
+ }
120
+ const apiKey =
121
+ typeof parsed.OPENAI_API_KEY === "string" &&
122
+ parsed.OPENAI_API_KEY.length > 0;
123
+ const chatgpt = Boolean(
124
+ parsed.tokens?.refresh_token || parsed.tokens?.access_token,
125
+ );
126
+ if (!apiKey && !chatgpt) return base;
127
+ const lastRefreshAt = parsed.last_refresh
128
+ ? Date.parse(parsed.last_refresh)
129
+ : NaN;
130
+ return {
131
+ provider: "codex",
132
+ loggedIn: true,
133
+ account: chatgpt ? "ChatGPT" : "API key",
134
+ lastRefreshAt: Number.isFinite(lastRefreshAt) ? lastRefreshAt : undefined,
135
+ expired: false,
136
+ };
137
+ }
138
+
139
+ async function readOrEmpty(path: string): Promise<string | undefined> {
140
+ try {
141
+ await stat(path);
142
+ return await readFile(path, "utf8");
143
+ } catch {
144
+ return undefined;
145
+ }
146
+ }
147
+
148
+ export async function readProviderStatus(
149
+ provider: AuthProvider,
150
+ env: NodeJS.ProcessEnv = process.env,
151
+ ): Promise<ProviderAuthStatus> {
152
+ const path =
153
+ provider === "claude" ? claudeCredentialsPath(env) : codexAuthPath(env);
154
+ const raw = await readOrEmpty(path);
155
+ const status =
156
+ raw === undefined
157
+ ? { provider, loggedIn: false, expired: true }
158
+ : provider === "claude"
159
+ ? parseClaudeCredentials(raw)
160
+ : parseCodexAuth(raw);
161
+ if (reportedExpired.has(provider)) return { ...status, expired: true };
162
+ return status;
163
+ }
164
+
165
+ export async function readAllProviderStatus(
166
+ env: NodeJS.ProcessEnv = process.env,
167
+ ): Promise<ProviderAuthStatus[]> {
168
+ return Promise.all(AUTH_PROVIDERS.map((p) => readProviderStatus(p, env)));
169
+ }
170
+
171
+ /** Whole days until `at`; negative when already past. */
172
+ export function daysUntil(at: number, now = Date.now()): number {
173
+ return Math.floor((at - now) / 86_400_000);
174
+ }
175
+
176
+ /** One-line human summary used by both the panel and the expiry alerts. */
177
+ export function describeProviderStatus(
178
+ s: ProviderAuthStatus,
179
+ now = Date.now(),
180
+ ): string {
181
+ if (!s.loggedIn) return "not signed in";
182
+ if (s.expired) return "login expired — sign in again";
183
+ const parts: string[] = [`signed in${s.account ? ` (${s.account})` : ""}`];
184
+ if (s.loginExpiresAt !== undefined) {
185
+ const days = daysUntil(s.loginExpiresAt, now);
186
+ parts.push(days <= 0 ? "login expires today" : `login expires in ${days}d`);
187
+ } else if (s.lastRefreshAt !== undefined) {
188
+ parts.push(
189
+ `refreshed ${Math.max(0, -daysUntil(s.lastRefreshAt, now))}d ago`,
190
+ );
191
+ }
192
+ return parts.join(", ");
193
+ }
@@ -8,16 +8,18 @@ import type { ChildProcess } from "node:child_process";
8
8
  import {
9
9
  getTrigger,
10
10
  updateTrigger,
11
+ SHUTDOWN_KILL_ERROR,
11
12
  type Trigger,
12
13
  type TriggerStatus,
13
14
  } from "../../../storage/trigger-store.js";
14
- import { log, logError } from "../../../util/log.js";
15
+ import { log, logDebug, logError } from "../../../util/log.js";
15
16
  import { appendDailyLog } from "../../../storage/daily-log.js";
16
17
  import {
17
18
  children,
18
19
  timeouts,
19
20
  logStreams,
20
21
  lineBuffers,
22
+ lifecycle,
21
23
  wardened,
22
24
  SIGTERM_GRACE_MS,
23
25
  WARDEN_GRACE_SLACK_MS,
@@ -53,6 +55,9 @@ export function cancelTrigger(id: string): boolean {
53
55
 
54
56
  /** Kill all running children — called during shutdown. */
55
57
  export async function shutdownTriggers(): Promise<void> {
58
+ // Flag first, unconditionally: a child that exits on its own from here on
59
+ // must not dispatch a wake either — the backend pool is going away.
60
+ lifecycle.shuttingDown = true;
56
61
  if (children.size === 0) return;
57
62
  log("triggers", `Shutting down ${children.size} running trigger(s)`);
58
63
  const ids = Array.from(children.keys());
@@ -70,7 +75,7 @@ export async function shutdownTriggers(): Promise<void> {
70
75
  } else {
71
76
  updateTrigger(id, {
72
77
  status: "terminated",
73
- lastError: "Killed by Talon shutdown",
78
+ lastError: SHUTDOWN_KILL_ERROR,
74
79
  });
75
80
  }
76
81
  killChild(id, c);
@@ -188,6 +193,18 @@ export async function finalizeExit(
188
193
  status === "cancelled" ||
189
194
  status === "terminated"
190
195
  ) {
196
+ // During shutdown the backend pool is being torn down alongside us, so a
197
+ // dispatch here can only fail ("Backend role not bound"). Skip it; the
198
+ // record keeps status=terminated + SHUTDOWN_KILL_ERROR and no lastFireAt
199
+ // bump, which is exactly what resumeAfterRestart's late death notice
200
+ // keys on — the chat hears about it on the next boot instead.
201
+ if (lifecycle.shuttingDown) {
202
+ logDebug(
203
+ "triggers",
204
+ `Skipped ${status} wake for "${t.name}" [${id}] — shutting down; deferred to next boot`,
205
+ );
206
+ return;
207
+ }
191
208
  await fireWake(id, status, payload, /* terminal */ true);
192
209
  }
193
210
  }
@@ -9,6 +9,7 @@ import {
9
9
  getAllTriggers,
10
10
  updateTrigger,
11
11
  RESTART_KILL_ERROR,
12
+ SHUTDOWN_KILL_ERROR,
12
13
  type Trigger,
13
14
  } from "../../../storage/trigger-store.js";
14
15
  import { log, logError } from "../../../util/log.js";
@@ -50,19 +51,25 @@ export async function resumeAfterRestart(): Promise<void> {
50
51
  // Late death notice. Two cases earn one:
51
52
  // - never fired at all (the old rule) — the chat heard nothing
52
53
  // from this trigger, so its termination is news; and
53
- // - killed by THIS restart (recoverInterrupted stamped the
54
- // marker error) — even a multi-fire watcher that signalled
55
- // mid-run was still an active promise when the process died,
56
- // and without this wake the chat never learns its watcher is
57
- // gone. (Previously gated on lastFireAt === undefined alone,
58
- // which silently dropped exactly those watchers.)
54
+ // - killed by THIS restart — either the crash path
55
+ // (recoverInterrupted stamped RESTART_KILL_ERROR) or the clean
56
+ // path (shutdownTriggers stamped SHUTDOWN_KILL_ERROR and
57
+ // finalizeExit deliberately skipped the wake because the
58
+ // backend pool was already going away). Even a multi-fire
59
+ // watcher that signalled mid-run was still an active promise
60
+ // when the process died, and without this wake the chat never
61
+ // learns its watcher is gone. (Previously gated on
62
+ // lastFireAt === undefined alone, which silently dropped
63
+ // exactly those watchers.)
59
64
  // Triggers that exited on their own already fired their terminal
60
65
  // wake (lastFireAt set, no marker) — they stay silent here.
61
66
  if (
62
67
  t.status === "terminated" &&
63
68
  t.endedAt &&
64
69
  Date.now() - t.endedAt < 5 * 60_000 &&
65
- (t.lastFireAt === undefined || t.lastError === RESTART_KILL_ERROR)
70
+ (t.lastFireAt === undefined ||
71
+ t.lastError === RESTART_KILL_ERROR ||
72
+ t.lastError === SHUTDOWN_KILL_ERROR)
66
73
  ) {
67
74
  await fireWake(t.id, "terminated", t.lastError, /* terminal */ true);
68
75
  }
@@ -20,6 +20,14 @@ export type TriggerDeps = {
20
20
  /** Reassignable on a holder object so submodules see the injected deps. */
21
21
  export const depsHolder: { deps: TriggerDeps | null } = { deps: null };
22
22
 
23
+ /**
24
+ * Set by shutdownTriggers for the rest of the process lifetime. finalizeExit
25
+ * consults it so the children it kills don't dispatch wakes into a backend
26
+ * pool that is being torn down at the same time. initTriggers clears it so a
27
+ * fresh lifecycle (next boot, or the next test) starts clean.
28
+ */
29
+ export const lifecycle = { shuttingDown: false };
30
+
23
31
  /** Live child handles, keyed by trigger id. */
24
32
  export const children = new Map<string, ChildProcess>();
25
33
  export const timeouts = new Map<string, ReturnType<typeof setTimeout>>();
@@ -43,6 +51,7 @@ export const WARDEN_GRACE_SLACK_MS = 2_000;
43
51
 
44
52
  export function initTriggers(d: TriggerDeps): void {
45
53
  depsHolder.deps = d;
54
+ lifecycle.shuttingDown = false;
46
55
  log("triggers", "Initialized");
47
56
  }
48
57
 
@@ -38,9 +38,20 @@ export const historyHandlers: SharedActionHandlers = {
38
38
 
39
39
  search_history: (body, chatId) => {
40
40
  const limit = Math.min(100, Number(body.limit ?? 20));
41
+ // Optional ISO date bounds; an unparseable one is ignored rather
42
+ // than silently narrowing the search to nothing.
43
+ const bound = (value: unknown): number | undefined => {
44
+ if (value === undefined || value === null || value === "")
45
+ return undefined;
46
+ const ts = Date.parse(String(value));
47
+ return Number.isFinite(ts) ? ts : undefined;
48
+ };
41
49
  return {
42
50
  ok: true,
43
- text: searchHistory(String(chatId), String(body.query ?? ""), limit),
51
+ text: searchHistory(String(chatId), String(body.query ?? ""), limit, {
52
+ after: bound(body.after),
53
+ before: bound(body.before),
54
+ }),
44
55
  };
45
56
  },
46
57
 
@@ -339,7 +339,7 @@ const FRIENDLY_MESSAGES: Record<ErrorReason, string> = {
339
339
  overloaded:
340
340
  "Upstream model is busy right now. Retrying with a faster fallback...",
341
341
  network: "Connection issue. Retrying shortly.",
342
- auth: "API key error. Bot operator: check the backend's credentials.",
342
+ auth: "API key error. Bot operator: check the backend's credentials (admins: /auth to sign in again).",
343
343
  context_length:
344
344
  "Conversation too long for the context window. Use /reset to start fresh.",
345
345
  session_expired: "Session expired. Retrying automatically...",