@nelsonlongxiang/dsh-open-a2a-net 0.6.30 → 0.6.32

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 (89) hide show
  1. package/README.md +4 -1
  2. package/README_zh.md +3 -0
  3. package/assets/nexusDist/assets/index-D5H8R8de.js +9 -0
  4. package/assets/nexusDist/assets/index-D9GYZyGZ.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +445 -57
  7. package/lib/a2a-client.js +9 -4
  8. package/lib/auth-observer.js +254 -0
  9. package/lib/blob-store.js +210 -0
  10. package/lib/card.js +4 -2
  11. package/lib/cli-deposit.js +122 -7
  12. package/lib/client.js +64 -64
  13. package/lib/client.js.map +1 -1
  14. package/lib/faces/admin.js +133 -43
  15. package/lib/faces/canvas-notify.js +12 -3
  16. package/lib/faces/files.js +155 -0
  17. package/lib/faces/nodes.js +171 -0
  18. package/lib/faces/session-log.js +162 -0
  19. package/lib/faces/wire.js +206 -39
  20. package/lib/index.js +809 -1720
  21. package/lib/joined-store.js +23 -0
  22. package/lib/ledger-conservation.js +97 -0
  23. package/lib/legacy/teams-bridge-inbound.js +156 -0
  24. package/lib/node-bind.js +534 -0
  25. package/lib/node-github.js +72 -0
  26. package/lib/node-self.js +453 -0
  27. package/lib/node-store.js +484 -0
  28. package/lib/node-tasks.js +342 -0
  29. package/lib/origin-claim.js +246 -0
  30. package/lib/peer-directory.js +134 -0
  31. package/lib/receipt.js +26 -1
  32. package/lib/skill/SKILL.md +3 -1
  33. package/lib/skill/references/cli.md +82 -3
  34. package/lib/skill/references/collaboration.md +146 -73
  35. package/lib/skill/references/discovery.md +29 -0
  36. package/lib/skill/references/intake.md +107 -0
  37. package/lib/skill/references/router.md +96 -0
  38. package/lib/skill/references/setup.md +2 -1
  39. package/lib/task-auth.js +483 -0
  40. package/lib/task-ledger.js +324 -12
  41. package/lib/team-store.js +24 -1
  42. package/lib/tools/act.js +383 -0
  43. package/lib/tools/files.js +201 -0
  44. package/lib/tools/info.js +684 -0
  45. package/lib/types/a2a-client.d.ts +10 -4
  46. package/lib/types/auth-observer.d.ts +110 -0
  47. package/lib/types/blob-store.d.ts +83 -0
  48. package/lib/types/cli-deposit.d.ts +38 -0
  49. package/lib/types/faces/admin.d.ts +14 -2
  50. package/lib/types/faces/canvas-notify.d.ts +4 -1
  51. package/lib/types/faces/files.d.ts +53 -0
  52. package/lib/types/faces/nodes.d.ts +69 -0
  53. package/lib/types/faces/session-log.d.ts +44 -0
  54. package/lib/types/faces/wire.d.ts +50 -5
  55. package/lib/types/index.d.ts +27 -0
  56. package/lib/types/joined-store.d.ts +9 -0
  57. package/lib/types/ledger-conservation.d.ts +77 -0
  58. package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
  59. package/lib/types/node-bind.d.ts +220 -0
  60. package/lib/types/node-github.d.ts +35 -0
  61. package/lib/types/node-self.d.ts +178 -0
  62. package/lib/types/node-store.d.ts +180 -0
  63. package/lib/types/node-tasks.d.ts +121 -0
  64. package/lib/types/origin-claim.d.ts +119 -0
  65. package/lib/types/peer-directory.d.ts +39 -0
  66. package/lib/types/receipt.d.ts +12 -0
  67. package/lib/types/task-auth.d.ts +271 -0
  68. package/lib/types/task-ledger.d.ts +143 -6
  69. package/lib/types/team-store.d.ts +9 -0
  70. package/lib/types/tools/act.d.ts +52 -0
  71. package/lib/types/tools/files.d.ts +38 -0
  72. package/lib/types/tools/info.d.ts +102 -0
  73. package/lib/types/types.d.ts +6 -0
  74. package/nexus-stage/src/canvas-ops.ts +17 -2
  75. package/nexus-stage/src/canvas-wire.ts +27 -19
  76. package/nexus-stage/src/federation.ts +1 -0
  77. package/nexus-stage/src/main.ts +41 -18
  78. package/nexus-stage/src/planning-member-handles.ts +34 -0
  79. package/nexus-stage/src/planning-mirror.ts +47 -3
  80. package/nexus-stage/src/planning-reachability.ts +28 -0
  81. package/nexus-stage/src/planning-types.ts +1 -1
  82. package/nexus-stage/src/planning-view.ts +30 -27
  83. package/nexus-stage/src/planning.css +4 -0
  84. package/nexus-stage/src/wb-directory.ts +13 -0
  85. package/nexus-stage/src/workbench.css +3 -0
  86. package/nexus-stage/src/workbench.ts +5 -1
  87. package/package.json +1 -1
  88. package/assets/nexusDist/assets/index-C_x3iAth.js +0 -9
  89. package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Project a verified peer card into directory rows and identify its own
