@nelsonlongxiang/dsh-open-a2a-net 0.6.27 → 0.6.29

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 (62) hide show
  1. package/README.md +85 -14
  2. package/README_zh.md +81 -7
  3. package/assets/nexusDist/assets/index-BqepxJ7E.css +1 -0
  4. package/assets/nexusDist/assets/index-CTWH6QRh.js +9 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +624 -612
  7. package/cordis.patch.yml +21 -21
  8. package/lib/canvas-store.js +5 -1
  9. package/lib/cli-deposit.js +61 -12
  10. package/lib/client.js +64 -64
  11. package/lib/client.js.map +1 -1
  12. package/lib/faces/admin.js +744 -0
  13. package/lib/index.js +222 -756
  14. package/lib/peer-store.js +60 -4
  15. package/lib/recursion-guard.js +78 -0
  16. package/lib/runtime.js +1 -0
  17. package/lib/skill/SKILL.md +48 -11
  18. package/lib/skill/references/cli.md +109 -0
  19. package/lib/skill/references/discovery.md +15 -4
  20. package/lib/skill/references/membership.md +29 -6
  21. package/lib/skill/references/pitfalls.md +12 -4
  22. package/lib/skill/references/routing.md +18 -8
  23. package/lib/skill/references/setup.md +152 -0
  24. package/lib/task-ledger.js +6 -0
  25. package/lib/types/canvas-store.d.ts +5 -1
  26. package/lib/types/cli-deposit.d.ts +30 -1
  27. package/lib/types/faces/admin.d.ts +132 -0
  28. package/lib/types/index.d.ts +11 -3
  29. package/lib/types/peer-store.d.ts +24 -1
  30. package/lib/types/recursion-guard.d.ts +56 -0
  31. package/lib/types/runtime.d.ts +34 -0
  32. package/lib/types/task-ledger.d.ts +6 -0
  33. package/nexus-stage/package-lock.json +2262 -2262
  34. package/nexus-stage/src/canvas-ops.ts +175 -175
  35. package/nexus-stage/src/canvas-wire.ts +156 -156
  36. package/nexus-stage/src/edges.ts +72 -72
  37. package/nexus-stage/src/federation-overlay.ts +103 -0
  38. package/nexus-stage/src/federation.ts +240 -240
  39. package/nexus-stage/src/layout-doc.ts +143 -143
  40. package/nexus-stage/src/layout-wire.ts +158 -158
  41. package/nexus-stage/src/planning-card-text.ts +73 -0
  42. package/nexus-stage/src/planning-fit.ts +45 -0
  43. package/nexus-stage/src/planning-legend.ts +38 -0
  44. package/nexus-stage/src/planning-overlap.ts +134 -0
  45. package/nexus-stage/src/planning-relayout.ts +121 -0
  46. package/nexus-stage/src/planning-search.ts +168 -0
  47. package/nexus-stage/src/planning-view.ts +94 -119
  48. package/nexus-stage/src/planning.css +39 -8
  49. package/nexus-stage/src/seat.ts +35 -35
  50. package/nexus-stage/src/stage-name.ts +13 -13
  51. package/nexus-stage/src/tokens.ts +31 -31
  52. package/nexus-stage/src/viewport.ts +24 -1
  53. package/nexus-stage/src/wb-chat.ts +20 -6
  54. package/nexus-stage/src/wb-ledger.ts +20 -7
  55. package/nexus-stage/src/wb-names.ts +43 -0
  56. package/nexus-stage/src/workbench.css +8 -2
  57. package/nexus-stage/src/workbench.ts +55 -17
  58. package/nexus-stage/src/world.ts +5 -1
  59. package/package.json +216 -214
  60. package/assets/nexusDist/assets/index-DszoLz8i.js +0 -5
  61. package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
  62. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
package/lib/peer-store.js CHANGED
@@ -21,6 +21,17 @@ export const FAILURE_PENALTY = 100;
21
21
  export const SUCCESS_REWARD = 100;
22
22
  /** Score floor: at or below this a non-seed peer is evicted. */
23
23
  export const EVICTION_FLOOR = 0;
