a2a-exposed 0.8.17 → 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.
package/lib/commands.mjs CHANGED
@@ -55,6 +55,7 @@ async function inboxRows(o) {
55
55
  return followInbox(async (cursor) => {
56
56
  const qs = [];
57
57
  if (o.context) qs.push("context=" + q(o.context));
58
+ if (o.from) qs.push("from=" + q(o.from));
58
59
  if (o.all) qs.push("all=1");
59
60
  if (cursor) qs.push("cursor=" + q(cursor));
60
61
  const path = "/owner/inbox" + (qs.length ? "?" + qs.join("&") : "");
@@ -125,7 +126,9 @@ export async function reply(id, o) {
125
126
  }
126
127
 
127
128
  export async function history(ctx, o) {
128
- const rows = await owner("GET", `/owner/history/${q(ctx)}?n=${Number(o.n || 50)}`);
129
+ const qs = new URLSearchParams({ n: String(Number(o.n || 50)) });
130
+ if (o.from) qs.set("from", o.from);
131
+ const rows = await owner("GET", `/owner/history/${q(ctx)}?${qs}`);
129
132
  if (o.json) return out(rows);
130
133
  for (const e of rows) {
131
134
  const tag = [e.event, e.state || e.role].filter(Boolean).join("/");
package/lib/main.mjs CHANGED
@@ -165,9 +165,11 @@ Status
165
165
  config print config (secrets masked) and its location
166
166
 
167
167
  Inbox (owner side)
168
- inbox [--context C] [--all] [--json] unhandled inbound tasks, and a reply to a message you sent until
169
- this list is read (peer text is UNTRUSTED). A reply line says to
170
- read it with history and continue with send. Then pending pairing
168
+ inbox [--context C] [--from PEER] [--all] [--json]
169
+ unhandled inbound tasks, and a reply to a message you sent until
170
+ this list is read (peer text is UNTRUSTED). --from is one peer
171
+ (the wake hint includes it). A reply line says to read it with
172
+ history and continue with send. Then pending pairing
171
173
  requests (tell your human; the dashboard URL when MANAGE_URL is set;
172
174
  --json: rows on stdout, requests on stderr). Follows every page; past
173
175
  10000 pages it errors instead of a short list
@@ -175,7 +177,8 @@ Inbox (owner side)
175
177
  working <taskId> mark task working
176
178
  reply <taskId> (--text T | --stdin) [--state completed|input-required|rejected|failed|working]
177
179
  [--artifact] [--artifact-name N] [--force]
178
- history <contextId> [-n 50] [--json] conversation log
180
+ history <contextId> [--from PEER] [-n 50] [--json]
181
+ conversation log. --from is one peer (the wake hint includes it)
179
182
  contexts recent conversations
180
183
  export [--sql] JSON dump of this A2A endpoint on stdout (peer-token hashes and
181
184
  encrypted peer tokens are in the dump; the owner token stays
@@ -312,9 +315,9 @@ const deployOpts = {
312
315
  };
313
316
  const SPEC = {
314
317
  init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts, wake: deployOpts, tunnel: tunnelOpts,
315
- inbox: { context: S, all: B, json: B },
318
+ inbox: { context: S, from: S, all: B, json: B },
316
319
  reply: { text: S, stdin: B, state: { type: "string", default: "completed" }, artifact: B, "artifact-name": { type: "string", default: "response" }, force: B },
317
- history: { n: { type: "string", short: "n", default: "50" }, json: B },
320
+ history: { from: S, n: { type: "string", short: "n", default: "50" }, json: B },
318
321
  token: { rotate: B },
319
322
  peers: { "token-env": S, "token-stdin": B },
320
323
  send: { to: S, text: S, context: S, task: S, push: B, proto: S },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.17",
3
+ "version": "0.8.18",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: a Cloudflare Worker, a wake webhook, or scheduled checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,13 @@
1
+ -- One row per peer. The same context id can be a separate conversation for each peer.
2
+ -- 0011's single-column primary key made a second peer's insert fail, which is how a caller
3
+ -- could tell a fresh id from one another peer already held.
4
+ CREATE TABLE conversation_owners_peer (
5
+ context_id TEXT NOT NULL,
6
+ peer TEXT NOT NULL,
7
+ created_at TEXT NOT NULL,
8
+ PRIMARY KEY (context_id, peer)
9
+ );
10
+ INSERT INTO conversation_owners_peer (context_id, peer, created_at)
11
+ SELECT context_id, peer, created_at FROM conversation_owners;
12
+ DROP TABLE conversation_owners;
13
+ ALTER TABLE conversation_owners_peer RENAME TO conversation_owners;
@@ -67,17 +67,12 @@ 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 an unused context for this peer. `conversation_owners.context_id` is unique, and the insert does nothing
71
- * when another peer already holds it or already has a task, history row, or outbound task on that id. */
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
72
  function claimContextStmt(ctx: TenantContext, contextId: string, peer: string, ts: string) {
73
73
  return ctx.DB.prepare(
74
- `INSERT INTO conversation_owners (context_id, peer, created_at)
75
- SELECT ?1, ?2, ?3
76
- WHERE NOT EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?1 AND peer != ?2)
77
- AND NOT EXISTS (SELECT 1 FROM tasks WHERE context_id = ?1 AND peer IS NOT NULL AND peer != '' AND peer != ?2)
78
- AND NOT EXISTS (SELECT 1 FROM history WHERE context_id = ?1 AND peer IS NOT NULL AND peer != '' AND peer != ?2)
79
- AND NOT EXISTS (SELECT 1 FROM outbound WHERE context_id = ?1 AND peer IS NOT NULL AND peer != '' AND peer != ?2)
80
- ON CONFLICT(context_id) DO NOTHING`,
74
+ `INSERT INTO conversation_owners (context_id, peer, created_at) VALUES (?1, ?2, ?3)
75
+ ON CONFLICT(context_id, peer) DO NOTHING`,
81
76
  ).bind(contextId, peer, ts);
82
77
  }
83
78
 
@@ -86,9 +81,9 @@ function saveTaskIfOwnerStmt(ctx: TenantContext, t: Json) {
86
81
  return ctx.DB.prepare(
87
82
  `INSERT INTO tasks (id, context_id, peer, state, protocol, created_at, updated_at, status_json, artifacts_json, metadata_json, push_configs_json)
88
83
  SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11
89
- WHERE (SELECT peer FROM conversation_owners WHERE context_id = ?2) = ?3
84
+ WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?2 AND peer = ?3)
90
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
91
- WHERE (SELECT peer FROM conversation_owners WHERE context_id = ?2) = ?3`,
86
+ WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?2 AND peer = ?3)`,
92
87
  ).bind(t.id, t.contextId, t._peer, t.status.state, t._protocol, t._createdAt, A.nowIso(), JSON.stringify(t.status),
93
88
  JSON.stringify(t.artifacts || []), t.metadata ? JSON.stringify(t.metadata) : null, JSON.stringify(t._push || []));
94
89
  }
@@ -97,7 +92,7 @@ function msgIfOwnerStmt(ctx: TenantContext, t: Json, m: Json) {
97
92
  return ctx.DB.prepare(
98
93
  `INSERT INTO messages (message_id, task_id, context_id, role, ts, message_json)
99
94
  SELECT ?1, ?2, ?3, ?4, ?5, ?6
100
- WHERE (SELECT peer FROM conversation_owners WHERE context_id = ?3) = ?7`,
95
+ WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?3 AND peer = ?7)`,
101
96
  ).bind(m.messageId, t.id, t.contextId, m.role, A.nowIso(), JSON.stringify(m), t._peer);
102
97
  }
103
98
 
@@ -105,7 +100,7 @@ function histIfOwnerStmt(ctx: TenantContext, contextId: string, e: Json, owner:
105
100
  return ctx.DB.prepare(
106
101
  `INSERT INTO history (context_id, ts, dir, peer, task_id, role, state, event, text, data_json)
107
102
  SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10
108
- WHERE (SELECT peer FROM conversation_owners WHERE context_id = ?1) = ?11`,
103
+ WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?1 AND peer = ?11)`,
109
104
  ).bind(contextId, e.ts || A.nowIso(), e.dir || "local", e.peer ?? null, e.taskId ?? null, e.role ?? null, e.state ?? null,
110
105
  e.event ?? null, e.text ?? null, e.data !== undefined ? JSON.stringify(e.data) : null, owner);
111
106
  }
@@ -635,15 +630,15 @@ function protectedResourceMetadata(ctx: TenantContext): Json {
635
630
  const V1: Record<string, string> = {
636
631
  SendMessage: "send", GetTask: "get", CancelTask: "cancel", CreateTaskPushNotificationConfig: "pushset",
637
632
  SetTaskPushNotificationConfig: "pushset", GetTaskPushNotificationConfig: "pushget", ListTasks: "list",
633
+ ListTaskPushNotificationConfig: "pushlist", ListTaskPushNotificationConfigs: "pushlist",
634
+ DeleteTaskPushNotificationConfig: "pushdel",
638
635
  SendStreamingMessage: "stream", SubscribeToTask: "stream",
639
636
  };
640
637
  const V03: Record<string, string> = {
641
638
  "message/send": "send", "tasks/get": "get", "tasks/cancel": "cancel", "tasks/pushNotificationConfig/set": "pushset",
642
- "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",
643
641
  };
644
- // Methods only the façade forwards (the inbox has one push config per task and no delete).
645
- const PROXY_V1: Record<string, string> = { ListTaskPushNotificationConfig: "pushlist", DeleteTaskPushNotificationConfig: "pushdel" };
646
- const PROXY_V03: Record<string, string> = { "tasks/pushNotificationConfig/list": "pushlist", "tasks/pushNotificationConfig/delete": "pushdel" };
647
642
  const EXTENDED_CARD_METHODS = new Set(["GetExtendedAgentCard", "agent/getAuthenticatedExtendedCard"]);
648
643
 
649
644
  /** Header, else query `A2A-Version`. A patch (`1.0.1`, `0.3.0`) is ignored. Empty means the method name chooses the
@@ -659,11 +654,9 @@ function declaredA2AVersion(req: Request): { raw: string; version: string | null
659
654
  return { raw, version: null };
660
655
  }
661
656
 
662
- function rpcOperation(method: string, proxy: boolean): { op: string; version: string } | null {
657
+ function rpcOperation(method: string): { op: string; version: string } | null {
663
658
  if (V1[method]) return { op: V1[method], version: "1.0" };
664
659
  if (V03[method]) return { op: V03[method], version: "0.3" };
665
- if (proxy && PROXY_V1[method]) return { op: PROXY_V1[method], version: "1.0" };
666
- if (proxy && PROXY_V03[method]) return { op: PROXY_V03[method], version: "0.3" };
667
660
  return null;
668
661
  }
669
662
 
@@ -710,10 +703,11 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
710
703
  }
711
704
  const contextId = task ? task.contextId : msg.contextId ? A.checkId(msg.contextId, "contextId") : A.newId();
712
705
  const ts = A.nowIso();
713
- // A client-provided id nobody owns is still this peer's new conversation (single-peer and outbound send
714
- // keep passing one). An id another peer already has is not. A2A forbids minting a replacement id when the
715
- // client id is refused, so this is the same error the façade returns for an unknown context, not a new id.
716
- // The claim and the first task are one batch: a second peer cannot pass a read and then write its own task.
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.
717
711
  const creating = !task;
718
712
  if (!task) task = { id: A.newId(), contextId, status: { state: "submitted", timestamp: ts }, artifacts: [], history: [], _peer: label, _protocol: version, _createdAt: ts, _push: [] };
719
713
  msg.contextId = contextId; msg.taskId = task.id;
@@ -728,11 +722,9 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
728
722
  saveTaskIfOwnerStmt(ctx, task),
729
723
  msgIfOwnerStmt(ctx, task, msg),
730
724
  histIfOwnerStmt(ctx, contextId, { dir: "in", peer: label, taskId: task.id, role: "user", text: preview, data: { messageId: msg.messageId } }, label),
731
- ctx.DB.prepare("SELECT peer FROM conversation_owners WHERE context_id = ?").bind(contextId),
732
725
  ]);
733
- const owner = (stored[4]?.results?.[0] as Json | undefined)?.peer;
734
726
  const wrote = stored[1]?.meta?.changes ?? 0;
735
- if (owner !== label || wrote < 1) throw new RpcError(-32602, UNKNOWN_CONTEXT);
727
+ if (wrote < 1) throw new RpcError(-32602, UNKNOWN_CONTEXT);
736
728
  } else {
737
729
  await ctx.DB.batch([
738
730
  saveTaskStmt(ctx, task), msgStmt(ctx, task, msg),
@@ -774,22 +766,104 @@ const rpcPushGet: Rpc = async (p, label, version, ctx) => {
774
766
  throw new RpcError(-32001, "Push notification config not found");
775
767
  };
776
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
+
777
825
  const rpcList: Rpc = async (p, label, version, ctx) => {
778
826
  const size = Math.min(Math.max(Number(p.pageSize) || 50, 1), 100);
779
- const where = p.contextId ? "peer = ? AND context_id = ?" : "peer = ?";
780
- const args = p.contextId ? [label, p.contextId] : [label];
781
- const total: Json = await ctx.DB.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE ${where}`).bind(...args).first();
782
- 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);
783
848
  const tasks = [];
784
- for (const r of rows.results as Json[]) {
849
+ for (const r of page) {
785
850
  const t = A.publicTask(await loadTask(ctx, r.id), version, p.historyLength ?? 0);
786
851
  if (!p.includeArtifacts) delete t.artifacts;
787
852
  tasks.push(t);
788
853
  }
789
- 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 };
790
861
  };
791
862
 
792
- 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
+ };
793
867
 
794
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.
795
869
  * The JSON-RPC id is echoed when the request body parses (JSON-RPC 2.0); WWW-Authenticate points to RFC 9728 metadata. */
@@ -836,7 +910,7 @@ async function handleRpc(req: Request, ctx: TenantContext, ectx: ExecutionContex
836
910
  const declared = declaredA2AVersion(req);
837
911
  if (declared.raw && !declared.version) return err(-32009, `A2A version ${declared.raw} not supported (supported: 0.3, 1.0)`);
838
912
  if (EXTENDED_CARD_METHODS.has(method)) return err(-32007, "Extended agent card is not configured");
839
- const found = rpcOperation(method, proxy);
913
+ const found = rpcOperation(method);
840
914
  if (!found) return err(-32601, "Method not found");
841
915
  const version = declared.version || found.version;
842
916
  const op = found.op;
@@ -1088,6 +1162,8 @@ async function handlePush(req: Request, ctx: TenantContext, ectx: ExecutionConte
1088
1162
 
1089
1163
  // ------------------------------------------------------------------ owner API (local CLI)
1090
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}$/;
1091
1167
 
1092
1168
  function inboxLimit(raw: string | null): number {
1093
1169
  if (raw === null) return M.INBOX_MAX;
@@ -1274,6 +1350,8 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1274
1350
  }
1275
1351
  if (seg[0] === "inbox" && m === "GET") {
1276
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");
1277
1355
  const all = url.searchParams.get("all") === "1";
1278
1356
  const limit = inboxLimit(url.searchParams.get("limit"));
1279
1357
  const cursor = inboxCursor(url.searchParams.get("cursor"));
@@ -1281,6 +1359,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1281
1359
  const args: Json[] = [];
1282
1360
  if (!all) q += " AND state IN ('submitted','working')";
1283
1361
  if (contextFilter) { A.checkId(contextFilter, "contextId"); q += " AND context_id = ?"; args.push(contextFilter); }
1362
+ if (fromFilter) { q += " AND peer = ?"; args.push(fromFilter); }
1284
1363
  if (cursor) { q += " AND (updated_at > ? OR (updated_at = ? AND id > ?))"; args.push(cursor.updatedAt, cursor.updatedAt, cursor.id); }
1285
1364
  q += " ORDER BY updated_at, id LIMIT ?";
1286
1365
  args.push(limit + 1);
@@ -1302,6 +1381,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1302
1381
  const oargs: Json[] = [];
1303
1382
  if (!all) oq += " AND updates_seen = 0";
1304
1383
  if (contextFilter) { oq += " AND context_id = ?"; oargs.push(contextFilter); }
1384
+ if (fromFilter) { oq += " AND peer = ?"; oargs.push(fromFilter); }
1305
1385
  oq += " ORDER BY sent_at LIMIT ?";
1306
1386
  oargs.push(limit);
1307
1387
  const replies = await ctx.DB.prepare(oq).bind(...oargs).all();
@@ -1369,9 +1449,22 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1369
1449
  }
1370
1450
  if (seg[0] === "history" && seg[1] && m === "GET") {
1371
1451
  const n = Math.min(Number(url.searchParams.get("n") || "50"), 1000);
1372
- // Rows for this conversation: this context, and a peer that owns it (a task or an outbound task).
1373
- // A null peer is a local note. When nothing owns the id yet, the rows that name it are the conversation.
1374
- const rows = await ctx.DB.prepare(`SELECT * FROM (
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 (
1375
1468
  SELECT * FROM history WHERE context_id = ? AND (
1376
1469
  peer IS NULL OR peer = ''
1377
1470
  OR peer IN (SELECT peer FROM tasks WHERE context_id = ? AND peer IS NOT NULL AND peer != '')
@@ -1381,7 +1474,9 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1381
1474
  AND NOT EXISTS (SELECT 1 FROM outbound WHERE context_id = ?)
1382
1475
  )
1383
1476
  ) ORDER BY seq DESC LIMIT ?
1384
- ) ORDER BY seq`).bind(seg[1], seg[1], seg[1], seg[1], seg[1], n).all();
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();
1385
1480
  return json((rows.results as Json[]).map((r) => ({ ...r, data: r.data_json ? JSON.parse(r.data_json) : undefined, data_json: undefined })));
1386
1481
  }
1387
1482
  if (seg[0] === "history" && !seg[1] && m === "POST") {
@@ -2823,6 +2918,11 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
2823
2918
  case "inbox": {
2824
2919
  const qs = new URLSearchParams();
2825
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
+ }
2826
2926
  if (a.all === true) qs.set("all", "1");
2827
2927
  if (a.cursor !== undefined) {
2828
2928
  const cursor = argStr(a, "cursor");
@@ -2850,7 +2950,13 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
2850
2950
  case "history": {
2851
2951
  const n = a.n === undefined ? 50 : Number(a.n);
2852
2952
  if (!Number.isInteger(n) || n < 1 || n > 1000) throw new ToolError("n must be 1-1000");
2853
- 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)));
2854
2960
  }
2855
2961
  case "mark_working": {
2856
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 } },
@@ -11,6 +11,7 @@ import m0008 from "../migrations/0008_oidc_txns.sql?raw";
11
11
  import m0009 from "../migrations/0009_outbound_seen.sql?raw";
12
12
  import m0010 from "../migrations/0010_conversation_peer.sql?raw";
13
13
  import m0011 from "../migrations/0011_conversation_owner.sql?raw";
14
+ import m0012 from "../migrations/0012_conversation_peer_owner.sql?raw";
14
15
  import type { Migration } from "./storage.ts";
15
16
 
16
17
  export const MIGRATIONS: Migration[] = [
@@ -25,4 +26,5 @@ export const MIGRATIONS: Migration[] = [
25
26
  { name: "0009_outbound_seen.sql", sql: m0009 },
26
27
  { name: "0010_conversation_peer.sql", sql: m0010 },
27
28
  { name: "0011_conversation_owner.sql", sql: m0011 },
29
+ { name: "0012_conversation_peer_owner.sql", sql: m0012 },
28
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.17";
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. */