3
+ * advertised canvas teams (#159). The card's core is signed; sessionTeams,
4
+ * teamMemberships, and canvasOrg are unsigned, serve-fresh claims. Nothing
5
+ * here grants admission: the caller must still apply its existing scope and
6
+ * the receiver must still enforce OriginClaim and target-level membership.
7
+ *
8
+ * @module src/peer-directory
9
+ */
10
+ import { CANVAS_MEMBER_CAP, CANVAS_NAME_MAX, CANVAS_TEAM_CAP } from "./canvas-store.js";
11
+ /** Only this card's own valid canvas namespace may name its teams. */
12
+ export function advertisedCanvasTeams(card) {
13
+ const found = [];
14
+ const seen = new Set();
15
+ for (const entry of (card.canvasOrg ?? []).slice(0, CANVAS_TEAM_CAP)) {
16
+ const name = entry.name;
17
+ if (name === '' || name !== name.trim() || name.length > CANVAS_NAME_MAX || name.includes('/') || /^\d+$/.test(name))
18
+ continue;
19
+ if (entry.team !== card.team + '/canvas/' + name || entry.members.length > CANVAS_MEMBER_CAP)
20
+ continue;
21
+ if (seen.has(entry.team))
22
+ continue;
23
+ seen.add(entry.team);
24
+ found.push(entry);
25
+ }
26
+ return found;
27
+ }
28
+ /** A nested zone may itself end in /canvas; preserve its own session alias. */
29
+ function sessionAliasAllowed(card, team) {
30
+ if (!/^.+\/canvas\/[^/]+$/.test(team))
31
+ return true;
32
+ const prefix = card.team + '/';
33
+ const suffix = team.startsWith(prefix) ? team.slice(prefix.length) : '';
34
+ return suffix !== '' && !suffix.includes('/');
35
+ }
36
+ /** Match a session claim without treating another zone's canvas as a session. */
37
+ export function sessionCandidateMatches(card, team) {
38
+ return sessionAliasAllowed(card, team) && (card.sessionTeams ?? []).some(entry => entry.team === team);
39
+ }
40
+ /** A member the publisher also serves as its own session is not ours. */
41
+ function peerPublishesSession(card, handle) {
42
+ return (card.sessionTeams ?? []).some(entry => entry.team === handle);
43
+ }
44
+ /** Match a scoped caller to this peer's canvas before sending the payload. */
45
+ export function canvasCandidateMatches(card, team, caller, resolveLocal) {
46
+ if (caller !== undefined && (resolveLocal?.(caller.handle) !== caller.id || peerPublishesSession(card, caller.handle)))
47
+ return false;
48
+ return advertisedCanvasTeams(card).some(entry => entry.team === team && (caller === undefined || entry.members.includes(caller.handle)));
49
+ }
50
+ /** A naked team string is unsafe when both a canvas and another face claim it. */
51
+ export function canvasTargetAmbiguous(cards, team, caller, resolveLocal, hasDelegation) {
52
+ const owners = cards.filter((card) => card !== undefined && advertisedCanvasTeams(card).some(entry => entry.team === team));
53
+ if (owners.length === 0)
54
+ return false;
55
+ if (caller !== undefined) {
56
+ if (resolveLocal?.(caller.handle) !== caller.id)
57
+ return true;
58
+ const ownerCollision = owners.some(card => peerPublishesSession(card, caller.handle)
59
+ && advertisedCanvasTeams(card).some(entry => entry.team === team && entry.members.includes(caller.handle)));
60
+ if (ownerCollision)
61
+ return true;
62
+ }
63
+ // Distinct signed publishers with the same qualified group name cannot be
64
+ // selected by the team-only API. URL aliases of one signed card are one.
65
+ const eligible = owners.filter(card => canvasCandidateMatches(card, team, caller, resolveLocal));
66
+ const identities = new Set(eligible.map(card => JSON.stringify([card.publicKey, card.session])));
67
+ return identities.size > 1 || hasDelegation || cards.some(card => card !== undefined && (card.team === team || sessionCandidateMatches(card, team)));
68
+ }
69
+ /**
70
+ * Existing peer rows plus optional canvas rows for the model-tool directory.
71
+ * The GUI's remote-node rows call with includeCanvas=false, so a group is
72
+ * never rendered as an extra session node. Membership is carried only as an
73
+ * internal scope hint and stripped before the tool's public result.
74
+ */
75
+ export function peerDirectoryRows(card, peer, includeCanvas) {
76
+ // The origin fields (publisher session label + LAN IP) are natural
77
+ // grouping dimensions: a fleet's rows cluster by the host that owns
78
+ // them, no manual grouping required.
79
+ const origin = card.lanIp !== undefined ? card.session + ' (' + card.lanIp + ')' : card.session;
80
+ // via = the peer URL the card was fetched from: the panel shows which
81
+ // host:port each remote row came from, so an unknown node is traceable
82
+ // to its publishing endpoint at a glance.
83
+ // legacy: the publisher's card carries no roster capability — a
84
+ // pre-roster node (0.5.43-). Rows from it are marked so the fleet
85
+ // audit (and the phase-2 teamed-only default flip) knows which peers
86
+ // never grow the roster face.
87
+ const legacy = card.capabilities?.roster !== true;
88
+ const rows = [
89
+ { team: card.team, session: card.session, name: card.name, description: '', origin, via: peer, ...(legacy ? { legacy } : {}) },
90
+ ];
91
+ // Roster reader half: the card's unsigned teamMemberships map each
92
+ // published session team to its declared roster teams, so a remote
93
+ // node's teamed/teamless state is visible without visiting its host.
94
+ const rosterOf = new Map();
95
+ for (const membership of card.teamMemberships ?? []) {
96
+ const list = rosterOf.get(membership.node) ?? [];
97
+ list.push(membership.team);
98
+ rosterOf.set(membership.node, list);
99
+ }
100
+ for (const entry of card.sessionTeams ?? []) {
101
+ // A foreign canvas-shaped claim cannot masquerade as a session. A valid
102
+ // own-zone session stays visible even if its zone ends in /canvas.
103
+ if (!sessionAliasAllowed(card, entry.team))
104
+ continue;
105
+ const declared = rosterOf.get(entry.team);
106
+ rows.push({ team: entry.team, session: card.session, name: entry.name, description: entry.description, origin, via: peer, ...(entry.workspace !== undefined ? { workspace: entry.workspace } : {}), ...(legacy ? { legacy } : {}), ...(declared !== undefined && declared.length > 0 ? { teams: declared } : {}) });
107
+ }
108
+ const publishedSessions = new Set((card.sessionTeams ?? []).map(entry => entry.team));
109
+ if (includeCanvas)
110
+ for (const entry of advertisedCanvasTeams(card)) {
111
+ const declared = rosterOf.get(entry.team);
112
+ rows.push({ team: entry.team, session: card.session, name: entry.name, description: 'canvas team', origin, via: peer, ...(legacy ? { legacy } : {}), ...(declared !== undefined && declared.length > 0 ? { teams: declared } : {}), canvasMembers: entry.members.filter(ref => !publishedSessions.has(ref)) });
113
+ }
114
+ return rows;
115
+ }
116
+ /**
117
+ * Whether one caller label can receive network traffic back. Composite
118
+ * `<zone>/<id>` shapes route anywhere (N1); bare zone names resolve to a
119
+ * live initiator per host, so cross-host they would steer whichever node
120
+ * shares the name — treated as unroutable everywhere.
121
+ */
122
+ export function routableCallerLabel(callerSession) {
123
+ return callerSession !== undefined &&
124
+ (/[a-z0-9-]+\/[0-9a-f]{8}$/i.test(callerSession) || /[a-z0-9-]+\/[a-z0-9]{4,}$/i.test(callerSession));
125
+ }
126
+ /**
127
+ * Why an async route from such a caller cannot expect its receipt: the
128
+ * autosend arm skips it and the steered hint is omitted, so the debt has
129
+ * no paying path — say so at dispatch time instead of silently stranding
130
+ * a pending row.
131
+ */
132
+ export function unroutableCallerNote(label) {
133
+ return `\n(Note: caller label "${label}" is not network-routable, so the target cannot auto-send a receipt back to you — reconcile via context_id follow-ups instead.)`;
134
+ }
package/lib/receipt.js CHANGED
@@ -11,6 +11,23 @@
11
11
  * open with a brace can never be mistaken for machine payloads.
12
12
  * @module @nelsonlongxiang/dsh-open-a2a-net/receipt
13
13
  */
14
+ /**
15
+ * #114 S5: what a task id may not contain. The id is spliced into the
16
+ * `[A2A direct] (task <id>)` steer header and the `[A2A receipt] task <id>`
17
+ * correlation header; a `)` would let one id's header start with another's
18
+ * (`foo) x` → `(task foo) x)`), and whitespace or control characters break
19
+ * the `\S+` correlation and the header's one-line shape.
20
+ */
21
+ const TASK_ID_FORBIDDEN = /[\s)\u0000-\u001f\u007f]/;
22
+ /**
23
+ * Refusal text for an unusable task id, or undefined when the id is fine.
24
+ * Both delivery paths (wire face, local candidate) refuse at intake.
25
+ */
26
+ export function taskIdRefusal(taskId) {
27
+ return TASK_ID_FORBIDDEN.test(taskId)
28
+ ? 'task_id must not contain whitespace, ")" or control characters — it is spliced into the [A2A direct] (task <id>) and [A2A receipt] task <id> headers'
29
+ : undefined;
30
+ }
14
31
  /** The network's fixed, case-sensitive correlation template. */
