agent-relay-server 0.62.2 → 0.62.3

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.
@@ -20,6 +20,8 @@ type Agent = {
20
20
  createdAt?: number;
21
21
  };
22
22
 
23
+ type ApprovalMode = "open" | "guarded" | "read-only";
24
+
23
25
  const args = process.argv.slice(2);
24
26
  let relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
25
27
  let orchestratorId = process.env.AGENT_RELAY_SMOKE_ORCHESTRATOR || "";
@@ -92,6 +94,26 @@ function findSpawnedAgent(agents: Agent[], provider: string, label: string, star
92
94
  .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))[0] || null;
93
95
  }
94
96
 
97
+ function approvalModesForProvider(provider: string): ApprovalMode[] {
98
+ if (provider === "claude") return ["open", "guarded", "read-only"];
99
+ return ["guarded"];
100
+ }
101
+
102
+ function promptForCase(provider: string, approvalMode: ApprovalMode, label: string, nonce: string): { prompt?: string; nonceLabel?: string } {
103
+ if (provider !== "claude") return {};
104
+ if (approvalMode === "guarded") return {};
105
+ if (approvalMode === "read-only") {
106
+ return {
107
+ prompt: `Agent Relay read-only spawn smoke. As your VERY FIRST action, use the Bash tool to run exactly: agent-relay /message label:${label} "${nonce}". Then wait for shutdown — do nothing else.`,
108
+ nonceLabel: "Bash relay CLI",
109
+ };
110
+ }
111
+ return {
112
+ prompt: `Agent Relay spawn smoke. As your VERY FIRST action, use the agent-relay MCP tool relay_send_message to send to "label:${label}" with body exactly "${nonce}". Then wait for shutdown — do nothing else.`,
113
+ nonceLabel: "relay MCP tool",
114
+ };
115
+ }
116
+
95
117
  const orchestrators = await api<Orchestrator[]>("GET", "/orchestrators");
96
118
  // Default selection prefers the orchestrator on the same host as the relay (the
97
119
  // gate runs there): a local spawn is faster and avoids cross-host teardown
@@ -123,79 +145,77 @@ for (const provider of providers) {
123
145
  continue;
124
146
  }
125
147
 
