a2a-exposed 0.8.16 → 0.8.18

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.
@@ -67,6 +67,44 @@ function histStmt(ctx: TenantContext, contextId: string, e: Json) {
67
67
  e.event ?? null, e.text ?? null, e.data !== undefined ? JSON.stringify(e.data) : null);
68
68
  }
69
69
 
70
+ /** Claim this peer's conversation. The primary key is (context_id, peer): another peer's row is a
71
+ * different conversation and is not read or replaced. The insert does nothing when this peer already holds the id. */
72
+ function claimContextStmt(ctx: TenantContext, contextId: string, peer: string, ts: string) {
73
+ return ctx.DB.prepare(
74
+ `INSERT INTO conversation_owners (context_id, peer, created_at) VALUES (?1, ?2, ?3)
75
+ ON CONFLICT(context_id, peer) DO NOTHING`,
76
+ ).bind(contextId, peer, ts);
77
+ }
78
+
79
+ /** A new task is stored only when this peer owns the context. Runs in the same batch as `claimContextStmt`. */
80
+ function saveTaskIfOwnerStmt(ctx: TenantContext, t: Json) {
81
+ return ctx.DB.prepare(
82
+ `INSERT INTO tasks (id, context_id, peer, state, protocol, created_at, updated_at, status_json, artifacts_json, metadata_json, push_configs_json)
83
+ SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11
84
+ WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?2 AND peer = ?3)
85
+ ON CONFLICT(id) DO UPDATE SET state=?4, updated_at=?7, status_json=?8, artifacts_json=?9, metadata_json=?10, push_configs_json=?11
86
+ WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?2 AND peer = ?3)`,
87
+ ).bind(t.id, t.contextId, t._peer, t.status.state, t._protocol, t._createdAt, A.nowIso(), JSON.stringify(t.status),
88
+ JSON.stringify(t.artifacts || []), t.metadata ? JSON.stringify(t.metadata) : null, JSON.stringify(t._push || []));
89
+ }
90
+
91
+ function msgIfOwnerStmt(ctx: TenantContext, t: Json, m: Json) {
92
+ return ctx.DB.prepare(
93
+ `INSERT INTO messages (message_id, task_id, context_id, role, ts, message_json)
94
+ SELECT ?1, ?2, ?3, ?4, ?5, ?6
95
+ WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?3 AND peer = ?7)`,
96
+ ).bind(m.messageId, t.id, t.contextId, m.role, A.nowIso(), JSON.stringify(m), t._peer);
97
+ }
98
+
99
+ function histIfOwnerStmt(ctx: TenantContext, contextId: string, e: Json, owner: string) {
100
+ return ctx.DB.prepare(
101
+ `INSERT INTO history (context_id, ts, dir, peer, task_id, role, state, event, text, data_json)
102
+ SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10
103
+ WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?1 AND peer = ?11)`,
104
+ ).bind(contextId, e.ts || A.nowIso(), e.dir || "local", e.peer ?? null, e.taskId ?? null, e.role ?? null, e.state ?? null,
105
+ e.event ?? null, e.text ?? null, e.data !== undefined ? JSON.stringify(e.data) : null, owner);
106
+ }
107
+
70
108
  // ------------------------------------------------------------------ self-aware fetch (a Worker cannot reliably fetch its own custom domain)
71
109
  /**
72
110
  * Another tenant on this deployment's domain. A public fetch of that hostname returns 522, so the call
@@ -118,7 +156,7 @@ async function doFetch(ctx: TenantContext, ectx: ExecutionContext, url: string,
118
156
  return fetch(url, { ...init, signal: AbortSignal.timeout(15000) });
119
157
  }
120
158
 
121
- // ------------------------------------------------------------------ wake webhook (presets), debounced per context via D1
159
+ // ------------------------------------------------------------------ wake webhook (presets), debounced per peer and context via D1
122
160
  const preset = (ctx: TenantContext) => (ctx.WAKE_PRESET || "generic").trim();
123
161
 
124
162
  function wakeConfig(ctx: TenantContext): WakeConfig {
@@ -207,6 +245,21 @@ const WAKE_RETRIES = 3;
207
245
  * flush it once Retry-After (30 s - 1 h) has passed, on the next request to the Worker or the optional cron. At most
208
246
  * WAKE_RETRIES times per wake; a newer message for the conversation merges in and starts over. Pairing and test wakes
209
247
  * are not retried (a pairing code lasts 60 minutes). */