24
+ /**
25
+ * Eviction cooldown (the zombie-referral breaker): an evicted dead address
26
+ * is refused by {@link PeerStore.offer} for this long — without it the
27
+ * address comes straight back through a linked peer's card advertising
28
+ * (evict → learn → fail → evict forever; observed grinding .156:3081 down
29
+ * 3400 points). v1 keeps the window FIXED at 24h (dead-letter TTL
30
+ * convention); progressive backoff is a future note, not this slice.
31
+ */
32
+ export const EVICTION_COOLDOWN_MS = 24 * 60 * 60 * 1000;
33
+ /** Ledger bound: the newest 64 evictions survive (LRU by evictedAt). */
34
+ export const EVICTION_LEDGER_CAP = 64;
24
35
  /**
25
36
  * Bounded, quality-scored peer collection with whole-file persistence.
26
37
  * Pure of any network: the caller drives fetch outcomes through
@@ -32,15 +43,19 @@ export class PeerStore {
32
43
  cap;
33
44
  persistDebounceMs;
34
45
  peers = new Map();
46
+ /** Eviction ledger (gh#46): url → when it was evicted. Seeds never enter. */
47
+ evictions = new Map();
48
+ now;
35
49
  /**
36
50
  * @param seeds - the configured seed URLs; always retained (never evicted).
37
51
  * @param path - persistence file (`<dsh-home>/a2a/peers.json`); empty = no persistence.
38
52
  * @param cap - peer bound, seeds included (default {@link PEER_CAP}).
39
53
  */
