super-dsh 0.1.6 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/client/index.js +741 -256
  2. package/cordis.patch.yml +16 -0
  3. package/package.json +7 -2
  4. package/superd-hub/dist/a2a-delivery.d.ts +189 -0
  5. package/superd-hub/dist/a2a-delivery.js +240 -0
  6. package/superd-hub/dist/a2a-host.d.ts +76 -0
  7. package/superd-hub/dist/a2a-host.js +145 -0
  8. package/superd-hub/dist/a2a-ingest.d.ts +31 -0
  9. package/superd-hub/dist/a2a-ingest.js +636 -0
  10. package/superd-hub/dist/a2a-log.d.ts +17 -0
  11. package/superd-hub/dist/a2a-log.js +42 -0
  12. package/superd-hub/dist/a2a-row.d.ts +65 -0
  13. package/superd-hub/dist/a2a-row.js +162 -0
  14. package/superd-hub/dist/a2a-service.d.ts +357 -0
  15. package/superd-hub/dist/a2a-service.js +980 -0
  16. package/superd-hub/dist/a2a-wire.d.ts +220 -0
  17. package/superd-hub/dist/a2a-wire.js +305 -0
  18. package/superd-hub/dist/agent-ids.d.ts +10 -0
  19. package/superd-hub/dist/agent-ids.js +68 -0
  20. package/superd-hub/dist/agent-roster.d.ts +29 -18
  21. package/superd-hub/dist/agent-roster.js +54 -15
  22. package/superd-hub/dist/agent-status.d.ts +119 -0
  23. package/superd-hub/dist/agent-status.js +386 -0
  24. package/superd-hub/dist/client/mesh-api.d.ts +134 -0
  25. package/superd-hub/dist/client/mesh-api.js +219 -0
  26. package/superd-hub/dist/client/mesh-groups.d.ts +63 -0
  27. package/superd-hub/dist/client/mesh-groups.js +66 -0
  28. package/superd-hub/dist/client/roster.d.ts +48 -0
  29. package/superd-hub/dist/client/roster.js +96 -0
  30. package/superd-hub/dist/exec-pty-channel.d.ts +58 -0
  31. package/superd-hub/dist/exec-pty-channel.js +162 -0
  32. package/superd-hub/dist/exec-pty-framing.d.ts +32 -0
  33. package/superd-hub/dist/exec-pty-framing.js +81 -0
  34. package/superd-hub/dist/exec-pty-rpc.d.ts +21 -0
  35. package/superd-hub/dist/exec-pty-rpc.js +128 -0
  36. package/superd-hub/dist/exec-pty-session.d.ts +76 -0
  37. package/superd-hub/dist/exec-pty-session.js +234 -0
  38. package/superd-hub/dist/exec.d.ts +56 -0
  39. package/superd-hub/dist/exec.js +225 -0
  40. package/superd-hub/dist/gateway.d.ts +39 -2
  41. package/superd-hub/dist/gateway.js +62 -3
  42. package/superd-hub/dist/hub-tool.d.ts +174 -0
  43. package/superd-hub/dist/hub-tool.js +464 -0
  44. package/superd-hub/dist/index.d.ts +15 -1
  45. package/superd-hub/dist/index.js +16 -0
  46. package/superd-hub/dist/machines.d.ts +45 -0
  47. package/superd-hub/dist/machines.js +125 -0
  48. package/superd-hub/dist/mesh-federate.d.ts +138 -0
  49. package/superd-hub/dist/mesh-federate.js +223 -0
  50. package/superd-hub/dist/mesh-gateway.d.ts +67 -0
  51. package/superd-hub/dist/mesh-gateway.js +408 -0
  52. package/superd-hub/dist/mesh-link.d.ts +274 -0
  53. package/superd-hub/dist/mesh-link.js +441 -0
  54. package/superd-hub/dist/mesh-rpc.d.ts +123 -0
  55. package/superd-hub/dist/mesh-rpc.js +250 -0
  56. package/superd-hub/dist/mux-client.d.ts +71 -0
  57. package/superd-hub/dist/mux-client.js +281 -0
  58. package/superd-hub/dist/pack-version.d.ts +2 -0
  59. package/superd-hub/dist/pack-version.js +30 -0
  60. package/superd-hub/dist/roster.d.ts +6 -0
  61. package/superd-hub/dist/rpc.d.ts +18 -2
  62. package/superd-hub/dist/rpc.js +34 -4
  63. package/superd-hub/dist/spawn-world.js +3 -1
  64. package/superd-hub/dist/switch-core.d.ts +62 -25
  65. package/superd-hub/dist/switch-core.js +53 -30
  66. package/superd-hub/dist/system-head.d.ts +22 -12
  67. package/superd-hub/dist/system-head.js +100 -84
  68. package/superd-hub/dist/wire-endpoints.d.ts +264 -0
  69. package/superd-hub/dist/wire-endpoints.js +180 -0
  70. package/superd-hub/lib/client/index.js +741 -256
  71. package/superd-hub/package.json +11 -2
  72. package/agent-agy/dist/agent-preset-projection.d.ts +0 -31
  73. package/agent-agy/dist/agent-preset-projection.js +0 -33
  74. package/agent-claude/dist/agent-preset-projection.js +0 -30
  75. package/agent-codex/dist/agent-preset-projection.js +0 -37
  76. package/agent-codex/dist/app-home.js +0 -65
  77. package/agent-codex/dist/spike.js +0 -46
  78. package/agent-hermes/dist/agent-preset-projection.js +0 -34
  79. package/agent-omp/dist/agent-preset-projection.js +0 -36
  80. package/agent-omp/dist/app-home.js +0 -86
  81. package/agent-pi/dist/agent-preset-projection.js +0 -29
  82. package/superd-hub/dist/client-shim.d.ts +0 -28
  83. package/superd-hub/dist/client-shim.js +0 -251
  84. package/superd-hub/dist/index-pass.d.ts +0 -22
  85. package/superd-hub/dist/index-pass.js +0 -47
  86. package/superd-hub/dist/labels.d.ts +0 -45
  87. package/superd-hub/dist/labels.js +0 -83