126
- const label = `smoke-${provider}-${Date.now()}`;
127
- const startedAt = Date.now();
128
- // #352: for providers seeded with an initial prompt, assert the spawn prompt was actually
129
- // DELIVERED and the first turn RAN by making the prompt's first action a relay tool call
130
- // (send a nonce message) and waiting for that nonce to land in the relay feed. Registration
131
- // alone (the old smoke) happens via the plugin regardless of prompt delivery, so it never
132
- // caught the silently-dropped spawn prompt. The nonce is the end-to-end proof.
133
- const nonce = `SMOKE-NONCE-${provider}-${Date.now()}-${Math.round(Math.random() * 1e9).toString(36)}`;
134
- const promptProvider = provider === "claude";
135
- const prompt = `Agent Relay spawn smoke. As your VERY FIRST action, use the agent-relay MCP tool relay_send_message to send to "label:${label}" with body exactly "${nonce}". Then wait for shutdown — do nothing else.`;
136
- console.log(`spawn ${provider} via ${orchestrator.id}`);
137
- await api("POST", `/agents/spawn`, {
138
- provider,
139
- orchestratorId: orchestrator.id,
140
- cwd,
141
- label,
142
- // Prompt-seeded providers must run UNATTENDED: under guarded mode the first-turn relay tool
143
- // call would block on a dashboard approval that never comes here. Autonomous workers spawn
144
- // open for the same reason. (Approval mode is orthogonal to prompt delivery, which is the
145
- // regression under test.)
146
- approvalMode: promptProvider ? "open" : "guarded",
147
- ...(promptProvider ? { prompt } : {}),
148
- });
149
-
150
- let agent: Agent | undefined;
151
- let managed: { tmuxSession: string } | undefined;
152
- try {
153
- agent = await waitFor(`waiting for ${provider} agent registration`, async () => {
154
- const agents = await api<Agent[]>("GET", "/agents");
155
- return findSpawnedAgent(agents, provider, label, startedAt);
148
+ for (const approvalMode of approvalModesForProvider(provider)) {
149
+ const label = `smoke-${provider}-${approvalMode}-${Date.now()}`;
150
+ const startedAt = Date.now();
151
+ // #352: for providers seeded with an initial prompt, assert the spawn prompt was actually
152
+ // DELIVERED and the first turn RAN by making the prompt's first action send a nonce message
153
+ // and waiting for that nonce to land in the relay feed. Registration alone happens via the
154
+ // plugin regardless of prompt delivery, so it never catches silently dropped spawn prompts.
155
+ const nonce = `SMOKE-NONCE-${provider}-${approvalMode}-${Date.now()}-${Math.round(Math.random() * 1e9).toString(36)}`;
156
+ const promptCase = promptForCase(provider, approvalMode, label, nonce);
157
+ console.log(`spawn ${provider} ${approvalMode} via ${orchestrator.id}`);
158
+ await api("POST", `/agents/spawn`, {
159
+ provider,
160
+ orchestratorId: orchestrator.id,
161
+ cwd,
162
+ label,
163
+ approvalMode,
164
+ ...(promptCase.prompt ? { prompt: promptCase.prompt } : {}),
156
165
  });
157
- console.log(`registered ${provider}: ${agent.id}`);
158
166
 
159
- managed = await waitFor(`waiting for ${provider} managed session`, async () => {
160
- const latest = (await api<Orchestrator[]>("GET", "/orchestrators")).find((orch) => orch.id === orchestrator.id);
161
- return latest?.managedAgents?.find((entry) => entry.label === label || entry.tmuxSession.includes(label)) || null;
162
- });
167
+ let agent: Agent | undefined;
168
+ let managed: { tmuxSession: string } | undefined;
169
+ try {
170
+ agent = await waitFor(`waiting for ${provider} ${approvalMode} agent registration`, async () => {
171
+ const agents = await api<Agent[]>("GET", "/agents");
172
+ return findSpawnedAgent(agents, provider, label, startedAt);
173
+ });
174
+ console.log(`registered ${provider} ${approvalMode}: ${agent.id}`);
163
175
 
164
- // Hard-assert the seeded first turn ran and reached relay (the prompt-delivery regression guard).
165
- if (promptProvider) {
166
- await waitFor(`waiting for ${provider} spawn-prompt first turn (nonce relay tool call)`, async () => {
167
- const feed = await api<Array<{ body?: string }>>("GET", "/messages?limit=80");
168
- return feed.some((m) => (m.body || "").includes(nonce)) ? true : null;
176
+ managed = await waitFor(`waiting for ${provider} ${approvalMode} managed session`, async () => {
177
+ const latest = (await api<Orchestrator[]>("GET", "/orchestrators")).find((orch) => orch.id === orchestrator.id);
178
+ return latest?.managedAgents?.find((entry) => entry.label === label || entry.tmuxSession.includes(label)) || null;
169
179
  });
170
- console.log(`spawn-prompt delivered + first turn called relay ${provider}: ${nonce}`);
171
- }
172
- } finally {
173
- // Always tear down a failed assertion (e.g. a re-introduced prompt-drop) must NOT leak a
174
- // managed agent on the host running the gate. Best-effort: teardown errors don't mask the
175
- // original failure.
176
- if (agent) {
177
- const id = agent.id;
178
- await api("POST", `/agents/${encodeURIComponent(id)}/actions`, { action: "shutdown" }).catch(() => {});
179
- await waitFor(`waiting for ${provider} agent cleanup`, async () => {
180
- const current = await apiOptional<Agent>("GET", `/agents/${encodeURIComponent(id)}`);
181
- return !current || (current.status === "offline" && current.ready === false) ? true : null;
182
- }).catch(() => {});
183
- await apiOptional("DELETE", `/agents/${encodeURIComponent(id)}`).catch(() => {});
184
- if (managed) {
185
- const tmuxSession = managed.tmuxSession;
186
- await api("POST", `/orchestrators/${encodeURIComponent(orchestrator.id)}/actions`, {
187
- action: "shutdown",
188
- agentId: id,
189
- tmuxSession,
190
- reason: "smoke-cleanup",
191
- }).catch(() => {});
192
- await waitFor(`waiting for ${provider} managed session cleanup`, async () => {
193
- const latest = (await api<Orchestrator[]>("GET", "/orchestrators")).find((orch) => orch.id === orchestrator.id);
194
- const stillManaged = latest?.managedAgents?.some((entry) => entry.tmuxSession === tmuxSession || entry.agentId === id);
195
- return stillManaged ? null : true;
180
+
181
+ // Hard-assert prompt-seeded cases ran and reached relay. Guarded Claude remains
182
+ // registration-only here: forcing a Bash/MCP first turn would intentionally block on
183
+ // the known unattended approval path.
184
+ if (promptCase.nonceLabel) {
185
+ await waitFor(`waiting for ${provider} ${approvalMode} spawn-prompt first turn (${promptCase.nonceLabel})`, async () => {
186
+ const feed = await api<Array<{ body?: string }>>("GET", "/messages?limit=80");
187
+ return feed.some((m) => (m.body || "").includes(nonce)) ? true : null;
188
+ });
189
+ console.log(`spawn-prompt delivered + first turn called relay ${provider} ${approvalMode}: ${nonce}`);
190
+ }
191
+ } finally {
192
+ // Always tear down — a failed assertion (e.g. a re-introduced prompt-drop) must NOT leak a
193
+ // managed agent on the host running the gate. Best-effort: teardown errors don't mask the
194
+ // original failure.
195
+ if (agent) {
196
+ const id = agent.id;
197
+ await api("POST", `/agents/${encodeURIComponent(id)}/actions`, { action: "shutdown" }).catch(() => {});
198
+ await waitFor(`waiting for ${provider} ${approvalMode} agent cleanup`, async () => {
199
+ const current = await apiOptional<Agent>("GET", `/agents/${encodeURIComponent(id)}`);
200
+ return !current || (current.status === "offline" && current.ready === false) ? true : null;
196
201
  }).catch(() => {});
202
+ await apiOptional("DELETE", `/agents/${encodeURIComponent(id)}`).catch(() => {});
203
+ if (managed) {
204
+ const tmuxSession = managed.tmuxSession;
205
+ await api("POST", `/orchestrators/${encodeURIComponent(orchestrator.id)}/actions`, {
206
+ action: "shutdown",
207
+ agentId: id,
208
+ tmuxSession,
209
+ reason: "smoke-cleanup",
210
+ }).catch(() => {});
211
+ await waitFor(`waiting for ${provider} ${approvalMode} managed session cleanup`, async () => {
212
+ const latest = (await api<Orchestrator[]>("GET", "/orchestrators")).find((orch) => orch.id === orchestrator.id);
213
+ const stillManaged = latest?.managedAgents?.some((entry) => entry.tmuxSession === tmuxSession || entry.agentId === id);
214
+ return stillManaged ? null : true;
215
+ }).catch(() => {});
216
+ }
217
+ console.log(`shutdown ${provider} ${approvalMode}: ${label}`);
197
218
  }
198
- console.log(`shutdown ${provider}: ${label}`);
199
219
  }
200
220
  }
201
221
  }
@@ -4,6 +4,7 @@ import { isRecord, stringValue, isMechanicalMessageKind } from "agent-relay-sdk"
4
4
  import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "../config.ts";
5
5
  import { parseJson } from "../utils";
6
6
  import { isLiveIsolatedWorkspace } from "../workspace-phase";
7
+ import { parseChannelRouteTarget } from "../channel-target";
7
8
  import {
8
9
  CONTRACT_REQUIREMENTS,
9
10
  contractCompatibility,
@@ -156,27 +157,76 @@ export function legacyChannelTargets(agent: AgentCard | null | undefined): strin
156
157
  return [...aliases];
157
158
  }
158
159
 
159
- export function matchingDeliveryAgents(target: string): AgentCard[] {
160
- if (!target) return [];
161
- const candidates = listAgents().filter(isDeliveryAgent);
162
- if (target === "broadcast") return candidates;
163
- const direct = getAgent(target);
164
- if (direct) return isDeliveryAgent(direct) ? [direct] : [];
165
- if (target.startsWith("tag:")) {
166
- const tag = target.slice(4);
167
- return candidates.filter((agent) => agent.tags.includes(tag));
160
+ type BroadcastDeliveryContext = Pick<SendMessageInput, "from" | "channel">;
161
+
162
+ function addTargetParticipants(target: string, scope: Set<string>, candidates: AgentCard[]): void {
163
+ const parsed = parseChannelRouteTarget(target);
164
+ switch (parsed.type) {
165
+ case "agent":
166
+ case "orchestrator":
167
+ if (candidates.some((agent) => agent.id === parsed.id)) scope.add(parsed.id);
168
+ return;
169
+ case "tag":
170
+ for (const agent of candidates) if (agent.tags.includes(parsed.id)) scope.add(agent.id);
171
+ return;
172
+ case "capability":
173
+ for (const agent of candidates) if (agent.capabilities.includes(parsed.id)) scope.add(agent.id);
174
+ return;
175
+ case "label":
176
+ for (const agent of candidates) if (agent.label === parsed.id) scope.add(agent.id);
177
+ return;
178
+ case "team":
179
+ for (const agent of candidates) if (agent.teamId === parsed.id) scope.add(agent.id);
180
+ return;
168
181
  }
169
- if (target.startsWith("cap:")) {
170
- const cap = target.slice(4);
171
- return candidates.filter((agent) => agent.capabilities.includes(cap));
182
+ }
183
+
184
+ function channelParticipantAgentIds(channel: string, candidates: AgentCard[], senderId?: string): Set<string> {
185
+ const candidateIds = new Set(candidates.map((agent) => agent.id));
186
+ const scope = new Set<string>();
187
+ if (senderId && candidateIds.has(senderId)) scope.add(senderId);
188
+ const rows = getDb().query(`
189
+ SELECT from_agent, to_target, resolved_to_agent
190
+ FROM messages
191
+ WHERE channel = ?
192
+ `).all(channel) as Array<{ from_agent: string; to_target: string; resolved_to_agent?: string | null }>;
193
+ for (const row of rows) {
194
+ if (candidateIds.has(row.from_agent)) scope.add(row.from_agent);
195
+ if (row.resolved_to_agent && candidateIds.has(row.resolved_to_agent)) scope.add(row.resolved_to_agent);
196
+ addTargetParticipants(row.to_target, scope, candidates);
172
197
  }
173
- if (target.startsWith("label:")) {
174
- const label = target.slice(6);
175
- return candidates.filter((agent) => agent.label === label);
198
+ return scope;
199
+ }
200
+
201
+ function scopedBroadcastCandidates(candidates: AgentCard[], context?: BroadcastDeliveryContext): AgentCard[] {
202
+ if (context?.channel) {
203
+ const scope = channelParticipantAgentIds(context.channel, candidates, context.from);
204
+ return candidates.filter((agent) => scope.has(agent.id));
176
205
  }
177
- if (target.startsWith("team:")) {
178
- const teamId = target.slice(5);
179
- return candidates.filter((agent) => agent.teamId === teamId);
206
+ const sender = context?.from ? getAgent(context.from) : null;
207
+ if (sender?.teamId) return candidates.filter((agent) => agent.teamId === sender.teamId);
208
+ return candidates;
209
+ }
210
+
211
+ export function matchingDeliveryAgents(target: string, context?: BroadcastDeliveryContext): AgentCard[] {
212
+ if (!target) return [];
213
+ const candidates = listAgents().filter(isDeliveryAgent);
214
+ const parsed = parseChannelRouteTarget(target);
215
+ switch (parsed.type) {
216
+ case "broadcast":
217
+ return scopedBroadcastCandidates(candidates, context);
218
+ case "agent": {
219
+ const direct = getAgent(parsed.id);
220
+ return direct && isDeliveryAgent(direct) ? [direct] : [];
221
+ }
222
+ case "tag":
223
+ return candidates.filter((agent) => agent.tags.includes(parsed.id));
224
+ case "capability":
225
+ return candidates.filter((agent) => agent.capabilities.includes(parsed.id));
226
+ case "label":
227
+ return candidates.filter((agent) => agent.label === parsed.id);
228
+ case "team":
229
+ return candidates.filter((agent) => agent.teamId === parsed.id);
180
230
  }
181
231
  return [];
182
232
  }
@@ -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,7 +366,7 @@ 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
372
  export function messageRequiresReply(message: Message): boolean {
@@ -265,13 +265,13 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
265
265
  }
266
266
  }
267
267
 
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
268
+ // #451/#461 — moderate default TTL for non-claimable FAN-OUT messages the sender didn't bound,
269
+ // so stale broadcast/tag/cap/label/team chat cannot wake a late-joining agent hours later. Exempt
270
270
  // (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)) {
271
+ // recipient), claimable work, and any message that already carried an explicit TTL — including an
272
+ // explicit `null` from routeNotification, whose per-type decision (e.g. a store-ahead
273
+ // `plan.bound_agent_exited` to `team:`) must win here.
274
+ if (ttlUnspecified && !claimable && isFanoutTarget(input.to)) {
275
275
  maxAgeSeconds = DEFAULT_FANOUT_TTL_SECONDS;
276
276
  }
277
277
 
@@ -480,4 +480,3 @@ export function getMessage(id: number): Message | null {
480
480
  const row = getDb().query(`${MSG_SELECT} WHERE m.id = ?`).get(id) as any;
481
481
  return row ? rowToMessage(row) : null;
482
482
  }
483
-