super-dsh 0.1.5 → 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 (79) hide show
  1. package/client/index.js +1010 -345
  2. package/cordis.patch.yml +22 -5
  3. package/package.json +8 -3
  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 +32 -17
  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/fusion-transport.d.ts +23 -21
  41. package/superd-hub/dist/fusion-transport.js +12 -40
  42. package/superd-hub/dist/gateway.d.ts +39 -2
  43. package/superd-hub/dist/gateway.js +62 -3
  44. package/superd-hub/dist/hub-tool.d.ts +174 -0
  45. package/superd-hub/dist/hub-tool.js +464 -0
  46. package/superd-hub/dist/index.d.ts +18 -4
  47. package/superd-hub/dist/index.js +19 -3
  48. package/superd-hub/dist/machines.d.ts +45 -0
  49. package/superd-hub/dist/machines.js +125 -0
  50. package/superd-hub/dist/mesh-federate.d.ts +138 -0
  51. package/superd-hub/dist/mesh-federate.js +223 -0
  52. package/superd-hub/dist/mesh-gateway.d.ts +67 -0
  53. package/superd-hub/dist/mesh-gateway.js +408 -0
  54. package/superd-hub/dist/mesh-link.d.ts +274 -0
  55. package/superd-hub/dist/mesh-link.js +441 -0
  56. package/superd-hub/dist/mesh-rpc.d.ts +123 -0
  57. package/superd-hub/dist/mesh-rpc.js +250 -0
  58. package/superd-hub/dist/mux-client.d.ts +71 -0
  59. package/superd-hub/dist/mux-client.js +281 -0
  60. package/superd-hub/dist/pack-version.d.ts +2 -0
  61. package/superd-hub/dist/pack-version.js +30 -0
  62. package/superd-hub/dist/roster.d.ts +10 -0
  63. package/superd-hub/dist/roster.js +9 -1
  64. package/superd-hub/dist/rpc.d.ts +18 -2
  65. package/superd-hub/dist/rpc.js +34 -4
  66. package/superd-hub/dist/spawn-world.js +3 -1
  67. package/superd-hub/dist/switch-core.d.ts +98 -21
  68. package/superd-hub/dist/switch-core.js +123 -28
  69. package/superd-hub/dist/system-head.d.ts +22 -12
  70. package/superd-hub/dist/system-head.js +100 -84
  71. package/superd-hub/dist/toast-core.d.ts +43 -0
  72. package/superd-hub/dist/toast-core.js +63 -0
  73. package/superd-hub/dist/wire-endpoints.d.ts +264 -0
  74. package/superd-hub/dist/wire-endpoints.js +180 -0
  75. package/superd-hub/dist/world-join.js +8 -3
  76. package/superd-hub/dist/world-mount.d.ts +27 -0
  77. package/superd-hub/dist/world-mount.js +67 -0
  78. package/superd-hub/lib/client/index.js +1010 -345
  79. package/superd-hub/package.json +11 -2
