talon-agent 5.26.6 → 5.28.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.
@@ -23,7 +23,7 @@ import { resolve } from "node:path";
23
23
  import { dirs } from "../../../util/paths.js";
24
24
  import { log, logWarn } from "../../../util/log.js";
25
25
  import { TalonError } from "../../errors.js";
26
- import { readArray, writePrivateJson } from "../persist.js";
26
+ import { readArray, writePrivateJson, writesSettled } from "../persist.js";
27
27
  import { faultText } from "../../engine/fault-text.js";
28
28
  import { raiseAlert, resolveAlert } from "../../frontend-runtime/alerts.js";
29
29
  import {
@@ -506,6 +506,14 @@ export class DeviceCredentialStore {
506
506
  }
507
507
  }
508
508
 
509
+ /**
510
+ * Wait for every background write queued so far (mintNow, bind, re-pair
511
+ * revocation, lastUsedAt touches) to reach disk or fail.
512
+ */
513
+ flush(): Promise<void> {
514
+ return writesSettled(this.file);
515
+ }
516
+
509
517
  private persistSoon(): void {
510
518
  void this.persist().catch((err: unknown) =>
511
519
  logWarn("mesh", `Could not persist mesh credentials: ${err}`),
@@ -25,6 +25,15 @@ export async function readArray<T>(path: string): Promise<T[]> {
25
25
  }
26
26
  }
27
27
 
28
+ /**
29
+ * Resolve once every write queued for `path` so far has finished (settled,
30
+ * success or failure) — for callers that persisted fire-and-forget and now
31
+ * need the file on disk.
32
+ */
33
+ export function writesSettled(path: string): Promise<void> {
34
+ return writeQueues.get(path) ?? Promise.resolve();
35
+ }
36
+
28
37
  /** Persist JSON atomically with 0600 perms, serialized per path. */
29
38
  export async function writePrivateJson(
30
39
  path: string,
package/src/core/types.ts CHANGED
@@ -172,6 +172,21 @@ export type OneShotAgentParams = {
172
172
  * and logs a throwing callback so a consumer bug can never fail the run.
173
173
  */
174
174
  onAssistantText?: (text: string) => void;
175
+ /**
176
+ * Resume this backend conversation instead of starting a fresh one — the
177
+ * handle an earlier run reported through `onSessionId`. Honoured only by
178
+ * backends whose `BackgroundRunner.supportsResume` is true (Claude SDK:
179
+ * session id; Codex: thread id); the others ignore it and start fresh,
180
+ * which is why the sub-agent runner checks that flag before relying on it.
181
+ */
182
+ resumeSessionId?: string;
183
+ /**
184
+ * Called once the backend knows the run's conversation handle (Claude SDK
185
+ * session id, Codex thread id) — a sub-agent persists it so a daemon
186
+ * restart can resume the conversation. Best-effort and synchronous, like
187
+ * `onAssistantText`: a throwing callback must never fail the run.
188
+ */
189
+ onSessionId?: (sessionId: string) => void;
175
190
  };
176
191
 
177
192
  /** How much cache telemetry a backend can surface in /status. */
@@ -14,9 +14,12 @@ export async function resolveChannel(
14
14
  const info = lookupDiscordChat(numericChatId);
15
15
  if (!info) return null;
16
16
  try {
17
- const ch = await client.channels.fetch(info.channelId);
18
- if (ch && "send" in ch && (ch as TextBasedChannel).isSendable?.()) {
19
- return ch as TextBasedChannel;
17
+ // A DM rebuilt from its chat key has no channel id — open it by user.
18
+ if (info.channelId) {
19
+ const ch = await client.channels.fetch(info.channelId);
20
+ if (ch && "send" in ch && (ch as TextBasedChannel).isSendable?.()) {
21
+ return ch as TextBasedChannel;
22
+ }
20
23
  }
21
24
  if (info.userId) {
22
25
  const user = await client.users.fetch(info.userId);
@@ -24,6 +24,11 @@ import type { Client } from "discord.js";
24
24
  import type { Gateway } from "../../../core/engine/gateway.js";
25
25
  import type { ActionResult } from "../../../core/types.js";
26
26
  import { resolveChannel } from "./channels.js";
27
+ import {
28
+ lookupDiscordChat,
29
+ parseDiscordChatKey,
30
+ registerDiscordChat,
31
+ } from "../handlers/registry.js";
27
32
  import { messagingHandlers, restoreScheduledMessages } from "./messaging.js";
28
33
  import { mediaHandlers } from "./media.js";
29
34
  import { chatInfoHandlers } from "./chat-info.js";
@@ -43,6 +48,24 @@ const handlers: DiscordActionHandlers = Object.assign(Object.create(null), {
43
48
  ...chatInfoHandlers,
44
49
  });
45
50
 
51
+ /**
52
+ * A plain send addressed to a chat this process hasn't registered — the
53
+ * channel that asked for a staged restore, say, before anyone has spoken
54
+ * in it since the restart. The sender names the chat by key in
55
+ * `body.target`; when that key is the one the numeric id was derived
56
+ * from, register it so the channel resolves. send_message only.
57
+ */
58
+ function adoptAddressedChat(
59
+ action: string,
60
+ body: Record<string, unknown>,
61
+ chatId: number,
62
+ ): void {
63
+ if (action !== "send_message" || lookupDiscordChat(chatId)) return;
64
+ const key = typeof body.target === "string" ? body.target : "";
65
+ const info = key ? parseDiscordChatKey(key) : undefined;
66
+ if (info && info.numericChatId === chatId) registerDiscordChat(info);
67
+ }
68
+
46
69
  export function createDiscordActionHandler(client: Client, gateway: Gateway) {
47
70
  const scheduledMessages = new Map<string, ReturnType<typeof setTimeout>>();
48
71
 
@@ -58,6 +81,7 @@ export function createDiscordActionHandler(client: Client, gateway: Gateway) {
58
81
  const handler = handlers[action];
59
82
  if (!handler) return null; // not a Discord action
60
83
 
84
+ adoptAddressedChat(action, body, chatId);
61
85
  const channel = await resolveChannel(client, chatId);
62
86
 
63
87
  // For non-channel actions (e.g. cancel_scheduled) that don't need a
@@ -180,6 +180,7 @@ export async function handleBackupComponent(
180
180
  id,
181
181
  requestedAt: Date.now(),
182
182
  requestedBy: chatId,
183
+ frontend: "discord",
183
184
  });
184
185
  respawnSelf(`discord /backup restore ${id}`);
185
186
  } catch (err) {
@@ -3,6 +3,7 @@
3
3
  * Discord channel info the action handler needs to post back.
4
4
  */
5
5
 
6
+ import { deriveNumericChatId } from "../../../core/frontend-runtime/chat-id.js";
6
7
  import {
7
8
  chatRegistry,
8
9
  chatRegistryByString,
@@ -21,3 +22,37 @@ export function lookupDiscordChat(
21
22
  ): DiscordChatInfo | undefined {
22
23
  return chatRegistry.get(numericChatId);
23
24
  }
25
+
26
+ /**
27
+ * Rebuild a chat's registry entry from its string key alone
28
+ * (`discord_guild_<guild>_<channel>` or `discord_dm_<user>`), for a chat
29
+ * this process hasn't seen a message from yet — the registry is in-memory,
30
+ * so after a restart only the allowed users' DMs are known until someone
31
+ * speaks. A DM entry carries no channel id; resolveChannel opens the DM
32
+ * from the user id. Undefined for anything that isn't a Discord chat key.
33
+ */
34
+ export function parseDiscordChatKey(
35
+ chatKey: string,
36
+ ): DiscordChatInfo | undefined {
37
+ const guild = /^discord_guild_(\d+)_(\d+)$/.exec(chatKey);
38
+ if (guild) {
39
+ return {
40
+ channelId: guild[2]!,
41
+ guildId: guild[1]!,
42
+ userId: null,
43
+ numericChatId: deriveNumericChatId(chatKey),
44
+ chatId: chatKey,
45
+ };
46
+ }
47
+ const dm = /^discord_dm_(\d+)$/.exec(chatKey);
48
+ if (dm) {
49
+ return {
50
+ channelId: "",
51
+ guildId: null,
52
+ userId: dm[1]!,
53
+ numericChatId: deriveNumericChatId(chatKey),
54
+ chatId: chatKey,
55
+ };
56
+ }
57
+ return undefined;
58
+ }
@@ -145,6 +145,7 @@ async function restore(
145
145
  id,
146
146
  requestedAt: Date.now(),
147
147
  requestedBy: ctx.entry.id,
148
+ frontend: "native",
148
149
  });
149
150
  } catch (err) {
150
151
  logError("backup", "Staging the restore failed", err);
@@ -15,6 +15,10 @@ import type {
15
15
  import type { Gateway } from "../../../core/engine/gateway.js";
16
16
  import type { NativeChats, ChatEntry } from "../chats/chats.js";
17
17
  import type { BridgeEvent, ClientButton } from "../protocol.js";
18
+ import {
19
+ deriveNumericChatId,
20
+ isNativeChatId,
21
+ } from "../../../core/frontend-runtime/chat-id.js";
18
22
 
19
23
  export type NativeActionDeps = {
20
24
  chats: NativeChats;
@@ -69,6 +73,28 @@ const NATIVE_ACTIONS = new Set([
69
73
  "send_chat_action",
70
74
  ]);
71
75
 
76
+ /**
77
+ * A plain send addressed to a native chat this daemon doesn't list — the
78
+ * chat that asked for a staged restore, say, when the restored database
79
+ * predates it. The sender names the chat by key in `body.target`; the
80
+ * chat is adopted (as a deep link would) so the message lands in its
81
+ * history and the app shows it. Only when the key really is the chat the
82
+ * numeric id was derived from, and only for send_message: edits,
83
+ * reactions and deletes address messages, which an unknown chat has none of.
84
+ */
85
+ function adoptAddressedChat(
86
+ chats: NativeChats,
87
+ action: string,
88
+ body: Record<string, unknown>,
89
+ chatId: number,
90
+ ): ChatEntry | undefined {
91
+ if (action !== "send_message") return undefined;
92
+ const key = typeof body.target === "string" ? body.target : "";
93
+ if (!key || !isNativeChatId(key) || deriveNumericChatId(key) !== chatId)
94
+ return undefined;
95
+ return chats.ensure(key);
96
+ }
97
+
72
98
  export function createNativeActionHandler(
73
99
  deps: NativeActionDeps,
74
100
  ): FrontendActionHandler {
@@ -77,7 +103,9 @@ export function createNativeActionHandler(
77
103
  return async (body, chatId): Promise<ActionResult | null> => {
78
104
  const action = typeof body.action === "string" ? body.action : "";
79
105
  if (!NATIVE_ACTIONS.has(action)) return null;
80
- const entry = chats.byNumeric(chatId);
106
+ const entry =
107
+ chats.byNumeric(chatId) ??
108
+ adoptAddressedChat(chats, action, body, chatId);
81
109
  if (!entry) return { ok: false, error: "No active native chat" };
82
110
 
83
111
  switch (action) {
@@ -166,6 +166,7 @@ export async function stageRestore(chatId: string, id: string): Promise<void> {
166
166
  id,
167
167
  requestedAt: Date.now(),
168
168
  requestedBy: chatId,
169
+ frontend: "telegram",
169
170
  });
170
171
  respawnSelf(`telegram /backup restore ${id}`);
171
172
  }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Agents repository — executes the statements in sql/agents.sql against
3
+ * the `agents` table; no SQL text lives here. The domain side (what gets
4
+ * written when, and the boot-time resume) is core/agents/registry.ts and
5
+ * core/agents/runner.ts.
6
+ */
7
+
8
+ import { getDatabase } from "../db.js";
9
+ import { agentsSql } from "../sql/statements.generated.js";
10
+
11
+ /** One persisted message waiting in an agent's mailbox. */
12
+ type PersistedAgentMessage = {
13
+ from: string;
14
+ text: string;
15
+ at: number;
16
+ };
17
+
18
+ /** A sub-agent as stored — everything a restart needs to bring it back. */
19
+ export type PersistedAgent = {
20
+ id: string;
21
+ label: string;
22
+ brief: string;
23
+ parentKind: "chat" | "agent";
24
+ /** Chat key (chat parent) or agent id (agent parent). */
25
+ parentId: string;
26
+ /** Frontend numeric chat id — chat parents only. */
27
+ parentNumericChatId?: number;
28
+ backendId: string;
29
+ model?: string;
30
+ requestedModel?: string;
31
+ reasoningEffort?: string;
32
+ timeoutMs?: number;
33
+ depth: number;
34
+ cwd?: string;
35
+ state: string;
36
+ createdAt: number;
37
+ startedAt?: number;
38
+ endedAt?: number;
39
+ updatedAt: number;
40
+ sessionId?: string;
41
+ inbox: PersistedAgentMessage[];
42
+ reported: boolean;
43
+ resultSummary?: string;
44
+ resultDetails?: string;
45
+ error?: string;
46
+ elapsedMs: number;
47
+ resumeCount: number;
48
+ interruptedAt?: number;
49
+ /** The spawn asked for the pre-flight lane instruction. */
50
+ preflight?: boolean;
51
+ };
52
+
53
+ type Row = {
54
+ id: string;
55
+ label: string;
56
+ brief: string;
57
+ parent_kind: string;
58
+ parent_id: string;
59
+ parent_numeric: number | null;
60
+ backend_id: string;
61
+ model: string | null;
62
+ requested_model: string | null;
63
+ effort: string | null;
64
+ timeout_ms: number | null;
65
+ depth: number;
66
+ cwd: string | null;
67
+ state: string;
68
+ created_at: number;
69
+ started_at: number | null;
70
+ ended_at: number | null;
71
+ updated_at: number;
72
+ session_id: string | null;
73
+ inbox_json: string;
74
+ reported: number;
75
+ result_summary: string | null;
76
+ result_details: string | null;
77
+ error: string | null;
78
+ elapsed_ms: number;
79
+ resume_count: number;
80
+ interrupted_at: number | null;
81
+ preflight: number;
82
+ };
83
+
84
+ function parseInbox(raw: string): PersistedAgentMessage[] {
85
+ try {
86
+ const parsed: unknown = JSON.parse(raw);
87
+ if (!Array.isArray(parsed)) return [];
88
+ return parsed.filter(
89
+ (m): m is PersistedAgentMessage =>
90
+ typeof m === "object" &&
91
+ m !== null &&
92
+ typeof (m as { from?: unknown }).from === "string" &&
93
+ typeof (m as { text?: unknown }).text === "string" &&
94
+ typeof (m as { at?: unknown }).at === "number",
95
+ );
96
+ } catch {
97
+ return [];
98
+ }
99
+ }
100
+
101
+ function rowToAgent(row: Row): PersistedAgent {
102
+ const agent: PersistedAgent = {
103
+ id: row.id,
104
+ label: row.label,
105
+ brief: row.brief,
106
+ parentKind: row.parent_kind === "agent" ? "agent" : "chat",
107
+ parentId: row.parent_id,
108
+ backendId: row.backend_id,
109
+ depth: row.depth,
110
+ state: row.state,
111
+ createdAt: row.created_at,
112
+ updatedAt: row.updated_at,
113
+ inbox: parseInbox(row.inbox_json),
114
+ reported: row.reported === 1,
115
+ elapsedMs: row.elapsed_ms,
116
+ resumeCount: row.resume_count,
117
+ };
118
+ if (row.parent_numeric !== null)
119
+ agent.parentNumericChatId = row.parent_numeric;
120
+ if (row.model !== null) agent.model = row.model;
121
+ if (row.requested_model !== null) agent.requestedModel = row.requested_model;
122
+ if (row.effort !== null) agent.reasoningEffort = row.effort;
123
+ if (row.timeout_ms !== null) agent.timeoutMs = row.timeout_ms;
124
+ if (row.cwd !== null) agent.cwd = row.cwd;
125
+ if (row.started_at !== null) agent.startedAt = row.started_at;
126
+ if (row.ended_at !== null) agent.endedAt = row.ended_at;
127
+ if (row.session_id !== null) agent.sessionId = row.session_id;
128
+ if (row.result_summary !== null) agent.resultSummary = row.result_summary;
129
+ if (row.result_details !== null) agent.resultDetails = row.result_details;
130
+ if (row.error !== null) agent.error = row.error;
131
+ if (row.interrupted_at !== null) agent.interruptedAt = row.interrupted_at;
132
+ if (row.preflight === 1) agent.preflight = true;
133
+ return agent;
134
+ }
135
+
136
+ export function upsert(a: PersistedAgent): void {
137
+ getDatabase()
138
+ .prepare(agentsSql.upsert)
139
+ .run(
140
+ a.id,
141
+ a.label,
142
+ a.brief,
143
+ a.parentKind,
144
+ a.parentId,
145
+ a.parentNumericChatId ?? null,
146
+ a.backendId,
147
+ a.model ?? null,
148
+ a.requestedModel ?? null,
149
+ a.reasoningEffort ?? null,
150
+ a.timeoutMs ?? null,
151
+ a.depth,
152
+ a.cwd ?? null,
153
+ a.state,
154
+ a.createdAt,
155
+ a.startedAt ?? null,
156
+ a.endedAt ?? null,
157
+ a.updatedAt,
158
+ a.sessionId ?? null,
159
+ JSON.stringify(a.inbox),
160
+ a.reported ? 1 : 0,
161
+ a.resultSummary ?? null,
162
+ a.resultDetails ?? null,
163
+ a.error ?? null,
164
+ a.elapsedMs,
165
+ a.resumeCount,
166
+ a.interruptedAt ?? null,
167
+ a.preflight ? 1 : 0,
168
+ );
169
+ }
170
+
171
+ export function get(id: string): PersistedAgent | undefined {
172
+ const row = getDatabase().prepare(agentsSql.get).get(id) as Row | undefined;
173
+ return row ? rowToAgent(row) : undefined;
174
+ }
175
+
176
+ /** Rows still queued/running — interrupted by the last restart. */
177
+ export function listInterrupted(): PersistedAgent[] {
178
+ const rows = getDatabase().prepare(agentsSql.listInterrupted).all() as Row[];
179
+ return rows.map(rowToAgent);
180
+ }
181
+
182
+ export function remove(id: string): boolean {
183
+ const result = getDatabase().prepare(agentsSql.remove).run(id) as {
184
+ changes?: number;
185
+ };
186
+ return (result.changes ?? 0) > 0;
187
+ }
188
+
189
+ /** Delete settled rows last touched before `cutoff`. Returns how many. */
190
+ export function pruneSettled(cutoff: number): number {
191
+ const result = getDatabase().prepare(agentsSql.pruneSettled).run(cutoff) as {
192
+ changes?: number;
193
+ };
194
+ return result.changes ?? 0;
195
+ }
196
+
197
+ /** Test-only: wipe the table between suites sharing a worker DB. */
198
+ export function removeAll(): void {
199
+ getDatabase().prepare(agentsSql.removeAll).run();
200
+ }
@@ -0,0 +1,43 @@
1
+ -- Statements for the agents table (see storage/agents/repo.ts for
2
+ -- the parameter order and row↔domain mapping).
3
+
4
+ -- name: upsert
5
+ INSERT OR REPLACE INTO agents
6
+ (id, label, brief, parent_kind, parent_id, parent_numeric, backend_id,
7
+ model, requested_model, effort, timeout_ms, depth, cwd, state,
8
+ created_at, started_at, ended_at, updated_at, session_id, inbox_json,
9
+ reported, result_summary, result_details, error, elapsed_ms,
10
+ resume_count, interrupted_at, preflight)
11
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
12
+
13
+ -- name: get
14
+ SELECT id, label, brief, parent_kind, parent_id, parent_numeric, backend_id,
15
+ model, requested_model, effort, timeout_ms, depth, cwd, state,
16
+ created_at, started_at, ended_at, updated_at, session_id, inbox_json,
17
+ reported, result_summary, result_details, error, elapsed_ms,
18
+ resume_count, interrupted_at, preflight
19
+ FROM agents WHERE id = ?
20
+
21
+ -- Rows a restart interrupted, parents before children (a child can only
22
+ -- be re-attached to a parent that is already back in the registry).
23
+
24
+ -- name: listInterrupted
25
+ SELECT id, label, brief, parent_kind, parent_id, parent_numeric, backend_id,
26
+ model, requested_model, effort, timeout_ms, depth, cwd, state,
27
+ created_at, started_at, ended_at, updated_at, session_id, inbox_json,
28
+ reported, result_summary, result_details, error, elapsed_ms,
29
+ resume_count, interrupted_at, preflight
30
+ FROM agents WHERE state IN ('queued', 'running')
31
+ ORDER BY depth, created_at
32
+
33
+ -- name: remove
34
+ DELETE FROM agents WHERE id = ?
35
+
36
+ -- Retention: settled rows past their window. Live rows are never pruned.
37
+
38
+ -- name: pruneSettled
39
+ DELETE FROM agents
40
+ WHERE state NOT IN ('queued', 'running') AND updated_at < ?
41
+
42
+ -- name: removeAll
43
+ DELETE FROM agents
@@ -406,3 +406,57 @@ CREATE TABLE IF NOT EXISTS backup_remotes (
406
406
  error TEXT,
407
407
  PRIMARY KEY (backup_id, target_id)
408
408
  );
409
+
410
+ -- Sub-agents, persisted so a daemon restart does not kill them. One row
411
+ -- per spawn, written at registration and kept current on every lifecycle
412
+ -- change (start, SDK session id, mailbox, report, settle). On boot, every
413
+ -- row still 'queued' or 'running' was interrupted by the restart and is
414
+ -- respawned — resuming its backend session when the backend can. See
415
+ -- resumeAgentsAfterRestart in core/agents/runner.ts.
416
+ CREATE TABLE IF NOT EXISTS agents (
417
+ id TEXT PRIMARY KEY,
418
+ label TEXT NOT NULL,
419
+ brief TEXT NOT NULL,
420
+ -- 'chat' | 'agent'; parent_id is the chat key or the parent agent id.
421
+ parent_kind TEXT NOT NULL,
422
+ parent_id TEXT NOT NULL,
423
+ -- The frontend's numeric chat id (chat parents only) — the dispatcher
424
+ -- needs it to wake the chat with the report.
425
+ parent_numeric INTEGER,
426
+ backend_id TEXT NOT NULL,
427
+ -- The model the run resolved to, and the one the caller asked for (null
428
+ -- = the backend default), so a resume can re-resolve if it vanished.
429
+ model TEXT,
430
+ requested_model TEXT,
431
+ effort TEXT,
432
+ timeout_ms INTEGER,
433
+ depth INTEGER NOT NULL DEFAULT 0,
434
+ cwd TEXT,
435
+ state TEXT NOT NULL,
436
+ created_at INTEGER NOT NULL,
437
+ started_at INTEGER,
438
+ ended_at INTEGER,
439
+ updated_at INTEGER NOT NULL,
440
+ -- Backend conversation handle (Claude SDK session id, Codex thread id),
441
+ -- recorded as soon as the backend reports it.
442
+ session_id TEXT,
443
+ -- Pending check_inbox messages, JSON array of {from,text,at}.
444
+ inbox_json TEXT NOT NULL DEFAULT '[]',
445
+ -- 1 once report_result landed; result_* hold what it said.
446
+ reported INTEGER NOT NULL DEFAULT 0,
447
+ result_summary TEXT,
448
+ result_details TEXT,
449
+ error TEXT,
450
+ -- Wall-clock the agent has already spent in earlier (interrupted) runs,
451
+ -- charged against its timeout when it is resumed.
452
+ elapsed_ms INTEGER NOT NULL DEFAULT 0,
453
+ -- How many times a restart has resumed it (bounded — a run that keeps
454
+ -- dying with the daemon eventually settles as failed).
455
+ resume_count INTEGER NOT NULL DEFAULT 0,
456
+ -- When the last restart interrupted it, if one did.
457
+ interrupted_at INTEGER,
458
+ -- 1 when the spawn asked for the pre-flight lane, so a re-briefed resume
459
+ -- keeps the instruction.
460
+ preflight INTEGER NOT NULL DEFAULT 0
461
+ );
462
+ CREATE INDEX IF NOT EXISTS idx_agents_state ON agents(state, depth, created_at);
@@ -410,7 +410,93 @@ CREATE TABLE IF NOT EXISTS backup_remotes (
410
410
  uploaded_at INTEGER,
411
411
  error TEXT,
412
412
  PRIMARY KEY (backup_id, target_id)
413
- );`;
413
+ );
414
+
415
+ -- Sub-agents, persisted so a daemon restart does not kill them. One row
416
+ -- per spawn, written at registration and kept current on every lifecycle
417
+ -- change (start, SDK session id, mailbox, report, settle). On boot, every
418
+ -- row still 'queued' or 'running' was interrupted by the restart and is
419
+ -- respawned — resuming its backend session when the backend can. See
420
+ -- resumeAgentsAfterRestart in core/agents/runner.ts.
421
+ CREATE TABLE IF NOT EXISTS agents (
422
+ id TEXT PRIMARY KEY,
423
+ label TEXT NOT NULL,
424
+ brief TEXT NOT NULL,
425
+ -- 'chat' | 'agent'; parent_id is the chat key or the parent agent id.
426
+ parent_kind TEXT NOT NULL,
427
+ parent_id TEXT NOT NULL,
428
+ -- The frontend's numeric chat id (chat parents only) — the dispatcher
429
+ -- needs it to wake the chat with the report.
430
+ parent_numeric INTEGER,
431
+ backend_id TEXT NOT NULL,
432
+ -- The model the run resolved to, and the one the caller asked for (null
433
+ -- = the backend default), so a resume can re-resolve if it vanished.
434
+ model TEXT,
435
+ requested_model TEXT,
436
+ effort TEXT,
437
+ timeout_ms INTEGER,
438
+ depth INTEGER NOT NULL DEFAULT 0,
439
+ cwd TEXT,
440
+ state TEXT NOT NULL,
441
+ created_at INTEGER NOT NULL,
442
+ started_at INTEGER,
443
+ ended_at INTEGER,
444
+ updated_at INTEGER NOT NULL,
445
+ -- Backend conversation handle (Claude SDK session id, Codex thread id),
446
+ -- recorded as soon as the backend reports it.
447
+ session_id TEXT,
448
+ -- Pending check_inbox messages, JSON array of {from,text,at}.
449
+ inbox_json TEXT NOT NULL DEFAULT '[]',
450
+ -- 1 once report_result landed; result_* hold what it said.
451
+ reported INTEGER NOT NULL DEFAULT 0,
452
+ result_summary TEXT,
453
+ result_details TEXT,
454
+ error TEXT,
455
+ -- Wall-clock the agent has already spent in earlier (interrupted) runs,
456
+ -- charged against its timeout when it is resumed.
457
+ elapsed_ms INTEGER NOT NULL DEFAULT 0,
458
+ -- How many times a restart has resumed it (bounded — a run that keeps
459
+ -- dying with the daemon eventually settles as failed).
460
+ resume_count INTEGER NOT NULL DEFAULT 0,
461
+ -- When the last restart interrupted it, if one did.
462
+ interrupted_at INTEGER,
463
+ -- 1 when the spawn asked for the pre-flight lane, so a re-briefed resume
464
+ -- keeps the instruction.
465
+ preflight INTEGER NOT NULL DEFAULT 0
466
+ );
467
+ CREATE INDEX IF NOT EXISTS idx_agents_state ON agents(state, depth, created_at);`;
468
+
469
+ export const agentsSql = {
470
+ upsert: `INSERT OR REPLACE INTO agents
471
+ (id, label, brief, parent_kind, parent_id, parent_numeric, backend_id,
472
+ model, requested_model, effort, timeout_ms, depth, cwd, state,
473
+ created_at, started_at, ended_at, updated_at, session_id, inbox_json,
474
+ reported, result_summary, result_details, error, elapsed_ms,
475
+ resume_count, interrupted_at, preflight)
476
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
477
+ get: `SELECT id, label, brief, parent_kind, parent_id, parent_numeric, backend_id,
478
+ model, requested_model, effort, timeout_ms, depth, cwd, state,
479
+ created_at, started_at, ended_at, updated_at, session_id, inbox_json,
480
+ reported, result_summary, result_details, error, elapsed_ms,
481
+ resume_count, interrupted_at, preflight
482
+ FROM agents WHERE id = ?
483
+
484
+ -- Rows a restart interrupted, parents before children (a child can only
485
+ -- be re-attached to a parent that is already back in the registry).`,
486
+ listInterrupted: `SELECT id, label, brief, parent_kind, parent_id, parent_numeric, backend_id,
487
+ model, requested_model, effort, timeout_ms, depth, cwd, state,
488
+ created_at, started_at, ended_at, updated_at, session_id, inbox_json,
489
+ reported, result_summary, result_details, error, elapsed_ms,
490
+ resume_count, interrupted_at, preflight
491
+ FROM agents WHERE state IN ('queued', 'running')
492
+ ORDER BY depth, created_at`,
493
+ remove: `DELETE FROM agents WHERE id = ?
494
+
495
+ -- Retention: settled rows past their window. Live rows are never pruned.`,
496
+ pruneSettled: `DELETE FROM agents
497
+ WHERE state NOT IN ('queued', 'running') AND updated_at < ?`,
498
+ removeAll: `DELETE FROM agents`,
499
+ } as const;
414
500
 
415
501
  export const backupsSql = {
416
502
  upsert: `INSERT OR REPLACE INTO backups