40
- constructor(seeds, path, cap = PEER_CAP, persistDebounceMs = 1_000) {
54
+ constructor(seeds, path, cap = PEER_CAP, persistDebounceMs = 1_000, now) {
41
55
  this.path = path;
42
56
  this.cap = cap;
43
57
  this.persistDebounceMs = persistDebounceMs;
58
+ this.now = now ?? (() => Date.now());
44
59
  this.restore();
45
60
  for (const seed of seeds) {
46
61
  if (seed === '')
@@ -116,6 +131,16 @@ export class PeerStore {
116
131
  return false;
117
132
  if (this.peers.has(url))
118
133
  return true;
134
+ // gh#46 cooldown: an address we EVICTED is refused while the window
135
+ // holds (a linked peer's card still advertising it must not re-admit a
136
+ // proven-dead address). Past the window it re-enters fresh — the peer
137
+ // fixed within a day heals naturally at INITIAL_SCORE.
138
+ const evictedAt = this.evictions.get(url);
139
+ if (evictedAt !== undefined) {
140
+ if (this.now() - evictedAt < EVICTION_COOLDOWN_MS)
141
+ return false;
142
+ this.evictions.delete(url);
143
+ }
119
144
  if (!this.hasRoom())
120
145
  return false;
121
146
  this.peers.set(url, { url, score: INITIAL_SCORE, seed: false });
@@ -147,12 +172,18 @@ export class PeerStore {
147
172
  const peer = this.peers.get(url);
148
173
  if (peer === undefined)
149
174
  return;
150
- peer.lastContactAt = Date.now();
175
+ peer.lastContactAt = this.now();
151
176
  peer.lastContactOk = false;
152
177
  if (!peer.seed) {
153
178
  peer.score -= FAILURE_PENALTY;
154
- if (peer.score <= EVICTION_FLOOR)
179
+ if (peer.score <= EVICTION_FLOOR) {
155
180
  this.peers.delete(url);
181
+ // gh#46: the eviction lands in the cooldown ledger — offer() refuses
182
+ // the URL while the window holds. Seeds never reach this branch
183
+ // (they are never evicted, so never ledgered: config stays exempt).
184
+ this.evictions.set(url, this.now());
185
+ this.pruneLedger();
186
+ }
156
187
  }
157
188
  this.persist();
158
189
  }
@@ -165,8 +196,11 @@ export class PeerStore {
165
196
  if (peer === undefined)
166
197
  return;
167
198
  peer.score += SUCCESS_REWARD;
168
- peer.lastContactAt = Date.now();
199
+ peer.lastContactAt = this.now();
169
200
  peer.lastContactOk = true;
201
+ // A re-admitted URL that answers has proven itself again — its ledger
202
+ // row (if any survived) is moot.
203
+ this.evictions.delete(url);
170
204
  this.persist();
171
205
  }
172
206
  /**
@@ -247,11 +281,32 @@ export class PeerStore {
247
281
  ...(typeof peer.lastContactOk === 'boolean' ? { lastContactOk: peer.lastContactOk } : {}),
248
282
  });
249
283
  }
284
+ // gh#46: the eviction ledger round-trips (absent = empty, pre-cooldown
285
+ // files stay fully compatible).
286
+ const evictions = snapshot.evictions;
287
+ if (Array.isArray(evictions)) {
288
+ for (const row of evictions) {
289
+ const e = row;
290
+ if (typeof e?.url === 'string' && e.url !== '' && typeof e.evictedAt === 'number' && Number.isFinite(e.evictedAt)) {
291
+ this.evictions.set(e.url, e.evictedAt);
292
+ }
293
+ }
294
+ this.pruneLedger();
295
+ }
250
296
  }
251
297
  catch {
252
298
  // A corrupt store is not fatal: fall back to seeds only.
253
299
  }
254
300
  }
301
+ /** Keep the ledger at its LRU bound: the oldest evictions fall off. */
302
+ pruneLedger() {
303
+ if (this.evictions.size <= EVICTION_LEDGER_CAP)
304
+ return;
305
+ const byAge = [...this.evictions.entries()].sort((a, b) => a[1] - b[1]);
306
+ const excess = this.evictions.size - EVICTION_LEDGER_CAP;
307
+ for (let i = 0; i < excess; i += 1)
308
+ this.evictions.delete(byAge[i][0]);
309
+ }
255
310
  /**
256
311
  * Persist the current peer set (no-op when no path was configured).
257
312
  * Writes are debounced and asynchronous: a sweep that settles dozens of
@@ -287,6 +342,7 @@ export class PeerStore {
287
342
  await mkdir(dirname(this.path), { recursive: true });
288
343
  const snapshot = {
289
344
  peers: [...this.peers.values()].map(peer => ({ ...peer })),
345
+ evictions: [...this.evictions.entries()].map(([url, evictedAt]) => ({ url, evictedAt })),
290
346
  };
291
347
  await writeFile(this.path, JSON.stringify(snapshot), { mode: 0o600 });
292
348
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Recursion guard (#60, first step: the hop budget) per
3
+ * docs/protocol/recursion-guard.md. State rides the payload: the origin
4
+ * mints the envelope with a full budget, every forwarded leg decrements,
5
+ * and a zero-budget arrival is refused with a STRUCTURED budget-exhausted
6
+ * error — silent swallowing is forbidden. The visited set (protocol §4.3)
7
+ * is the SECOND step and is deliberately absent here.
8
+ *
9
+ * Wire form: the X-A2A-Guard header carries a base64 JSON payload
10
+ * { visitToken, hopBudget, deadlineAt? }. An ABSENT header means a
11
+ * non-forwarding origin: the receiver grants the default one-shot budget
12
+ * and records guardMode 'default' (protocol §6 — old peers are
13
+ * constrained, not broken). Parse cost is O(1) for the budget path.
14
+ * @module @nelsonlongxiang/dsh-open-a2a-net/recursion-guard
15
+ */
16
+ export const DEFAULT_HOP_BUDGET = 4;
17
+ /** Wire code for a zero-budget arrival (cluster-C registry, after -32006). */
18
+ export const WIRE_ERROR_BUDGET_EXHAUSTED = -32007;
19
+ /**
20
+ * Ingress check: parse the header, validate the budget, fail closed. A
21
+ * malformed payload falls back to the default budget (a guard that cannot
22
+ * be evaluated must not reject honest first-hop traffic; the budget bound
23
+ * still applies). hopBudget <= 0 on arrival ⇒ budget-exhausted.
24
+ */
25
+ export function checkGuardAtIngress(header) {
26
+ if (header === undefined || header === '') {
27
+ return { kind: 'ok', guard: { visitToken: '', hopBudget: DEFAULT_HOP_BUDGET }, mode: 'default' };
28
+ }
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(Buffer.from(header, 'base64').toString('utf8'));
32
+ }
33
+ catch {
34
+ return { kind: 'ok', guard: { visitToken: '', hopBudget: DEFAULT_HOP_BUDGET }, mode: 'default' };
35
+ }
36
+ const hopBudget = parsed?.hopBudget;
37
+ if (typeof hopBudget !== 'number' || !Number.isInteger(hopBudget)) {
38
+ return { kind: 'ok', guard: { visitToken: '', hopBudget: DEFAULT_HOP_BUDGET }, mode: 'default' };
39
+ }
40
+ if (hopBudget <= 0)
41
+ return { kind: 'budget-exhausted' };
42
+ const visitToken = typeof parsed.visitToken === 'string'
43
+ ? String(parsed.visitToken)
44
+ : '';
45
+ const deadlineRaw = parsed.deadlineAt;
46
+ return {
47
+ kind: 'ok',
48
+ guard: { visitToken, hopBudget, ...(typeof deadlineRaw === 'number' ? { deadlineAt: deadlineRaw } : {}) },
49
+ mode: 'explicit',
50
+ };
51
+ }
52
+ /** Mint the outbound guard for a first-hop dispatch (full budget at origin). */
53
+ export function mintGuard() {
54
+ return {
55
+ visitToken: Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join(''),
56
+ hopBudget: DEFAULT_HOP_BUDGET,
57
+ };
58
+ }
59
+ /** Serialize for the X-A2A-Guard header. */
60
+ export function guardHeader(guard) {
61
+ return Buffer.from(JSON.stringify(guard), 'utf8').toString('base64');
62
+ }
63
+ /**
64
+ * The ingress refusal path (gh#60): budget-exhausted answers the structured
65
+ * error, records the receiptless ledger row, and reports true (the caller
66
+ * returns). The whole response lives here so the ingress seam stays ONE
67
+ * line (the index.ts ratchet pays-as-it-goes: no net line growth).
68
+ */
69
+ export function refuseIfBudgetExhausted(req, res, team, caller, taskId, ledger) {
70
+ const verdict = checkGuardAtIngress(req.headers['x-a2a-guard']);
71
+ if (verdict.kind !== 'budget-exhausted')
72
+ return false;
73
+ ledger.trackReceiptless(taskId, team, caller === '' ? 'unknown' : caller, undefined, 'budget-exhausted at ingress (hopBudget=0)');
74
+ const payload = JSON.stringify({ error: 'budget-exhausted: the hop budget is spent (recursion-guard #60) — this delivery traversed its full forwarding allowance and is refused at ingress', code: WIRE_ERROR_BUDGET_EXHAUSTED, team, task_status: 'TASK_STATE_FAILED' });
75
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
76
+ res.end(payload);
77
+ return true;
78
+ }
package/lib/runtime.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: a2a-network
3
- description: Use when a task needs cross-session or cross-node collaboration over the DSH A2A network — joining and teaming, routing work to remote teams and agents, reconciling receipts and the task ledger, and reading the A2A workbench (/__dsh_a2a_nexus/). Covers admission rules, the receipt contract, and the failure shapes that look like bugs but are contract semantics.
3
+ description: Use when a task needs cross-session or cross-node collaboration over the DSH A2A network — setting it up (install, config, apiKey, peers, canvas links for one host, two peers, or hosts that cannot reach each other), joining and teaming, routing work to remote teams and agents, reconciling receipts and the task ledger, and reading the A2A workbench (/__dsh_a2a_nexus/). Covers admission rules, the receipt contract, and the failure shapes that look like bugs but are contract semantics.
4
4
  ---
5
5
 
6
6
  # DSH A2A Network
@@ -22,6 +22,24 @@ surface on every node.
22
22
  (`<zone>/canvas/<name>`, user-composed multi-member groups).
23
23
  - **Task** — one routed round. Carries a task id, an idempotency fingerprint,
24
24
  and a receipt contract.
25
+ - **CLI identity** — a transient caller outside any session (the `a2a-net`
26
+ command, 0.6.27+): its own Ed25519 key and a handle `<name>/<16 hex>`,
27
+ deposited on ONE home host for up to 4h, with a mailbox instead of a
28
+ wakeable session. See [cli.md](references/cli.md).
29
+
30
+ ## Setting up
31
+
32
+ Start with [setup.md](references/setup.md). It is a checked path for three
33
+ network shapes:
34
+ - **one host**: install, join the sessions, give them a shared team;
35
+ - **two hosts that reach each other**: one shared `apiKey`, `announce` plus
36
+ peers seeded both ways, and a canvas team naming both handles;
37
+ - **hosts that cannot reach each other**: host forwarding is not shipped
38
+ yet (#57), so tunnel or delegate per hop.
39
+
40
+ The owner does the gestures (install, config, join, allowlist, canvas). The
41
+ agent prepares the exact config and verifies each step with `a2a_whoami`,
42
+ `a2a_probe`, `a2a_teams` and one round with a receipt.
25
43
 
26
44
  ## Routing (a2a_route)
27
45
 
@@ -32,10 +50,15 @@ a2a_route { team: "<zone>/<id8 or canvas name>", message, context_id?, async? }
32
50
  - `async: true` delivers without waiting (`delivered:true` + task id); the
33
51
  target routes a receipt — a message starting `[A2A receipt] task <id>` —
34
52
  back to your team.
35
- - Sync (default) waits for the round's final answer. If the 15s transport
36
- budget fires first, the result is **-32005 DELIVERED-UNSETTLED**: the
37
- delivery landed and the peer keeps executing. **Never re-dispatch blindly**
38
- — reconcile via the task id (the receipt or `a2a_teams` coordinates) instead.
53
+ - Sync (default) waits for the round's final answer. A target on this host
54
+ gets up to 180s; past that the tool answers the delivered shape
55
+ (`TASK_STATE_DELIVERED`) and the receipt contract carries the rest. A
56
+ remote peer's delivery rides a 15s transport budget; if it fires first, the
57
+ result is **-32005 DELIVERED-UNSETTLED**: the delivery landed and the peer
58
+ keeps executing. Either way, **never re-dispatch blindly** — reconcile via
59
+ the task id (the receipt, `a2a_status` activity, or `a2a_tasks`) instead.
60
+ - Model tools cannot route to the bare process team (`<zone>` alone, the
61
+ host's own mailbox): target a session handle or a canvas team.
39
62
  - `context_id` from a previous reply continues that conversation.
40
63
 
41
64
  ## Membership rules
@@ -51,10 +74,15 @@ a2a_route { team: "<zone>/<id8 or canvas name>", message, context_id?, async? }
51
74
 
52
75
  ## Reading the state
53
76
 
54
- `__dsh_a2a/state` (per node) carries `sessions` (live/joined), `sessionTeams`,
55
- `remote` (peer session rows with `origin` = node label + LAN IP and `via` =
56
- the peer URL), `activity` (routing ring), `tasks` (the ledger: pending /
57
- stale / archived). The A2A workbench (`/__dsh_a2a_nexus/`, merged responsive
77
+ `__dsh_a2a/state` (per node, key-gated like every control route) carries
78
+ `sessions` (live/joined, with their teams), `canvas` (canvas teams; remote
79
+ members show `remote: true`), `remote` (peer session rows with `origin` =
80
+ node label + LAN IP and `via` = the peer URL), `activity` (routing ring),
81
+ and the ledger: `tasks` (pending, each with `settleable`),
82
+ `tasksStaleCount`, `tasksDead`, and `archived` (newest 64 settled rows, each
83
+ with `outcome`, `endedBy` and `late`) plus `archivedCount` /
84
+ `archivedSplit`. `sessionTeams` is a card field, not a state field. The A2A
85
+ workbench (`/__dsh_a2a_nexus/`, merged responsive
58
86
  shell since 0.6.2 — the 2D planning board is the stage, directory / ledger /
59
87
  activity / plan ride a collapsible rail, narrow screens get a drawer)
60
88
  renders the same data for humans.
@@ -66,12 +94,21 @@ renders the same data for humans.
66
94
  - `-32000` with "caller_session is required" — the delivery had no caller
67
95
  identity; the protocol contract requires it.
68
96
  - `-32005` — sync wait budget fired; delivery landed, reconcile the task id.
69
- - "No live DSH session node accepts team X" — the team resolved but no
70
- session node accepted the round; check whether the target session is live.
97
+ - "No live DSH session node accepts team X" — no session node on the host
98
+ that received the delivery accepted the round. Either the target session is
99
+ not live/joined there, or X lives on ANOTHER host and the caller delivered
100
+ straight to this host's `/a2a/direct` (the `a2a-net` CLI does this):
101
+ direct delivery never relays to peers — `a2a_route` from a session does.
102
+ - `[PATTERN_NO_MINT]` / `[ALLOWLIST_DENY]` on `a2a_team_join` — only an EXACT
103
+ allowlisted name joins; adding that name is the owner's act of creating
104
+ the team. `[CANVAS_TEAM]` — canvas membership is assigned in the workbench,
105
+ never by the roster tool.
71
106
 
72
107
  ## Details
73
108
 
109
+ - [setup.md](references/setup.md) — setup and checks: one host, two peers, beyond direct peers
74
110
  - [routing.md](references/routing.md) — receipts, idempotency, ledger anatomy
75
111
  - [discovery.md](references/discovery.md) — cards, peer boundary model, invisibility
76
112
  - [membership.md](references/membership.md) — join/leave, allowlist, canvas teams
77
113
  - [pitfalls.md](references/pitfalls.md) — operational pitfalls observed in production
114
+ - [cli.md](references/cli.md) — the `a2a-net` CLI: deposit, mailbox, inbox hooks, limits
@@ -0,0 +1,109 @@
1
+ # The a2a-net CLI
2
+
3
+ A transient caller that is not a DSH session — a script, a terminal, another
4
+ agent harness — joins the network through the `a2a-net` command (bin of
5
+ `@nelsonlongxiang/dsh-open-a2a-net`, 0.6.27+). It has its own key, deposits
6
+ itself on ONE home host, and receives through a mailbox; nothing wakes.
7
+ `a2a-net help` prints the command map, `a2a-net tools` the alignment with the
8
+ ten model tools.
9
+
10
+ ## Identity and home host
11
+
12
+ - `a2a-net init <name> [--uuid 16hex] [--home URL] [--force]` creates an
13
+ Ed25519 identity: handle `<name>/<16 hex>`, stored in
14
+ `$A2A_CLI_HOME/cli.json` (default `~/.a2a/cli.json`, mode 0600). An
15
+ existing identity is never replaced without `--force` (the old handle's
16
+ deposit stops verifying).
17
+ - Home host: `--home` > `$A2A_HOME` > the home saved by `init --home` >
18
+ `http://127.0.0.1:3080`.
19
+ - Keyed hosts: `--key` > `$A2A_KEY` (prefer the variable; a flag value stays
20
+ in shell history). A host behind dsh-pocket also wants its session token as
21
+ the `dsh_pocket_token` cookie: set `$A2A_POCKET_TOKEN` (or `$A2A_TOKEN`).
22
+
23
+ ## Deposit (户口)
24
+
25
+ - `a2a-net publish` deposits the signed CLI card on the home host
26
+ (`/__dsh_a2a/cli-deposit`, a control route: key-gated like every write).
27
+ The deposit lives at most 4h. An expired deposit is no longer routable and
28
+ its card is no longer served, but it stays in the host's directory as an
29
+ OFFLINE row (gh#47; the newest 16 are kept) so canvas refs to it never
30
+ dangle silently. Publish again (same handle, same key) to come back.
31
+ - While deposited, the host serves the card at
32
+ `<home>/a2a/cli/<handle>/.well-known/agent-card.json` and lists the handle
33
+ in its directory as "cli — deposited identity (no wake; mailbox)".
34
+ - `whoami` / `status` show ONLINE with the hours left, OFFLINE, or UNKNOWN
35
+ when the deposit face cannot be read (e.g. a host build without it).
36
+ - A host build that predates gh#45 has no deposit face: publish gets HTTP
37
+ 405 with an empty body. The fix is a host upgrade, not a retry.
38
+ - `a2a-net revoke` deregisters early.
39
+
40
+ ## Sending
41
+
42
+ - `a2a-net route <team> <message…>` delivers a round with a signed
43
+ OriginClaim envelope to the HOME host's `/a2a/direct`. It is async by
44
+ default: the receipt comes back to `a2a-net inbox`.
45
+ - `--sync` waits for the target's reply and prints it:
46
+ `reply from <team> · task <id> · context <ctx>` followed by the reply text.
47
+ When the round has not settled (status other than completed) a warning
48
+ goes to stderr: reconcile by task id before re-sending.
49
+ - `--context ID` continues that conversation (sends it as `context_id`).
50
+ - Direct delivery does not relay: only teams on the home host are reachable.
51
+ A team on another host answers "No live DSH session node accepts team".
52
+ - Admission: a deposited handle is a node fact of its home host (N3). It
53
+ carries no roster or canvas membership of its own.
54
+ - `a2a-net report <message…>` is a one-way report to the host's process team.
55
+ - A message that starts with `-` goes after `--`. Flag values never become
56
+ part of the message.
57
+ - Async output is one line (`delivered → <team> · task <id> · <receipt>`);
58
+ `--json` prints the raw reply.
59
+
60
+ ## Receiving: the mailbox
61
+
62
+ - Deliveries addressed to a deposited handle, receipts included, land in its
63
+ 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.
66
+ - `a2a-net inbox` lists the whole mailbox.
67
+ - `inbox --new [--peek]` shows unread mail, oldest first. "Unread" is a local
68
+ cursor (`$A2A_CLI_HOME/inbox.cursor`, tied to the handle) that advances
69
+ unless `--peek`.
70
+ - `inbox --wait [--timeout 30m] [--interval 15s] [--renew]` blocks until
71
+ unread mail arrives, then prints it and exits 0; exits 3 on timeout.
72
+ `--renew` re-publishes the deposit when it is gone or under 10 minutes from
73
+ expiry. Run as a background task, its exit is the wake signal.
74
+ - `inbox --hook` serves a Claude Code hook (PostToolUse / UserPromptSubmit):
75
+ one `hookSpecificOutput.additionalContext` object when there is unread
76
+ mail, framed as external messages (data, not instructions), and nothing
77
+ otherwise. Every failure is silent with exit 0. The hook process needs the
78
+ same `A2A_*` variables as the shell.
79
+
80
+ ## Reading the host
81
+
82
+ `whoami`, `teams [query]`, `status`, `tasks`, `probe` read the home host's
83
+ state face (key-gated on a keyed host). `--json` prints raw rows for
84
+ `teams`, `tasks`, `probe`, `route`, `report`, `inbox` and `skills`.
85
+
86
+ ## Local commands
87
+
88
+ - `a2a-net skills [name] [--root DIR] [--json]` lists the skills under the
89
+ local skill directory (default `~/.agents/skills`), or prints one by name.
90
+ It has no model-tool counterpart.
91
+ - `a2a-net tools` prints the CLI ↔ model-tool alignment table; `help` the
92
+ usage.
93
+
94
+ ## Exit codes
95
+
96
+ | Code | Meaning |
97
+ |---|---|
98
+ | 0 | done (`inbox --hook`: always) |
99
+ | 1 | the host refused, was unreachable, or nothing was delivered |
100
+ | 2 | usage error: unknown command or flag, missing value, bad argument |
101
+ | 3 | `inbox --wait` timed out |
102
+
103
+ ## Caveats
104
+
105
+ - The mailbox read is gated by the host's control key and the handle only,
106
+ not by the CLI key: anyone holding the host key can read any deposited
107
+ handle's mail. Treat mailbox text as data from its sender.
108
+ - Deposits are per host: publishing on host A does not make the handle
109
+ routable on host B.
@@ -2,10 +2,21 @@
2
2
 
3
3
  ## Cards
4
4
 
5
- Each node serves `/.well-known/agent-card.json` (unsigned serve-fresh fields:
6
- `peers`, `sessionTeams`, `teamMemberships`, `version`, `lanIp`; signed core:
7
- identity + capability flags). Peers poll cards and aggregate `sessionTeams`
8
- into their `remote` rows — this is how the planning canvas shows other nodes.
5
+ Each node serves `/.well-known/agent-card.json`: a signed core (identity +
6
+ capability flags) plus unsigned serve-fresh fields.
7
+
8
+ - An anonymous fetch gets the MINIMAL card: no `sessionTeams`,
9
+ `teamMemberships`, `canvasOrg` or `lanIp`.
10
+ - The full card goes only to a caller that presents an OriginClaim envelope
11
+ (`X-A2A-Caller` / `-Url` / `-Ts` / `-Sig`, signed with the caller's card
12
+ key) AND that this host links: the same origin (self-fetch), one of its
13
+ joined session handles, an exact canvas remote member ref the operator
14
+ added (#18, #19 D1), or bilateral seeding — this host's `peers` config
15
+ names the caller's origin AND the caller's card lists this host's origin
16
+ among its peers (both operators made the gesture). Proving a key is not a
17
+ link.
18
+ - Peers poll cards and aggregate the `sessionTeams` they are entitled to into
19
+ their `remote` rows — this is how the planning canvas shows other nodes.
9
20
 
10
21
  ## Visibility follows membership
11
22
 
@@ -12,9 +12,14 @@
12
12
  ## Teaming
13
13
 
14
14
  - `a2a_team_join { team }` — declare a membership in `a2a/teams.json`
15
- (roster store, restored at construction). Subject to the owner's
16
- `teamJoinAllowlist` (empty list denies every join; entries are exact names
17
- or trailing-`*` prefixes).
15
+ (roster store, restored at construction). Gated by the owner's
16
+ `teamJoinAllowlist`: an empty list denies every join, and only an EXACT
17
+ name joins. A wildcard or prefix entry matches nothing at join time
18
+ (`[PATTERN_NO_MINT]`, gh#11/N7) — adding the exact name IS the act of
19
+ creating the team, which is a human act. The owner edits the list in the
20
+ workbench owner panel (header ⚙, gh#34) or in the profile patch.
21
+ - A canvas team (`/canvas/` in the name) cannot be joined through the roster
22
+ tool (`[CANVAS_TEAM]`): canvas membership is assigned by hand.
18
23
  - `a2a_team_leave { team }` — retract one declaration.
19
24
  - Canvas teams (`<zone>/canvas/<name>`) are user-composed groups created in
20
25
  the planning view (建队) — any joined node can be a member; a node may sit
@@ -24,9 +29,18 @@
24
29
 
25
30
  - Outbound: a session with no declared team has no network
26
31
  (`teamScopeRouting`, default true; explicit false restores the open seam).
27
- - Inbound: `/a2a/direct` requires a SHARED team between caller and target —
28
- the caller's declared teams (roster store) must intersect the target's
29
- routable set. Refusal text names the missing membership.
32
+ - Inbound, same-zone caller (`<this zone>/<id8>`): `/a2a/direct` requires a
33
+ SHARED team, judged at the TARGET level (F5, gh#26) — the caller's declared
34
+ set (roster ∪ canvas memberships) must intersect the target session's own
35
+ link set (its roster ∪ its canvas teams); a host-level link alone does not
36
+ open every session team. The process face (target = the bare zone) keeps
37
+ the host-level exemption. Refusal text names declared vs target links.
38
+ - Inbound, foreign-zone caller: admitted only through a canvas remote member
39
+ ref this host's operator added by hand (N3/N4); the caller's own card
40
+ claims are self-description, not links.
41
+ - Inbound, free-form caller label: only this host's own node facts — a joined
42
+ session id, a live node label, or a deposited CLI handle (see
43
+ [cli.md](cli.md)).
30
44
  - Anonymous deliveries (no `caller_session`) are refused outright.
31
45
 
32
46
  ## Information-plane scoping (S5, default on)
@@ -43,6 +57,15 @@
43
57
  - A host whose nodes declare no teams keeps host-internal facts only;
44
58
  explicit `teamScopeInfo: false` restores the open plane.
45
59
 
60
+ ## Exposure tiers (#19)
61
+
62
+ - `a2a_exposure` sets a session's own tier: `linked` (the default floor) or
63
+ `fleet` (a voluntary opt-up). There is no tier below linked in v1, and a
64
+ group's visibility derives from its strictest member (no group knob).
65
+ - On the card face, member listings go to LINKED callers only (see
66
+ [discovery.md](discovery.md)); as of 0.6.27 the fleet tier is recorded but
67
+ does not widen the card face.
68
+
46
69
  ## Leaving
47
70
 
48
71
  - `a2a_team_leave` per team; the network-level leave (退网) removes the
@@ -14,10 +14,18 @@
14
14
  - **Do not blindly re-dispatch after a sync timeout.** -32005 means the
15
15
  delivery landed; the peer may still be executing. Reconcile the task id
16
16
  first (receipt or query), or you will double-execute.
17
- - **Unroutable caller labels** (e.g. CLI-initiated `cli-manager/...`) cannot
18
- receive receipts — use the initiating session's own node team, or expect
19
- the receipt to be dropped. Deliveries still land.
20
- - A sync route's 15s transport budget is shorter than most model turns. For
17
+ - **Unroutable caller labels** (a free-form `caller_session` such as
18
+ `cli-manager/...`) cannot receive receipts — use the initiating session's
19
+ own node team, or expect the receipt to be dropped. Deliveries still land.
20
+ The `a2a-net` CLI (0.6.27+) is the supported exception: a DEPOSITED handle
21
+ is routable on its home host and receives into its mailbox
22
+ ([cli.md](cli.md)).
23
+ - **Direct delivery does not relay.** `POST /a2a/direct` only serves teams on
24
+ the host that receives it; a team on another host answers "No live DSH
25
+ session node accepts team". `a2a_route` resolves peers and forwards;
26
+ direct callers (the CLI included) must target their home host's teams.
27
+ - A sync route to a remote peer rides a 15s transport budget (a target on
28
+ this host gets 180s) — both are shorter than many model turns. For
21
29
  anything that runs long, prefer `async: true` and the receipt contract.
22
30
 
23
31
  ## Names and prefixes
@@ -17,14 +17,24 @@ polling `a2a_teams` cannot see another host's ledger.
17
17
 
18
18
  ## The ledger
19
19
 
20
- `__dsh_a2a/state` → `tasks`:
21
-
22
- - **pending** — routed, receipt still owed. An owed task with a delivered
23
- marker is waiting on the target's turn, not lost.
24
- - **stale/dead** — past the stale TTL, auto-dead-lettered. A dead task that
25
- actually completed at the peer is a reconciliation candidate, not a rerun.
26
- - **archived** — settled with a correlated receipt. The archive is bounded
27
- (oldest-first eviction).
20
+ `__dsh_a2a/state` splits the ledger into separate fields:
21
+
22
+ - **`tasks`** — pending rows: routed, receipt still owed. An owed task with a
23
+ delivered marker is waiting on the target's turn, not lost. Each row
24
+ carries `settleable` (young, expected and answerable).
25
+ - **`tasksStaleCount`** — how many pending rows are past the one-hour
26
+ settleable window or expect no receipt: conversational tail, not debt.
27
+ - **`tasksDead`** — past the stale TTL (`taskStaleTtlMs`, 24h), auto-dead-
28
+ lettered, with `reason` and `direction` when known. A dead task that
29
+ actually completed at the peer is a reconciliation candidate, not a rerun;
30
+ a late receipt still settles it.
31
+ - **`archived`** — rows that left the ledger, newest 64, newest first. Each
32
+ has `outcome` and `endedBy`: `receipt` (a correlated receipt), `echo`,
33
+ `abandoned` or `cancelled` (the caller ended it through
34
+ `/__dsh_a2a/tasks/abandon` or `/tasks/cancel`). `late: true` marks a
35
+ receipt that arrived after the caller ended the row; `direction` appears
36
+ when known. `archivedCount` and `archivedSplit` (count per outcome) cover
37
+ the whole archive.
28
38
 
29
39
  ## Idempotency
30
40