agent-relay-server 0.62.2 → 0.63.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 (161) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-DRrxfjyT.js → MessageBubble-Cl6ufPHw.js} +2 -2
  4. package/public/assets/MessageBubble-Cl6ufPHw.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-Dp2eYazz.js → PlanGraphPanel-BnfjmkQ8.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-Dp2eYazz.js.map → PlanGraphPanel-BnfjmkQ8.js.map} +1 -1
  7. package/public/assets/{activity--t0U2yzq.js → activity-aFxvGLtd.js} +2 -2
  8. package/public/assets/{activity--t0U2yzq.js.map → activity-aFxvGLtd.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BMtJYzxn.js → agent-profiles-B4mleUDz.js} +2 -2
  10. package/public/assets/{agent-profiles-BMtJYzxn.js.map → agent-profiles-B4mleUDz.js.map} +1 -1
  11. package/public/assets/{agents-Ck9AePlc.js → agents-DW5djToQ.js} +2 -2
  12. package/public/assets/{agents-Ck9AePlc.js.map → agents-DW5djToQ.js.map} +1 -1
  13. package/public/assets/{analytics-CzfK387L.js → analytics-CRi6thtg.js} +2 -2
  14. package/public/assets/{analytics-CzfK387L.js.map → analytics-CRi6thtg.js.map} +1 -1
  15. package/public/assets/{automation-Cgw8vtE8.js → automation-Do4OpauI.js} +2 -2
  16. package/public/assets/{automation-Cgw8vtE8.js.map → automation-Do4OpauI.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-DnEEPtOP.js → branch-state-badge-D3W3w7zz.js} +2 -2
  18. package/public/assets/{branch-state-badge-DnEEPtOP.js.map → branch-state-badge-D3W3w7zz.js.map} +1 -1
  19. package/public/assets/{channels-Dc3L2MQs.js → channels-BytdR9NS.js} +2 -2
  20. package/public/assets/{channels-Dc3L2MQs.js.map → channels-BytdR9NS.js.map} +1 -1
  21. package/public/assets/{chat-Fc6iJ7sa.js → chat-CvaQ2czL.js} +2 -2
  22. package/public/assets/{chat-Fc6iJ7sa.js.map → chat-CvaQ2czL.js.map} +1 -1
  23. package/public/assets/{connectors-Dc2ttGWs.js → connectors-BLQDp8Bc.js} +2 -2
  24. package/public/assets/{connectors-Dc2ttGWs.js.map → connectors-BLQDp8Bc.js.map} +1 -1
  25. package/public/assets/{display-DzNvOu1j.js → display-B06I5sqa.js} +3 -3
  26. package/public/assets/display-B06I5sqa.js.map +1 -0
  27. package/public/assets/{formatted-body-impl-Bg-gx7rG.js → formatted-body-impl-0Lf8e1Ic.js} +2 -2
  28. package/public/assets/{formatted-body-impl-Bg-gx7rG.js.map → formatted-body-impl-0Lf8e1Ic.js.map} +1 -1
  29. package/public/assets/{index-BeDncjN4.js → index-C3su8ezq.js} +6 -6
  30. package/public/assets/{index-BeDncjN4.js.map → index-C3su8ezq.js.map} +1 -1
  31. package/public/assets/{insights-c08aW-7n.js → insights-64uExH1E.js} +2 -2
  32. package/public/assets/{insights-c08aW-7n.js.map → insights-64uExH1E.js.map} +1 -1
  33. package/public/assets/{integrations-BwpgcqnF.js → integrations-HyC1EaIp.js} +2 -2
  34. package/public/assets/{integrations-BwpgcqnF.js.map → integrations-HyC1EaIp.js.map} +1 -1
  35. package/public/assets/{maintenance-CTKP0kWB.js → maintenance-EVBpKH07.js} +2 -2
  36. package/public/assets/{maintenance-CTKP0kWB.js.map → maintenance-EVBpKH07.js.map} +1 -1
  37. package/public/assets/{managed-agents-BofCxo8d.js → managed-agents-BLZPxGuw.js} +2 -2
  38. package/public/assets/{managed-agents-BofCxo8d.js.map → managed-agents-BLZPxGuw.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-CQ-6BIb8.js → markdown-preview-impl-Bg6rCWYN.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-CQ-6BIb8.js.map → markdown-preview-impl-Bg6rCWYN.js.map} +1 -1
  41. package/public/assets/{memory-DvfXjMoi.js → memory-Bjr8bgjj.js} +2 -2
  42. package/public/assets/{memory-DvfXjMoi.js.map → memory-Bjr8bgjj.js.map} +1 -1
  43. package/public/assets/{messages-C9VsPzZ_.js → messages-CRYw1F4z.js} +2 -2
  44. package/public/assets/{messages-C9VsPzZ_.js.map → messages-CRYw1F4z.js.map} +1 -1
  45. package/public/assets/{orchestrators-MffVueeE.js → orchestrators-D-co1vdp.js} +2 -2
  46. package/public/assets/{orchestrators-MffVueeE.js.map → orchestrators-D-co1vdp.js.map} +1 -1
  47. package/public/assets/{overview-BSVajefM.js → overview-Ddy5czri.js} +2 -2
  48. package/public/assets/{overview-BSVajefM.js.map → overview-Ddy5czri.js.map} +1 -1
  49. package/public/assets/{pairs-B5711-Cs.js → pairs-CFVM8uHL.js} +2 -2
  50. package/public/assets/{pairs-B5711-Cs.js.map → pairs-CFVM8uHL.js.map} +1 -1
  51. package/public/assets/{security-DkUwK9Vj.js → security-Df4_76bh.js} +2 -2
  52. package/public/assets/{security-DkUwK9Vj.js.map → security-Df4_76bh.js.map} +1 -1
  53. package/public/assets/{select-DrM2RRiT.js → select-Cq3BBnL9.js} +2 -2
  54. package/public/assets/{select-DrM2RRiT.js.map → select-Cq3BBnL9.js.map} +1 -1
  55. package/public/assets/{settings-DdKCb-vy.js → settings-DOLEYXZL.js} +2 -2
  56. package/public/assets/{settings-DdKCb-vy.js.map → settings-DOLEYXZL.js.map} +1 -1
  57. package/public/assets/{store-iTiheuTD.js → store-C1E8jmNE.js} +4 -4
  58. package/public/assets/store-C1E8jmNE.js.map +1 -0
  59. package/public/assets/{tasks-e1y1PFfO.js → tasks-BcsmPi9_.js} +2 -2
  60. package/public/assets/{tasks-e1y1PFfO.js.map → tasks-BcsmPi9_.js.map} +1 -1
  61. package/public/assets/{teams-BUcxoGC_.js → teams-BQrV3eKI.js} +2 -2
  62. package/public/assets/{teams-BUcxoGC_.js.map → teams-BQrV3eKI.js.map} +1 -1
  63. package/public/assets/{terminal-viewer-impl-CmZjQOB2.js → terminal-viewer-impl-Dle04qpH.js} +2 -2
  64. package/public/assets/{terminal-viewer-impl-CmZjQOB2.js.map → terminal-viewer-impl-Dle04qpH.js.map} +1 -1
  65. package/public/assets/{work-queue-CXBQx43n.js → work-queue-BLhTLg4o.js} +2 -2
  66. package/public/assets/{work-queue-CXBQx43n.js.map → work-queue-BLhTLg4o.js.map} +1 -1
  67. package/public/assets/{workspaces-D2EfZh9a.js → workspaces-4NCNTmRQ.js} +3 -3
  68. package/public/assets/{workspaces-D2EfZh9a.js.map → workspaces-4NCNTmRQ.js.map} +1 -1
  69. package/public/index.html +3 -3
  70. package/scripts/orchestrator-spawn-smoke.ts +87 -67
  71. package/src/agent-ref.ts +8 -6
  72. package/src/automations/constants.ts +12 -0
  73. package/src/automations/cron.ts +129 -0
  74. package/src/automations/crud.ts +84 -0
  75. package/src/automations/dispatch.ts +275 -0
  76. package/src/automations/index.ts +5 -0
  77. package/src/automations/reconcile.ts +245 -0
  78. package/src/automations/storage.ts +181 -0
  79. package/src/automations/types.ts +16 -0
  80. package/src/automations/validation.ts +140 -0
  81. package/src/automations.ts +1 -1045
  82. package/src/db/activity.ts +1 -1
  83. package/src/db/agent-predicates.ts +9 -0
  84. package/src/db/agent-search.ts +2 -2
  85. package/src/db/agents.ts +13 -41
  86. package/src/db/artifacts.ts +8 -8
  87. package/src/db/channel-identity.ts +38 -0
  88. package/src/db/channels.ts +37 -61
  89. package/src/db/continuation-archives.ts +4 -4
  90. package/src/db/delivery.ts +75 -30
  91. package/src/db/inbox.ts +1 -1
  92. package/src/db/integrations.ts +4 -4
  93. package/src/db/issue-lifecycle.ts +6 -6
  94. package/src/db/mappers.ts +1 -2
  95. package/src/db/merge-lease.ts +3 -3
  96. package/src/db/message-claims.ts +16 -0
  97. package/src/db/message-queries.ts +8 -0
  98. package/src/db/message-reads.ts +157 -18
  99. package/src/db/messages.ts +23 -48
  100. package/src/db/notification-subscriptions.ts +2 -2
  101. package/src/db/orchestrators.ts +4 -4
  102. package/src/db/pair-maintenance.ts +9 -0
  103. package/src/db/pairs.ts +10 -18
  104. package/src/db/plans.ts +1 -1
  105. package/src/db/policy-runtime.ts +14 -0
  106. package/src/db/project-entries.ts +1 -1
  107. package/src/db/projects.ts +2 -2
  108. package/src/db/shared.ts +7 -0
  109. package/src/db/stats.ts +2 -2
  110. package/src/db/task-core.ts +18 -0
  111. package/src/db/task-maintenance.ts +24 -0
  112. package/src/db/tasks.ts +5 -17
  113. package/src/db/teams.ts +3 -3
  114. package/src/db/workspaces.ts +6 -6
  115. package/src/issue-tracker/github.ts +3 -3
  116. package/src/maintenance/constants.ts +35 -0
  117. package/src/maintenance/events.ts +32 -0
  118. package/src/maintenance/index.ts +7 -0
  119. package/src/maintenance/jobs.ts +340 -0
  120. package/src/maintenance/orphaned-sessions.ts +86 -0
  121. package/src/maintenance/scheduler.ts +231 -0
  122. package/src/maintenance/teams.ts +65 -0
  123. package/src/maintenance/types.ts +31 -0
  124. package/src/maintenance/workspaces.ts +251 -0
  125. package/src/maintenance.ts +1 -1099
  126. package/src/mcp/identity.ts +131 -0
  127. package/src/mcp/index.ts +208 -0
  128. package/src/mcp/tools-messaging.ts +459 -0
  129. package/src/mcp/tools-spawn.ts +145 -0
  130. package/src/mcp/tools-workspace.ts +227 -0
  131. package/src/mcp/types.ts +24 -0
  132. package/src/mcp/validation.ts +104 -0
  133. package/src/mcp.ts +1 -1319
  134. package/src/notification-router.ts +2 -2
  135. package/src/notification-rules.ts +2 -2
  136. package/src/project-ingest.ts +3 -3
  137. package/src/project-instructions.ts +1 -1
  138. package/src/provider-config-store.ts +3 -3
  139. package/src/provider-state.ts +1 -1
  140. package/src/reviewer-pipeline.ts +1 -1
  141. package/src/self-resume.ts +3 -7
  142. package/src/services/agent-action.ts +2 -2
  143. package/src/services/issue-activity.ts +0 -4
  144. package/src/services/issue-associations.ts +2 -2
  145. package/src/services/issue-lifecycle.ts +7 -7
  146. package/src/services/managed-running.ts +1 -1
  147. package/src/services/parity-harness.ts +3 -3
  148. package/src/services/plan-graph.ts +3 -3
  149. package/src/services/send-message.ts +16 -12
  150. package/src/services/shutdown-agent.ts +2 -2
  151. package/src/services/team-templates.ts +2 -2
  152. package/src/services/team.ts +2 -2
  153. package/src/settings/modules.ts +1 -1
  154. package/src/settings/registry.ts +3 -3
  155. package/src/spawn-command.ts +1 -1
  156. package/src/sse.ts +0 -4
  157. package/src/workspace-actions.ts +1 -1
  158. package/src/workspace-probe.ts +3 -7
  159. package/public/assets/MessageBubble-DRrxfjyT.js.map +0 -1
  160. package/public/assets/display-DzNvOu1j.js.map +0 -1
  161. package/public/assets/store-iTiheuTD.js.map +0 -1
