@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.
- package/README.md +85 -14
- package/README_zh.md +81 -7
- package/assets/nexusDist/assets/index-BqepxJ7E.css +1 -0
- package/assets/nexusDist/assets/index-CTWH6QRh.js +9 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +624 -612
- package/cordis.patch.yml +21 -21
- package/lib/canvas-store.js +5 -1
- package/lib/cli-deposit.js +61 -12
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +744 -0
- package/lib/index.js +222 -756
- package/lib/peer-store.js +60 -4
- package/lib/recursion-guard.js +78 -0
- package/lib/runtime.js +1 -0
- package/lib/skill/SKILL.md +48 -11
- package/lib/skill/references/cli.md +109 -0
- package/lib/skill/references/discovery.md +15 -4
- package/lib/skill/references/membership.md +29 -6
- package/lib/skill/references/pitfalls.md +12 -4
- package/lib/skill/references/routing.md +18 -8
- package/lib/skill/references/setup.md +152 -0
- package/lib/task-ledger.js +6 -0
- package/lib/types/canvas-store.d.ts +5 -1
- package/lib/types/cli-deposit.d.ts +30 -1
- package/lib/types/faces/admin.d.ts +132 -0
- package/lib/types/index.d.ts +11 -3
- package/lib/types/peer-store.d.ts +24 -1
- package/lib/types/recursion-guard.d.ts +56 -0
- package/lib/types/runtime.d.ts +34 -0
- package/lib/types/task-ledger.d.ts +6 -0
- package/nexus-stage/package-lock.json +2262 -2262
- package/nexus-stage/src/canvas-ops.ts +175 -175
- package/nexus-stage/src/canvas-wire.ts +156 -156
- package/nexus-stage/src/edges.ts +72 -72
- package/nexus-stage/src/federation-overlay.ts +103 -0
- package/nexus-stage/src/federation.ts +240 -240
- package/nexus-stage/src/layout-doc.ts +143 -143
- package/nexus-stage/src/layout-wire.ts +158 -158
- package/nexus-stage/src/planning-card-text.ts +73 -0
- package/nexus-stage/src/planning-fit.ts +45 -0
- package/nexus-stage/src/planning-legend.ts +38 -0
- package/nexus-stage/src/planning-overlap.ts +134 -0
- package/nexus-stage/src/planning-relayout.ts +121 -0
- package/nexus-stage/src/planning-search.ts +168 -0
- package/nexus-stage/src/planning-view.ts +94 -119
- package/nexus-stage/src/planning.css +39 -8
- package/nexus-stage/src/seat.ts +35 -35
- package/nexus-stage/src/stage-name.ts +13 -13
- package/nexus-stage/src/tokens.ts +31 -31
- package/nexus-stage/src/viewport.ts +24 -1
- package/nexus-stage/src/wb-chat.ts +20 -6
- package/nexus-stage/src/wb-ledger.ts +20 -7
- package/nexus-stage/src/wb-names.ts +43 -0
- package/nexus-stage/src/workbench.css +8 -2
- package/nexus-stage/src/workbench.ts +55 -17
- package/nexus-stage/src/world.ts +5 -1
- package/package.json +216 -214
- package/assets/nexusDist/assets/index-DszoLz8i.js +0 -5
- package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
- 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 =
|
|
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 =
|
|
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 {};
|
package/lib/skill/SKILL.md
CHANGED
|
@@ -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.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
55
|
-
`
|
|
56
|
-
|
|
57
|
-
|
|
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" —
|
|
70
|
-
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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).
|
|
16
|
-
`teamJoinAllowlist
|
|
17
|
-
or
|
|
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
|
|
28
|
-
the
|
|
29
|
-
|
|
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** (
|
|
18
|
-
receive receipts — use the initiating session's
|
|
19
|
-
the receipt to be dropped. Deliveries still land.
|
|
20
|
-
-
|
|
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`
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
marker is waiting on the target's turn, not lost.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|