248
+ function pendingWakePeer(pending: string | null | undefined): string {
249
+ if (!pending) return "";
250
+ try {
251
+ const from = JSON.parse(pending)?.from;
252
+ return typeof from === "string" ? from : "";
253
+ } catch { return ""; }
254
+ }
255
+
256
+ /** The wake row for this peer's conversation. A row stored before peer was part of the key has peer ''. */
257
+ async function loadWake(ctx: TenantContext, contextId: string, peer: string): Promise<Json | null> {
258
+ const exact: Json = await ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ? AND peer = ?").bind(contextId, peer).first();
259
+ if (exact || !peer) return exact;
260
+ return ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ? AND peer = ''").bind(contextId).first();
261
+ }
262
+
210
263
  async function requeueWake(ctx: TenantContext, ectx: ExecutionContext, payload: Json, retryAfter: string | null) {
211
264
  if (payload.kind !== "inbound") return;
212
265
  const attempts = (Number(payload.attempts) || 0) + 1;
@@ -214,16 +267,18 @@ async function requeueWake(ctx: TenantContext, ectx: ExecutionContext, payload:
214
267
  const secs = Number(retryAfter);
215
268
  const waitMs = Math.min(3600, Math.max(30, Number.isFinite(secs) && secs > 0 ? secs : 60)) * 1000;
216
269
  const now = Date.now(), contextId = payload.contextId;
217
- const cur: Json = await ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ?").bind(contextId).first();
270
+ const peer = typeof payload.from === "string" ? payload.from : "";
271
+ const cur: Json = await loadWake(ctx, contextId, peer);
218
272
  const prev = cur?.pending_json ? JSON.parse(cur.pending_json) : null;
219
273
  const merged = { ...payload, attempts, taskIds: [...new Set([...(prev?.taskIds || []), ...(payload.taskIds || [])])] };
220
274
  // flushWake and nextCronAt wait until last_sent_ms + debounce. Store Retry-After minus that debounce so the
221
275
  // deadline is Retry-After itself, including when the debounce is longer than Retry-After. Do not keep the
222
276
  // previous last_sent_ms: that clock is the send we just made, and keeping it would wait out the debounce.
223
277
  const due = now + waitMs - debounceMs(ctx);
224
- await ctx.DB.prepare(`INSERT INTO wakes (context_id, last_sent_ms, pending_json, pending_since_ms) VALUES (?, ?, ?, ?)
225
- ON CONFLICT(context_id) DO UPDATE SET pending_json = excluded.pending_json, last_sent_ms = excluded.last_sent_ms,
226
- pending_since_ms = COALESCE(wakes.pending_since_ms, excluded.pending_since_ms)`).bind(contextId, due, JSON.stringify(merged), now).run();
278
+ const rowPeer = cur && (cur.peer === peer || cur.peer === "" || cur.peer == null) ? (cur.peer ?? "") : peer;
279
+ await ctx.DB.prepare(`INSERT INTO wakes (context_id, peer, last_sent_ms, pending_json, pending_since_ms) VALUES (?, ?, ?, ?, ?)
280
+ ON CONFLICT(context_id, peer) DO UPDATE SET pending_json = excluded.pending_json, last_sent_ms = excluded.last_sent_ms,
281
+ pending_since_ms = COALESCE(wakes.pending_since_ms, excluded.pending_since_ms)`).bind(contextId, rowPeer === "" ? peer : rowPeer, due, JSON.stringify(merged), now).run();
227
282
  log("wake_requeued", { contextId, retryInS: Math.round(waitMs / 1000), attempt: attempts });
228
283
  // The row exists now. A hosted alarm has to be set from this turn: the request that started sendWake may already have returned.
229
284
  await noteWakeWrite(ectx);
@@ -232,33 +287,45 @@ async function requeueWake(ctx: TenantContext, ectx: ExecutionContext, payload:
232
287
  async function wake(ctx: TenantContext, ectx: ExecutionContext, contextId: string, taskId: string, from: string, preview: string, kind = "inbound") {
233
288
  const payload: Json = { contextId, taskId, taskIds: [taskId], from, preview: preview.slice(0, 300), kind };
234
289
  const now = Date.now(), deb = debounceMs(ctx);
235
- const row: Json = await ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ?").bind(contextId).first();
290
+ let row: Json = await loadWake(ctx, contextId, from);
291
+ // A pre-peer row (peer '') is this conversation when it names this peer, or names nobody yet.
292
+ // Another peer's legacy row stays put; this peer gets its own row.
293
+ if (row && row.peer === "" && from) {
294
+ const named = pendingWakePeer(row.pending_json);
295
+ if (!named || named === from) {
296
+ const moved = await ctx.DB.prepare("UPDATE wakes SET peer = ? WHERE context_id = ? AND peer = ''").bind(from, contextId).run();
297
+ if (changed(moved) === 1) row = { ...row, peer: from };
298
+ else row = await ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ? AND peer = ?").bind(contextId, from).first();
299
+ } else row = null;
300
+ }
301
+ const peer = row?.peer ?? from;
236
302
  let claimed = false;
237
303
  if (!row) {
238
- const r = await ctx.DB.prepare("INSERT INTO wakes (context_id, last_sent_ms) VALUES (?, ?) ON CONFLICT DO NOTHING").bind(contextId, now).run();
304
+ const r = await ctx.DB.prepare("INSERT INTO wakes (context_id, peer, last_sent_ms) VALUES (?, ?, ?) ON CONFLICT DO NOTHING").bind(contextId, peer, now).run();
239
305
  claimed = r.meta.changes === 1;
240
306
  } else if (!row.pending_json && now - row.last_sent_ms >= deb) {
241
- const r = await ctx.DB.prepare("UPDATE wakes SET last_sent_ms = ? WHERE context_id = ? AND last_sent_ms = ? AND pending_json IS NULL")
242
- .bind(now, contextId, row.last_sent_ms).run();
307
+ const r = await ctx.DB.prepare("UPDATE wakes SET last_sent_ms = ? WHERE context_id = ? AND peer = ? AND last_sent_ms = ? AND pending_json IS NULL")
308
+ .bind(now, contextId, peer, row.last_sent_ms).run();
243
309
  claimed = r.meta.changes === 1;
244
310
  }
245
311
  if (claimed && (await wakeBudgetOk(ctx))) { ectx.waitUntil(sendWake(ctx, ectx, payload)); return; }
246
312
  // debounce window (or hourly cap reached): merge into the pending wake and flush it later
247
- const cur: Json = await ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ?").bind(contextId).first();
313
+ const cur: Json = await loadWake(ctx, contextId, from);
314
+ const curPeer = cur?.peer ?? peer;
248
315
  const prev = cur?.pending_json ? JSON.parse(cur.pending_json) : null;
249
316
  payload.taskIds = [...new Set([...(prev?.taskIds || []), taskId])];
250
- await ctx.DB.prepare("UPDATE wakes SET pending_json = ?, pending_since_ms = COALESCE(pending_since_ms, ?) WHERE context_id = ?")
251
- .bind(JSON.stringify(payload), now, contextId).run();
317
+ await ctx.DB.prepare("UPDATE wakes SET pending_json = ?, pending_since_ms = COALESCE(pending_since_ms, ?) WHERE context_id = ? AND peer = ?")
318
+ .bind(JSON.stringify(payload), now, contextId, curPeer).run();
252
319
  // The pending row exists now. A hosted alarm is set in this turn, including when the delay is too long for waitUntil.
253
320
  await noteWakeWrite(ectx);
254
321
  const delay = Math.max(0, (cur?.last_sent_ms || now) + deb - now) + 100;
255
322
  // waitUntil can outlive the response by ~30s. Longer windows are flushed by a later request, the optional
256
323
  // single-inbox cron, or the registered alarm (set above).
257
- if (delay <= 25000) ectx.waitUntil(new Promise((res) => setTimeout(res, delay)).then(() => flushWake(ctx, ectx, contextId)));
324
+ if (delay <= 25000) ectx.waitUntil(new Promise((res) => setTimeout(res, delay)).then(() => flushWake(ctx, ectx, contextId, curPeer)));
258
325
  }
259
326
 
260
- async function flushWake(ctx: TenantContext, ectx: ExecutionContext, contextId: string) {
261
- const row: Json = await ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ?").bind(contextId).first();
327
+ async function flushWake(ctx: TenantContext, ectx: ExecutionContext, contextId: string, peer: string) {
328
+ const row: Json = await ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ? AND peer = ?").bind(contextId, peer).first();
262
329
  if (!row || !row.pending_json) return;
263
330
  const now = Date.now();
264
331
  if (now - row.last_sent_ms < debounceMs(ctx)) return;
@@ -268,16 +335,16 @@ async function flushWake(ctx: TenantContext, ectx: ExecutionContext, contextId:
268
335
  if ((b?.count ?? 0) >= maxPerHour(ctx)) return; // keep pending until the next hour
269
336
  }
270
337
  const r = await ctx.DB.prepare(
271
- "UPDATE wakes SET pending_json = NULL, pending_since_ms = NULL, last_sent_ms = ? WHERE context_id = ? AND last_sent_ms = ? AND pending_json IS NOT NULL",
272
- ).bind(now, contextId, row.last_sent_ms).run();
338
+ "UPDATE wakes SET pending_json = NULL, pending_since_ms = NULL, last_sent_ms = ? WHERE context_id = ? AND peer = ? AND last_sent_ms = ? AND pending_json IS NOT NULL",
339
+ ).bind(now, contextId, peer, row.last_sent_ms).run();
273
340
  if (r.meta.changes === 1 && (await wakeBudgetOk(ctx))) await sendWake(ctx, ectx, JSON.parse(row.pending_json));
274
341
  }
275
342
 
276
343
  /** Flush every pending wake whose debounce window has passed (called opportunistically and from cron). */
277
344
  async function flushDue(ctx: TenantContext, ectx: ExecutionContext) {
278
- const rows = await ctx.DB.prepare("SELECT context_id FROM wakes WHERE pending_json IS NOT NULL AND last_sent_ms <= ? LIMIT 20")
345
+ const rows = await ctx.DB.prepare("SELECT context_id, peer FROM wakes WHERE pending_json IS NOT NULL AND last_sent_ms <= ? LIMIT 20")
279
346
  .bind(Date.now() - debounceMs(ctx)).all();
280
- for (const r of rows.results as Json[]) await flushWake(ctx, ectx, r.context_id);
347
+ for (const r of rows.results as Json[]) await flushWake(ctx, ectx, r.context_id, typeof r.peer === "string" ? r.peer : "");
281
348
  }
282
349
 
283
350
  /**
@@ -563,15 +630,15 @@ function protectedResourceMetadata(ctx: TenantContext): Json {
563
630
  const V1: Record<string, string> = {
564
631
  SendMessage: "send", GetTask: "get", CancelTask: "cancel", CreateTaskPushNotificationConfig: "pushset",
565
632
  SetTaskPushNotificationConfig: "pushset", GetTaskPushNotificationConfig: "pushget", ListTasks: "list",
633
+ ListTaskPushNotificationConfig: "pushlist", ListTaskPushNotificationConfigs: "pushlist",
634
+ DeleteTaskPushNotificationConfig: "pushdel",
566
635
  SendStreamingMessage: "stream", SubscribeToTask: "stream",
567
636
  };
568
637
  const V03: Record<string, string> = {
569
638
  "message/send": "send", "tasks/get": "get", "tasks/cancel": "cancel", "tasks/pushNotificationConfig/set": "pushset",
570
- "tasks/pushNotificationConfig/get": "pushget", "message/stream": "stream", "tasks/resubscribe": "stream",
639
+ "tasks/pushNotificationConfig/get": "pushget", "tasks/pushNotificationConfig/list": "pushlist",
640
+ "tasks/pushNotificationConfig/delete": "pushdel", "message/stream": "stream", "tasks/resubscribe": "stream",
571
641
  };
572
- // Methods only the façade forwards (the inbox has one push config per task and no delete).
573
- const PROXY_V1: Record<string, string> = { ListTaskPushNotificationConfig: "pushlist", DeleteTaskPushNotificationConfig: "pushdel" };
574
- const PROXY_V03: Record<string, string> = { "tasks/pushNotificationConfig/list": "pushlist", "tasks/pushNotificationConfig/delete": "pushdel" };
575
642
  const EXTENDED_CARD_METHODS = new Set(["GetExtendedAgentCard", "agent/getAuthenticatedExtendedCard"]);
576
643
 
577
644
  /** Header, else query `A2A-Version`. A patch (`1.0.1`, `0.3.0`) is ignored. Empty means the method name chooses the
@@ -587,11 +654,9 @@ function declaredA2AVersion(req: Request): { raw: string; version: string | null
587
654
  return { raw, version: null };
588
655
  }
589
656
 
590
- function rpcOperation(method: string, proxy: boolean): { op: string; version: string } | null {
657
+ function rpcOperation(method: string): { op: string; version: string } | null {
591
658
  if (V1[method]) return { op: V1[method], version: "1.0" };
592
659
  if (V03[method]) return { op: V03[method], version: "0.3" };
593
- if (proxy && PROXY_V1[method]) return { op: PROXY_V1[method], version: "1.0" };
594
- if (proxy && PROXY_V03[method]) return { op: PROXY_V03[method], version: "0.3" };
595
660
  return null;
596
661
  }
597
662
 
@@ -602,6 +667,10 @@ async function ownedTask(ctx: TenantContext, id: unknown, label: string): Promis
602
667
  return t;
603
668
  }
604
669
 
670
+ /** A contextId this caller does not get to use. The same sentence is returned for an id the façade has no
671
+ * record of. It does not say whether the id exists, or which peer holds it. */
672
+ const UNKNOWN_CONTEXT = "Invalid params: unknown contextId (omit contextId to start a new conversation, then use the one returned)";
673
+
605
674
  function normPushCfg(cfg: Json, version: string): Json {
606
675
  if (!cfg || typeof cfg !== "object" || typeof cfg.url !== "string") throw new RpcError(-32602, "push notification config needs url");
607
676
  const out: Json = { id: typeof cfg.id === "string" && cfg.id ? cfg.id.slice(0, 128) : A.newId(), url: cfg.url, _version: version };
@@ -634,6 +703,12 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
634
703
  }
635
704
  const contextId = task ? task.contextId : msg.contextId ? A.checkId(msg.contextId, "contextId") : A.newId();
636
705
  const ts = A.nowIso();
706
+ // A2A lets an agent accept a client-supplied contextId and forbids minting a replacement when it will not.
707
+ // This inbox accepts the id for the caller. A peer that already has it continues that conversation. An id
708
+ // this agent has never seen, and an id another peer already has, are the same for the caller: a new
709
+ // conversation of their own. The other peer's tasks, history, and wake are not read or written. The claim
710
+ // and the first task are one batch, so the task is stored only when this peer's owner row is present.
711
+ const creating = !task;
637
712
  if (!task) task = { id: A.newId(), contextId, status: { state: "submitted", timestamp: ts }, artifacts: [], history: [], _peer: label, _protocol: version, _createdAt: ts, _push: [] };
638
713
  msg.contextId = contextId; msg.taskId = task.id;
639
714
  task.history.push(msg);
@@ -641,10 +716,21 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
641
716
  if (pushCfg) task._push = [...task._push.filter((c: Json) => c.id !== pushCfg.id), pushCfg];
642
717
  if (params.metadata && typeof params.metadata === "object") task.metadata = { ...(task.metadata || {}), ...params.metadata };
643
718
  const preview = A.textOf(msg);
644
- await ctx.DB.batch([
645
- saveTaskStmt(ctx, task), msgStmt(ctx, task, msg),
646
- histStmt(ctx, contextId, { dir: "in", peer: label, taskId: task.id, role: "user", text: preview, data: { messageId: msg.messageId } }),
647
- ]);
719
+ if (creating) {
720
+ const stored = await ctx.DB.batch([
721
+ claimContextStmt(ctx, contextId, label, ts),
722
+ saveTaskIfOwnerStmt(ctx, task),
723
+ msgIfOwnerStmt(ctx, task, msg),
724
+ histIfOwnerStmt(ctx, contextId, { dir: "in", peer: label, taskId: task.id, role: "user", text: preview, data: { messageId: msg.messageId } }, label),
725
+ ]);
726
+ const wrote = stored[1]?.meta?.changes ?? 0;
727
+ if (wrote < 1) throw new RpcError(-32602, UNKNOWN_CONTEXT);
728
+ } else {
729
+ await ctx.DB.batch([
730
+ saveTaskStmt(ctx, task), msgStmt(ctx, task, msg),
731
+ histStmt(ctx, contextId, { dir: "in", peer: label, taskId: task.id, role: "user", text: preview, data: { messageId: msg.messageId } }),
732
+ ]);
733
+ }
648
734
  log("message_received", { peer: label, contextId, taskId: task.id, chars: preview.length, version });
649
735
  await wake(ctx, ectx, contextId, task.id, label, preview);
650
736
  const pt = A.publicTask(task, version, conf.historyLength);
@@ -680,22 +766,104 @@ const rpcPushGet: Rpc = async (p, label, version, ctx) => {
680
766
  throw new RpcError(-32001, "Push notification config not found");
681
767
  };
682
768
 
769
+ /** Config id for list/delete. 1.0 sends `{ taskId, id }`. 0.3 sends `{ id: taskId, pushNotificationConfigId }`. */
770
+ function pushConfigId(p: Json): string | undefined {
771
+ const id = p.pushNotificationConfigId || (p.taskId ? p.id : undefined);
772
+ return typeof id === "string" && id ? id : undefined;
773
+ }
774
+
775
+ /** Cursor over this task's configs. The token is an index, not a config id, url, or token. */
776
+ function pushListStart(raw: unknown): number {
777
+ if (raw === undefined || raw === null || raw === "") return 0;
778
+ if (typeof raw !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(raw)) throw new RpcError(-32602, "Invalid params: pageToken");
779
+ let text = "";
780
+ try { text = b64urlDecode(raw); } catch { throw new RpcError(-32602, "Invalid params: pageToken"); }
781
+ const m = /^cfg\n(\d{1,6})$/.exec(text);
782
+ if (!m) throw new RpcError(-32602, "Invalid params: pageToken");
783
+ return Number(m[1]);
784
+ }
785
+
786
+ const rpcPushList: Rpc = async (p, label, version, ctx) => {
787
+ const t = await ownedTask(ctx, p.taskId || p.id, label);
788
+ const all = (t._push || []).map((c: Json) => pubCfg(t.id, c, version));
789
+ if (!version.startsWith("1")) return all;
790
+ const size = Math.min(Math.max(Number(p.pageSize) || 50, 1), 100);
791
+ const start = pushListStart(p.pageToken);
792
+ const configs = all.slice(start, start + size);
793
+ const next = start + configs.length;
794
+ return { configs, nextPageToken: next < all.length ? b64url(`cfg\n${next}`) : "" };
795
+ };
796
+
797
+ const rpcPushDel: Rpc = async (p, label, version, ctx) => {
798
+ const t = await ownedTask(ctx, p.taskId || p.id, label);
799
+ const id = pushConfigId(p);
800
+ if (!id) throw new RpcError(-32602, "Invalid params: push notification config id");
801
+ const kept = (t._push || []).filter((c: Json) => c.id !== id);
802
+ if (kept.length !== (t._push || []).length) {
803
+ t._push = kept;
804
+ await saveTaskStmt(ctx, t).run();
805
+ }
806
+ // Idempotent: a missing config on this peer's task is the same as a config already removed.
807
+ return version.startsWith("1") ? {} : null;
808
+ };
809
+
810
+ /** Keyset cursor for ListTasks, newest update first. Empty means the first page. A bad token is rejected
811
+ * without echoing it. The query still filters by the caller, so the cursor cannot select another peer's tasks. */
812
+ function listCursor(raw: unknown): { updatedAt: string; id: string } | null {
813
+ if (raw === undefined || raw === null || raw === "") return null;
814
+ if (typeof raw !== "string" || !/^[A-Za-z0-9_-]{1,256}$/.test(raw)) throw new RpcError(-32602, "Invalid params: pageToken");
815
+ let text = "";
816
+ try { text = b64urlDecode(raw); } catch { throw new RpcError(-32602, "Invalid params: pageToken"); }
817
+ const nl = text.indexOf("\n");
818
+ if (nl <= 0) throw new RpcError(-32602, "Invalid params: pageToken");
819
+ const updatedAt = text.slice(0, nl);
820
+ const id = text.slice(nl + 1);
821
+ if (!INBOX_STAMP.test(updatedAt) || !A.ID_RE.test(id) || id.includes("\n")) throw new RpcError(-32602, "Invalid params: pageToken");
822
+ return { updatedAt, id };
823
+ }
824
+
683
825
  const rpcList: Rpc = async (p, label, version, ctx) => {
684
826
  const size = Math.min(Math.max(Number(p.pageSize) || 50, 1), 100);
685
- const where = p.contextId ? "peer = ? AND context_id = ?" : "peer = ?";
686
- const args = p.contextId ? [label, p.contextId] : [label];
687
- const total: Json = await ctx.DB.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE ${where}`).bind(...args).first();
688
- const rows = await ctx.DB.prepare(`SELECT id FROM tasks WHERE ${where} ORDER BY updated_at DESC LIMIT ?`).bind(...args, size).all();
827
+ const cursor = listCursor(p.pageToken);
828
+ const where = ["peer = ?"];
829
+ const args: Json[] = [label];
830
+ if (p.contextId !== undefined && p.contextId !== "") {
831
+ if (typeof p.contextId !== "string" || !A.ID_RE.test(p.contextId)) throw new RpcError(-32602, "Invalid params: invalid contextId");
832
+ where.push("context_id = ?");
833
+ args.push(p.contextId);
834
+ }
835
+ const whereSql = where.join(" AND ");
836
+ const total: Json = await ctx.DB.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE ${whereSql}`).bind(...args).first();
837
+ const pageWhere = [...where];
838
+ const pageArgs = [...args];
839
+ if (cursor) {
840
+ pageWhere.push("(updated_at < ? OR (updated_at = ? AND id < ?))");
841
+ pageArgs.push(cursor.updatedAt, cursor.updatedAt, cursor.id);
842
+ }
843
+ const rows = await ctx.DB.prepare(
844
+ `SELECT id, updated_at FROM tasks WHERE ${pageWhere.join(" AND ")} ORDER BY updated_at DESC, id DESC LIMIT ?`,
845
+ ).bind(...pageArgs, size + 1).all();
846
+ const found = (rows.results || []) as Json[];
847
+ const page = found.slice(0, size);
689
848
  const tasks = [];
690
- for (const r of rows.results as Json[]) {
849
+ for (const r of page) {
691
850
  const t = A.publicTask(await loadTask(ctx, r.id), version, p.historyLength ?? 0);
692
851
  if (!p.includeArtifacts) delete t.artifacts;
693
852
  tasks.push(t);
694
853
  }
695
- return { tasks, nextPageToken: "", pageSize: size, totalSize: total?.n ?? tasks.length };
854
+ let nextPageToken = "";
855
+ if (found.length > size && page.length) {
856
+ const updatedAt = String(page[page.length - 1].updated_at);
857
+ const id = String(page[page.length - 1].id);
858
+ if (INBOX_STAMP.test(updatedAt) && A.ID_RE.test(id)) nextPageToken = b64url(`${updatedAt}\n${id}`);
859
+ }
860
+ return { tasks, nextPageToken, pageSize: size, totalSize: total?.n ?? tasks.length };
696
861
  };
697
862
 
698
- const HANDLERS: Record<string, Rpc> = { send: rpcSend, get: rpcGet, cancel: rpcCancel, pushset: rpcPushSet, pushget: rpcPushGet, list: rpcList };
863
+ const HANDLERS: Record<string, Rpc> = {
864
+ send: rpcSend, get: rpcGet, cancel: rpcCancel, pushset: rpcPushSet, pushget: rpcPushGet,
865
+ pushlist: rpcPushList, pushdel: rpcPushDel, list: rpcList,
866
+ };
699
867
 
700
868
  /** 401 for A2A calls. Unless pairing is off, the error says how to get a token (device flow), so a generic agent can follow it.
701
869
  * The JSON-RPC id is echoed when the request body parses (JSON-RPC 2.0); WWW-Authenticate points to RFC 9728 metadata. */
@@ -742,7 +910,7 @@ async function handleRpc(req: Request, ctx: TenantContext, ectx: ExecutionContex
742
910
  const declared = declaredA2AVersion(req);
743
911
  if (declared.raw && !declared.version) return err(-32009, `A2A version ${declared.raw} not supported (supported: 0.3, 1.0)`);
744
912
  if (EXTENDED_CARD_METHODS.has(method)) return err(-32007, "Extended agent card is not configured");
745
- const found = rpcOperation(method, proxy);
913
+ const found = rpcOperation(method);
746
914
  if (!found) return err(-32601, "Method not found");
747
915
  const version = declared.version || found.version;
748
916
  const op = found.op;
@@ -857,7 +1025,7 @@ async function proxyRpc(req: Request, ctx: TenantContext, c: { raw: string; rid:
857
1025
  // may still be a live upstream context (another peer's, or one opened outside the façade), and the façade can't
858
1026
  // tell, so client-chosen context ids are refused; omit contextId to start a conversation and reuse the returned one.
859
1027
  if ((await ownerOf(ctx, "context", m.contextId)) !== c.label)
860
- return err(-32602, "Invalid params: unknown contextId (omit contextId to start a new conversation, then use the one returned)");
1028
+ return err(-32602, UNKNOWN_CONTEXT);
861
1029
  }
862
1030
  } else {
863
1031
  const tid = F.taskIdOf(c.op, c.params);
@@ -994,6 +1162,8 @@ async function handlePush(req: Request, ctx: TenantContext, ectx: ExecutionConte
994
1162
 
995
1163
  // ------------------------------------------------------------------ owner API (local CLI)
996
1164
  const INBOX_STAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/;
1165
+ /** Inbound labels and outbound aliases. Used to scope a conversation read to one peer. */
1166
+ const PEER_FILTER = /^[A-Za-z0-9_.-]{1,64}$/;
997
1167
 
998
1168
  function inboxLimit(raw: string | null): number {
999
1169
  if (raw === null) return M.INBOX_MAX;
@@ -1120,22 +1290,25 @@ async function ackPendingWakes(ctx: TenantContext, seen: { taskId: string; conte
1120
1290
  byContext.set(row.contextId, ids);
1121
1291
  }
1122
1292
  for (const [contextId, seenIds] of byContext) {
1123
- const row: Json = await ctx.DB.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").bind(contextId).first();
1124
- if (typeof row?.pending_json !== "string") continue;
1125
- let payload: Json;
1126
- try { payload = JSON.parse(row.pending_json); } catch { continue; }
1127
- const wakeKind = typeof payload?.kind === "string" && payload.kind ? payload.kind : "inbound";
1128
- if (wakeKind !== kind) continue;
1129
- if (typeof payload?.contextId === "string" && payload.contextId !== contextId) continue;
1130
- const ids = wakeTaskIds(payload);
1131
- if (!ids.some((id) => seenIds.has(id))) continue;
1132
- const kept = ids.filter((id) => !seenIds.has(id));
1133
- if (!kept.length) {
1134
- await ctx.DB.prepare("UPDATE wakes SET pending_json = NULL, pending_since_ms = NULL WHERE context_id = ? AND pending_json = ?").bind(contextId, row.pending_json).run();
1135
- continue;
1293
+ const rows = await ctx.DB.prepare("SELECT peer, pending_json FROM wakes WHERE context_id = ? AND pending_json IS NOT NULL").bind(contextId).all();
1294
+ for (const row of (rows.results || []) as Json[]) {
1295
+ if (typeof row?.pending_json !== "string") continue;
1296
+ let payload: Json;
1297
+ try { payload = JSON.parse(row.pending_json); } catch { continue; }
1298
+ const wakeKind = typeof payload?.kind === "string" && payload.kind ? payload.kind : "inbound";
1299
+ if (wakeKind !== kind) continue;
1300
+ if (typeof payload?.contextId === "string" && payload.contextId !== contextId) continue;
1301
+ const ids = wakeTaskIds(payload);
1302
+ if (!ids.some((id) => seenIds.has(id))) continue;
1303
+ const kept = ids.filter((id) => !seenIds.has(id));
1304
+ const peer = typeof row.peer === "string" ? row.peer : "";
1305
+ if (!kept.length) {
1306
+ await ctx.DB.prepare("UPDATE wakes SET pending_json = NULL, pending_since_ms = NULL WHERE context_id = ? AND peer = ? AND pending_json = ?").bind(contextId, peer, row.pending_json).run();
1307
+ continue;
1308
+ }
1309
+ const next = { ...payload, taskIds: kept, taskId: kept[kept.length - 1] };
1310
+ await ctx.DB.prepare("UPDATE wakes SET pending_json = ? WHERE context_id = ? AND peer = ? AND pending_json = ?").bind(JSON.stringify(next), contextId, peer, row.pending_json).run();
1136
1311
  }
1137
- const next = { ...payload, taskIds: kept, taskId: kept[kept.length - 1] };
1138
- await ctx.DB.prepare("UPDATE wakes SET pending_json = ? WHERE context_id = ? AND pending_json = ?").bind(JSON.stringify(next), contextId, row.pending_json).run();
1139
1312
  }
1140
1313
  }
1141
1314
 
@@ -1177,6 +1350,8 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1177
1350
  }
1178
1351
  if (seg[0] === "inbox" && m === "GET") {
1179
1352
  const contextFilter = url.searchParams.get("context");
1353
+ const fromFilter = url.searchParams.get("from");
1354
+ if (fromFilter !== null && !PEER_FILTER.test(fromFilter)) throw new HttpError(400, "from is invalid");
1180
1355
  const all = url.searchParams.get("all") === "1";
1181
1356
  const limit = inboxLimit(url.searchParams.get("limit"));
1182
1357
  const cursor = inboxCursor(url.searchParams.get("cursor"));
@@ -1184,6 +1359,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1184
1359
  const args: Json[] = [];
1185
1360
  if (!all) q += " AND state IN ('submitted','working')";
1186
1361
  if (contextFilter) { A.checkId(contextFilter, "contextId"); q += " AND context_id = ?"; args.push(contextFilter); }
1362
+ if (fromFilter) { q += " AND peer = ?"; args.push(fromFilter); }
1187
1363
  if (cursor) { q += " AND (updated_at > ? OR (updated_at = ? AND id > ?))"; args.push(cursor.updatedAt, cursor.updatedAt, cursor.id); }
1188
1364
  q += " ORDER BY updated_at, id LIMIT ?";
1189
1365
  args.push(limit + 1);
@@ -1205,6 +1381,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1205
1381
  const oargs: Json[] = [];
1206
1382
  if (!all) oq += " AND updates_seen = 0";
1207
1383
  if (contextFilter) { oq += " AND context_id = ?"; oargs.push(contextFilter); }
1384
+ if (fromFilter) { oq += " AND peer = ?"; oargs.push(fromFilter); }
1208
1385
  oq += " ORDER BY sent_at LIMIT ?";
1209
1386
  oargs.push(limit);
1210
1387
  const replies = await ctx.DB.prepare(oq).bind(...oargs).all();
@@ -1272,7 +1449,34 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1272
1449
  }
1273
1450
  if (seg[0] === "history" && seg[1] && m === "GET") {
1274
1451
  const n = Math.min(Number(url.searchParams.get("n") || "50"), 1000);
1275
- const rows = await ctx.DB.prepare("SELECT * FROM (SELECT * FROM history WHERE context_id = ? ORDER BY seq DESC LIMIT ?) ORDER BY seq").bind(seg[1], n).all();
1452
+ const fromFilter = url.searchParams.get("from");
1453
+ if (fromFilter !== null && !PEER_FILTER.test(fromFilter)) return json({ error: "from is invalid" }, 400);
1454
+ // Without `from`, rows for every peer that owns this context id (a task or an outbound task), plus local notes.
1455
+ // With `from`, one peer's rows only, so another peer's conversation is not in this read.
1456
+ const rows = await ctx.DB.prepare(fromFilter ? `SELECT * FROM (
1457
+ SELECT * FROM history WHERE context_id = ? AND (
1458
+ peer = ?
1459
+ OR (
1460
+ (peer IS NULL OR peer = '')
1461
+ AND (
1462
+ task_id IN (SELECT id FROM tasks WHERE context_id = ? AND peer = ?)
1463
+ OR task_id IN (SELECT task_id FROM outbound WHERE context_id = ? AND peer = ?)
1464
+ )
1465
+ )
1466
+ ) ORDER BY seq DESC LIMIT ?
1467
+ ) ORDER BY seq` : `SELECT * FROM (
1468
+ SELECT * FROM history WHERE context_id = ? AND (
1469
+ peer IS NULL OR peer = ''
1470
+ OR peer IN (SELECT peer FROM tasks WHERE context_id = ? AND peer IS NOT NULL AND peer != '')
1471
+ OR peer IN (SELECT peer FROM outbound WHERE context_id = ? AND peer IS NOT NULL AND peer != '')
1472
+ OR (
1473
+ NOT EXISTS (SELECT 1 FROM tasks WHERE context_id = ?)
1474
+ AND NOT EXISTS (SELECT 1 FROM outbound WHERE context_id = ?)
1475
+ )
1476
+ ) ORDER BY seq DESC LIMIT ?
1477
+ ) ORDER BY seq`).bind(...(fromFilter
1478
+ ? [seg[1], fromFilter, seg[1], fromFilter, seg[1], fromFilter, n]
1479
+ : [seg[1], seg[1], seg[1], seg[1], seg[1], n])).all();
1276
1480
  return json((rows.results as Json[]).map((r) => ({ ...r, data: r.data_json ? JSON.parse(r.data_json) : undefined, data_json: undefined })));
1277
1481
  }
1278
1482
  if (seg[0] === "history" && !seg[1] && m === "POST") {
@@ -1434,6 +1638,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1434
1638
  ctx.DB.prepare("DELETE FROM history WHERE context_id = ?").bind(c),
1435
1639
  ctx.DB.prepare("DELETE FROM outbound WHERE context_id = ?").bind(c),
1436
1640
  ctx.DB.prepare("DELETE FROM wakes WHERE context_id = ?").bind(c),
1641
+ ctx.DB.prepare("DELETE FROM conversation_owners WHERE context_id = ?").bind(c),
1437
1642
  ]);
1438
1643
  n += res.reduce((s, r) => s + (r.meta.changes || 0), 0);
1439
1644
  }
@@ -2713,6 +2918,11 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
2713
2918
  case "inbox": {
2714
2919
  const qs = new URLSearchParams();
2715
2920
  if (a.contextId !== undefined) qs.set("context", A.checkId(argStr(a, "contextId"), "contextId"));
2921
+ if (a.from !== undefined) {
2922
+ const from = argStr(a, "from");
2923
+ if (!PEER_FILTER.test(from)) throw new ToolError("from is invalid");
2924
+ qs.set("from", from);
2925
+ }
2716
2926
  if (a.all === true) qs.set("all", "1");
2717
2927
  if (a.cursor !== undefined) {
2718
2928
  const cursor = argStr(a, "cursor");
@@ -2740,7 +2950,13 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
2740
2950
  case "history": {
2741
2951
  const n = a.n === undefined ? 50 : Number(a.n);
2742
2952
  if (!Number.isInteger(n) || n < 1 || n > 1000) throw new ToolError("n must be 1-1000");
2743
- return M.toolText(`History, oldest first. ${UNTRUSTED}`, must(await ownerCall(ctx, ectx, "GET", `/owner/history/${enc(A.checkId(argStr(a, "contextId"), "contextId"))}?n=${n}`)));
2953
+ let histPath = `/owner/history/${enc(A.checkId(argStr(a, "contextId"), "contextId"))}?n=${n}`;
2954
+ if (a.from !== undefined) {
2955
+ const from = argStr(a, "from");
2956
+ if (!PEER_FILTER.test(from)) throw new ToolError("from is invalid");
2957
+ histPath += `&from=${enc(from)}`;
2958
+ }
2959
+ return M.toolText(`History, oldest first. ${UNTRUSTED}`, must(await ownerCall(ctx, ectx, "GET", histPath)));
2744
2960
  }
2745
2961
  case "mark_working": {
2746
2962
  const r = must(await ownerCall(ctx, ectx, "POST", `/owner/tasks/${enc(A.checkId(argStr(a, "taskId"), "taskId"))}/working`));
package/worker/src/mcp.ts CHANGED
@@ -323,6 +323,7 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
323
323
  description: `Open tasks other agents sent to this A2A endpoint (state submitted or working), oldest first, one page at a time, plus a reply to a message you sent (kind "outbound") until this inbox lists it, plus pending pairing requests. When the result says more tasks are waiting, call inbox again with the cursor before you stop. Handle each inbound task, then answer it with reply. A reply to a message you sent: read it with history, and continue that conversation with send. ${RULES}`,
324
324
  inputSchema: { type: "object", properties: {
325
325
  contextId: str("Only this conversation"),
326
+ from: str("Only this peer (the wake's from). The same context id can be a different peer's conversation"),
326
327
  all: { type: "boolean", description: "Include finished tasks on this page" },
327
328
  cursor: str("Cursor from the previous inbox page, when more tasks are waiting"),
328
329
  limit: { type: "integer", minimum: 1, maximum: INBOX_MAX, description: `Tasks in this page (default ${INBOX_PAGE}, at most ${INBOX_MAX})` },
@@ -332,7 +333,7 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
332
333
  inputSchema: { type: "object", properties: { taskId: str("Task id from the A2A endpoint") }, required: ["taskId"], additionalProperties: false } },
333
334
  { name: "history", title: "Conversation history", annotations: ro,
334
335
  description: `The log of one conversation (both directions), oldest first. ${RULES}`,
335
- inputSchema: { type: "object", properties: { contextId: str("Conversation (context) id"), n: { type: "integer", minimum: 1, maximum: 1000, description: "Entries (default 50)" } }, required: ["contextId"], additionalProperties: false } },
336
+ inputSchema: { type: "object", properties: { contextId: str("Conversation (context) id"), from: str("Only this peer (the wake's from)"), n: { type: "integer", minimum: 1, maximum: 1000, description: "Entries (default 50)" } }, required: ["contextId"], additionalProperties: false } },
336
337
  { name: "mark_working", title: "Mark task working", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
337
338
  description: "Tell the peer you are working on a task (state working; the peer is notified if it asked for push updates). Use for anything that takes more than a moment.",
338
339
  inputSchema: { type: "object", properties: { taskId: str("Task id") }, required: ["taskId"], additionalProperties: false } },
@@ -9,6 +9,9 @@ import m0006 from "../migrations/0006_mcp.sql?raw";
9
9
  import m0007 from "../migrations/0007_cimd.sql?raw";
10
10
  import m0008 from "../migrations/0008_oidc_txns.sql?raw";
11
11
  import m0009 from "../migrations/0009_outbound_seen.sql?raw";
12
+ import m0010 from "../migrations/0010_conversation_peer.sql?raw";
13
+ import m0011 from "../migrations/0011_conversation_owner.sql?raw";
14
+ import m0012 from "../migrations/0012_conversation_peer_owner.sql?raw";
12
15
  import type { Migration } from "./storage.ts";
13
16
 
14
17
  export const MIGRATIONS: Migration[] = [
@@ -21,4 +24,7 @@ export const MIGRATIONS: Migration[] = [
21
24
  { name: "0007_cimd.sql", sql: m0007 },
22
25
  { name: "0008_oidc_txns.sql", sql: m0008 },
23
26
  { name: "0009_outbound_seen.sql", sql: m0009 },
27
+ { name: "0010_conversation_peer.sql", sql: m0010 },
28
+ { name: "0011_conversation_owner.sql", sql: m0011 },
29
+ { name: "0012_conversation_peer_owner.sql", sql: m0012 },
24
30
  ];
@@ -105,25 +105,31 @@ function noQuery(q: Record<string, string>): string | null {
105
105
  }
106
106
 
107
107
  function inboxQuery(q: Record<string, string>): string | null {
108
- for (const k of Object.keys(q)) if (k !== "context" && k !== "all" && k !== "limit" && k !== "cursor") return null;
108
+ for (const k of Object.keys(q)) if (k !== "context" && k !== "all" && k !== "limit" && k !== "cursor" && k !== "from") return null;
109
109
  if (q.context !== undefined && !idOk(q.context)) return null;
110
110
  if (q.all !== undefined && q.all !== "1") return null;
111
111
  if (q.limit !== undefined && (!/^[1-9]\d{0,2}$/.test(q.limit) || Number(q.limit) > 200)) return null;
112
112
  if (q.cursor !== undefined && !/^[A-Za-z0-9_-]{1,256}$/.test(q.cursor)) return null;
113
+ if (q.from !== undefined && !/^[A-Za-z0-9_.-]{1,64}$/.test(q.from)) return null;
113
114
  const sp = new URLSearchParams();
114
115
  if (q.context) sp.set("context", q.context);
115
116
  if (q.all) sp.set("all", "1");
116
117
  if (q.limit) sp.set("limit", q.limit);
117
118
  if (q.cursor) sp.set("cursor", q.cursor);
119
+ if (q.from) sp.set("from", q.from);
118
120
  const s = sp.toString();
119
121
  return s ? `?${s}` : "";
120
122
  }
121
123
 
122
124
  function historyQuery(q: Record<string, string>): string | null {
123
- for (const k of Object.keys(q)) if (k !== "n") return null;
124
- if (q.n === undefined) return "";
125
- if (!/^[1-9][0-9]{0,3}$/.test(q.n) || Number(q.n) > 1000) return null;
126
- return `?n=${q.n}`;
125
+ for (const k of Object.keys(q)) if (k !== "n" && k !== "from") return null;
126
+ if (q.n !== undefined && (!/^[1-9][0-9]{0,3}$/.test(q.n) || Number(q.n) > 1000)) return null;
127
+ if (q.from !== undefined && !/^[A-Za-z0-9_.-]{1,64}$/.test(q.from)) return null;
128
+ const sp = new URLSearchParams();
129
+ if (q.n) sp.set("n", q.n);
130
+ if (q.from) sp.set("from", q.from);
131
+ const s = sp.toString();
132
+ return s ? `?${s}` : "";
127
133
  }
128
134
 
129
135
  /** `format=sql` is the only export query. The RPC still calls the JSON handler and redacts before building SQL. */
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.16";
2
+ export const VERSION = "0.8.18";
@@ -92,9 +92,11 @@ export function pairingInstructions(p: PairingInfo, cli: string): string {
92
92
 
93
93
  function hintFor(ev: WakeEvent, cli: string): string {
94
94
  if (ev.kind === "pairing_request") return `${cli} pair list`;
95
+ // `--from` keeps the read on this peer. The same context id can be another peer's separate conversation.
96
+ const from = /^[A-Za-z0-9_.-]{1,64}$/.test(ev.from) ? ` --from ${ev.from}` : "";
95
97
  return ev.kind === "outbound_update"
96
- ? `${cli} history ${ev.contextId}`
97
- : `${cli} inbox --context ${ev.contextId}`;
98
+ ? `${cli} history ${ev.contextId}${from}`
99
+ : `${cli} inbox --context ${ev.contextId}${from}`;
98
100
  }
99
101
 
100
102
  /** Human/agent-readable wake text. `withPreview=false` keeps peer-authored text out entirely. */