package/src/db/inbox.ts CHANGED
@@ -212,7 +212,7 @@ export function createChatHistoryImport(input: {
212
212
  return getChatHistoryImport(id)!;
213
213
  }
214
214
 
215
- export function getChatHistoryImport(id: string): ChatHistoryImport | null {
215
+ function getChatHistoryImport(id: string): ChatHistoryImport | null {
216
216
  const row = getDb().query("SELECT * FROM chat_history_imports WHERE id = ?").get(id) as any | undefined;
217
217
  if (!row) return null;
218
218
  const entries = (getDb().query(
@@ -212,7 +212,7 @@ export function listIntegrationTaskStats(): IntegrationTaskStats[] {
212
212
  }));
213
213
  }
214
214
 
215
- export type IntegrationRegistryInput = {
215
+ type IntegrationRegistryInput = {
216
216
  name: string;
217
217
  displayName?: string;
218
218
  description?: string;
@@ -231,7 +231,7 @@ export type IntegrationRegistryInput = {
231
231
  source?: "api" | "env" | "observed";
232
232
  };
233
233
 
234
- export function rowToIntegrationRegistry(row: any): Omit<IntegrationSummary, "configured" | "observed" | "callbackConfigured" | "rateLimit" | "taskStats"> & {
234
+ function rowToIntegrationRegistry(row: any): Omit<IntegrationSummary, "configured" | "observed" | "callbackConfigured" | "rateLimit" | "taskStats"> & {
235
235
  source: "api" | "env" | "observed";
236
236
  lastEventAt?: number;
237
237
  lastTaskAt?: number;
@@ -312,7 +312,7 @@ export function upsertIntegrationRegistry(input: IntegrationRegistryInput): Retu
312
312
  return getIntegrationRegistry(name)!;
313
313
  }
314
314
 
315
- export function getIntegrationRegistry(name: string): ReturnType<typeof rowToIntegrationRegistry> | null {
315
+ function getIntegrationRegistry(name: string): ReturnType<typeof rowToIntegrationRegistry> | null {
316
316
  const row = getDb().query("SELECT * FROM integration_registry WHERE name = ?").get(name) as any;
317
317
  return row ? rowToIntegrationRegistry(row) : null;
318
318
  }
@@ -321,7 +321,7 @@ export function listIntegrationRegistry(): ReturnType<typeof rowToIntegrationReg
321
321
  return (getDb().query("SELECT * FROM integration_registry ORDER BY display_name COLLATE NOCASE, name COLLATE NOCASE").all() as any[]).map(rowToIntegrationRegistry);
322
322
  }
323
323
 
324
- export function updateIntegrationObserved(name: string, eventAt: number): void {
324
+ function updateIntegrationObserved(name: string, eventAt: number): void {
325
325
  const now = Date.now();
326
326
  getDb().query(`
327
327
  INSERT INTO integration_registry (name, enabled, scopes, targets, channels, tags, manifest, source, created_at, updated_at, last_event_at, last_task_at)
@@ -1,11 +1,11 @@
1
1
  import { getDb, ValidationError } from "./connection.ts";
2
2
 
3
- export const ISSUE_LIFECYCLE_ACTIONS = ["close", "reopen"] as const;
4
- export const ISSUE_LIFECYCLE_STATUSES = ["succeeded", "failed"] as const;
5
- export type IssueLifecycleAction = (typeof ISSUE_LIFECYCLE_ACTIONS)[number];
6
- export type IssueLifecycleStatus = (typeof ISSUE_LIFECYCLE_STATUSES)[number];
3
+ const ISSUE_LIFECYCLE_ACTIONS = ["close", "reopen"] as const;
4
+ const ISSUE_LIFECYCLE_STATUSES = ["succeeded", "failed"] as const;
5
+ type IssueLifecycleAction = (typeof ISSUE_LIFECYCLE_ACTIONS)[number];
6
+ type IssueLifecycleStatus = (typeof ISSUE_LIFECYCLE_STATUSES)[number];
7
7
 
8
- export interface IssueLifecycleActionRecord {
8
+ interface IssueLifecycleActionRecord {
9
9
  id: number;
10
10
  issueRefId: string;
11
11
  action: IssueLifecycleAction;
@@ -24,7 +24,7 @@ export interface IssueLifecycleActionRecord {
24
24
  updatedAt: number;
25
25
  }
26
26
 
27
- export interface UpsertIssueLifecycleActionInput {
27
+ interface UpsertIssueLifecycleActionInput {
28
28
  issueRefId: string;
29
29
  action: IssueLifecycleAction;
30
30
  landKey: string;
package/src/db/mappers.ts CHANGED
@@ -14,8 +14,7 @@ import {
14
14
  } from "../contracts";
15
15
  import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
16
16
  import { matchAgents } from "../agent-ref";
17
- import { parseStringArray } from "./agents.ts";
18
- import { artifactId } from "./artifacts.ts";
17
+ import { parseStringArray } from "./shared.ts";
19
18
  import type {
20
19
  AgentCard,
21
20
  ActivityEvent,
@@ -80,7 +80,7 @@ import type {
80
80
 
81
81
  // --- Per-repo merge serialization lease (issue #157) -----------------------
82
82
 
83
- export interface MergeLeaseRecord {
83
+ interface MergeLeaseRecord {
84
84
  repoRoot: string;
85
85
  workspaceId: string;
86
86
  commandId?: string;
@@ -89,7 +89,7 @@ export interface MergeLeaseRecord {
89
89
  expiresAt: number;
90
90
  }
91
91
 
92
- export function rowToMergeLease(row: any): MergeLeaseRecord {
92
+ function rowToMergeLease(row: any): MergeLeaseRecord {
93
93
  return {
94
94
  repoRoot: row.repo_root,
95
95
  workspaceId: row.workspace_id,
@@ -171,7 +171,7 @@ export interface MergePauseRecord {
171
171
  expiresAt: number;
172
172
  }
173
173
 
174
- export function rowToMergePause(row: any): MergePauseRecord {
174
+ function rowToMergePause(row: any): MergePauseRecord {
175
175
  return {
176
176
  id: row.id,
177
177
  repoRoot: row.repo_root,
@@ -0,0 +1,16 @@
1
+ import { CLAIM_LEASE_MS } from "../config";
2
+ import { getDb } from "./connection.ts";
3
+
4
+ export function claimMessageRow(messageId: number, agentId: string, now: number): { ok: false; error: string } | { ok: true } {
5
+ const expiresAt = now + CLAIM_LEASE_MS;
6
+ // Idempotent: a same-agent re-claim refreshes the lease (preserving the original claimed_at).
7
+ // Without this, a delivery failure that locally drops the claim strands the message until the
8
+ // lease expires (#261) — the holder's retry would otherwise 409 against its own claim.
9
+ const result = getDb().query(
10
+ "UPDATE messages SET claimed_by = ?, claimed_at = COALESCE(claimed_at, ?), claim_expires_at = ? WHERE id = ? AND (claimed_by IS NULL OR claimed_by = ?)"
11
+ ).run(agentId, now, expiresAt, messageId, agentId);
12
+
13
+ // Atomic: if changes === 0, someone else claimed it between our read and write
14
+ if (result.changes === 0) return { ok: false, error: "claim race — already claimed" };
15
+ return { ok: true };
16
+ }
@@ -0,0 +1,8 @@
1
+ import { getDb } from "./connection.ts";
2
+ import { MSG_SELECT, rowToMessage } from "./mappers.ts";
3
+ import type { Message } from "../types";
4
+
5
+ export function getMessage(id: number): Message | null {
6
+ const row = getDb().query(`${MSG_SELECT} WHERE m.id = ?`).get(id) as any;
7
+ return row ? rowToMessage(row) : null;
8
+ }
@@ -15,7 +15,7 @@ import {
15
15
  import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
16
16
  import { matchAgents } from "../agent-ref";
17
17
  import { getAgent } from "./agents.ts";
18
- import { sendMessageWithResult } from "./messages.ts";
18
+ import { getMessage, sendMessageWithResult } from "./messages.ts";
19
19
  import { deleteArtifactLinksForEntity } from "./artifacts.ts";
20
20
  import { getDb, timedQuery } from "./connection.ts";
21
21
  import { legacyChannelTargets, isChannelAgentId } from "./delivery.ts";
@@ -159,6 +159,124 @@ export function listAgentChatHistory(query: {
159
159
  return ascending ? messages : messages.reverse();
160
160
  }
161
161
 
162
+ function agentParticipationClauses(
163
+ alias: string,
164
+ agent: AgentCard,
165
+ legacyTargets: string[],
166
+ params: any[],
167
+ ): string[] {
168
+ const clauses = [
169
+ `${alias}.from_agent = ?`,
170
+ `${alias}.to_target = ?`,
171
+ `${alias}.resolved_to_agent = ?`,
172
+ ];
173
+ params.push(agent.id, agent.id, agent.id);
174
+ for (const tag of agent.tags) {
175
+ clauses.push(`${alias}.to_target = ?`);
176
+ params.push(`tag:${tag}`);
177
+ }
178
+ for (const cap of agent.capabilities) {
179
+ clauses.push(`${alias}.to_target = ?`);
180
+ params.push(`cap:${cap}`);
181
+ }
182
+ if (agent.label) {
183
+ clauses.push(`${alias}.to_target = ?`);
184
+ params.push(`label:${agent.label}`);
185
+ }
186
+ if (agent.teamId) {
187
+ clauses.push(`${alias}.to_target = ?`);
188
+ params.push(`team:${agent.teamId}`);
189
+ }
190
+ for (const target of legacyTargets) {
191
+ clauses.push(`${alias}.to_target = ?`);
192
+ params.push(target);
193
+ }
194
+ return clauses;
195
+ }
196
+
197
+ function scopedBroadcastClause(agent: AgentCard | null | undefined, legacyTargets: string[], params: any[]): string {
198
+ const senderGlobal = "NOT EXISTS (SELECT 1 FROM agents sender WHERE sender.id = m.from_agent AND sender.team_id IS NOT NULL)";
199
+ const noChannel = agent?.teamId
200
+ ? `(m.channel IS NULL AND (${senderGlobal} OR EXISTS (SELECT 1 FROM agents sender WHERE sender.id = m.from_agent AND sender.team_id = ?)))`
201
+ : `(m.channel IS NULL AND ${senderGlobal})`;
202
+ if (agent?.teamId) params.push(agent.teamId);
203
+ if (!agent) return noChannel;
204
+
205
+ const participantParams: any[] = [];
206
+ const participantClauses = agentParticipationClauses("cp", agent, legacyTargets, participantParams);
207
+ const channel = `(m.channel IS NOT NULL AND (m.from_agent = ? OR EXISTS (
208
+ SELECT 1 FROM messages cp
209
+ WHERE cp.channel = m.channel
210
+ AND cp.id <> m.id
211
+ AND (${participantClauses.join(" OR ")})
212
+ )))`;
213
+ params.push(agent.id, ...participantParams);
214
+ return `(${noChannel} OR ${channel})`;
215
+ }
216
+
217
+ function freshForSessionClause(agent: AgentCard | null | undefined, params: any[]): string {
218
+ // Same-ms registration+send can happen in tests and on fast local loops; >= preserves genuine
219
+ // post-registration messages while still excluding historical fan-out backlog for fresh sessions.
220
+ params.push(agent?.createdAt ?? Date.now());
221
+ return "(m.claimable = 1 OR m.created_at >= ?)";
222
+ }
223
+
224
+ function freshFanoutTargetClause(target: string, agent: AgentCard | null | undefined, params: any[]): string {
225
+ params.push(target);
226
+ return `(m.to_target = ? AND ${freshForSessionClause(agent, params)})`;
227
+ }
228
+
229
+ /**
230
+ * Emojis that count as an explicit acknowledgement of a message thread (#437).
231
+ * A reaction with one of these suppresses the reply-reminder on subsequent deliveries.
232
+ * Policy: thumbs-up (👍) and check-mark (✅) classes only — passing reactions like ❤️
233
+ * or 👀 are not settling and must never cause false suppression.
234
+ */
235
+ export const ACK_REACTION_EMOJIS: readonly string[] = ["👍", "✅"];
236
+
237
+ /**
238
+ * Annotates messages with `replyExpected: false` when this agent has already settled them
239
+ * via a direct/thread reply OR an ack-class reaction. Used at poll time so the runner's
240
+ * delivery formatting naturally drops them from the reply-reminder anchor (#437).
241
+ *
242
+ * A single batch SQL query covers all replyable messages — no per-message round-trips.
243
+ */
244
+ function annotateAnsweredMessages(messages: Message[], agentId: string): Message[] {
245
+ const replyableIds = messages
246
+ .filter((m) => m.replyExpected !== false && Number.isSafeInteger(m.id) && m.id > 0)
247
+ .map((m) => m.id);
248
+ if (replyableIds.length === 0) return messages;
249
+
250
+ const idPlaceholders = replyableIds.map(() => "?").join(", ");
251
+ const ackPlaceholders = ACK_REACTION_EMOJIS.map(() => "?").join(", ");
252
+
253
+ const answeredRows = getDb().query(`
254
+ SELECT m.id FROM messages m
255
+ WHERE m.id IN (${idPlaceholders})
256
+ AND (
257
+ EXISTS (
258
+ SELECT 1 FROM messages r
259
+ WHERE r.from_agent = ?
260
+ AND (
261
+ r.reply_to = m.id
262
+ OR (m.thread_id IS NOT NULL AND r.thread_id = m.thread_id AND r.id > m.id)
263
+ )
264
+ )
265
+ OR EXISTS (
266
+ SELECT 1 FROM message_reactions mr
267
+ WHERE mr.message_id = m.id
268
+ AND mr.actor_id = ?
269
+ AND mr.emoji IN (${ackPlaceholders})
270
+ AND mr.created_at >= m.created_at
271
+ )
272
+ )
273
+ `).all(...replyableIds, agentId, agentId, ...ACK_REACTION_EMOJIS) as Array<{ id: number }>;
274
+
275
+ if (answeredRows.length === 0) return messages;
276
+ const answeredIds = new Set(answeredRows.map((r) => r.id));
277
+ return messages.map((m) => (answeredIds.has(m.id) ? { ...m, replyExpected: false as const } : m));
278
+ }
279
+
162
280
  export function pollMessages(query: PollQuery): Message[] {
163
281
  const agent = getAgent(query.for);
164
282
  const agentTags = agent?.tags ?? [];
@@ -170,24 +288,28 @@ export function pollMessages(query: PollQuery): Message[] {
170
288
  const conditions: string[] = [];
171
289
  const params: any[] = [];
172
290
 
173
- // Build target matching: direct + broadcast + tag + capability + label.
291
+ // Build target matching: direct + scoped broadcast + tag + capability + label.
174
292
  // Channel agents also accept legacy bare provider targets (for example
175
293
  // "telegram") so older clients keep working after canonical IDs became
176
294
  // provider:account ("telegram:default").
177
- const targetClauses = ["to_target = ?", "resolved_to_agent = ?", "to_target = 'broadcast'"];
295
+ const targetClauses = ["to_target = ?", "resolved_to_agent = ?"];
178
296
  params.push(query.for, query.for);
297
+ {
298
+ const broadcastParams: any[] = [];
299
+ const broadcastScope = scopedBroadcastClause(agent, agentLegacyChannelTargets, broadcastParams);
300
+ const fresh = freshForSessionClause(agent, broadcastParams);
301
+ targetClauses.push(`(m.to_target = 'broadcast' AND ${broadcastScope} AND ${fresh})`);
302
+ params.push(...broadcastParams);
303
+ }
179
304
 
180
305
  for (const tag of agentTags) {
181
- targetClauses.push("to_target = ?");
182
- params.push(`tag:${tag}`);
306
+ targetClauses.push(freshFanoutTargetClause(`tag:${tag}`, agent, params));
183
307
  }
184
308
  for (const cap of agentCaps) {
185
- targetClauses.push("to_target = ?");
186
- params.push(`cap:${cap}`);
309
+ targetClauses.push(freshFanoutTargetClause(`cap:${cap}`, agent, params));
187
310
  }
188
311
  if (agentLabel) {
189
- targetClauses.push("to_target = ?");
190
- params.push(`label:${agentLabel}`);
312
+ targetClauses.push(freshFanoutTargetClause(`label:${agentLabel}`, agent, params));
191
313
  }
192
314
  if (agentTeamId) {
193
315
  targetClauses.push("to_target = ?");
@@ -244,10 +366,10 @@ export function pollMessages(query: PollQuery): Message[] {
244
366
  params.push(limit);
245
367
 
246
368
  const rows = timedQuery("pollMessages", () => getDb().query(sql).all(...params) as any[]);
247
- return rows.map(rowToMessage);
369
+ return annotateAnsweredMessages(rows.map(rowToMessage), query.for);
248
370
  }
249
371
 
250
- export function messageRequiresReply(message: Message): boolean {
372
+ function messageRequiresReply(message: Message): boolean {
251
373
  // Server-owned notification flag (#283) wins over every kind/sender heuristic below: an
252
374
  // explicit replyExpected:false is a fire-and-forget message that must never become an obligation.
253
375
  if (message.replyExpected === false) return false;
@@ -265,13 +387,13 @@ export function recordField(value: unknown): Record<string, unknown> | undefined
265
387
  return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
266
388
  }
267
389
 
268
- export function relayConversationId(message: Message): string | undefined {
390
+ function relayConversationId(message: Message): string | undefined {
269
391
  const conversation = recordField(message.payload?.conversation);
270
392
  const replyContext = recordField(message.payload?.replyContext);
271
393
  return stringField(replyContext?.conversationId) ?? stringField(conversation?.id);
272
394
  }
273
395
 
274
- export function isCoveredByLaterAgentResponse(message: Message, agentId: string): boolean {
396
+ function isCoveredByLaterAgentResponse(message: Message, agentId: string): boolean {
275
397
  // Order by id, not created_at: ids are monotonic insertion order, so this is
276
398
  // robust when a reply lands in the same millisecond as the message it covers
277
399
  // (created_at > … strictly would miss it, leaving the message wrongly pending).
@@ -292,7 +414,7 @@ export function isCoveredByLaterAgentResponse(message: Message, agentId: string)
292
414
  });
293
415
  }
294
416
 
295
- export function replyObligationFromMessage(message: Message, agentId: string): ReplyObligation {
417
+ function replyObligationFromMessage(message: Message, agentId: string): ReplyObligation {
296
418
  return {
297
419
  messageId: message.id,
298
420
  agentId,
@@ -360,6 +482,25 @@ export function channelObligationAnsweredBy(
360
482
  return open.length === 1 ? open[0]!.messageId : null;
361
483
  }
362
484
 
485
+ /**
486
+ * Spawn report-back linking (#492): a spawned worker's actual task is delivered as the provider
487
+ * prompt, while `seedSpawnReplyObligation` creates a short Stop-hook contract message so the worker
488
+ * cannot go idle without reporting back. Workers commonly send their natural completion report
489
+ * directly to the spawning coordinator; when there is exactly one open spawn obligation from that
490
+ * coordinator, that send is the answer. Link it to the obligation at send time so the Stop hook does
491
+ * not nag the worker into re-sending the same verdict through `/reply`.
492
+ */
493
+ export function spawnObligationAnsweredBy(
494
+ agentId: string,
495
+ spawnerAgentId: string,
496
+ ): number | null {
497
+ const open = listPendingReplyObligations(agentId, 50).filter((obligation) => {
498
+ if (obligation.from !== spawnerAgentId) return false;
499
+ return getMessage(obligation.messageId)?.payload?.source === "spawn-obligation";
500
+ });
501
+ return open.length === 1 ? open[0]!.messageId : null;
502
+ }
503
+
363
504
  /**
364
505
  * #413 / vent #121 — seed a pending reply obligation for a freshly-registered spawned worker so
365
506
  * it cannot strand its verdict. A spawned agent gets its task as the positional spawn prompt, NOT
@@ -368,7 +509,7 @@ export function channelObligationAnsweredBy(
368
509
  * spawn command by its `spawnRequestId` (same lookup as `seedContinuationObjectiveFromSpawnPrompt`),
369
510
  * and — only when the spawn carried `expectReply` — posts a message FROM the spawner TO the worker
370
511
  * and marks it read, making it an obligation immediately. The Stop hook then refuses to let the
371
- * worker idle until it `/reply`s, which auto-routes back to the spawner and wakes it.
512
+ * worker idle until it reports back; a natural direct send to the spawner is linked at send time.
372
513
  *
373
514
  * Call only on first registration; idempotent regardless (idempotencyKey dedups a double-fire).
374
515
  * Never throws — a failure here must not abort registration. Returns the obligation message id,
@@ -393,15 +534,13 @@ export function seedSpawnReplyObligation(
393
534
  const params = row ? parseJson<unknown>(row.params, {}) : {};
394
535
  if (!isRecord(params) || params.expectReply !== true) return null;
395
536
 
396
- const objective = typeof params.prompt === "string" ? params.prompt.trim() : "";
397
537
  const contract = `Report-back requested by ${spawnedBy}. When you finish your spawned task, reply here with your result so the coordinator is notified — you should not go idle before delivering it.`;
398
- const body = objective ? `${contract}\n\nYour task was:\n${objective}` : contract;
399
538
 
400
539
  const { message } = sendMessageWithResult({
401
540
  from: spawnedBy,
402
541
  to: agentId,
403
542
  subject: "Report your result when done",
404
- body,
543
+ body: contract,
405
544
  replyExpected: true,
406
545
  // `payload.source` is the server-owned "this requires a reply" signal (see messageRequiresReply).
407
546
  payload: { source: "spawn-obligation", spawnRequestId },
@@ -20,7 +20,11 @@ import { cleanAttachmentRefs, linkAttachmentRefs, validateAttachmentRefs } from
20
20
  import { ClaimError, ValidationError, getDb } from "./connection.ts";
21
21
  import { enforceQueueLimit, isChannelAgentId, matchingDeliveryAgents } from "./delivery.ts";
22
22
  import { MSG_SELECT, normalizeReactionEmoji, rowToMessage } from "./mappers.ts";
23
- import { getTask, insertTaskEvent, releaseExpiredClaims } from "./tasks.ts";
23
+ import { claimMessageRow } from "./message-claims.ts";
24
+ import { getMessage } from "./message-queries.ts";
25
+ import { runningAgentForPolicy } from "./policy-runtime.ts";
26
+ import { getTask, releaseExpiredClaims } from "./tasks.ts";
27
+ import { insertTaskEvent } from "./task-core.ts";
24
28
  import type {
25
29
  AgentCard,
26
30
  ActivityEvent,
@@ -84,6 +88,10 @@ import type {
84
88
  WorkspaceStatus,
85
89
  } from "../types";
86
90
 
91
+ export { claimMessageRow } from "./message-claims.ts";
92
+ export { getMessage } from "./message-queries.ts";
93
+ export { runningAgentForPolicy } from "./policy-runtime.ts";
94
+
87
95
  export function findMessageByIdempotencyKey(from: string, key: string): Message | null {
88
96
  const row = getDb()
89
97
  .query(`${MSG_SELECT} WHERE m.from_agent = ? AND m.idempotency_key = ? LIMIT 1`)
@@ -119,51 +127,38 @@ function upsertSessionStepInPlace(existing: Message, input: SendMessageInput, no
119
127
  return getMessage(existing.id);
120
128
  }
121
129
 
122
- export function policyNameFromTarget(target: string): string | null {
130
+ function policyNameFromTarget(target: string): string | null {
123
131
  if (!target.startsWith("policy:")) return null;
124
132
  const name = target.slice("policy:".length).trim();
125
133
  return name || null;
126
134
  }
127
135
 
128
- export function spawnPolicyExists(policyName: string): boolean {
136
+ function spawnPolicyExists(policyName: string): boolean {
129
137
  const row = getDb().query("SELECT 1 FROM config WHERE namespace = 'spawn-policy' AND key = ?").get(policyName);
130
138
  return Boolean(row);
131
139
  }
132
140
 
133
- export function runningAgentForPolicy(policyName: string): string | null {
134
- const row = getDb().query(`
135
- SELECT agent_id
136
- FROM managed_agent_state
137
- WHERE policy_name = ? AND status = 'running' AND agent_id IS NOT NULL
138
- `).get(policyName) as { agent_id?: string } | undefined;
139
- if (!row?.agent_id) return null;
140
- const agent = getAgent(row.agent_id);
141
- if (!agent || agent.status === "offline") return null;
142
- return row.agent_id;
143
- }
144
-
145
-
146
- export function claimableAllowedForTarget(target: string): boolean {
141
+ function claimableAllowedForTarget(target: string): boolean {
147
142
  return matchingDeliveryAgents(target).length > 1;
148
143
  }
149
144
 
150
- export function shouldStoreClaimable(input: SendMessageInput): boolean {
145
+ function shouldStoreClaimable(input: SendMessageInput): boolean {
151
146
  if (!input.claimable) return false;
152
147
  if (input.kind === "task" || input.kind === "system") return true;
153
148
  if (input.kind === "channel.event") return true;
154
149
  return claimableAllowedForTarget(input.to);
155
150
  }
156
151
 
157
- export function inferMessageKind(input: SendMessageInput): Message["kind"] {
152
+ function inferMessageKind(input: SendMessageInput): Message["kind"] {
158
153
  if (input.kind) return input.kind;
159
154
  if (input.claimable) return "task";
160
155
  if (isChannelAgentId(input.from) || isChannelAgentId(input.to)) return "channel.event";
161
156
  return "chat";
162
157
  }
163
158
 
164
- export const REPLY_DUPLICATE_WINDOW_MS = 2 * 60 * 1000;
159
+ const REPLY_DUPLICATE_WINDOW_MS = 2 * 60 * 1000;
165
160
 
166
- export function findRecentDuplicateReply(input: SendMessageInput, threadId: number | null, now: number, hasAttachments: boolean): Message | null {
161
+ function findRecentDuplicateReply(input: SendMessageInput, threadId: number | null, now: number, hasAttachments: boolean): Message | null {
167
162
  if (!input.replyTo || threadId === null || hasAttachments) return null;
168
163
  const row = getDb().query(`
169
164
  ${MSG_SELECT}
@@ -192,7 +187,7 @@ export function findRecentDuplicateReply(input: SendMessageInput, threadId: numb
192
187
  // receive time — but it must be a sane epoch-ms value. Returns null (column stays NULL, so
193
188
  // readers fall back to created_at) for absent/invalid values or anything more than a minute
194
189
  // in the future (clock-skew guard). Only a real backfilled time is stored.
195
- export function sanitizeOccurredAt(occurredAt: number | undefined, receivedAt: number): number | null {
190
+ function sanitizeOccurredAt(occurredAt: number | undefined, receivedAt: number): number | null {
196
191
  if (typeof occurredAt !== "number" || !Number.isFinite(occurredAt)) return null;
197
192
  if (occurredAt <= 0 || occurredAt > receivedAt + 60_000) return null;
198
193
  return Math.floor(occurredAt);
@@ -265,13 +260,13 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
265
260
  }
266
261
  }
267
262
 
268
- // #451 — moderate default TTL for fire-and-forget FAN-OUT messages the sender didn't bound, so a
269
- // stale broadcast/tag/cap/label/team push can't wake a late-joining agent hours later. Exempt
263
+ // #451/#461 — moderate default TTL for non-claimable FAN-OUT messages the sender didn't bound,
264
+ // so stale broadcast/tag/cap/label/team chat cannot wake a late-joining agent hours later. Exempt
270
265
  // (never auto-expire, preserving store-ahead #234): direct DMs and policy targets (single
271
- // recipient), reply-obligations (replyExpected !== false), claimable work, and any message that
272
- // already carried an explicit TTL — including an explicit `null` from routeNotification, whose
273
- // per-type decision (e.g. a store-ahead `plan.bound_agent_exited` to `team:`) must win here.
274
- if (ttlUnspecified && input.replyExpected === false && !claimable && isFanoutTarget(input.to)) {
266
+ // recipient), claimable work, and any message that already carried an explicit TTL — including an
267
+ // explicit `null` from routeNotification, whose per-type decision (e.g. a store-ahead
268
+ // `plan.bound_agent_exited` to `team:`) must win here.
269
+ if (ttlUnspecified && !claimable && isFanoutTarget(input.to)) {
275
270
  maxAgeSeconds = DEFAULT_FANOUT_TTL_SECONDS;
276
271
  }
277
272
 
@@ -369,20 +364,6 @@ export function findMessageByTelegramSource(input: {
369
364
  return row ? rowToMessage(row) : null;
370
365
  }
371
366
 
372
- export function claimMessageRow(messageId: number, agentId: string, now: number): { ok: false; error: string } | { ok: true } {
373
- const expiresAt = now + CLAIM_LEASE_MS;
374
- // Idempotent: a same-agent re-claim refreshes the lease (preserving the original claimed_at).
375
- // Without this, a delivery failure that locally drops the claim strands the message until the
376
- // lease expires (#261) — the holder's retry would otherwise 409 against its own claim.
377
- const result = getDb().query(
378
- "UPDATE messages SET claimed_by = ?, claimed_at = COALESCE(claimed_at, ?), claim_expires_at = ? WHERE id = ? AND (claimed_by IS NULL OR claimed_by = ?)"
379
- ).run(agentId, now, expiresAt, messageId, agentId);
380
-
381
- // Atomic: if changes === 0, someone else claimed it between our read and write
382
- if (result.changes === 0) return { ok: false, error: "claim race — already claimed" };
383
- return { ok: true };
384
- }
385
-
386
367
  export function claimMessage(messageId: number, agentId: string, guard?: AgentSessionGuard): { ok: boolean; error?: string; task?: Task } {
387
368
  releaseExpiredClaims();
388
369
  const session = validateAgentSession(agentId, guard);
@@ -475,9 +456,3 @@ export function renewMessageClaim(messageId: number, agentId: string, guard?: Ag
475
456
  })();
476
457
  return { ok: true, task };
477
458
  }
478
-
479
- export function getMessage(id: number): Message | null {
480
- const row = getDb().query(`${MSG_SELECT} WHERE m.id = ?`).get(id) as any;
481
- return row ? rowToMessage(row) : null;
482
- }
483
-
@@ -9,10 +9,10 @@ import { getDb, ValidationError } from "./connection";
9
9
  export const NOTIFICATION_SUBSCRIPTION_TYPES = ["*", ...Object.keys(DEFAULT_TTL_MS)] as Array<NotificationType | "*">;
10
10
  export const NOTIFICATION_SUBSCRIPTION_MODES = ["opt_in", "opt_out", "mute"] as const;
11
11
 
12
- export type NotificationSubscriptionType = NotificationType | "*";
12
+ type NotificationSubscriptionType = NotificationType | "*";
13
13
  export type NotificationSubscriptionMode = (typeof NOTIFICATION_SUBSCRIPTION_MODES)[number];
14
14
 
15
- export interface NotificationSubscription {
15
+ interface NotificationSubscription {
16
16
  id: string;
17
17
  agentId: string;
18
18
  type: NotificationSubscriptionType;
@@ -82,7 +82,7 @@ import type {
82
82
 
83
83
  // --- Orchestrators ---
84
84
 
85
- export function rowToOrchestrator(row: any): Orchestrator {
85
+ function rowToOrchestrator(row: any): Orchestrator {
86
86
  const meta = parseJson<Record<string, unknown>>(row.meta, {});
87
87
  const runtimePackage = parseRuntimePackage(meta.package);
88
88
  const version = stringValue(meta.version);
@@ -133,7 +133,7 @@ export function rowToOrchestrator(row: any): Orchestrator {
133
133
  };
134
134
  }
135
135
 
136
- export function parseOrchestratorUpgrade(value: unknown): OrchestratorUpgradeState | undefined {
136
+ function parseOrchestratorUpgrade(value: unknown): OrchestratorUpgradeState | undefined {
137
137
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
138
138
  const v = value as Record<string, unknown>;
139
139
  const desiredVersion = stringValue(v.desiredVersion);
@@ -167,7 +167,7 @@ export function setOrchestratorUpgradeState(id: string, state: OrchestratorUpgra
167
167
  }
168
168
 
169
169
 
170
- export function orchestratorHealth(version: string | undefined, compatibility: ReturnType<typeof contractCompatibility>): OrchestratorHealth {
170
+ function orchestratorHealth(version: string | undefined, compatibility: ReturnType<typeof contractCompatibility>): OrchestratorHealth {
171
171
  const issues: OrchestratorHealth["issues"] = [];
172
172
  if (!version) {
173
173
  issues.push({ code: "missing-version", detail: "Orchestrator did not report a package version." });
@@ -204,7 +204,7 @@ export function orchestratorHealth(version: string | undefined, compatibility: R
204
204
  };
205
205
  }
206
206
 
207
- export function mergeOrchestratorRuntimeMeta(meta: Record<string, unknown>, input: OrchestratorRuntimeInput): Record<string, unknown> {
207
+ function mergeOrchestratorRuntimeMeta(meta: Record<string, unknown>, input: OrchestratorRuntimeInput): Record<string, unknown> {
208
208
  const contracts = parseRuntimeContracts(input.contracts, input.protocolVersion !== undefined ? { orchestratorProtocol: input.protocolVersion } : {});
209
209
  const capabilities = parseRuntimeCapabilities(input.capabilities);
210
210
  return {
@@ -0,0 +1,9 @@
1
+ import { getDb } from "./connection.ts";
2
+
3
+ export function closeOpenPairsForAgent(agentId: string, now: number = Date.now()): void {
4
+ getDb().query(`
5
+ UPDATE pairs
6
+ SET status = 'ended', ended_at = ?, ended_by = ?, updated_at = ?
7
+ WHERE status IN ('pending', 'active') AND (requester_id = ? OR target_id = ?)
8
+ `).run(now, agentId, now, agentId, agentId);
9
+ }