agent-relay-server 0.33.0 → 0.34.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.
@@ -0,0 +1,551 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { randomUUID } from "node:crypto";
3
+ import { isRecord, stringValue, isMechanicalMessageKind } from "agent-relay-sdk";
4
+ import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "../config.ts";
5
+ import { parseJson } from "../utils";
6
+ import { isLiveIsolatedWorkspace } from "../workspace-phase";
7
+ import {
8
+ CONTRACT_REQUIREMENTS,
9
+ contractCompatibility,
10
+ parseRuntimeCapabilities,
11
+ parseRuntimeContracts,
12
+ parseRuntimePackage,
13
+ type RuntimeContracts,
14
+ } from "../contracts";
15
+ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
16
+ import { matchAgents } from "../agent-ref";
17
+ import { evaluatePoolBindings, expectedChannelAgentId, upsertChannelForAgent } from "./channels.ts";
18
+ import { ValidationError, getDb } from "./connection.ts";
19
+ import { rowToAgent, rowToContextSnapshot } from "./mappers.ts";
20
+ import { closeOpenPairsForAgent } from "./pairs.ts";
21
+ import { TASK_SELECT, insertTaskEvent } from "./tasks.ts";
22
+ import { electWorkspaceStewards, electWorkspaceStewardsForAgent } from "./workspaces.ts";
23
+ import type {
24
+ AgentCard,
25
+ ActivityEvent,
26
+ ActivityEventInput,
27
+ AgentKind,
28
+ AgentSessionGuard,
29
+ Artifact,
30
+ ArtifactBlob,
31
+ ArtifactKind,
32
+ ArtifactLink,
33
+ ArtifactSensitivity,
34
+ ArtifactVisibility,
35
+ AttachmentRef,
36
+ ChannelBinding,
37
+ ChannelBindingMode,
38
+ ChannelRouteTarget,
39
+ ChatHistoryImport,
40
+ ChatHistoryImportEntry,
41
+ ChannelSummary,
42
+ ChannelTargetHealth,
43
+ CreatePairInput,
44
+ HealthCheck,
45
+ HealthReport,
46
+ ManagedAgent,
47
+ ManagedSessionExitDiagnostics,
48
+ Message,
49
+ MessageDeliveryAttempt,
50
+ MessageDeliveryStatus,
51
+ Orchestrator,
52
+ OrchestratorHealth,
53
+ OrchestratorRuntimeInput,
54
+ OrchestratorStatus,
55
+ OrchestratorUpgradeState,
56
+ PairActionInput,
57
+ PairMessageInput,
58
+ PairSession,
59
+ PairStatus,
60
+ RegisterAgentInput,
61
+ ReplyObligation,
62
+ RegisterOrchestratorInput,
63
+ SendMessageInput,
64
+ PollQuery,
65
+ SpawnApprovalMode,
66
+ SpawnProvider,
67
+ Task,
68
+ TaskEvent,
69
+ TaskSeverity,
70
+ TaskStatus,
71
+ IntegrationEventInput,
72
+ IntegrationSummary,
73
+ IntegrationTaskStats,
74
+ InboxDraft,
75
+ InboxState,
76
+ InboxThreadState,
77
+ ContextSnapshot,
78
+ ContextState,
79
+ ProviderCapabilities,
80
+ TaskStatusInput,
81
+ WorkspaceMetadata,
82
+ WorkspaceRecord,
83
+ WorkspaceStatus,
84
+ } from "../types";
85
+
86
+ const CONTEXT_SNAPSHOT_DEBOUNCE_MS = 60_000;
87
+
88
+ export function parseStringArray(raw: string): string[] {
89
+ const parsed = parseJson<unknown>(raw, []);
90
+ if (!Array.isArray(parsed)) return [];
91
+ return parsed.filter((value): value is string => typeof value === "string");
92
+ }
93
+
94
+ export function normalizeTags(tags: string[] | undefined): string[] {
95
+ return [...new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))];
96
+ }
97
+
98
+ export function inferAgentKind(input: Pick<RegisterAgentInput, "id" | "kind" | "tags" | "capabilities" | "meta">): AgentKind {
99
+ if (input.kind) return input.kind;
100
+ if (input.id === "user") return "user";
101
+ if (input.id === "system") return "system";
102
+ const metaKind = stringValue(input.meta?.kind);
103
+ if (metaKind === "channel" || metaKind === "communication-channel") return "channel";
104
+ if (metaKind === "orchestrator") return "orchestrator";
105
+ if ((input.tags ?? []).includes("channel") || (input.capabilities ?? []).includes("channel")) return "channel";
106
+ return "provider";
107
+ }
108
+
109
+ export function inferProviderTag(input: RegisterAgentInput): "claude" | "codex" | undefined {
110
+ if (inferAgentKind(input) !== "provider") return undefined;
111
+ const meta = input.meta ?? {};
112
+ const values = [
113
+ input.id,
114
+ input.name,
115
+ input.rig,
116
+ ...(input.tags ?? []),
117
+ stringValue(meta.provider),
118
+ stringValue(meta.client),
119
+ stringValue(meta.runtime),
120
+ stringValue(meta.agentType),
121
+ ].filter((value): value is string => typeof value === "string");
122
+
123
+ if (values.some((value) => value.toLowerCase().includes("codex"))) return "codex";
124
+ if (values.some((value) => value.toLowerCase().includes("claude"))) return "claude";
125
+
126
+ // Older Claude hooks did not always send an explicit provider tag, but did
127
+ // send Claude-style approval metadata. Codex also sends approvalMode, so this
128
+ // fallback only runs after Codex signals have been ruled out.
129
+ if (Object.prototype.hasOwnProperty.call(meta, "approvalMode")) return "claude";
130
+ return undefined;
131
+ }
132
+
133
+ export function tagsWithProvider(input: RegisterAgentInput): string[] {
134
+ const tags = normalizeTags(input.tags);
135
+ const provider = inferProviderTag(input);
136
+ if (!provider || tags.includes(provider)) return tags;
137
+ return [provider, ...tags];
138
+ }
139
+
140
+
141
+ export function upsertAgent(input: RegisterAgentInput): AgentCard {
142
+ const now = Date.now();
143
+ const kind = inferAgentKind(input);
144
+ if (kind === "channel") {
145
+ const expectedId = expectedChannelAgentId(input);
146
+ if (input.id !== expectedId) {
147
+ throw new ValidationError(`channel agent id must be canonical: ${expectedId}`);
148
+ }
149
+ }
150
+ const tags = tagsWithProvider(input);
151
+ // Preserve the existing label across re-registrations unless the caller
152
+ // explicitly sends one (including null to clear).
153
+ const labelProvided = Object.prototype.hasOwnProperty.call(input, "label");
154
+ const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
155
+ const instanceProvided = Boolean(input.instanceId);
156
+ const stmt = getDb().query(`
157
+ INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, provider_capabilities, context_state, meta, spawned_by, last_seen, created_at)
158
+ VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $providerCapabilities, $contextState, $meta, $spawnedBy, $now, $now)
159
+ ON CONFLICT(id) DO UPDATE SET
160
+ name = $name,
161
+ kind = $kind,
162
+ label = CASE WHEN $labelProvided = 1 THEN $label ELSE agents.label END,
163
+ tags = $tags,
164
+ machine = coalesce($machine, agents.machine),
165
+ rig = coalesce($rig, agents.rig),
166
+ capabilities = $capabilities,
167
+ ready = CASE WHEN $readyProvided = 1 THEN $ready ELSE agents.ready END,
168
+ status = $status,
169
+ instance_id = CASE WHEN $instanceProvided = 1 THEN $instanceId ELSE agents.instance_id END,
170
+ epoch = CASE
171
+ WHEN $instanceProvided = 1 AND (agents.instance_id IS NULL OR agents.instance_id <> $instanceId) THEN agents.epoch + 1
172
+ ELSE agents.epoch
173
+ END,
174
+ provider_capabilities = coalesce($providerCapabilities, agents.provider_capabilities),
175
+ context_state = coalesce($contextState, agents.context_state),
176
+ meta = $meta,
177
+ spawned_by = coalesce($spawnedBy, agents.spawned_by),
178
+ last_seen = $now
179
+ `);
180
+
181
+ stmt.run({
182
+ $id: input.id,
183
+ $name: input.name,
184
+ $kind: kind,
185
+ $label: input.label ?? null,
186
+ $labelProvided: labelProvided ? 1 : 0,
187
+ $tags: JSON.stringify(tags),
188
+ $machine: input.machine ?? null,
189
+ $rig: input.rig ?? null,
190
+ $capabilities: JSON.stringify(input.capabilities ?? []),
191
+ $ready: input.ready ? 1 : 0,
192
+ $readyProvided: readyProvided ? 1 : 0,
193
+ $status: input.status ?? "idle",
194
+ $instanceId: input.instanceId ?? null,
195
+ $instanceProvided: instanceProvided ? 1 : 0,
196
+ $initialEpoch: instanceProvided ? 1 : 0,
197
+ $providerCapabilities: input.providerCapabilities ? JSON.stringify(input.providerCapabilities) : null,
198
+ $contextState: input.context ? JSON.stringify(input.context) : null,
199
+ $meta: JSON.stringify(input.meta ?? {}),
200
+ $spawnedBy: input.spawnedBy ?? null,
201
+ $now: now,
202
+ });
203
+ if (input.context) recordContextSnapshot(input.id, input.context, now);
204
+
205
+ const agent = getAgent(input.id)!;
206
+ if (agent.kind === "channel") upsertChannelForAgent(agent);
207
+ evaluatePoolBindings();
208
+ // A (re)joining agent may revive a dormant repo steward — re-elect for the
209
+ // repos it owns live workspaces in (issue #157, steward survives offline gap).
210
+ if (agent.status !== "offline") electWorkspaceStewardsForAgent(agent.id);
211
+ return agent;
212
+ }
213
+
214
+ export function validateAgentSession(id: string, guard?: AgentSessionGuard): { ok: boolean; error?: string } {
215
+ if (!guard || (!guard.instanceId && guard.epoch === undefined)) return { ok: true };
216
+ const agent = getAgent(id);
217
+ if (!agent) return { ok: false, error: "agent not found" };
218
+ if (guard.instanceId && agent.instanceId !== guard.instanceId) {
219
+ return { ok: false, error: "stale agent instance" };
220
+ }
221
+ if (guard.epoch !== undefined && agent.epoch !== guard.epoch) {
222
+ return { ok: false, error: "stale agent instance" };
223
+ }
224
+ return { ok: true };
225
+ }
226
+
227
+ export function setLabel(id: string, label: string | null): boolean {
228
+ const normalized = label && label.trim() ? label.trim() : null;
229
+ return (
230
+ getDb().query("UPDATE agents SET label = ? WHERE id = ?").run(normalized, id).changes > 0
231
+ );
232
+ }
233
+
234
+ export function setTags(id: string, tags: string[]): AgentCard | null {
235
+ const normalized = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
236
+ const result = getDb().query("UPDATE agents SET tags = ?, last_seen = ? WHERE id = ?").run(JSON.stringify(normalized), Date.now(), id);
237
+ return result.changes > 0 ? getAgent(id) : null;
238
+ }
239
+
240
+ export function getAgent(id: string): AgentCard | null {
241
+ const row = getDb().query("SELECT * FROM agents WHERE id = ?").get(id) as any;
242
+ return row ? rowToAgent(row) : null;
243
+ }
244
+
245
+ export function listAgents(filter?: {
246
+ tag?: string;
247
+ machine?: string;
248
+ status?: string;
249
+ }): AgentCard[] {
250
+ let sql = "SELECT * FROM agents WHERE 1=1";
251
+ const params: any[] = [];
252
+
253
+ if (filter?.tag) {
254
+ sql += " AND EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)";
255
+ params.push(filter.tag);
256
+ }
257
+ if (filter?.machine) {
258
+ sql += " AND machine = ?";
259
+ params.push(filter.machine);
260
+ }
261
+ if (filter?.status) {
262
+ sql += " AND status = ?";
263
+ params.push(filter.status);
264
+ }
265
+
266
+ sql += " ORDER BY last_seen DESC";
267
+ return (getDb().query(sql).all(...params) as any[]).map(rowToAgent);
268
+ }
269
+
270
+
271
+ export function countLiveSpawnedAgents(parentId: string, now: number = Date.now()): number {
272
+ const row = getDb().query(
273
+ "SELECT count(*) AS n FROM agents WHERE spawned_by = ? AND status NOT IN ('offline', 'stale') AND ready = 1 AND last_seen > ?",
274
+ ).get(parentId, now - STALE_TTL_MS) as { n: number };
275
+ return row.n;
276
+ }
277
+
278
+ export function setStatus(id: string, status: AgentCard["status"], guard?: AgentSessionGuard): boolean {
279
+ if (!validateAgentSession(id, guard).ok) return false;
280
+ const now = Date.now();
281
+ const ready = status === "offline" ? 0 : undefined;
282
+ const sql = ready === 0
283
+ ? "UPDATE agents SET status = ?, ready = 0, last_seen = ? WHERE id = ?"
284
+ : "UPDATE agents SET status = ?, last_seen = ? WHERE id = ?";
285
+ const changed = getDb().query(sql).run(status, now, id).changes > 0;
286
+ if (changed && status === "offline") closeOpenPairsForAgent(id, now);
287
+ if (changed && status === "offline") electWorkspaceStewards();
288
+ return changed;
289
+ }
290
+
291
+ export function markReady(id: string, ready: boolean, guard?: AgentSessionGuard): boolean {
292
+ if (!validateAgentSession(id, guard).ok) return false;
293
+ const now = Date.now();
294
+ return (
295
+ getDb()
296
+ .query("UPDATE agents SET ready = ?, last_seen = ? WHERE id = ?")
297
+ .run(ready ? 1 : 0, now, id).changes > 0
298
+ );
299
+ }
300
+
301
+ export function mergeAgentMeta(id: string, meta: Record<string, unknown>, guard?: AgentSessionGuard): boolean {
302
+ if (!validateAgentSession(id, guard).ok) return false;
303
+ const agent = getAgent(id);
304
+ if (!agent) return false;
305
+ const merged = { ...(agent.meta ?? {}), ...meta };
306
+ const result = getDb()
307
+ .query("UPDATE agents SET meta = ?, last_seen = ? WHERE id = ?")
308
+ .run(JSON.stringify(merged), Date.now(), id);
309
+ return result.changes > 0;
310
+ }
311
+
312
+ export function recordAgentExitDiagnostics(id: string, diagnostics: ManagedSessionExitDiagnostics): AgentCard | null {
313
+ const agent = getAgent(id);
314
+ if (!agent) return null;
315
+ const now = Date.now();
316
+ const merged = {
317
+ ...(agent.meta ?? {}),
318
+ lastError: diagnostics.lastError,
319
+ lastExit: diagnostics,
320
+ lastExitAt: diagnostics.detectedAt || now,
321
+ };
322
+ const result = getDb()
323
+ .query("UPDATE agents SET status = 'offline', ready = 0, meta = ?, last_seen = ? WHERE id = ?")
324
+ .run(JSON.stringify(merged), now, id);
325
+ if (result.changes <= 0) return null;
326
+ closeOpenPairsForAgent(id, now);
327
+ return getAgent(id);
328
+ }
329
+
330
+ export function heartbeat(
331
+ id: string,
332
+ guard?: AgentSessionGuard,
333
+ runtime?: { providerCapabilities?: ProviderCapabilities; context?: ContextState },
334
+ ): boolean {
335
+ if (!validateAgentSession(id, guard).ok) return false;
336
+ const now = Date.now();
337
+ if (runtime?.providerCapabilities || runtime?.context) {
338
+ const result = getDb()
339
+ .query(`
340
+ UPDATE agents SET
341
+ last_seen = ?,
342
+ status = CASE WHEN status = 'offline' THEN 'idle' ELSE status END,
343
+ provider_capabilities = coalesce(?, provider_capabilities),
344
+ context_state = coalesce(?, context_state)
345
+ WHERE id = ?
346
+ `)
347
+ .run(
348
+ now,
349
+ runtime.providerCapabilities ? JSON.stringify(runtime.providerCapabilities) : null,
350
+ runtime.context ? JSON.stringify(runtime.context) : null,
351
+ id,
352
+ );
353
+ if (result.changes > 0 && runtime.context) recordContextSnapshot(id, runtime.context, now);
354
+ return result.changes > 0;
355
+ }
356
+ const result = getDb()
357
+ .query("UPDATE agents SET last_seen = ?, status = CASE WHEN status = 'offline' THEN 'idle' ELSE status END WHERE id = ?")
358
+ .run(now, id);
359
+ return result.changes > 0;
360
+ }
361
+
362
+ export function listContextSnapshots(agentId: string, options: { since?: number; limit?: number } = {}): ContextSnapshot[] {
363
+ const limit = Math.max(1, Math.min(options.limit ?? 288, 1000));
364
+ const params: any[] = [agentId];
365
+ let sql = "SELECT * FROM context_snapshots WHERE agent_id = ?";
366
+ if (options.since !== undefined) {
367
+ sql += " AND captured_at >= ?";
368
+ params.push(options.since);
369
+ }
370
+ sql += " ORDER BY captured_at ASC LIMIT ?";
371
+ params.push(limit);
372
+ return (getDb().query(sql).all(...params) as any[]).map(rowToContextSnapshot);
373
+ }
374
+
375
+ export function pruneContextSnapshots(agentId?: string, olderThan = Date.now() - DAY_MS): number {
376
+ const result = agentId
377
+ ? getDb().query("DELETE FROM context_snapshots WHERE agent_id = ? AND captured_at < ?").run(agentId, olderThan)
378
+ : getDb().query("DELETE FROM context_snapshots WHERE captured_at < ?").run(olderThan);
379
+ return result.changes;
380
+ }
381
+
382
+ export function recordContextSnapshot(agentId: string, context: ContextState, now: number): void {
383
+ const latest = getDb()
384
+ .query("SELECT captured_at FROM context_snapshots WHERE agent_id = ? ORDER BY captured_at DESC LIMIT 1")
385
+ .get(agentId) as { captured_at: number } | undefined;
386
+ if (latest && latest.captured_at > now - CONTEXT_SNAPSHOT_DEBOUNCE_MS) return;
387
+
388
+ getDb().query(`
389
+ INSERT INTO context_snapshots (
390
+ agent_id,
391
+ utilization,
392
+ lifecycle_state,
393
+ tokens_used,
394
+ tokens_max,
395
+ source,
396
+ confidence,
397
+ context_state,
398
+ captured_at
399
+ )
400
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
401
+ `).run(
402
+ agentId,
403
+ context.utilization,
404
+ context.lifecycleState,
405
+ context.tokensUsed ?? null,
406
+ context.tokensMax ?? null,
407
+ context.source,
408
+ context.confidence,
409
+ JSON.stringify(context),
410
+ now,
411
+ );
412
+ pruneContextSnapshots(agentId, now - DAY_MS);
413
+ }
414
+
415
+
416
+ export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
417
+ const now = Date.now();
418
+ const cutoff = now - ttlMs;
419
+ getDb().query("UPDATE agents SET last_seen = ? WHERE id IN ('user', 'system')").run(now);
420
+ const rows = getDb()
421
+ .query(
422
+ "UPDATE agents SET status = 'offline', ready = 0 WHERE status NOT IN ('offline', 'stale') AND last_seen < ? AND id NOT IN ('user', 'system') RETURNING id"
423
+ )
424
+ .all(cutoff) as any[];
425
+ for (const row of rows) {
426
+ revokeRuntimeTokensForAgent(row.id, now);
427
+ closeOpenPairsForAgent(row.id, now);
428
+ }
429
+ return rows.map((r: any) => r.id);
430
+ }
431
+
432
+ // On-demand automation tasks (targetMode=on_demand_agent) are bound to a single
433
+ // ephemeral agent spawned just for that task. When that agent's claim is released —
434
+ // clean shutdown, prune, lease expiry, or orphan-grace — the task must NOT return to
435
+ // the claimable pool: its target is a unique `label:automation-…` that no other agent
436
+ // will ever match, so a re-opened task lingers forever as an orphaned claim. Resolve
437
+ // those as done instead; automation reconcile then settles the run as succeeded.
438
+ // Run this BEFORE the generic re-open UPDATE at each release site, with the same WHERE
439
+ // condition: it flips matching single-target tasks to 'done', which the subsequent
440
+ // re-open (gated on status IN claimed/in_progress/blocked/orphaned) then skips.
441
+ export function settleSingleTargetOnDemandTasks(condition: string, params: any[], now: number, reason: string): void {
442
+ const selectSql = `${TASK_SELECT} WHERE (${condition}) AND json_extract(metadata, '$.targetMode') = 'on_demand_agent' AND status IN ('claimed', 'in_progress', 'blocked', 'orphaned')`;
443
+ const rows = getDb().query(selectSql).all(...params) as any[];
444
+ if (rows.length === 0) return;
445
+ getDb().query(
446
+ `UPDATE tasks SET status = 'done', claim_expires_at = NULL, updated_at = ?, last_seen_at = ? WHERE (${condition}) AND json_extract(metadata, '$.targetMode') = 'on_demand_agent' AND status IN ('claimed', 'in_progress', 'blocked', 'orphaned')`
447
+ ).run(now, now, ...params);
448
+ for (const row of rows) {
449
+ insertTaskEvent(row.id, {
450
+ source: "agent-relay",
451
+ type: "task.auto-completed",
452
+ severity: row.severity,
453
+ title: "On-demand task auto-resolved",
454
+ body: `On-demand agent ${row.claimed_by ?? "(unknown)"} exited (${reason}); task resolved so it does not orphan`,
455
+ metadata: { agentId: row.claimed_by, reason, completedBy: "relay" },
456
+ }, now);
457
+ }
458
+ }
459
+
460
+ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
461
+ const cutoff = Date.now() - maxOfflineMs;
462
+ return getDb().transaction(() => {
463
+ const rows = getDb()
464
+ .query(
465
+ "SELECT id FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system')"
466
+ )
467
+ .all(cutoff) as any[];
468
+ if (rows.length === 0) return [];
469
+ const now = Date.now();
470
+
471
+ // Release claims held by pruned agents so work becomes claimable again.
472
+ getDb()
473
+ .query(
474
+ "UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE claimed_by IN (SELECT id FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system'))"
475
+ )
476
+ .run(cutoff);
477
+
478
+ const offlineClaimCondition = "claimed_by IN (SELECT id FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system'))";
479
+ settleSingleTargetOnDemandTasks(offlineClaimCondition, [cutoff], now, "agent-pruned");
480
+
481
+ getDb()
482
+ .query(
483
+ `UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE ${offlineClaimCondition} AND status IN ('claimed', 'in_progress', 'blocked')`
484
+ )
485
+ .run(now, cutoff);
486
+
487
+ for (const row of rows) {
488
+ revokeRuntimeTokensForAgent(row.id, now);
489
+ closeOpenPairsForAgent(row.id, now);
490
+ }
491
+
492
+ getDb()
493
+ .query(
494
+ "DELETE FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system')"
495
+ )
496
+ .run(cutoff);
497
+
498
+ return rows.map((r: any) => r.id);
499
+ })();
500
+ }
501
+
502
+ export function deleteAgent(id: string): { ok: boolean; error?: string } {
503
+ if (id === "user" || id === "system") {
504
+ return { ok: false, error: "built-in agent cannot be deleted" };
505
+ }
506
+ const deleted = getDb().transaction(() => {
507
+ // Release any claims held by this agent so the tasks become claimable again.
508
+ // from_agent is left intact as historical record.
509
+ const now = Date.now();
510
+ getDb().query("UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE claimed_by = ?").run(id);
511
+ settleSingleTargetOnDemandTasks("claimed_by = ?", [id], now, "agent-removed");
512
+ getDb().query("UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE claimed_by = ? AND status IN ('claimed', 'in_progress', 'blocked')").run(now, id);
513
+ revokeRuntimeTokensForAgent(id, now);
514
+ closeOpenPairsForAgent(id, now);
515
+ return getDb().query("DELETE FROM agents WHERE id = ?").run(id).changes > 0;
516
+ })();
517
+ return deleted ? { ok: true } : { ok: false, error: "agent not found" };
518
+ }
519
+
520
+ export function revokeRuntimeTokensForAgent(agentId: string, now = Date.now()): string[] {
521
+ const row = getDb().query("SELECT meta FROM agents WHERE id = ?").get(agentId) as { meta?: string } | undefined;
522
+ const jtis = runtimeTokenJtisFromMeta(parseJson(row?.meta ?? "{}", {}));
523
+ if (jtis.length === 0) return [];
524
+ const revokedAt = Math.floor(now / 1000);
525
+ const placeholders = jtis.map(() => "?").join(",");
526
+ const rows = getDb().query(`
527
+ UPDATE tokens
528
+ SET revoked_at = ?
529
+ WHERE revoked_at IS NULL
530
+ AND role = 'provider'
531
+ AND profile_id IN ('provider-agent', 'provider-interactive', 'provider-child')
532
+ AND jti IN (${placeholders})
533
+ RETURNING jti
534
+ `).all(revokedAt, ...jtis) as Array<{ jti: string }>;
535
+ return rows.map((item) => item.jti);
536
+ }
537
+
538
+ export function runtimeTokenJtisFromMeta(meta: Record<string, unknown>): string[] {
539
+ const jtis = new Set<string>();
540
+ const auth = isRecord(meta.auth) ? meta.auth : undefined;
541
+ const direct = typeof auth?.jti === "string" ? auth.jti : typeof meta.runtimeTokenJti === "string" ? meta.runtimeTokenJti : undefined;
542
+ if (direct) jtis.add(direct);
543
+ const extra = Array.isArray(auth?.jtis) ? auth.jtis : Array.isArray(meta.runtimeTokenJtis) ? meta.runtimeTokenJtis : [];
544
+ for (const item of extra) {
545
+ if (typeof item === "string") jtis.add(item);
546
+ }
547
+ return [...jtis];
548
+ }
549
+
550
+ // --- Tasks ---
551
+