@@ -0,0 +1,636 @@
1
+ /**
2
+ * A2A ingest — the post-office's execution side (postoffice R3; ADR 0011,
3
+ * spec D16–D19, plan 2026-09-28-machine-mesh-postoffice-rework 契约要点).
4
+ *
5
+ * WHERE THE WORK LIVES (D16 ruling): when a request envelope (new-conversation
6
+ * / message / stop) arrives — over the wire via `POST /api/superd/a2a`
7
+ * (mesh-rpc) or short-circuited in-process by the initiator's own hub (R4)
8
+ * — the session-plane business logic runs HERE, on the machine that owns the
9
+ * session: resolve the LOCAL target (a2a-host's local leg), create-or-resume
10
+ * the mirror registry row, inject the teaching envelope (renderA2aEnvelope,
11
+ * initiator = envelope.from), and capture the completion on the LOCAL follow
12
+ * stream (subscribeSessionCompletion — same machinery as the initiator-side
13
+ * service; zero cross-machine session-plane calls anywhere).
14
+ *
15
+ * REGISTRY (双侧同形): the mirror row points AT THE INITIATOR —
16
+ * {machine: from.machine, agent: from.agent, sessionId: LOCAL session} — the
17
+ * symmetric dual of the initiator's row. The conversationId is
18
+ * INITIATOR-MINTED, carried on the envelope, and adopted VERBATIM; a
19
+ * collision (the shortId already bound to a different conversation) is
20
+ * rejected fail-loud. The title lands verbatim from the envelope (D17:
21
+ * mirror rows adopt verbatim; titles are immutable on both sides).
22
+ *
23
+ * RETURN ROUTING (D16): a captured completion becomes a reply envelope
24
+ * (aborted-with-no-text → a bounded notice envelope, D3) routed to the
25
+ * initiator: from.machine === self → in-process delivery (a2a-delivery);
26
+ * a machines.json row exists → wire POST (cookie 随行); otherwise the
27
+ * bounded persistent outbox (`<home>/superd/a2a-outbox.json`, ≤32, oldest
28
+ * dropped) until a route appears or the sweep pulls it (R5).
29
+ *
30
+ * Inbound RETURN envelopes (reply/notice POSTed at us, or pulled from a
31
+ * peer's outbox by R5's sweep) deliver into the local initiator session
32
+ * through the same a2a-delivery face; the receipt for those POSTs is the
33
+ * delivery outcome (additive to the contract's new/message/stop receipts).
34
+ */
35
+ import { randomUUID } from 'node:crypto';
36
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
37
+ import { join } from 'node:path';
38
+ import { A2A_BUFFER_MAX, A2A_NO_SESSION_CLOSE_REASON, A2A_NO_TURN_ACTIVE_NOTE, addressErrorBody, renderA2aEnvelope, sessionRpc, subscribeSessionCompletion, turnLivenessOf, loadConversations, saveConversations, } from './a2a-service.js';
39
+ import { EnvelopeError } from './envelope.js';
40
+ import { WIRE } from './wire-endpoints.js';
41
+ import { boundNoticeSummary, deliverNotice, deliverReply } from './a2a-delivery.js';
42
+ import { A2aAddressError, isA2aEnvelope, postA2aEnvelope, } from './a2a-wire.js';
43
+ import { ensureSelf, listRemotes } from './machines.js';
44
+ import { cookieOf } from './mesh-link.js';
45
+ function outboxPath(home) {
46
+ return join(home, 'superd', 'a2a-outbox.json');
47
+ }
48
+ function loadOutbox(home) {
49
+ let raw;
50
+ try {
51
+ raw = readFileSync(outboxPath(home), 'utf8');
52
+ }
53
+ catch {
54
+ return { envelopes: [] };
55
+ }
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(raw);
59
+ }
60
+ catch {
61
+ return { envelopes: [] };
62
+ }
63
+ const list = parsed.envelopes;
64
+ if (!Array.isArray(list))
65
+ return { envelopes: [] };
66
+ // entries validated on load — corrupt rows drop, never crash the hub
67
+ return { envelopes: list.filter((e) => isA2aEnvelope(e)) };
68
+ }
69
+ function saveOutbox(home, file) {
70
+ const dir = join(home, 'superd');
71
+ mkdirSync(dir, { recursive: true });
72
+ const target = outboxPath(home);
73
+ const tmp = `${target}.tmp`;
74
+ writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
75
+ chmodSync(tmp, 0o600);
76
+ renameSync(tmp, target);
77
+ }
78
+ // ---------------- inbound exactly-once (0.1.6-j double-delivery fix)
79
+ /** Bounded inbound-messageId seen-set: an envelope the sender re-POSTs (parked after a transport glitch, re-pulled by the sweep) is re-ACKed, never re-executed. */
80
+ const SEEN_MESSAGE_IDS_MAX = 128;
81
+ function seenIdsPath(home) {
82
+ return join(home, 'superd', 'a2a-seen.json');
83
+ }
84
+ function seenMessageIds(home) {
85
+ try {
86
+ const parsed = JSON.parse(readFileSync(seenIdsPath(home), 'utf8'));
87
+ const ids = typeof parsed === 'object' && parsed !== null ? parsed.ids : undefined;
88
+ return new Set(Array.isArray(ids) ? ids.filter((v) => typeof v === 'string') : []);
89
+ }
90
+ catch {
91
+ return new Set();
92
+ }
93
+ }
94
+ function rememberMessageId(home, messageId) {
95
+ const ids = [...seenMessageIds(home)];
96
+ if (!ids.includes(messageId))
97
+ ids.push(messageId);
98
+ while (ids.length > SEEN_MESSAGE_IDS_MAX)
99
+ ids.shift();
100
+ mkdirSync(join(home, 'superd'), { recursive: true });
101
+ const target = seenIdsPath(home);
102
+ const tmp = `${target}.tmp`;
103
+ writeFileSync(tmp, JSON.stringify({ ids }, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
104
+ chmodSync(tmp, 0o600);
105
+ renameSync(tmp, target);
106
+ }
107
+ // ---------------------------------------------------------------- helpers
108
+ function isRecord(v) {
109
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
110
+ }
111
+ function messageOf(error) {
112
+ return error instanceof Error ? error.message : String(error);
113
+ }
114
+ /** Receipt error body of any thrown failure — an EnvelopeError's wire body passes verbatim. */
115
+ function receiptErrorOf(error) {
116
+ if (error instanceof EnvelopeError)
117
+ return { ...error.wire };
118
+ if (error instanceof Error)
119
+ return { code: 'gateway/uncaught', message: error.message };
120
+ return { code: 'gateway/uncaught', message: String(error) };
121
+ }
122
+ // ---------------------------------------------------------------- service
123
+ export function createA2aIngest(deps) {
124
+ const now = deps.now ?? Date.now;
125
+ const alog = deps.log;
126
+ const selfId = () => ensureSelf(deps.home, 'local').id;
127
+ // ---------------- registry (mirror rows)
128
+ const mirrorRowOf = (conversationId) => {
129
+ const rows = loadConversations(deps.home).conversations;
130
+ return rows.find((r) => r.shortId === conversationId || r.sessionId === conversationId);
131
+ };
132
+ /**
133
+ * Adopt the initiator-minted conversationId verbatim (fail-loud on
134
+ * collision); an exact re-delivery of the same conversation is an
135
+ * idempotent adoption (resume). @returns the row.
136
+ */
137
+ const adoptMirrorRow = (env, sessionId, title) => {
138
+ const file = loadConversations(deps.home);
139
+ const rows = file.conversations;
140
+ const existing = rows.find((r) => r.shortId === env.conversationId || r.sessionId === sessionId);
141
+ if (existing !== undefined) {
142
+ const sameConversation = existing.shortId === env.conversationId &&
143
+ existing.sessionId === sessionId &&
144
+ existing.machine === env.from.machine && existing.agent === env.from.agent &&
145
+ existing.hostAgent === env.to.id;
146
+ if (sameConversation)
147
+ return existing;
148
+ throw new Error(`a2a: conversation-id conflict — "${env.conversationId}" is already bound to ${existing.machine}-${existing.agent}/${existing.sessionId} (claimed by ${env.from.machine}-${env.from.agent}/${sessionId})`);
149
+ }
150
+ const row = {
151
+ shortId: env.conversationId,
152
+ machine: env.from.machine,
153
+ agent: env.from.agent,
154
+ sessionId,
155
+ title,
156
+ // R4 amendment: mirror rows carry the LOCAL a6 hosting the session —
157
+ // presence is the locally-hosted marker continuation routing keys on.
158
+ hostAgent: env.to.id,
159
+ // 0.1.7-i: the OPENER's session persists on mirror rows too — the
160
+ // initiatorSession field's R4 "initiator rows only" contract predates
161
+ // the send-failed notice (a failed write-back must address the original
162
+ // initiator even when this hub has never seen their send path) and the
163
+ // self-route write-back branch already expects it here. Additive; the
164
+ // initiator-row detection keys on hostAgent absence, never this field.
165
+ initiatorSession: env.from.sessionId,
166
+ createdAt: now(),
167
+ lastActivityAt: now(),
168
+ };
169
+ rows.push(row);
170
+ saveConversations(deps.home, { conversations: rows });
171
+ alog?.('info', 'conversation.adopt', { conversation: row.shortId, session: row.sessionId, peer: `${env.from.machine}-${env.from.agent}`, hostAgent: env.to.id });
172
+ return row;
173
+ };
174
+ const touchMirror = (row) => {
175
+ const rows = loadConversations(deps.home).conversations;
176
+ const idx = rows.findIndex((r) => r.sessionId === row.sessionId);
177
+ if (idx === -1)
178
+ return;
179
+ rows[idx] = { ...rows[idx], lastActivityAt: now() };
180
+ saveConversations(deps.home, { conversations: rows });
181
+ };
182
+ /** D30: record the peer's last delivered output on the conversation row (agent_status reads it, ownership-gated). */
183
+ const recordLastOutput = (conversationId, text) => {
184
+ const rows = loadConversations(deps.home).conversations;
185
+ const idx = rows.findIndex((r) => r.shortId === conversationId || r.sessionId === conversationId);
186
+ if (idx === -1)
187
+ return;
188
+ rows[idx] = { ...rows[idx], lastOutput: text, lastOutputAt: now() };
189
+ saveConversations(deps.home, { conversations: rows });
190
+ };
191
+ // ---------------- return routing
192
+ /** Deliver one return envelope into the LOCAL initiator session (self-routed or inbound). */
193
+ const deliverLocal = (envelope, sender, noticeReason = 'peer') => {
194
+ const initiator = { machine: envelope.to.machine, agent: envelope.to.agent, sessionId: envelope.to.sessionId };
195
+ const row = mirrorRowOf(envelope.conversation);
196
+ if (envelope.kind === 'reply') {
197
+ const outcome = deliverReply(deps.delivery, initiator, envelope.text, {
198
+ senderMachine: sender.machine,
199
+ senderAgent: sender.agent,
200
+ conversationId: envelope.conversation,
201
+ messageId: envelope.messageId,
202
+ title: row?.title,
203
+ // D20: the provenance flag rides the wire additively — the delivering
204
+ // side renders the "transcribed, never sent via channel" prefix from
205
+ // it; absent = the peer chose to send this through the channel.
206
+ transcribed: envelope.transcribed === true,
207
+ });
208
+ // D30: the peer's output arrived here — remember it on the row (the
209
+ // initiator can later read it through agent_status; ownership-gated).
210
+ if (envelope.text !== '')
211
+ recordLastOutput(envelope.conversation, envelope.text);
212
+ alog?.('info', 'deliver.local', { conversation: envelope.conversation, kind: envelope.kind, outcome: outcome.delivered, from: `${sender.machine}-${sender.agent}` });
213
+ return outcome;
214
+ }
215
+ const outcome = deliverNotice(deps.delivery, initiator, envelope.summary, {
216
+ senderMachine: sender.machine,
217
+ senderAgent: sender.agent,
218
+ conversationId: envelope.conversation,
219
+ // 0.1.7-h R2: the peer's own reason rides the wire (additive); the
220
+ // fallback covers self-routed notices and pre-0.1.7-h peers.
221
+ reason: envelope.reason ?? noticeReason,
222
+ });
223
+ alog?.('info', 'deliver.local', { conversation: envelope.conversation, kind: envelope.kind, outcome: outcome.delivered, from: `${sender.machine}-${sender.agent}` });
224
+ return outcome;
225
+ };
226
+ const parkInOutbox = (envelope) => {
227
+ const file = loadOutbox(deps.home);
228
+ file.envelopes.push(envelope);
229
+ while (file.envelopes.length > A2A_BUFFER_MAX)
230
+ file.envelopes.shift();
231
+ saveOutbox(deps.home, file);
232
+ };
233
+ const routeReturn = async (envelope, meta) => {
234
+ if (envelope.to.machine === selfId()) {
235
+ deliverLocal(envelope, { machine: selfId(), agent: meta.responderAgent }, meta.noticeReason);
236
+ alog?.('info', 'route.deliver', { conversation: envelope.conversation, kind: envelope.kind });
237
+ return 'delivered';
238
+ }
239
+ const machine = listRemotes(deps.home).find((r) => r.id === envelope.to.machine);
240
+ if (machine !== undefined) {
241
+ try {
242
+ await postA2aEnvelope(machine.origin, envelope, {
243
+ cookie: cookieOf(machine.id, deps.home),
244
+ fetchImpl: deps.fetchImpl,
245
+ });
246
+ alog?.('info', 'route.post', { conversation: envelope.conversation, kind: envelope.kind, to: envelope.to.machine });
247
+ return 'posted';
248
+ }
249
+ catch {
250
+ // peer briefly down — park for the next contact (sweep pull / next send)
251
+ parkInOutbox(envelope);
252
+ alog?.('warn', 'route.park', { conversation: envelope.conversation, kind: envelope.kind, to: envelope.to.machine, reason: 'peer-unreachable' });
253
+ return 'outbox';
254
+ }
255
+ }
256
+ parkInOutbox(envelope);
257
+ alog?.('warn', 'route.park', { conversation: envelope.conversation, kind: envelope.kind, to: envelope.to.machine, reason: 'unknown-machine' });
258
+ return 'outbox';
259
+ };
260
+ /** The completion watcher behind every successful inject (D16 → D20 v3). */
261
+ const watchCompletion = (gateway, row, env) => {
262
+ const to = { machine: env.from.machine, agent: env.from.agent, sessionId: env.from.sessionId };
263
+ // D20: arm-time watermark — a channel send with lastSentAt ABOVE this
264
+ // happened inside this armed turn's window, i.e. the peer's send WAS
265
+ // this turn's artifact and the watch must not add a second one.
266
+ const armedSentAt = row.lastSentAt ?? 0;
267
+ const noticeOf = (summary, reason = 'peer') => ({
268
+ kind: 'notice',
269
+ to,
270
+ conversation: row.shortId,
271
+ summary: boundNoticeSummary(summary),
272
+ messageId: `a2a-notice-${randomUUID()}`,
273
+ sender: { machine: selfId(), agent: env.to.id },
274
+ // 0.1.7-h R2: the cause rides the wire (additive) so the receiving side
275
+ // renders reason= into the model-facing notice line.
276
+ reason,
277
+ });
278
+ subscribeSessionCompletion({ gateway, sessionId: row.sessionId, conversationId: row.shortId }, (completion) => {
279
+ // D20 v3 — the channel consumes exactly two machine facts (turn
280
+ // boundary + last output) and NOTHING from the settlement
281
+ // taxonomy: no abort/complete adjectives, no reason branches. A
282
+ // turn end yields at most ONE artifact:
283
+ // initiator stop stamped inside the window → stopped-by-initiator
284
+ // notice (0.1.7-h R2 — the initiator's own stop must not read
285
+ // as the peer going silent)
286
+ // send inside the window → the send(s) were it (watch stays quiet)
287
+ // latch set (channel used in an earlier turn) → plain notice
288
+ // latch clear + non-empty last output → TRANSCRIBED reply
289
+ // latch clear + empty output → plain notice (reason=peer-silent)
290
+ // Hallucinated/irrelevant content is content-layer business —
291
+ // transcribe verbatim and let the sender judge.
292
+ const fresh = mirrorRowOf(row.shortId);
293
+ // F4 (0.1.8-b): the CHANNEL-SEND-WINDOW check comes FIRST. If the peer
294
+ // already sent through the channel inside this turn, that send IS the
295
+ // turn's artifact — a stop stamped afterwards (the initiator stopping a
296
+ // turn that already answered) must not add a second one. The old order
297
+ // let the stop notice override the quiet rule, yielding two artifacts
298
+ // for one turn.
299
+ if ((fresh?.lastSentAt ?? 0) > armedSentAt) {
300
+ alog?.('info', 'completion.quiet', { conversation: row.shortId, reason: 'channel-send-in-window' });
301
+ return;
302
+ }
303
+ if ((fresh?.stoppedAt ?? 0) > armedSentAt) {
304
+ alog?.('info', 'completion.stopped', { conversation: row.shortId });
305
+ const stopped = noticeOf(`conversation ${row.shortId}: turn stopped by initiator request`, 'stop');
306
+ void routeReturn(stopped, { responderAgent: env.to.id, noticeReason: 'stop' }).catch(() => parkInOutbox(stopped));
307
+ return;
308
+ }
309
+ const envelope = fresh?.channelUsed === undefined && completion.text !== ''
310
+ ? {
311
+ kind: 'reply',
312
+ to,
313
+ conversation: row.shortId,
314
+ text: completion.text,
315
+ messageId: `a2a-relay-${randomUUID()}`,
316
+ sender: { machine: selfId(), agent: env.to.id },
317
+ transcribed: true,
318
+ }
319
+ : noticeOf(`conversation ${row.shortId}: turn ended, no reply`, 'peer');
320
+ alog?.('info', envelope.kind === 'reply' ? 'completion.transcript' : 'completion.notice', {
321
+ conversation: row.shortId,
322
+ ...(envelope.kind === 'reply' ? { bytes: completion.text.length } : {}),
323
+ });
324
+ void routeReturn(envelope, { responderAgent: env.to.id }).catch(() => parkInOutbox(envelope));
325
+ }, {
326
+ requestId: env.messageId,
327
+ onError: (error) => {
328
+ alog?.('warn', 'completion.error', { conversation: row.shortId, error: error.message });
329
+ const notice = noticeOf(`A2A completion stream for conversation ${row.shortId} failed: ${error.message}`, 'stream-dead');
330
+ void routeReturn(notice, { responderAgent: env.to.id, noticeReason: 'stream-dead' }).catch(() => parkInOutbox(notice));
331
+ },
332
+ });
333
+ alog?.('info', 'completion.armed', { conversation: row.shortId, session: row.sessionId, watermark: armedSentAt });
334
+ };
335
+ // ---------------- request kinds
336
+ const ingestRequest = async (env) => {
337
+ const fail = (error, sessionId = '', conversationId) => {
338
+ alog?.('warn', 'ingest.fail', { code: error.code, conversation: conversationId, message: env.messageId });
339
+ return {
340
+ outcome: 'failed',
341
+ messageId: env.messageId,
342
+ ...(conversationId === undefined ? {} : { conversationId }),
343
+ sessionId,
344
+ error,
345
+ };
346
+ };
347
+ //
348
+ // MESSAGE envelopes resolve the registry row FIRST: a peer write-back
349
+ // (an initiator row, no hostAgent) delivers without ever touching the
350
+ // local session plane — the plane is only resolved when THIS machine
351
+ // hosts the conversation and must inject.
352
+ //
353
+ let row;
354
+ if (env.kind === 'message') {
355
+ row = mirrorRowOf(env.conversationId);
356
+ if (row === undefined) {
357
+ return fail({
358
+ code: 'a2a/conversation-not-found',
359
+ message: `a2a: conversation "${env.conversationId}" is not in the registry — the initiator's conversationId does not resolve here`,
360
+ });
361
+ }
362
+ //
363
+ // D20 SELF-ROUTE WRITE-BACK: the single MIRROR row of a local↔local
364
+ // conversation, and the sender is the hosted peer itself (from.agent ===
365
+ // row.hostAgent, from.machine === self) — deliver into the initiator's
366
+ // session with peer attribution, NEVER inject (the row's session is the
367
+ // SENDER's own; injecting would loop the message back to its author).
368
+ //
369
+ // F2 (0.1.8-b): the discriminator is the SENDER'S SESSION, not the
370
+ // address. When the conversation's target IS the caller's own address
371
+ // (self-addressed), `from.agent === row.hostAgent` also holds for the
372
+ // INITIATOR's outbound continuation, so the old address test classified
373
+ // it as the peer's write-back and echoed the initiator's own text back
374
+ // as a "peer reply". The hosted peer's write-back origin carries
375
+ // sessionId === row.sessionId; the initiator's carries its own.
376
+ if (row.hostAgent !== undefined && env.from.machine === selfId() && env.from.sessionId === row.sessionId) {
377
+ if (env.to.machine !== selfId()) {
378
+ return fail({
379
+ code: 'a2a/conversation-machine-mismatch',
380
+ message: `a2a: conversation "${row.shortId}" write-back must target this machine, not ${env.to.machine}`,
381
+ }, row.sessionId, row.shortId);
382
+ }
383
+ if (row.initiatorSession === undefined || row.initiatorSession === '') {
384
+ return fail({
385
+ code: 'a2a/no-initiator-session',
386
+ message: `a2a: conversation "${row.shortId}" has no persisted initiator session — asynchronous write-backs cannot be addressed`,
387
+ }, row.sessionId, row.shortId);
388
+ }
389
+ const writeBackOutcome = deliverReply(deps.delivery, {
390
+ machine: env.to.machine,
391
+ agent: env.to.id,
392
+ sessionId: row.initiatorSession,
393
+ }, env.message, {
394
+ senderMachine: env.from.machine,
395
+ senderAgent: env.from.agent,
396
+ conversationId: row.shortId,
397
+ messageId: env.messageId,
398
+ title: row.title,
399
+ });
400
+ // D30 (0.1.8-b): the peer's channel reply IS its last output — persist it
401
+ // here too (the transcript path records it in deliverLocal; the write-back
402
+ // branches bypassed that, leaving lastOutput dead for channel-using peers).
403
+ if (env.message !== '')
404
+ recordLastOutput(row.shortId, env.message);
405
+ alog?.('info', 'deliver.writeback', { conversation: row.shortId, from: `${env.from.machine}-${env.from.agent}`, via: 'self-route', outcome: writeBackOutcome.delivered });
406
+ // v1 send-receipt shape (exactly-once wrapper contract)
407
+ return { outcome: 'delivered', messageId: env.messageId, conversationId: row.shortId, sessionId: row.initiatorSession };
408
+ }
409
+ if (row.machine !== env.from.machine || row.agent !== env.from.agent) {
410
+ return fail({
411
+ code: 'a2a/conversation-machine-mismatch',
412
+ message: `a2a: conversation "${row.shortId}" belongs to ${row.machine}-${row.agent}, not ${env.from.machine}-${env.from.agent}`,
413
+ }, row.sessionId, row.shortId);
414
+ }
415
+ //
416
+ // R4 amendment — PEER WRITE-BACK: the row is an INITIATOR row (no
417
+ // hostAgent ⇒ the session lives at the PEER). A message envelope from
418
+ // the very peer, addressed to this machine, is the peer using its
419
+ // superd-hub tool with OUR shortId (the teaching envelope's
420
+ // asynchronous-reply clause): deliver into the initiator's session with
421
+ // peer attribution — NEVER a session injection (the session is not
422
+ // ours to drive). deliverReply buffers into the bounded unread buffer
423
+ // when the initiator session is not live (existing machinery, keyed by
424
+ // the initiator's session id).
425
+ //
426
+ if (row.hostAgent === undefined) {
427
+ if (env.to.machine !== selfId()) {
428
+ return fail({
429
+ code: 'a2a/conversation-machine-mismatch',
430
+ message: `a2a: conversation "${row.shortId}" is hosted at ${row.machine}-${row.agent} — a peer write-back must target this machine, not ${env.to.machine}`,
431
+ }, row.sessionId, row.shortId);
432
+ }
433
+ if (row.initiatorSession === undefined || row.initiatorSession === '') {
434
+ return fail({
435
+ code: 'a2a/no-initiator-session',
436
+ message: `a2a: conversation "${row.shortId}" has no persisted initiator session — asynchronous write-backs cannot be addressed`,
437
+ }, row.sessionId, row.shortId);
438
+ }
439
+ const writeBackOutcome = deliverReply(deps.delivery, {
440
+ machine: env.to.machine,
441
+ agent: env.to.id,
442
+ sessionId: row.initiatorSession,
443
+ }, env.message, {
444
+ senderMachine: env.from.machine,
445
+ senderAgent: env.from.agent,
446
+ conversationId: row.shortId,
447
+ messageId: env.messageId,
448
+ title: row.title,
449
+ });
450
+ // D30 (0.1.8-b): persist the peer's channel reply as the conversation's last output
451
+ if (env.message !== '')
452
+ recordLastOutput(row.shortId, env.message);
453
+ // v1 send-receipt shape (double-delivery fix): a {delivered:…} body
454
+ // fails the sender's receipt guard → park → sweep re-POST → the
455
+ // write-back would deliver twice.
456
+ alog?.('info', 'deliver.writeback', { conversation: row.shortId, from: `${env.from.machine}-${env.from.agent}`, via: 'peer', outcome: writeBackOutcome.delivered });
457
+ return { outcome: 'delivered', messageId: env.messageId, conversationId: row.shortId, sessionId: row.initiatorSession };
458
+ }
459
+ }
460
+ const local = { machine: env.to.machine, id: env.to.id };
461
+ let gateway;
462
+ try {
463
+ gateway = deps.gatewayFor(local);
464
+ }
465
+ catch (error) {
466
+ const body = error instanceof A2aAddressError ? addressErrorBody(error) : receiptErrorOf(error);
467
+ return fail(body);
468
+ }
469
+ if (gateway === undefined) {
470
+ return fail({ code: 'a2a/gateway-unavailable', message: A2aAddressError.messageOf('unreachable', local) });
471
+ }
472
+ try {
473
+ if (env.kind === 'new-conversation') {
474
+ const created = await sessionRpc(gateway, WIRE.sessionCreate, {});
475
+ if (!isRecord(created) || typeof created.sessionId !== 'string' || created.sessionId === '') {
476
+ return fail({ code: 'a2a/bad-result', message: 'a2a: session/create returned no sessionId' });
477
+ }
478
+ // D17: mirror rows adopt the title VERBATIM (the '[undefined]' placeholder included)
479
+ row = adoptMirrorRow(env, created.sessionId, env.title);
480
+ }
481
+ // (message envelopes resolved their row above — only mirror rows reach here)
482
+ }
483
+ catch (error) {
484
+ // adoptMirrorRow's fail-loud collision + any registry write failure
485
+ return fail(receiptErrorOf(error));
486
+ }
487
+ if (row === undefined) {
488
+ // unreachable by construction (message rows resolved above; new-conversation adopted or failed)
489
+ return fail({ code: 'a2a/conversation-not-found', message: `a2a: conversation "${env.conversationId}" did not resolve to a hosted session` });
490
+ }
491
+ const promptRequest = {
492
+ requestId: env.messageId,
493
+ sessionId: row.sessionId,
494
+ mode: env.delivery ?? 'queue',
495
+ content: [{
496
+ type: 'text',
497
+ text: renderA2aEnvelope({
498
+ machine: env.from.machine,
499
+ agent: env.from.agent,
500
+ title: row.title,
501
+ shortId: row.shortId,
502
+ message: env.message,
503
+ }),
504
+ }],
505
+ };
506
+ try {
507
+ // 'delivered' on every successful submission (end-to-end delivery complete, D28)
508
+ await sessionRpc(gateway, WIRE.promptSubmit, promptRequest);
509
+ }
510
+ catch (error) {
511
+ return fail(receiptErrorOf(error), row.sessionId, row.shortId);
512
+ }
513
+ touchMirror(row);
514
+ watchCompletion(gateway, row, env);
515
+ return { outcome: 'delivered', messageId: env.messageId, conversationId: row.shortId, sessionId: row.sessionId };
516
+ };
517
+ const ingestStop = async (env) => {
518
+ const stopped = (scope, reason) => {
519
+ alog?.('warn', 'stop.refused', { conversation: env.conversationId, scope, reason });
520
+ return { stopped: false, scope, ...(reason === undefined ? {} : { reason }) };
521
+ };
522
+ const row = mirrorRowOf(env.conversationId);
523
+ if (row === undefined) {
524
+ return stopped(env.scope === 'session' ? 'turn-only' : 'turn', `a2a: conversation "${env.conversationId}" is not in the registry`);
525
+ }
526
+ const local = { machine: env.to.machine, id: env.to.id };
527
+ let gateway;
528
+ try {
529
+ gateway = deps.gatewayFor(local);
530
+ }
531
+ catch (error) {
532
+ return stopped(env.scope === 'session' ? 'turn-only' : 'turn', messageOf(error));
533
+ }
534
+ if (gateway === undefined) {
535
+ return stopped(env.scope === 'session' ? 'turn-only' : 'turn', A2aAddressError.messageOf('unreachable', local));
536
+ }
537
+ try {
538
+ // D5 honesty (same read as the initiator-side stop): liveness BEFORE cancel
539
+ const live = await turnLivenessOf(gateway, row.sessionId);
540
+ await sessionRpc(gateway, WIRE.turnCancel, { sessionId: row.sessionId });
541
+ const result = live === false
542
+ ? (env.scope === 'turn'
543
+ ? { scope: 'turn', cancelled: false, note: A2A_NO_TURN_ACTIVE_NOTE }
544
+ : { scope: 'turn-only', cancelled: false, note: A2A_NO_TURN_ACTIVE_NOTE, reason: A2A_NO_SESSION_CLOSE_REASON })
545
+ : (env.scope === 'turn'
546
+ ? { scope: 'turn', cancelled: true }
547
+ : { scope: 'turn-only', cancelled: true, reason: A2A_NO_SESSION_CLOSE_REASON });
548
+ const parts = [result.scope === 'turn-only' ? result.reason : undefined, 'note' in result ? result.note : undefined];
549
+ if (result.cancelled === true) {
550
+ // 0.1.7-h R2: stamp the row so this hub's completion watch attributes
551
+ // the turn end it is about to observe to the initiator's stop, not to
552
+ // the peer going silent. Watermark-safe: the watch only honors stamps
553
+ // set after its own arm time.
554
+ const rows = loadConversations(deps.home).conversations;
555
+ const idx = rows.findIndex((r) => r.sessionId === row.sessionId);
556
+ if (idx !== -1) {
557
+ rows[idx] = { ...rows[idx], stoppedAt: Date.now() };
558
+ saveConversations(deps.home, { conversations: rows });
559
+ }
560
+ }
561
+ alog?.('info', 'stop', { conversation: env.conversationId, stopped: result.cancelled, scope: result.scope });
562
+ return {
563
+ stopped: result.cancelled,
564
+ scope: result.scope,
565
+ ...(parts.some((p) => p !== undefined) ? { reason: parts.filter((p) => p !== undefined).join('; ') } : {}),
566
+ };
567
+ }
568
+ catch (error) {
569
+ return stopped(env.scope === 'session' ? 'turn-only' : 'turn', messageOf(error));
570
+ }
571
+ };
572
+ const dispatch = async (envelope) => {
573
+ switch (envelope.kind) {
574
+ case 'new-conversation':
575
+ case 'message':
576
+ return ingestRequest(envelope);
577
+ case 'stop':
578
+ return ingestStop(envelope);
579
+ case 'reply':
580
+ case 'notice': {
581
+ // inbound return envelope (peer POSTed / sweep pulled). Sender
582
+ // attribution, in order: the envelope's own `sender` (additive v1 —
583
+ // the producing hub stamps itself), else the registry row (the local
584
+ // row for this conversation points AT the peer), else honest
585
+ // 'unknown'. NEVER selfId() — the receiver is not the sender (the
586
+ // 0.1.6-h cross-machine bug: replies landed attributed bfwu-8ah589).
587
+ const row = mirrorRowOf(envelope.conversation);
588
+ const sender = envelope.sender ??
589
+ (row !== undefined ? { machine: row.machine, agent: row.agent } : { machine: 'unknown', agent: 'unknown' });
590
+ deliverLocal(envelope, sender);
591
+ // v1 send-receipt shape: the sender's postA2aEnvelope guard accepts
592
+ // exactly this. The 0.1.6-j double delivery was a {delivered:…} body
593
+ // failing that guard AFTER the receiver had already delivered.
594
+ return { outcome: 'delivered', messageId: envelope.messageId, conversationId: envelope.conversation, sessionId: envelope.to.sessionId };
595
+ }
596
+ }
597
+ };
598
+ return {
599
+ async ingest(envelope) {
600
+ // exactly-once over an at-least-once transport (2026-09-28 first-person
601
+ // acceptance finding §7.1): an envelope the sender re-POSTs is
602
+ // re-ACKed with its receipt and NEVER re-executed. Stop envelopes
603
+ // carry no messageId and stay naturally idempotent (cancelling a dead
604
+ // turn answers honestly).
605
+ const mid = envelope.messageId;
606
+ if (mid !== undefined && seenMessageIds(deps.home).has(mid)) {
607
+ alog?.('info', 'ingest.park', { message: mid, reason: 'duplicate' });
608
+ return envelope.kind === 'reply' || envelope.kind === 'notice'
609
+ ? { outcome: 'delivered', messageId: mid, conversationId: envelope.conversation, sessionId: envelope.to.sessionId }
610
+ : { outcome: 'delivered', messageId: mid, conversationId: envelope.conversationId ?? '', sessionId: '' };
611
+ }
612
+ const out = await dispatch(envelope);
613
+ const outcome = out.outcome;
614
+ const accepted = typeof outcome === 'string' ? outcome === 'delivered' : true;
615
+ if (mid !== undefined && accepted)
616
+ rememberMessageId(deps.home, mid);
617
+ alog?.(accepted ? 'info' : 'warn', 'ingest.done', {
618
+ kind: envelope.kind,
619
+ outcome: accepted ? 'delivered' : typeof outcome === 'string' ? outcome : 'failed',
620
+ message: mid,
621
+ conversation: envelope.conversationId ?? envelope.conversation,
622
+ });
623
+ return out;
624
+ },
625
+ claimOutbox(machine) {
626
+ const file = loadOutbox(deps.home);
627
+ const claimed = file.envelopes.filter((e) => e.to.machine === machine);
628
+ if (claimed.length === 0)
629
+ return [];
630
+ saveOutbox(deps.home, { envelopes: file.envelopes.filter((e) => e.to.machine !== machine) });
631
+ alog?.('info', 'outbox.claim', { machine, count: claimed.length });
632
+ return claimed;
633
+ },
634
+ routeReturn,
635
+ };
636
+ }
@@ -0,0 +1,17 @@
1
+ export type A2aLogLevel = 'info' | 'warn' | 'error';
2
+ /** Field values: scalars only; `undefined` fields are dropped. */
3
+ export type A2aLogFields = Record<string, string | number | boolean | undefined>;
4
+ export type A2aLogFn = (level: A2aLogLevel, event: string, fields?: A2aLogFields) => void;
5
+ /**
6
+ * Render one journal-friendly line: `superd-hub: a2a.<event> k=v k=v`.
7
+ * Values are quoted (JSON) only when they contain whitespace, so the common
8
+ * case stays grep-friendly: `a2a.completion.transcript conversation=abc123 bytes=42`.
9
+ */
10
+ export declare function formatA2aLogLine(event: string, fields?: A2aLogFields): string;
11
+ /**
12
+ * The host wiring both rows share: level → process console. NOT ctx.logger —
13
+ * the pack composes no cordis logger-console exporter, so LoggerService
14
+ * records go nowhere (verified live 2026-09-29); console matches the
15
+ * world-join / world-provision precedent and lands in every unit log.
16
+ */
17
+ export declare function consoleA2aLog(): A2aLogFn;