@@ -0,0 +1,980 @@
1
+ /**
2
+ * A2A conversation service (mesh P3 Task 2): the conversation registry plus
3
+ * create / resume / inject / completion-capture against a target agent's
4
+ * session plane, reached through an injected `gatewayFor(addr)` resolver
5
+ * (local in-process gateway or remote wire — the service cannot tell).
6
+ *
7
+ * Pure service core, no cordis dependencies: assembly (completion cb → reply
8
+ * delivery) is Task 3's layer. Every session-plane call is built from the
9
+ * Task 1 vocabulary (`wire-endpoints.ts`): endpoint constants + payload
10
+ * mirrors + `wireArgsOf()` — never hand-rolled payloads.
11
+ *
12
+ * Frame vocabulary for completion capture (upstream anchors,
13
+ * `upstream/deepseek-harness` @ dsh-v0.1.7-rc.1):
14
+ * - follow frames: `SessionFollowFrame` union
15
+ * packages/api/session-controller/src/types.ts:550 — opening
16
+ * `{type:'snapshot', records}` then ordered `{type:'event', event}`
17
+ * entries; `assistant-stream` frames are live partials (ignored — final
18
+ * text comes from the durable `assistant/message` events).
19
+ * - event entries: `SessionEventEntry` types.ts:424, `SessionWireEvent`
20
+ * {type, seq, time, data} types.ts:436.
21
+ * - turn lifecycle: `SessionEventMap` 'turn/start' | 'turn/end'
22
+ * packages/core/session/src/types.ts:288–297; `turn/end.data.reason.kind`
23
+ * = TurnEndReasonMap kinds (types.ts:201 — completed/aborted/blocked/
24
+ * error/max-tokens/interrupted/forked).
25
+ * - final text: `assistant/message` events carry `{turn, step, message,
26
+ * stream, interrupted?}` (packages/core/session/src/index.ts:286,
27
+ * settlement shape :675; `interrupted === true` marks a replaced attempt,
28
+ * client/sessions/assistant-stream.ts:189). Text blocks of
29
+ * `message.content` (`TextBlock` packages/llm/llm/src/types.ts:61) are
30
+ * joined with '\n\n'.
31
+ * - send correlation: `session/prompt`'s client-minted `requestId`
32
+ * (`SessionPromptRequest` types.ts:331) is persisted on the accepted
33
+ * user message as `data.source.rpcId` (source kind `user-rpc`,
34
+ * types.ts:196–199) — the stream matcher keys on it.
35
+ *
36
+ * Controller rulings baked in (2026-09-28):
37
+ * - receipt outcome: 'delivered' on EVERY successful prompt submission
38
+ * (D28: end-to-end delivery complete); 'failed' carries an error body.
39
+ * - completion correlation is BY TURN via the requestId arming above: one
40
+ * `turn/end` fires the cb exactly once (debounce-free, order-safe on the
41
+ * session log's contiguous seq space); the requestId-armed subscription
42
+ * auto-disposes after its turn fires, so a send observes at most one
43
+ * completion. Snapshot-replayed history never re-fires a fired turn.
44
+ * - stop('session'): upstream has NO session-close RPC (Task 1 finding —
45
+ * `WIRE.sessionClose === undefined`); the scope degrades to turnCancel
46
+ * and the result reports `{scope:'turn-only', reason}` so callers can
47
+ * notice the degradation (the upstream analogues live on the workspace
48
+ * namespace and stay out of the session plane).
49
+ */
50
+ import { randomUUID } from 'node:crypto';
51
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
52
+ import { join } from 'node:path';
53
+ import { EnvelopeError } from './envelope.js';
54
+ import { A2aAddressError, A2A_TITLE_PLACEHOLDER, A2A_NOTICE_SUMMARY_MAX, normalizeA2aTitle, postA2aEnvelope, a2aAddressOf } from './a2a-wire.js';
55
+ import { ensureSelf, listRemotes, mintShortId as mintMachineId } from './machines.js';
56
+ import { cookieOf } from './mesh-link.js';
57
+ import { WIRE, wireArgsOf } from './wire-endpoints.js';
58
+ // compat re-export (postoffice R1): the class moved to the protocol layer
59
+ // (a2a-wire.ts) so the wire helpers can classify with it; every existing
60
+ // consumer importing it from this module keeps compiling unchanged.
61
+ export { A2aAddressError };
62
+ function conversationsPath(home) {
63
+ return join(home, 'superd', 'a2a-conversations.json');
64
+ }
65
+ function isConversationRow(v) {
66
+ if (typeof v !== 'object' || v === null)
67
+ return false;
68
+ const r = v;
69
+ return (typeof r.shortId === 'string' && r.shortId !== '' &&
70
+ typeof r.machine === 'string' && r.machine !== '' &&
71
+ typeof r.agent === 'string' && r.agent !== '' &&
72
+ typeof r.sessionId === 'string' && r.sessionId !== '' &&
73
+ (r.title === undefined || typeof r.title === 'string') &&
74
+ (r.hostAgent === undefined || typeof r.hostAgent === 'string') &&
75
+ (r.initiatorSession === undefined || typeof r.initiatorSession === 'string') &&
76
+ (r.channelUsed === undefined || r.channelUsed === true) &&
77
+ (r.lastSentAt === undefined || typeof r.lastSentAt === 'number') &&
78
+ (r.lastOutput === undefined || typeof r.lastOutput === 'string') &&
79
+ (r.lastOutputAt === undefined || typeof r.lastOutputAt === 'number') &&
80
+ typeof r.createdAt === 'number' && typeof r.lastActivityAt === 'number');
81
+ }
82
+ /** Read the registry; a missing or unreadable/corrupt file degrades to empty (never throws). */
83
+ export function loadConversations(home) {
84
+ let raw;
85
+ try {
86
+ raw = readFileSync(conversationsPath(home), 'utf8');
87
+ }
88
+ catch {
89
+ return { conversations: [] };
90
+ }
91
+ let parsed;
92
+ try {
93
+ parsed = JSON.parse(raw);
94
+ }
95
+ catch {
96
+ return { conversations: [] };
97
+ }
98
+ if (typeof parsed !== 'object' || parsed === null)
99
+ return { conversations: [] };
100
+ const list = parsed['conversations'];
101
+ if (!Array.isArray(list))
102
+ return { conversations: [] };
103
+ return { conversations: list.filter(isConversationRow) };
104
+ }
105
+ /** Persist the registry: recursive mkdir, tmp write, chmod 0600, atomic rename (machines.ts discipline). */
106
+ export function saveConversations(home, file) {
107
+ if (!Array.isArray(file.conversations)) {
108
+ throw new TypeError('a2a: save rejected — file must be {conversations: []}');
109
+ }
110
+ for (const r of file.conversations) {
111
+ if (!isConversationRow(r)) {
112
+ throw new TypeError('a2a: save rejected — every row needs shortId/machine/agent/sessionId/createdAt/lastActivityAt');
113
+ }
114
+ }
115
+ const dir = join(home, 'superd');
116
+ mkdirSync(dir, { recursive: true });
117
+ const target = conversationsPath(home);
118
+ const tmp = `${target}.tmp`;
119
+ writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
120
+ chmodSync(tmp, 0o600);
121
+ renameSync(tmp, target);
122
+ }
123
+ /** Mint core: strip the optional leading `session-`, take the first 6 chars of the
124
+ * base, collision-lengthen 6→8→10… (step 2). Shared by the fresh-mint path and the
125
+ * one-time registry migration so both produce identical ids. */
126
+ function mintShortIdOf(sessionId, taken) {
127
+ const stripped = sessionId.startsWith('session-') ? sessionId.slice('session-'.length) : sessionId;
128
+ const base = stripped === '' ? sessionId : stripped;
129
+ for (let len = 6; len < base.length; len += 2) {
130
+ const candidate = base.slice(0, len);
131
+ if (!taken.has(candidate))
132
+ return candidate;
133
+ }
134
+ if (!taken.has(base))
135
+ return base;
136
+ throw new Error(`a2a: short-id space exhausted for session ${sessionId}`);
137
+ }
138
+ /**
139
+ * shortId mint (D2 fix): DSH session ids are `session-<uuid>`, so slicing the
140
+ * raw id only ever captures the constant prefix (the live registry degenerated
141
+ * to "session-", "session-3", …). Strip the prefix, mint the first 6 chars of
142
+ * the uuid part, collision-lengthen 6→8→10… (step 2, spec §11). Ids without the
143
+ * prefix (older/foreign spellings) mint from the whole id the same way. A
144
+ * re-seen sessionId keeps its row's shortId (stable across restarts); stored
145
+ * shortIds are reserved as taken — DEGENERATE ones are re-minted once by
146
+ * {@link migrateConversations}, all others stay verbatim forever.
147
+ */
148
+ export function shortIdFor(sessionId, rows) {
149
+ const existing = rows.find((r) => r.sessionId === sessionId);
150
+ if (existing)
151
+ return existing.shortId;
152
+ return mintShortIdOf(sessionId, new Set(rows.map((r) => r.shortId)));
153
+ }
154
+ /**
155
+ * Degenerate-pattern predicate (D2 migration): a stored shortId that still
156
+ * carries the `session-` prefix AND whose remainder is shorter than the new
157
+ * mint length — exactly the ids the old prefix-slicing bug produced. Such ids
158
+ * carry (almost) no information and teach peers to echo garbage.
159
+ */
160
+ export function isDegenerateShortId(shortId) {
161
+ return shortId.startsWith('session-') && shortId.length < 'session-'.length + 6;
162
+ }
163
+ /**
164
+ * One-time registry migration (D2 amended, review 2026-09-28): rows matching
165
+ * the degenerate pattern are re-minted with the current algorithm and written
166
+ * back; non-degenerate rows are never touched. Peers still holding an old
167
+ * degenerate id in their context is accepted collateral — the registry holds
168
+ * test data today, which is the cheapest migration window there will ever be.
169
+ * @returns the re-minted rows (empty when nothing needed migrating).
170
+ */
171
+ export function migrateConversations(home) {
172
+ const rows = loadConversations(home).conversations;
173
+ if (!rows.some((r) => isDegenerateShortId(r.shortId)))
174
+ return [];
175
+ const taken = new Set(rows.filter((r) => !isDegenerateShortId(r.shortId)).map((r) => r.shortId));
176
+ const minted = new Map();
177
+ for (const row of rows) {
178
+ if (!isDegenerateShortId(row.shortId))
179
+ continue;
180
+ const shortId = mintShortIdOf(row.sessionId, taken);
181
+ taken.add(shortId);
182
+ minted.set(row.sessionId, shortId);
183
+ }
184
+ const next = rows.map((r) => (minted.has(r.sessionId) ? { ...r, shortId: minted.get(r.sessionId) } : r));
185
+ saveConversations(home, { conversations: next });
186
+ return next.filter((r) => minted.has(r.sessionId));
187
+ }
188
+ // ---------------------------------------------------------------- envelopes
189
+ /**
190
+ * THE teaching envelope — one template constant (D20 v3, 2026-09-29: the
191
+ * SINGLE merged text, no variants). Filled with the INITIATOR's identity
192
+ * (machine-agent address = the write-back target), the conversation title,
193
+ * the body, and the shortId a superd-hub tool addresses. Tail = the user's
194
+ * 2026-09-29 redline, four short paragraphs, each carrying one load:
195
+ * provenance + channel-only principle / heterogeneity disclosure (tool
196
+ * availability genuinely differs per runtime — this is what stops a
197
+ * tool-less model from hunting for one) / how+when to send (sending does
198
+ * not end the turn) / the fallback, phrased from the RECEIVER's operational
199
+ * truth ("the hub receive no hub message" — intentionally or prevented;
200
+ * wording per user ruling 0.1.7-h: the receiver is the hub, the message
201
+ * class is a hub message). The "if available" conditional is evaluated by
202
+ * the MODEL against its own tool
203
+ * list; never pre-select variants on unknowable runtime state. Never a
204
+ * separate user prompt (the OMP 20-tool-call incident).
205
+ */
206
+ export const A2A_MESSAGE_TEMPLATE = 'A2A message from {machine}-{agent} (conversation "{title}"):\n\n{message}\n\nThis conversation is initiated through superd-hub by the sender. The sender sees only what arrives through this conversation.\n\nAs superd-hub hosts various kinds of agent runtimes, integration into each runtime and superd-hub tool availability in each runtime session might differ.\n\nTo reply, send a message back: with a superd-hub tool, if available, {action:"send", agent:"{machine}-{agent}", conversationId:"{shortId}", message:"…"}. You can send at any point, more than once — sending does not end your turn.\n\nIf the hub receive no hub message from you (intentionally or whatever situation prevents so), your last output at this turn\'s end is delivered to the sender as your reply.';
207
+ /** Title shown when the conversation has none. */
208
+ export const A2A_UNTITLED = '(untitled)';
209
+ /**
210
+ * Render the teaching envelope for one send — a SINGLE pass over the template
211
+ * (injection fix, 2026-09-28): each placeholder is expanded exactly once from
212
+ * the fields, and user-supplied values (title, message) are never re-scanned,
213
+ * so a title containing the literal `{shortId}` or `{message}` lands verbatim
214
+ * instead of being expanded by a later replaceAll round.
215
+ */
216
+ export function renderA2aEnvelope(fields) {
217
+ const title = fields.title === undefined || fields.title === '' || fields.title === A2A_TITLE_PLACEHOLDER ? A2A_UNTITLED : fields.title;
218
+ return A2A_MESSAGE_TEMPLATE.replace(/\{(machine|agent|title|shortId|message)\}/g, (_match, key) => {
219
+ switch (key) {
220
+ case 'machine': return fields.machine;
221
+ case 'agent': return fields.agent;
222
+ case 'title': return title;
223
+ case 'shortId': return fields.shortId;
224
+ default: return fields.message;
225
+ }
226
+ });
227
+ }
228
+ /** Bounded unread buffer size per initiator session. */
229
+ export const A2A_BUFFER_MAX = 32;
230
+ /** D28: the sender's synchronous receipt window. If the end-to-end wire has not settled by then the hub answers `accepted` and lets the SAME in-flight request finish in the background. */
231
+ export const A2A_SYNC_RECEIPT_MS = 10_000;
232
+ /** D28: the TOTAL wire budget for one send (120s). The POST timeout is set from the request start, so after the 10s synchronous window the SAME in-flight request still has ~110s left to settle; the peer may spend it bringing the runtime up (spawn OR resume — indistinguishable at the process level). */
233
+ export const A2A_WIRE_TOTAL_BUDGET_MS = 120_000;
234
+ /** stop('session') degradation notice — the session plane has no close RPC upstream. */
235
+ export const A2A_NO_SESSION_CLOSE_REASON = 'no session-close endpoint upstream';
236
+ /** stop() idle note — the honest answer when the pre-cancel liveness snapshot showed no live turn (D5). */
237
+ export const A2A_NO_TURN_ACTIVE_NOTE = 'no turn was active';
238
+ function a2aError(code, message, details) {
239
+ return new EnvelopeError(details === undefined ? { code, message } : { code, message, details });
240
+ }
241
+ function isRecord(v) {
242
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
243
+ }
244
+ /** Map any thrown error into the receipt error body — an EnvelopeError's wire body passes verbatim. */
245
+ function receiptErrorOf(error) {
246
+ if (error instanceof EnvelopeError)
247
+ return { ...error.wire };
248
+ if (error instanceof Error)
249
+ return { code: 'gateway/uncaught', message: error.message };
250
+ return { code: 'gateway/uncaught', message: String(error) };
251
+ }
252
+ function messageOf(error) {
253
+ return error instanceof Error ? error.message : String(error);
254
+ }
255
+ /** Fresh non-aborted signal for one-shot rpc calls (T2 exposes no cancellation surface on send/stop). */
256
+ function freshSignal() {
257
+ return new AbortController().signal;
258
+ }
259
+ /** Map one typed address failure to its wire error body (D7): the error's own
260
+ * message; the code keeps 'a2a/gateway-unavailable' for the unreachable branch
261
+ * (today's code) and names the kind otherwise. */
262
+ export function addressErrorBody(error) {
263
+ return { code: error.kind === 'unreachable' ? 'a2a/gateway-unavailable' : `a2a/${error.kind}`, message: error.message };
264
+ }
265
+ /** (gatewayOrThrow retired in R4 — the send/stop paths route envelopes; the
266
+ * local resolver is consumed by subscribeCompletion and the ingest assembly.) */
267
+ /** Bound for the stop() liveness read — a healthy session plane answers the first
268
+ * follow frame in well under a second, even cross-machine; the bound only fires on
269
+ * a wedged plane, where the cancel RPC itself would stall anyway. */
270
+ const STOP_LIVENESS_TIMEOUT_MS = 3_000;
271
+ /**
272
+ * D5 turn-liveness read: open the follow stream, replay the FIRST snapshot
273
+ * frame's records, and report whether a turn is still open (a turn/start not
274
+ * followed by its turn/end). Read manually (no for-await) and aborted BEFORE
275
+ * returning — closing an async generator suspended at an await must not gate
276
+ * the answer (the close promise can reject on abort and would eat the result).
277
+ * @returns true (a turn is live), false (idle), or undefined when the plane
278
+ * could not answer (stream death, stream end, no snapshot, read timeout) —
279
+ * callers treat undefined conservatively: cancel anyway, keep today's shape.
280
+ */
281
+ export async function turnLivenessOf(gateway, sessionId) {
282
+ const controller = new AbortController();
283
+ const timer = setTimeout(() => controller.abort(), STOP_LIVENESS_TIMEOUT_MS);
284
+ try {
285
+ const request = { address: { kind: 'session', sessionId } }; // no assistantStream — liveness needs no live partials
286
+ const iterable = await gateway.openWireStream(WIRE.streamOpen, wireArgsOf(WIRE.streamOpen, request), controller.signal);
287
+ const iterator = (await iterable)[Symbol.asyncIterator]();
288
+ for (;;) {
289
+ const next = await iterator.next();
290
+ if (next.done)
291
+ return undefined;
292
+ const frame = next.value;
293
+ if (!isRecord(frame) || frame['type'] !== 'snapshot')
294
+ continue;
295
+ const records = frame['records'];
296
+ if (!Array.isArray(records))
297
+ return undefined;
298
+ let openTurn;
299
+ for (const record of records) {
300
+ if (!isRecord(record) || record['type'] !== 'event')
301
+ continue;
302
+ const event = record['event'];
303
+ if (!isRecord(event) || typeof event['type'] !== 'string')
304
+ continue;
305
+ const data = isRecord(event['data']) ? event['data'] : {};
306
+ if (event['type'] === 'turn/start' && typeof data['turn'] === 'number')
307
+ openTurn = data['turn'];
308
+ else if (event['type'] === 'turn/end' && data['turn'] === openTurn)
309
+ openTurn = undefined;
310
+ }
311
+ controller.abort(); // snapshot consumed — drop the stream now
312
+ return openTurn !== undefined;
313
+ }
314
+ }
315
+ catch {
316
+ return undefined;
317
+ }
318
+ finally {
319
+ controller.abort();
320
+ clearTimeout(timer);
321
+ }
322
+ }
323
+ /**
324
+ * One session-plane RPC through the gateway: payload via `wireArgsOf`,
325
+ * `{ok:false,error}` rethrown as a typed EnvelopeError (error body verbatim),
326
+ * transport throws pass through untouched (they are already gateway/* typed).
327
+ */
328
+ export async function sessionRpc(gateway, endpoint, request) {
329
+ const result = await gateway.dispatchRpc(endpoint, wireArgsOf(endpoint, request), freshSignal());
330
+ if (!isRecord(result) || typeof result['ok'] !== 'boolean') {
331
+ throw a2aError('a2a/bad-result', `a2a: ${endpoint} returned a non-envelope result`);
332
+ }
333
+ if (result['ok'] === true)
334
+ return result['value'];
335
+ const err = result['error'];
336
+ const wire = isRecord(err) && typeof err['code'] === 'string' && typeof err['message'] === 'string'
337
+ ? { code: err['code'], message: err['message'], ...(err['details'] === undefined ? {} : { details: err['details'] }) }
338
+ : { code: 'gateway/bad-response', message: `a2a: ${endpoint} rejected with a malformed error body` };
339
+ throw new EnvelopeError(wire);
340
+ }
341
+ /** Text blocks of a message content array joined with '\n\n' ("" when none). */
342
+ function assistantTextOf(content) {
343
+ if (!Array.isArray(content))
344
+ return '';
345
+ const texts = [];
346
+ for (const part of content) {
347
+ if (isRecord(part) && part['type'] === 'text' && typeof part['text'] === 'string')
348
+ texts.push(part['text']);
349
+ }
350
+ return texts.join('\n\n');
351
+ }
352
+ /**
353
+ * The completion watcher behind `subscribeCompletion` (extracted verbatim in
354
+ * postoffice R3): one follow stream over the addressed session, turn-text
355
+ * reduction, requestId arming, one resubscribe, snapshot-history fire.
356
+ * Same contract as the service method — every fired completion is one cb,
357
+ * armed subscriptions auto-dispose after their turn fires.
358
+ */
359
+ export function subscribeSessionCompletion(source, cb, opts = {}) {
360
+ const controller = new AbortController();
361
+ const state = {
362
+ openTurn: undefined,
363
+ lastText: new Map(),
364
+ armedSeen: false,
365
+ armedTurn: undefined,
366
+ fired: new Set(),
367
+ completedHistorical: new Map(),
368
+ };
369
+ let disposed = false;
370
+ const dispose = () => {
371
+ disposed = true;
372
+ controller.abort();
373
+ };
374
+ const followRequest = {
375
+ address: { kind: 'session', sessionId: source.sessionId },
376
+ assistantStream: true,
377
+ };
378
+ const fire = (turn, reason) => {
379
+ state.fired.add(turn);
380
+ cb({
381
+ conversationId: source.conversationId,
382
+ sessionId: source.sessionId,
383
+ turn,
384
+ reason,
385
+ text: state.lastText.get(turn)?.text ?? '',
386
+ });
387
+ if (opts.requestId !== undefined)
388
+ dispose();
389
+ };
390
+ const applyEvent = (event, live) => {
391
+ if (!isRecord(event) || typeof event['type'] !== 'string')
392
+ return;
393
+ const seq = typeof event['seq'] === 'number' ? event['seq'] : Number.MAX_SAFE_INTEGER;
394
+ const data = isRecord(event['data']) ? event['data'] : {};
395
+ switch (event['type']) {
396
+ case 'turn/start':
397
+ if (typeof data['turn'] === 'number')
398
+ state.openTurn = data['turn'];
399
+ break;
400
+ case 'user/message': {
401
+ const sourceRec = isRecord(data['source']) ? data['source'] : undefined;
402
+ if (opts.requestId !== undefined && !state.armedSeen && sourceRec?.['rpcId'] === opts.requestId) {
403
+ state.armedSeen = true;
404
+ state.armedTurn = state.openTurn;
405
+ }
406
+ break;
407
+ }
408
+ case 'assistant/message': {
409
+ if (data['interrupted'] === true)
410
+ break; // replaced attempt — the real settlement follows
411
+ const turn = data['turn'];
412
+ if (typeof turn !== 'number')
413
+ break;
414
+ const message = isRecord(data['message']) ? data['message'] : undefined;
415
+ const text = assistantTextOf(message?.['content']);
416
+ const prior = state.lastText.get(turn);
417
+ if (prior === undefined || seq >= prior.seq)
418
+ state.lastText.set(turn, { text, seq });
419
+ break;
420
+ }
421
+ case 'turn/end': {
422
+ const turn = data['turn'];
423
+ if (typeof turn !== 'number')
424
+ break;
425
+ if (state.openTurn === turn)
426
+ state.openTurn = undefined;
427
+ const reason = isRecord(data['reason']) && typeof data['reason']['kind'] === 'string'
428
+ ? data['reason']['kind']
429
+ : 'unknown';
430
+ if (!live) {
431
+ state.completedHistorical.set(turn, reason);
432
+ break;
433
+ }
434
+ const shouldFire = opts.requestId === undefined
435
+ ? !state.fired.has(turn)
436
+ : state.armedSeen && !state.fired.has(turn) && (state.armedTurn === undefined || state.armedTurn === turn);
437
+ if (shouldFire)
438
+ fire(turn, reason);
439
+ break;
440
+ }
441
+ default:
442
+ break; // merge-extensible event names are not ours to interpret
443
+ }
444
+ };
445
+ /** After a snapshot: an armed request whose turn already completed in history fires from history (prompt-accepted-before-subscribe race). */
446
+ const maybeFireFromHistory = () => {
447
+ if (opts.requestId === undefined)
448
+ return;
449
+ if (!state.armedSeen || state.armedTurn === undefined)
450
+ return;
451
+ if (state.fired.has(state.armedTurn) || !state.completedHistorical.has(state.armedTurn))
452
+ return;
453
+ fire(state.armedTurn, state.completedHistorical.get(state.armedTurn) ?? 'unknown');
454
+ };
455
+ const handleFrame = (frame) => {
456
+ if (!isRecord(frame))
457
+ return;
458
+ if (frame['type'] === 'snapshot') {
459
+ const records = frame['records'];
460
+ if (Array.isArray(records)) {
461
+ for (const record of records) {
462
+ if (isRecord(record) && record['type'] === 'event')
463
+ applyEvent(record['event'], false);
464
+ }
465
+ }
466
+ maybeFireFromHistory();
467
+ }
468
+ else if (frame['type'] === 'event') {
469
+ applyEvent(frame['event'], true);
470
+ }
471
+ // 'assistant-stream' frames are live partials — final text is durable
472
+ };
473
+ const onDeath = (error, attempt) => {
474
+ if (disposed || controller.signal.aborted)
475
+ return;
476
+ if (attempt === 0) {
477
+ open(1); // exactly one resubscribe
478
+ return;
479
+ }
480
+ opts.onError?.({
481
+ code: 'a2a/stream-dead',
482
+ message: `a2a: completion stream for ${source.conversationId} died twice — ${messageOf(error)}`,
483
+ });
484
+ };
485
+ const open = (attempt) => {
486
+ let iterable;
487
+ try {
488
+ iterable = source.gateway.openWireStream(WIRE.streamOpen, wireArgsOf(WIRE.streamOpen, followRequest), controller.signal);
489
+ }
490
+ catch (error) {
491
+ onDeath(error, attempt);
492
+ return;
493
+ }
494
+ void (async () => {
495
+ try {
496
+ const stream = await iterable;
497
+ for await (const frame of stream) {
498
+ if (disposed)
499
+ return;
500
+ handleFrame(frame);
501
+ }
502
+ onDeath(new Error('stream ended'), attempt); // a live follow stream never ends while the session lives
503
+ }
504
+ catch (error) {
505
+ onDeath(error, attempt);
506
+ }
507
+ })();
508
+ };
509
+ open(0);
510
+ return dispose;
511
+ }
512
+ // ---------------------------------------------------------------- service
513
+ export function createA2aService(deps) {
514
+ const now = deps.now ?? Date.now;
515
+ const alog = deps.log;
516
+ // degenerate row exists; after the first pass the registry stays clean.
517
+ migrateConversations(deps.home);
518
+ const bufferByInitiator = new Map();
519
+ const persist = (conversations) => {
520
+ saveConversations(deps.home, { conversations });
521
+ };
522
+ const findRow = (conversationId) => {
523
+ const rows = loadConversations(deps.home).conversations;
524
+ return rows.find((r) => r.shortId === conversationId || r.sessionId === conversationId);
525
+ };
526
+ /**
527
+ * R4: record the INITIATOR row for a conversation the envelope routing
528
+ * just created/receipted. One row per conversation: a row already bound to
529
+ * the shortId (the mirror row a SELF-hosted conversation adopted on our
530
+ * own ingest) covers the id — never a duplicate. Title normalized at mint
531
+ * (D17); the initiator's session id is persisted for peer write-backs.
532
+ */
533
+ const recordInitiatorRow = (shortId, to, sessionId, title, initiator) => {
534
+ const rows = loadConversations(deps.home).conversations;
535
+ // D20 self-route: the single MIRROR row (hostAgent set, initiator local)
536
+ // IS the initiator row too — backfill the write-back address instead of
537
+ // returning (a cross-machine mirror row never reaches here: its
538
+ // conversation was initiated on the peer's hub, not ours).
539
+ const existing = rows.find((r) => r.shortId === shortId || r.sessionId === sessionId);
540
+ if (existing !== undefined) {
541
+ if (existing.hostAgent !== undefined && existing.machine === selfM4() && existing.initiatorSession === undefined) {
542
+ persist(rows.map((r) => (r.sessionId === existing.sessionId ? { ...r, initiatorSession: initiator.sessionId } : r)));
543
+ }
544
+ return;
545
+ }
546
+ rows.push({
547
+ shortId,
548
+ machine: to.machine,
549
+ agent: to.id,
550
+ sessionId,
551
+ title,
552
+ initiatorSession: initiator.sessionId,
553
+ createdAt: now(),
554
+ lastActivityAt: now(),
555
+ });
556
+ persist(rows);
557
+ };
558
+ /** Bump the activity stamp; optionally backfill the initiator session on INITIATOR rows and self-route MIRROR rows (never on cross-machine mirror rows). */
559
+ const touchRow = (row, initiatorSession) => {
560
+ const rows = loadConversations(deps.home).conversations;
561
+ const idx = rows.findIndex((r) => r.sessionId === row.sessionId);
562
+ if (idx === -1)
563
+ return;
564
+ // D17: only the activity stamp moves — a later write path never touches title.
565
+ const backfill = initiatorSession !== undefined &&
566
+ (rows[idx].hostAgent === undefined || rows[idx].machine === selfM4());
567
+ rows[idx] = {
568
+ ...rows[idx],
569
+ lastActivityAt: now(),
570
+ ...(backfill ? { initiatorSession } : {}),
571
+ };
572
+ persist(rows);
573
+ };
574
+ const selfM4 = () => ensureSelf(deps.home, 'local').id;
575
+ /** D20: mark the hosted peer as channel-capable for this conversation — monotonic latch + send watermark. */
576
+ const markChannelUsed = (row) => {
577
+ const rows = loadConversations(deps.home).conversations;
578
+ const idx = rows.findIndex((r) => r.sessionId === row.sessionId);
579
+ if (idx === -1)
580
+ return;
581
+ rows[idx] = { ...rows[idx], channelUsed: true, lastSentAt: now() };
582
+ persist(rows);
583
+ };
584
+ /** Mint a fresh conversation id — random 6 over [a-z0-9], collision-checked against our registry (the initiator mints; the peer adopts verbatim). */
585
+ const mintConversationId = () => mintMachineId(6, loadConversations(deps.home).conversations.map((r) => r.shortId));
586
+ /** The wire `from` of one initiator identity (full-length session id, D6). */
587
+ const originOf = (initiator) => ({ machine: initiator.machine, agent: initiator.agent, sessionId: initiator.sessionId });
588
+ /**
589
+ * R4 THE ROUTER: one envelope, two legs — target machine === self → the
590
+ * SAME ingest handler the HTTP endpoint calls (in-process short-circuit;
591
+ * protocol-identical by construction); a paired remote → wire POST with
592
+ * its cookie riding along. Unpaired → the typed unknown-machine failure.
593
+ */
594
+ const routeEnvelope = async (envelope, timeoutMs) => {
595
+ const target = a2aAddressOf(envelope);
596
+ if (target.machine === selfM4()) {
597
+ const ingest = deps.ingest?.();
598
+ if (ingest === undefined) {
599
+ throw a2aError('a2a/no-ingest', `a2a: envelope for ${target.machine}-${target.id} addresses THIS machine but no ingest is wired (post-office assembly incomplete)`);
600
+ }
601
+ return ingest.ingest(envelope);
602
+ }
603
+ const machine = listRemotes(deps.home).find((r) => r.id === target.machine);
604
+ if (machine === undefined) {
605
+ throw new A2aAddressError('unknown-machine', target);
606
+ }
607
+ return postA2aEnvelope(machine.origin, envelope, {
608
+ cookie: cookieOf(machine.id, deps.home),
609
+ fetchImpl: deps.fetchImpl,
610
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
611
+ });
612
+ };
613
+ /** Map one wire stop receipt back onto the typed result (canonical constants; the ingest emits exactly these). */
614
+ const mapStopReceipt = (receipt) => {
615
+ if (receipt.scope === 'turn') {
616
+ return receipt.stopped
617
+ ? { scope: 'turn', cancelled: true }
618
+ : { scope: 'turn', cancelled: false, note: A2A_NO_TURN_ACTIVE_NOTE };
619
+ }
620
+ return receipt.stopped
621
+ ? { scope: 'turn-only', cancelled: true, reason: A2A_NO_SESSION_CLOSE_REASON }
622
+ : { scope: 'turn-only', cancelled: false, note: A2A_NO_TURN_ACTIVE_NOTE, reason: A2A_NO_SESSION_CLOSE_REASON };
623
+ };
624
+ const service = {
625
+ async send(req, initiator) {
626
+ const messageId = `a2a-${randomUUID()}`;
627
+ const fail = (error, sessionId = '', conversationId) => {
628
+ alog?.('warn', 'send.fail', { code: error.code, conversation: conversationId, message: messageId });
629
+ return {
630
+ outcome: 'failed',
631
+ messageId,
632
+ ...(conversationId === undefined ? {} : { conversationId }),
633
+ sessionId,
634
+ error,
635
+ };
636
+ };
637
+ let row;
638
+ let to = req.to;
639
+ let writeBack = false;
640
+ if (req.conversationId !== undefined) {
641
+ row = findRow(req.conversationId);
642
+ if (row === undefined) {
643
+ return fail({
644
+ code: 'a2a/conversation-not-found',
645
+ message: `a2a: conversation "${req.conversationId}" is not in the registry — send without conversationId (creates one) or check the id`,
646
+ });
647
+ }
648
+ // D24/T3: a continuation is addressed by id alone. When the model
649
+ // omits `agent`, derive the route from the row kind + the caller's
650
+ // session (the model never states the owner/peer). When it still
651
+ // passes `agent`, that value is a checked assertion below.
652
+ // D24/T3 + F2 (0.1.8-c): the route AND the write-back decision are
653
+ // SESSION facts. `hosted` = this row's session lives HERE at hostAgent;
654
+ // `callerIsHost` = the caller IS that hosted session (the peer replying
655
+ // through its own channel). Deriving the write-back from ADDRESS
656
+ // equality breaks the self-addressed case (row.agent === row.hostAgent),
657
+ // where the initiator's own continuation looks identical to the host's
658
+ // and got mis-classified as a peer write-back (echoing the initiator's
659
+ // own words back at it while the responder received nothing).
660
+ const hosted = row.hostAgent !== undefined;
661
+ const callerIsHost = hosted && initiator.sessionId === row.sessionId;
662
+ if (to === undefined) {
663
+ if (callerIsHost) {
664
+ // the hosted peer itself replies → write-back to the initiator
665
+ to = { machine: row.machine, id: row.agent };
666
+ }
667
+ else if (hosted && row.initiatorSession !== undefined && row.initiatorSession !== '' && initiator.sessionId === row.initiatorSession) {
668
+ // the opener continues → self-route into the hosted session
669
+ to = { machine: selfM4(), id: row.hostAgent };
670
+ }
671
+ else if (!hosted) {
672
+ // initiator row → the session lives at the peer
673
+ to = { machine: row.machine, id: row.agent };
674
+ }
675
+ else {
676
+ return fail({
677
+ code: 'a2a/not-a-party',
678
+ message: `a2a: conversation "${row.shortId}" — this session is neither its initiator nor its host, so it cannot act on it`,
679
+ }, row.sessionId, row.shortId);
680
+ }
681
+ alog?.('info', 'send.route-derived', { conversation: row.shortId, to: `${to.machine}-${to.id}` });
682
+ }
683
+ // Address consistency by row kind (R4 continuation routing):
684
+ // mirror rows (hostAgent) are hosted HERE and addressed as
685
+ // {self, hostAgent}; initiator rows keep today's peer-address check.
686
+ // D20 WRITE-BACK exception: on a mirror row, addressing the row's
687
+ // {machine, agent} (the INITIATOR) is the hosted peer using its
688
+ // channel — the teaching envelope's reply clause. It routes to the
689
+ // initiator's hub (write-back delivery there), and it SETS THE LATCH:
690
+ // from this send on, the completion watch never transcribes again.
691
+ const expected = hosted
692
+ ? { machine: selfM4(), id: row.hostAgent }
693
+ : { machine: row.machine, id: row.agent };
694
+ // F2: the write-back is decided by SESSION (only the hosted session
695
+ // replies), never re-derived from address equality.
696
+ writeBack = callerIsHost;
697
+ if (!writeBack && (to.machine !== expected.machine || to.id !== expected.id)) {
698
+ return fail({
699
+ code: 'a2a/conversation-machine-mismatch',
700
+ message: `a2a: conversation "${row.shortId}" belongs to ${expected.machine}-${expected.id}, not ${to.machine}-${to.id}`,
701
+ }, row.sessionId, row.shortId);
702
+ }
703
+ if (writeBack) {
704
+ // The latch is the runtime TRUTH about the peer (it chose the
705
+ // channel), set before routing so the completion watch can never
706
+ // race it; a routing failure parks/retries without un-latching.
707
+ markChannelUsed(row);
708
+ alog?.('info', 'send.channel', { conversation: row.shortId, from: `${selfM4()}-${row.hostAgent}`, latch: true });
709
+ }
710
+ }
711
+ if (to === undefined) {
712
+ return fail({ code: 'a2a/no-target', message: 'a2a: send needs `agent` to open a new conversation' });
713
+ }
714
+ // Build the envelope: create mints the conversation id (initiator-side,
715
+ // random 6); continuation routes by row kind — mirror rows to OUR OWN
716
+ // ingest (the conversation is hosted here), initiator rows to the
717
+ // machine/agent the row points at.
718
+ const from = originOf(initiator);
719
+ let envelope;
720
+ // hoisted for the failed-receipt leg below: a failed WRITE-BACK must
721
+ // still reach the original initiator (0.1.7-h R2 send-failed notice).
722
+ let writeBackOrigin;
723
+ if (row === undefined) {
724
+ envelope = {
725
+ kind: 'new-conversation',
726
+ to: { machine: to.machine, id: to.id },
727
+ conversationId: mintConversationId(),
728
+ title: normalizeA2aTitle(req.title),
729
+ message: req.message,
730
+ from,
731
+ messageId,
732
+ ...(req.delivery === undefined ? {} : { delivery: req.delivery }),
733
+ };
734
+ }
735
+ else {
736
+ // (writeBack derives from the continuation block above)
737
+ // D20: a write-back's `from` is the HOSTED PEER itself (its own
738
+ // session on this row) — the initiator-side ingest attributes the
739
+ // reply from this origin, and the self-route ingest recognizes the
740
+ // shape (from.agent === row.hostAgent) to deliver, never inject.
741
+ const peerOrigin = writeBack && row.hostAgent !== undefined
742
+ ? { machine: selfM4(), agent: row.hostAgent, sessionId: row.sessionId }
743
+ : undefined;
744
+ writeBackOrigin = peerOrigin;
745
+ const to = peerOrigin !== undefined
746
+ ? { machine: row.machine, id: row.agent }
747
+ : row.hostAgent !== undefined
748
+ ? { machine: selfM4(), id: row.hostAgent }
749
+ : { machine: row.machine, id: row.agent };
750
+ envelope = {
751
+ kind: 'message',
752
+ to,
753
+ conversationId: row.shortId,
754
+ message: req.message,
755
+ from: peerOrigin ?? from,
756
+ messageId,
757
+ ...(req.delivery === undefined ? {} : { delivery: req.delivery }),
758
+ };
759
+ }
760
+ // D28/T5: hub-to-hub is a SYNCHRONOUS POST whose response IS the
761
+ // completion notification — there is no poll surface. We only keep the
762
+ // SENDER's receipt synchronous within A2A_SYNC_RECEIPT_MS; if the wire has
763
+ // not settled by then answer `accepted` and let the SAME in-flight request
764
+ // finish in the background, delivering `delivered`/`undelivered` as a
765
+ // notice.
766
+ let syncTimer;
767
+ const wire = routeEnvelope(envelope, A2A_WIRE_TOTAL_BUDGET_MS).then((a) => ({ ok: true, a }), (error) => ({ ok: false, error }));
768
+ const raced = await Promise.race([
769
+ wire,
770
+ new Promise((resolve) => { syncTimer = setTimeout(() => resolve('sync-timeout'), deps.syncReceiptMs ?? A2A_SYNC_RECEIPT_MS); }),
771
+ ]);
772
+ if (syncTimer !== undefined)
773
+ clearTimeout(syncTimer);
774
+ if (raced === 'sync-timeout') {
775
+ const conversationId = envelope.conversationId;
776
+ void wire.then((settled) => {
777
+ const a = settled.ok ? settled.a : undefined;
778
+ const delivered = a?.outcome === 'delivered';
779
+ if (delivered && row === undefined && a?.sessionId !== undefined && a.sessionId !== '' && a.conversationId !== undefined && a.conversationId !== '') {
780
+ recordInitiatorRow(a.conversationId, to, a.sessionId, normalizeA2aTitle(req.title), initiator);
781
+ }
782
+ const peerMsg = a?.error?.message;
783
+ const detail = delivered
784
+ ? 'the message reached the peer'
785
+ : peerMsg ?? (settled.ok ? 'the peer hub rejected the envelope' : receiptErrorOf(settled.error).message);
786
+ const raw = `conversation ${conversationId}: ${delivered ? 'delivered' : 'undelivered'} — ${detail}`;
787
+ const notice = {
788
+ kind: 'notice',
789
+ to: { machine: initiator.machine, agent: initiator.agent, sessionId: initiator.sessionId },
790
+ conversation: conversationId,
791
+ summary: raw.length > A2A_NOTICE_SUMMARY_MAX ? raw.slice(0, A2A_NOTICE_SUMMARY_MAX - 1) + '…' : raw,
792
+ messageId: `a2a-notice-${randomUUID()}`,
793
+ sender: { machine: to.machine, agent: to.id },
794
+ reason: delivered ? 'delivered' : 'undelivered',
795
+ };
796
+ alog?.('info', 'send.settled', { conversation: conversationId, outcome: delivered ? 'delivered' : 'undelivered' });
797
+ void routeEnvelope(notice).catch((error) => {
798
+ alog?.('warn', 'send.settled-notice-fail', { conversation: conversationId, error: messageOf(error) });
799
+ });
800
+ });
801
+ alog?.('info', 'send.accepted', { conversation: conversationId, message: messageId });
802
+ return {
803
+ outcome: 'accepted',
804
+ messageId,
805
+ conversationId,
806
+ sessionId: '',
807
+ note: `the peer hub has not confirmed end-to-end delivery within ${A2A_SYNC_RECEIPT_MS / 1000}s — the SAME in-flight request keeps running (${(A2A_WIRE_TOTAL_BUDGET_MS - A2A_SYNC_RECEIPT_MS) / 1000}s left of a ${A2A_WIRE_TOTAL_BUDGET_MS / 1000}s total budget) and its outcome arrives as a notice; do not resend`,
808
+ };
809
+ }
810
+ let answer;
811
+ if (!raced.ok) {
812
+ // D7 layering preserved: typed address failures (unknown-machine,
813
+ // unreachable transport) and wire faults land as failed receipts —
814
+ // send never throws.
815
+ const body = raced.error instanceof A2aAddressError ? addressErrorBody(raced.error) : receiptErrorOf(raced.error);
816
+ return fail(body, row?.sessionId ?? '', row?.shortId);
817
+ }
818
+ answer = raced.a;
819
+ if (!('outcome' in answer)) {
820
+ return fail({ code: 'a2a/bad-result', message: 'a2a: envelope routed but the answer was not a send receipt' }, row?.sessionId ?? '', row?.shortId ?? envelope.conversationId);
821
+ }
822
+ if (answer.outcome !== 'delivered') {
823
+ // D1 hardening: a failed receipt that still carries the peer's session
824
+ // and conversation ids keeps the row addressable — retries resume.
825
+ if (row === undefined && answer.sessionId !== '' && answer.conversationId !== undefined && answer.conversationId !== '') {
826
+ recordInitiatorRow(answer.conversationId, to, answer.sessionId, normalizeA2aTitle(req.title), initiator);
827
+ }
828
+ alog?.('warn', 'send.fail', { code: (answer.error ?? { code: 'a2a/remote-failed' }).code, conversation: answer.conversationId ?? row?.shortId, message: messageId });
829
+ // 0.1.7-h R2 live finding (r2e2eh probe): a FAILED write-back still
830
+ // bumps the row's send watermark (markChannelUsed ran at send time),
831
+ // so the peer's completion watch goes quiet — the turn's only artifact
832
+ // was lost and the original initiator never heard a word. The taxonomy
833
+ // reserved 'send-failed' for exactly this: mint the notice and route it
834
+ // to the original initiator (notices deliver without a registry row —
835
+ // deliverLocal keys off envelope.to alone; deliverNotice re-bounds the
836
+ // summary). Fire-and-forget: the caller still gets its failed receipt;
837
+ // parked sends stay silent (a parked envelope is pending, not failed —
838
+ // it delivers on retry).
839
+ if (writeBackOrigin !== undefined && row?.initiatorSession !== undefined && row.initiatorSession !== '') {
840
+ const notice = {
841
+ kind: 'notice',
842
+ to: { machine: row.machine, agent: row.agent, sessionId: row.initiatorSession },
843
+ conversation: row.shortId,
844
+ // answer.error is the WIRE error shape {code, message} — messageOf it and it stringifies as [object Object].
845
+ // Bound at MINT time: the receiving side's strict guard 400s an
846
+ // over-max summary (0.1.7-l live finding — the long ingest error
847
+ // message blew the 120 bound and the notice died as 'unreachable').
848
+ summary: (() => {
849
+ const raw = `conversation ${row.shortId}: the peer's reply could not be delivered — ${answer.error?.message ?? 'unknown error'}`;
850
+ return raw.length > A2A_NOTICE_SUMMARY_MAX ? raw.slice(0, A2A_NOTICE_SUMMARY_MAX - 1) + '…' : raw;
851
+ })(),
852
+ messageId: `a2a-notice-${randomUUID()}`,
853
+ sender: { machine: selfM4(), agent: row.hostAgent ?? row.agent },
854
+ reason: 'send-failed',
855
+ };
856
+ // 0.1.7-i live lesson: a swallowed emission failure reads as "no notice"
857
+ // downstream — journal it (the r2e2ej probe chased a silent strict-guard
858
+ // rejection for a round because of this exact swallow).
859
+ void routeEnvelope(notice).catch((error) => {
860
+ alog?.('warn', 'send.notice-fail', { conversation: row.shortId, error: messageOf(error) });
861
+ });
862
+ }
863
+ return {
864
+ outcome: 'failed',
865
+ messageId,
866
+ ...(answer.conversationId === undefined ? {} : { conversationId: answer.conversationId }),
867
+ sessionId: answer.sessionId,
868
+ error: answer.error ?? { code: 'a2a/remote-failed', message: 'a2a: the peer ingest rejected the envelope without an error body' },
869
+ };
870
+ }
871
+ const conversationId = answer.conversationId ?? envelope.conversationId;
872
+ if (row === undefined) {
873
+ // SELF-hosted creates already adopted the mirror row on our ingest —
874
+ // recordInitiatorRow never duplicates; remote creates record ours.
875
+ recordInitiatorRow(conversationId, to, answer.sessionId, normalizeA2aTitle(req.title), initiator);
876
+ // (kept from the retired newConversation: a create leaves a distinct
877
+ // initiator-side journal line, not just send.ok)
878
+ alog?.('info', 'conversation.new', { conversation: conversationId, session: answer.sessionId, peer: `${to.machine}-${to.id}`, initiator: `${initiator.machine}-${initiator.agent}` });
879
+ }
880
+ else {
881
+ touchRow(row, row.hostAgent === undefined ? initiator.sessionId : undefined);
882
+ }
883
+ alog?.('info', 'send.ok', { conversation: conversationId, to: `${envelope.to.machine}-${envelope.to.id}`, message: messageId });
884
+ return { outcome: 'delivered', messageId, conversationId, sessionId: answer.sessionId };
885
+ },
886
+ subscribeCompletion(conv, cb, opts = {}) {
887
+ const row = findRow(conv.conversationId);
888
+ if (row === undefined) {
889
+ opts.onError?.({
890
+ code: 'a2a/conversation-not-found',
891
+ message: `a2a: conversation "${conv.conversationId}" is not in the registry`,
892
+ });
893
+ return () => { };
894
+ }
895
+ if (row.machine !== conv.to.machine || row.agent !== conv.to.id) {
896
+ opts.onError?.({
897
+ code: 'a2a/conversation-machine-mismatch',
898
+ message: `a2a: conversation "${row.shortId}" belongs to ${row.machine}-${row.agent}, not ${conv.to.machine}-${conv.to.id}`,
899
+ });
900
+ return () => { };
901
+ }
902
+ let gateway;
903
+ try {
904
+ gateway = deps.gatewayFor(conv.to);
905
+ }
906
+ catch (error) {
907
+ const body = error instanceof A2aAddressError ? addressErrorBody(error) : receiptErrorOf(error);
908
+ opts.onError?.(body);
909
+ return () => { };
910
+ }
911
+ if (gateway === undefined) {
912
+ opts.onError?.({ code: 'a2a/gateway-unavailable', message: A2aAddressError.messageOf('unreachable', conv.to) });
913
+ return () => { };
914
+ }
915
+ // The watcher itself is the shared session-plane primitive (postoffice
916
+ // R3): the ingest leg subscribes the same machinery against the LOCAL
917
+ // gateway without the initiator-side registry checks.
918
+ return subscribeSessionCompletion({ gateway, sessionId: row.sessionId, conversationId: row.shortId }, cb, opts);
919
+ },
920
+ async stop(req, scope) {
921
+ const row = findRow(req.conversationId);
922
+ if (row === undefined) {
923
+ throw a2aError('a2a/conversation-not-found', `a2a: conversation "${req.conversationId}" is not in the registry`);
924
+ }
925
+ const expected = row.hostAgent !== undefined
926
+ ? { machine: selfM4(), id: row.hostAgent }
927
+ : { machine: row.machine, id: row.agent };
928
+ // D24/T3: the session owner is derived from the row; `to` (the model's
929
+ // `agent`), when supplied, is only a checked assertion.
930
+ if (req.to !== undefined && (req.to.machine !== expected.machine || req.to.id !== expected.id)) {
931
+ throw a2aError('a2a/conversation-machine-mismatch', `a2a: conversation "${row.shortId}" belongs to ${expected.machine}-${expected.id}, not ${req.to.machine}-${req.to.id}`);
932
+ }
933
+ // R4: the cancel executes where the session lives — the D5 liveness
934
+ // read, the cancel, and the session-scope degradation are all the
935
+ // INGEST side's business now (same machine as the session).
936
+ const envelope = { kind: 'stop', to: expected, conversationId: row.shortId, scope };
937
+ let answer;
938
+ try {
939
+ answer = await routeEnvelope(envelope);
940
+ }
941
+ catch (error) {
942
+ if (error instanceof EnvelopeError)
943
+ throw error;
944
+ if (error instanceof A2aAddressError) {
945
+ const body = addressErrorBody(error);
946
+ throw a2aError(body.code, body.message);
947
+ }
948
+ throw a2aError('gateway/uncaught', messageOf(error));
949
+ }
950
+ if (!('stopped' in answer)) {
951
+ throw a2aError('a2a/bad-result', 'a2a: stop envelope answered a non-receipt');
952
+ }
953
+ return mapStopReceipt(answer);
954
+ },
955
+ buffer(initiatorSessionId, message) {
956
+ const list = bufferByInitiator.get(initiatorSessionId) ?? [];
957
+ list.push({ ...message, initiatorSessionId, bufferedAt: now() });
958
+ while (list.length > A2A_BUFFER_MAX)
959
+ list.shift();
960
+ bufferByInitiator.set(initiatorSessionId, list);
961
+ alog?.('info', 'deliver.buffer', { session: initiatorSessionId, conversation: message.conversationId, depth: list.length });
962
+ },
963
+ read(initiatorSessionId) {
964
+ if (initiatorSessionId !== undefined) {
965
+ const list = bufferByInitiator.get(initiatorSessionId) ?? [];
966
+ bufferByInitiator.delete(initiatorSessionId);
967
+ return list;
968
+ }
969
+ const swept = [];
970
+ for (const list of bufferByInitiator.values())
971
+ swept.push(...list);
972
+ bufferByInitiator.clear();
973
+ return swept;
974
+ },
975
+ listConversations() {
976
+ return loadConversations(deps.home).conversations.map((r) => ({ ...r }));
977
+ },
978
+ };
979
+ return service;
980
+ }