15
32
  export const RECEIPT_PATTERN = /^\[A2A receipt\] task (\S+)\s*([\s\S]*)$/;
16
33
  /** Controlled outcome vocabulary — the ONLY branchable field of an envelope. */
@@ -23,7 +40,7 @@ export const RECEIPT_OUTCOMES = [
23
40
  'sync_completed',
24
41
  'unknown',
25
42
  ];
26
- const KNOWN_ENVELOPE_KEYS = ['outcome', 'idempotencyKey', 'sessionKey', 'elapsedMs', 'artifacts'];
43
+ const KNOWN_ENVELOPE_KEYS = ['outcome', 'idempotencyKey', 'sessionKey', 'elapsedMs', 'artifacts', 'onBehalfOf', 'authRef'];
27
44
  void KNOWN_ENVELOPE_KEYS;
28
45
  function tryEnvelopeLine(line) {
29
46
  const trimmed = line.trim();
@@ -46,6 +63,10 @@ function tryEnvelopeLine(line) {
46
63
  fields.sessionKey = record['sessionKey'];
47
64
  if (typeof record['elapsedMs'] === 'number' && Number.isFinite(record['elapsedMs']))
48
65
  fields.elapsedMs = record['elapsedMs'];
66
+ if (typeof record['onBehalfOf'] === 'string' && record['onBehalfOf'] !== '')
67
+ fields.onBehalfOf = record['onBehalfOf'];
68
+ if (typeof record['authRef'] === 'string' && /^[0-9a-f]{16}$/.test(record['authRef']))
69
+ fields.authRef = record['authRef'];
49
70
  if (Object.keys(fields).length === 0)
50
71
  return undefined;
51
72
  return fields;
@@ -95,6 +116,10 @@ export function formatReceipt(taskId, summary, fields) {
95
116
  ordered['sessionKey'] = fields.sessionKey;
96
117
  if (fields.elapsedMs !== undefined)
97
118
  ordered['elapsedMs'] = fields.elapsedMs;
119
+ if (fields.onBehalfOf !== undefined)
120
+ ordered['onBehalfOf'] = fields.onBehalfOf;
121
+ if (fields.authRef !== undefined)
122
+ ordered['authRef'] = fields.authRef;
98
123
  const keys = Object.keys(ordered);
99
124
  if (keys.length === 0)
100
125
  return header;
@@ -112,4 +112,6 @@ renders the same data for humans.
112
112
  - [membership.md](references/membership.md) — join/leave, allowlist, canvas teams
113
113
  - [pitfalls.md](references/pitfalls.md) — operational pitfalls observed in production
114
114
  - [cli.md](references/cli.md) — the `a2a-net` CLI: deposit, mailbox, inbox hooks, limits
115
- - [collaboration.md](references/collaboration.md) — collaboration norm (Chinese): single-seat first, decide-then-report, release tiers, stuck gates, credentials, receipts
115
+ - [collaboration.md](references/collaboration.md) — collaboration norm (Chinese): single-seat first, decide-then-report with no pre-authorization (a high-tier change needs one non-self node's review PASS on the same SHA), release tiers, stuck gates, credentials, receipts
116
+ - [intake.md](references/intake.md) — intake node (Chinese): handling `workbench-event` deliveries from workbench-market — accept/decline/ask with a reason, open a GitHub issue on accept, write the decision back with the reply token
117
+ - [router.md](references/router.md) — the chief's system prompt (Chinese), self-contained: route workbench events and owner requests to the right project's dev, order cross-project resource conflicts (it authorizes nothing since collaboration v1.18); immutable layer + self-kept mutable layer
@@ -28,6 +28,11 @@ ten model tools.
28
28
  its card is no longer served, but it stays in the host's directory as an
29
29
  OFFLINE row (gh#47; the newest 16 are kept) so canvas refs to it never
30
30
  dangle silently. Publish again (same handle, same key) to come back.
31
+ - A handle stays pinned to the key it was published with, live or offline
32
+ (gh#137): publishing it with a different key (e.g. after `init --force
33
+ --uuid <same>`) is refused with "held by another key". Run `a2a-net revoke`
34
+ first; the next publish then takes the new key. A key swap is therefore
35
+ always an explicit act, never a silent overwrite.
31
36
  - While deposited, the host serves the card at
32
37
  `<home>/a2a/cli/<handle>/.well-known/agent-card.json` and lists the handle
33
38
  in its directory as "cli — deposited identity (no wake; mailbox)".
@@ -37,16 +42,75 @@ ten model tools.
37
42
  405 with an empty body. The fix is a host upgrade, not a retry.
38
43
  - `a2a-net revoke` deregisters early.
39
44
 
45
+ ## Files (#91 file channel)
46
+
47
+ - `a2a-net file send <path…> <team> [--note TEXT]` stages each file (≤64 MiB,
48
+ ≤16 per send) on the home host by sha256, granted to `<team>` for 24h, and
49
+ routes ONE message with one `[A2A file] v1 sha256=… size=… name=… from=<home>`
50
+ line per file. File bytes never ride a message.
51
+ - `a2a-net file fetch "<reference line>" [--out DIR]` pulls a referenced file,
52
+ checks its size and sha256, and saves it as `<sha12>-<name>` (no links
53
+ followed, no overwrite of other content). A CLI identity proves itself only
54
+ on its HOME host, so only files whose `from` is the home host can be fetched;
55
+ for a file on another host, ask the sender to send it through your home host.
56
+ - Sessions use the `a2a_file_send` / `a2a_file_fetch` tools for the same thing.
57
+
40
58
  ## Sending
41
59
 
42
60
  - `a2a-net route <team> <message…>` delivers a round with a signed
43
61
  OriginClaim envelope to the HOME host's `/a2a/direct`. It is async by
44
62
  default: the receipt comes back to `a2a-net inbox`.
63
+ - The envelope (`X-A2A-Caller`, `X-A2A-Caller-Url`, `X-A2A-Caller-Ts`,
64
+ `X-A2A-Caller-Sig`) is signed with the identity key, over the identity's
65
+ deposit face on the home host. It also carries the bound form
66
+ (`X-A2A-Caller-Aud`, `X-A2A-Caller-Sig2`, docs/protocol/delivery-origin-auth.md
67
+ §2.1): a signature over the origin, method and path the CLI actually
68
+ dials, so it is good only at that host's `/a2a/direct`. A host with gh#133 verifies it on the
69
+ delivery face, from its own deposit table (it never dials the CLI's
70
+ address back, so a proxy or tailnet address is fine): the delivery's
71
+ authorization record says `proof: cli-identity` with the full handle as
72
+ the locally deposited identity; it is not market-origin proof and does not
73
+ count as an absent-envelope legacy delivery.
74
+ - An older host refuses the envelope as `not-derivable`. The CLI says so on
75
+ stderr and resends the same task id once unsigned, the way earlier CLIs
76
+ always sent (accepted only while the host's OriginClaim window is open).
77
+ - Dialling the home through an address the host does not list as its own
78
+ (a Tailscale address, a card-proxy port such as 13080, a pocket tunnel)
79
+ makes the bound form miss. While the host's `originClaimUnboundWindow` is
80
+ open the route still lands and the miss shows only on the host, in
81
+ `boundMissInbound`; `a2a-net status` prints that gauge beside the address
82
+ the CLI dials. Once the window is closed the route is refused as
83
+ `wrong-audience`: stderr names the dialled origin and the fix — the host
84
+ operator adds it to `originClaimAudiences`, or `--home` / `$A2A_HOME`
85
+ points at an address the host lists. A proxy that rewrites the path
86
+ (`<home>` with a path prefix) fails the same way as `bad-sig`. These
87
+ refusals are never resent unsigned. A redirect from the home is never
88
+ followed either, on any command (the API key, pocket cookie and claim
89
+ would travel with it): the command fails naming the status and
90
+ `Location`, and a route is not resent.
91
+ - An identity may not be named after the host's zone (the deposit is refused).
45
92
  - `--sync` waits for the target's reply and prints it:
46
93
  `reply from <team> · task <id> · context <ctx>` followed by the reply text.
47
94
  When the round has not settled (status other than completed) a warning
48
95
  goes to stderr: reconcile by task id before re-sending.
49
96
  - `--context ID` continues that conversation (sends it as `context_id`).
97
+ - Every route and report carries a task id born in the CLI
98
+ (`direct-<12 hex>`). The host runs one task id at most once (same id and
99
+ same message: 409 replay; different message: 409 conflict). Resending is
100
+ therefore safe:
101
+ - A dropped connection or a 5xx is resent automatically, up to 3 times,
102
+ with the SAME id (backoff 1s, 2s, 4s).
103
+ - If it still fails, stderr names the id: rerun with `--task <id>` to
104
+ resend it without risking a second execution.
105
+ - A resend the host already has prints `already received … — not sent a
106
+ second time` with the earlier outcome from `/a2a/query`: `pending` (an
107
+ async delivery waiting for its receipt) or `completed` (with its reply).
108
+ If that earlier attempt failed, the CLI exits 1 with its reason.
109
+ - A `--sync` resend whose round has no reply yet exits 1: rerun later
110
+ with `--task <id> --sync` to fetch the reply.
111
+ - Reusing an id for a different message, or switching `--sync` on or off,
112
+ is refused (exit 1): drop `--task` to send it as a new task.
113
+ - `report` takes `--task` the same way.
50
114
  - Direct delivery does not relay: only teams on the home host are reachable.
51
115
  A team on another host answers "No live DSH session node accepts team".
52
116
  - Admission: a deposited handle is a node fact of its home host (N3). It
@@ -61,12 +125,27 @@ ten model tools.
61
125
 
62
126
  - Deliveries addressed to a deposited handle, receipts included, land in its
63
127
  mailbox on the home host and answer `receipt: "mailbox"`. Entries are
64
- `{at, from, text}` (text ≤4000 chars), newest first, 64 kept. A read does
65
- not remove anything.
128
+ `{at, from, text, seq}`, newest first, 64 kept. A read does not remove
129
+ anything.
130
+ - `seq` numbers a handle's mail 1, 2, 3 … in arrival order. It is never
131
+ reused and survives restarts. The read also carries `epoch`; `next_seq`;
132
+ `oldest_seq`; and `dropped`, the count of mail the 64-row cap pushed out.
133
+ `epoch` changes whenever seq numbers could repeat: the store starts empty,
134
+ its snapshot is unreadable or renumbered, or a write to disk failed (the
135
+ next restart would reload older numbers).
136
+ - Text over 4000 chars is cut and marked `truncated: true, chars: <original>`.
137
+ - The host's state face totals both losses for every handle as
138
+ `cliMailbox: {dropped, truncated}`.
66
139
  - `a2a-net inbox` lists the whole mailbox.
67
140
  - `inbox --new [--peek]` shows unread mail, oldest first. "Unread" is a local
68
141
  cursor (`$A2A_CLI_HOME/inbox.cursor`, tied to the handle) that advances
69
- unless `--peek`.
142
+ unless `--peek`. On a host with `seq` the cursor is the last seq read:
143
+ - Mail the cap pushed out before you read it is reported once, by
144
+ `--new`, `--wait` and `--hook` alike (stderr, or inside the hook frame):
145
+ `N messages were pushed out of the mailbox before they were read`. It
146
+ cannot be recovered; tell the user.
147
+ - On an older host, or after the store starts over (a new `epoch`), the
148
+ cursor falls back to the newest `at` seen.
70
149
  - `inbox --wait [--timeout 30m] [--interval 15s] [--renew]` blocks until
71
150
  unread mail arrives, then prints it and exits 0; exits 3 on timeout.
72
151
  `--renew` re-publishes the deposit when it is gone or under 10 minutes from