@nelsonlongxiang/dsh-open-a2a-net 0.6.28 → 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 (55) 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 -624
  7. package/cordis.patch.yml +21 -21
  8. package/lib/client.js +64 -64
  9. package/lib/client.js.map +1 -1
  10. package/lib/faces/admin.js +744 -0
  11. package/lib/index.js +139 -737
  12. package/lib/recursion-guard.js +78 -0
  13. package/lib/runtime.js +1 -0
  14. package/lib/skill/SKILL.md +34 -9
  15. package/lib/skill/references/cli.md +24 -7
  16. package/lib/skill/references/discovery.md +5 -2
  17. package/lib/skill/references/pitfalls.md +2 -1
  18. package/lib/skill/references/routing.md +18 -8
  19. package/lib/skill/references/setup.md +152 -0
  20. package/lib/task-ledger.js +6 -0
  21. package/lib/types/faces/admin.d.ts +132 -0
  22. package/lib/types/index.d.ts +11 -3
  23. package/lib/types/recursion-guard.d.ts +56 -0
  24. package/lib/types/runtime.d.ts +34 -0
  25. package/lib/types/task-ledger.d.ts +6 -0
  26. package/nexus-stage/package-lock.json +2262 -2262
  27. package/nexus-stage/src/canvas-ops.ts +175 -175
  28. package/nexus-stage/src/canvas-wire.ts +156 -156
  29. package/nexus-stage/src/edges.ts +72 -72
  30. package/nexus-stage/src/federation-overlay.ts +103 -103
  31. package/nexus-stage/src/federation.ts +240 -240
  32. package/nexus-stage/src/layout-doc.ts +143 -143
  33. package/nexus-stage/src/layout-wire.ts +158 -158
  34. package/nexus-stage/src/planning-card-text.ts +73 -0
  35. package/nexus-stage/src/planning-fit.ts +45 -0
  36. package/nexus-stage/src/planning-legend.ts +38 -0
  37. package/nexus-stage/src/planning-overlap.ts +134 -0
  38. package/nexus-stage/src/planning-relayout.ts +121 -0
  39. package/nexus-stage/src/planning-search.ts +168 -0
  40. package/nexus-stage/src/planning-view.ts +63 -88
  41. package/nexus-stage/src/planning.css +39 -8
  42. package/nexus-stage/src/seat.ts +35 -35
  43. package/nexus-stage/src/stage-name.ts +13 -13
  44. package/nexus-stage/src/tokens.ts +31 -31
  45. package/nexus-stage/src/viewport.ts +24 -1
  46. package/nexus-stage/src/wb-chat.ts +20 -6
  47. package/nexus-stage/src/wb-ledger.ts +20 -7
  48. package/nexus-stage/src/wb-names.ts +43 -0
  49. package/nexus-stage/src/workbench.css +8 -2
  50. package/nexus-stage/src/workbench.ts +55 -17
  51. package/nexus-stage/src/world.ts +5 -1
  52. package/package.json +216 -214
  53. package/assets/nexusDist/assets/index-1niOBYxp.js +0 -5
  54. package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
  55. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
@@ -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
@@ -27,6 +27,20 @@ surface on every node.
27
27
  deposited on ONE home host for up to 4h, with a mailbox instead of a
28
28
  wakeable session. See [cli.md](references/cli.md).
29
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.
43
+
30
44
  ## Routing (a2a_route)
31
45
 
32
46
  ```text
@@ -36,10 +50,15 @@ a2a_route { team: "<zone>/<id8 or canvas name>", message, context_id?, async? }
36
50
  - `async: true` delivers without waiting (`delivered:true` + task id); the
37
51
  target routes a receipt — a message starting `[A2A receipt] task <id>` —
38
52
  back to your team.
39
- - Sync (default) waits for the round's final answer. If the 15s transport
40
- budget fires first, the result is **-32005 DELIVERED-UNSETTLED**: the
41
- delivery landed and the peer keeps executing. **Never re-dispatch blindly**
42
- — 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.
43
62
  - `context_id` from a previous reply continues that conversation.
44
63
 
45
64
  ## Membership rules
@@ -55,10 +74,15 @@ a2a_route { team: "<zone>/<id8 or canvas name>", message, context_id?, async? }
55
74
 
56
75
  ## Reading the state
57
76
 
58
- `__dsh_a2a/state` (per node) carries `sessions` (live/joined), `sessionTeams`,
59
- `remote` (peer session rows with `origin` = node label + LAN IP and `via` =
60
- the peer URL), `activity` (routing ring), `tasks` (the ledger: pending /
61
- 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
62
86
  shell since 0.6.2 — the 2D planning board is the stage, directory / ledger /
63
87
  activity / plan ride a collapsible rail, narrow screens get a drawer)
64
88
  renders the same data for humans.
@@ -82,6 +106,7 @@ renders the same data for humans.
82
106
 
83
107
  ## Details
84
108
 
109
+ - [setup.md](references/setup.md) — setup and checks: one host, two peers, beyond direct peers
85
110
  - [routing.md](references/routing.md) — receipts, idempotency, ledger anatomy
86
111
  - [discovery.md](references/discovery.md) — cards, peer boundary model, invisibility
87
112
  - [membership.md](references/membership.md) — join/leave, allowlist, canvas teams
@@ -24,7 +24,10 @@ ten model tools.
24
24
 
25
25
  - `a2a-net publish` deposits the signed CLI card on the home host
26
26
  (`/__dsh_a2a/cli-deposit`, a control route: key-gated like every write).
27
- The deposit lives at most 4h, then deregisters; publish again to return.
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.
28
31
  - While deposited, the host serves the card at
29
32
  `<home>/a2a/cli/<handle>/.well-known/agent-card.json` and lists the handle
30
33
  in its directory as "cli — deposited identity (no wake; mailbox)".
@@ -36,8 +39,14 @@ ten model tools.
36
39
 
37
40
  ## Sending
38
41
 
39
- - `a2a-net route <team> <message…>` delivers an async round with a signed
40
- OriginClaim envelope to the HOME host's `/a2a/direct`.
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`).
41
50
  - Direct delivery does not relay: only teams on the home host are reachable.
42
51
  A team on another host answers "No live DSH session node accepts team".
43
52
  - Admission: a deposited handle is a node fact of its home host (N3). It
@@ -45,8 +54,8 @@ ten model tools.
45
54
  - `a2a-net report <message…>` is a one-way report to the host's process team.
46
55
  - A message that starts with `-` goes after `--`. Flag values never become
47
56
  part of the message.
48
- - Output is one line (`delivered → <team> · task <id> · <receipt>`); `--json`
49
- prints the raw reply.
57
+ - Async output is one line (`delivered → <team> · task <id> · <receipt>`);
58
+ `--json` prints the raw reply.
50
59
 
51
60
  ## Receiving: the mailbox
52
61
 
@@ -71,8 +80,16 @@ ten model tools.
71
80
  ## Reading the host
72
81
 
73
82
  `whoami`, `teams [query]`, `status`, `tasks`, `probe` read the home host's
74
- state face (key-gated on a keyed host). `teams`, `tasks` and `probe` take
75
- `--json`.
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.
76
93
 
77
94
  ## Exit codes
78
95
 
@@ -10,8 +10,11 @@ capability flags) plus unsigned serve-fresh fields.
10
10
  - The full card goes only to a caller that presents an OriginClaim envelope
11
11
  (`X-A2A-Caller` / `-Url` / `-Ts` / `-Sig`, signed with the caller's card
12
12
  key) AND that this host links: the same origin (self-fetch), one of its
13
- joined session handles, or an exact canvas remote member ref the operator
14
- added (#18, #19 D1). Proving a key is not a link.
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.
15
18
  - Peers poll cards and aggregate the `sessionTeams` they are entitled to into
16
19
  their `remote` rows — this is how the planning canvas shows other nodes.
17
20
 
@@ -24,7 +24,8 @@
24
24
  the host that receives it; a team on another host answers "No live DSH
25
25
  session node accepts team". `a2a_route` resolves peers and forwards;
26
26
  direct callers (the CLI included) must target their home host's teams.
27
- - A sync route's 15s transport budget is shorter than most model turns. For
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
28
29
  anything that runs long, prefer `async: true` and the receipt contract.
29
30
 
30
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
 
@@ -0,0 +1,152 @@
1
+ # Setup: one host, two peers, and beyond
2
+
3
+ A step-by-step path from a fresh install to a working round on three shapes
4
+ of network. Each step says WHO acts (owner or agent) and how to CHECK it
5
+ before moving on: most "it doesn't route" reports are a skipped check.
6
+
7
+ **Owner acts vs agent acts.** Installing the plugin, writing config,
8
+ restarting, joining a session to the network, adding exact names to
9
+ `teamJoinAllowlist`, and building canvas teams are human acts (the network
10
+ is human-established, gh#7 N1 / gh#11 N7). An agent prepares the exact
11
+ config, asks the owner to apply it, then verifies with the model tools. Never
12
+ drive the loopback control routes from a shell to do the owner's part:
13
+ presenting the operator's gesture on the operator's behalf is exactly the
14
+ #9 hole.
15
+
16
+ ## 0. Preflight
17
+
18
+ | Check | How | Expect |
19
+ |---|---|---|
20
+ | Plugin present | `a2a_whoami` | `ok: true`. `face: session` inside a session, `host` otherwise |
21
+ | Version | `/__dsh_a2a/state` `version`, or the workbench header badge | the same version on every host you will link (0.6.28+) |
22
+ | Clock | `date` on each host | NTP-synced: card signatures carry `expiresAt`, and the OriginClaim envelope allows ±5 min |
23
+
24
+ ## 1. One host: sessions talking to each other
25
+
26
+ **Owner:**
27
+ 1. Install, then restart `dsh web`:
28
+ `dsh plugin --profile web add @nelsonlongxiang/dsh-open-a2a-net`
29
+ 2. Leave the config at its defaults. `sessionNodes: true` exposes main
30
+ sessions as joinable nodes; no `apiKey` is needed while the listener is
31
+ bound to loopback.
32
+ 3. Join each session that should collaborate: sidebar → A2A 网络 → toggle.
33
+ An unjoined session is invisible and refused (see
34
+ [membership.md](membership.md)).
35
+ 4. Give the sessions a shared team (`teamScopeRouting` is on by default, so a
36
+ teamless session has no network). Pick ONE of:
37
+ - **Canvas team (recommended):** workbench `/__dsh_a2a_nexus/` → select
38
+ the session cards → 建队. Canvas membership is assigned by hand.
39
+ - **Roster team:** add the EXACT name (for example `dsh/review`) to
40
+ `teamJoinAllowlist` in the workbench owner panel (header ⚙). Each
41
+ session then declares it with `a2a_team_join { team: "dsh/review" }`.
42
+ A `*`/prefix entry authorizes nothing (`[PATTERN_NO_MINT]`).
43
+
44
+ **Agent, verify:**
45
+ - `a2a_whoami`: `node.joined: true`, the handle `<zone>/<id8>`, and the
46
+ team under `teams.roster` or `teams.canvas`.
47
+ - `a2a_teams`: the other session's handle is listed.
48
+ - One sync round:
49
+ `a2a_route { team: "<other handle>", message: "ping: reply 'pong'" }`.
50
+ A local target gets up to 180 s. Continue the conversation with the
51
+ returned `context_id`.
52
+
53
+ ## 2. Two hosts that can reach each other (peers)
54
+
55
+ Do section 1 on both hosts first. Then the **owner**, on BOTH hosts:
56
+
57
+ 1. **Reachability, both ways.** Each host must open the other's web
58
+ listener (LAN, VPN or tunnel). There is no built-in NAT traversal.
59
+ 2. **One shared `apiKey`.** Bind beyond loopback only with a key. The
60
+ delivery face `/a2a/direct` and the log face require `X-API-Key` when a
61
+ key is set, and a host sends ITS OWN `apiKey` on every peer request. So
62
+ keyed hosts that talk to each other must carry the SAME key (or both
63
+ none, loopback-only). A mismatch shows up as HTTP 401
64
+ `X-API-Key required on the delivery face`.
65
+ 3. **Announce and seed each other.** Seeding BOTH ways is what makes the
66
+ hosts linked: each side then reads the other's full card. One-sided
67
+ seeding does not link.
68
+
69
+ ```yaml
70
+ - id: a2a
71
+ name: '@nelsonlongxiang/dsh-open-a2a-net'
72
+ config:
73
+ announce: true
74
+ apiKey: '<same secret on every linked host>'
75
+ peers: ['http://<other-host>:<port>'] # the other side lists this host
76
+ ```
77
+
78
+ Restart. `announce` toggled from the owner panel applies on the next
79
+ restart.
80
+ 4. **Link the sessions across hosts with a canvas team.** Hosts default to
81
+ the same zone name (`team: 'dsh'`), so a remote caller `dsh/<id8>`
82
+ resolves to no local session. A roster declaration cannot admit it,
83
+ because the roster holds this host's own sessions only. The record that
84
+ admits it is a **canvas team naming both exact handles**. Build it in the
85
+ workbench (建队 with the local session, then add the remote handle):
86
+ - on the host that receives rounds, or
87
+ - on either host, once they are linked (step 3).
88
+
89
+ The other side then reads that board's `canvasOrg` from the peer's full
90
+ card it has cached (gh#71, recognized both ways). `a2a_probe` warms that
91
+ cache. Receipts flowing back are admitted the same way.
92
+
93
+ Admission is target-scoped (F5): the remote member reaches only the
94
+ sessions inside that canvas team. A linked peer can also edit your canvas
95
+ through `/__dsh_a2a/fleet-canvas-op`; your host's link gate decides.
96
+
97
+ **Agent, verify on each host:**
98
+ - `a2a_probe`: the peer is reachable. A miss is classified as `unreachable`
99
+ (network, port, tunnel) or `rejected` (key, signature, clock).
100
+ - State face `remote` rows: the peer's sessions appear, with `via` set to
101
+ the peer URL. `a2a_teams` shows peer rows only for teams in this host's
102
+ joined scope (S5, `teamScopeInfo`), so an empty list is not proof that the
103
+ link failed.
104
+ - One async round with a receipt:
105
+ `a2a_route { team: "<remote handle>", message: "...", async: true }`,
106
+ then check `a2a_tasks` until the row lands in `archived` with
107
+ `endedBy: receipt`. A peer delivery rides a 15 s transport budget:
108
+ `-32005` means it was delivered and is still running. Reconcile; never
109
+ resend.
110
+
111
+ ## 3. Beyond direct peers (A cannot reach C)
112
+
113
+ Host-to-host forwarding is **not shipped yet**. Today a delivery reaches only
114
+ the host it is sent to:
115
+ - `/a2a/direct` never relays.
116
+ - A session's `a2a_route` dials the host that publishes the team.
117
+ - #57 Plan A (host forwarding) is designed. Its prerequisite #60 hop budget
118
+ has landed at ingress.
119
+
120
+ Until forwarding ships, pick one:
121
+
122
+ 1. **Make it a peer pair.** Open a tunnel or port-forward so A reaches C,
123
+ then do section 2 for A↔C. This is the simplest and keeps receipts
124
+ end-to-end.
125
+ 2. **Delegate through B.** Route the task to a session on B, the host both
126
+ sides reach, and have that session route on to C and report back. Each
127
+ hop is its own task with its own receipt: B's ledger owns the B→C leg.
128
+ Keep messages self-contained and use `async: true` per hop.
129
+ 3. **Names across zones.** `delegates` publishes signed name records, so a
130
+ name can resolve through other zones (≤5 hops, loops fail closed). That
131
+ is name resolution, not message forwarding: the final delivery still
132
+ goes straight to the resolved host, so that host must be reachable.
133
+
134
+ ## 4. Callers outside a session
135
+
136
+ Scripts and terminals use the `a2a-net` CLI: deposit on ONE home host, read
137
+ a mailbox, no wake. The same rule applies: the CLI reaches only teams on its
138
+ home host. See [cli.md](cli.md).
139
+
140
+ ## 5. Symptom → step
141
+
142
+ | Symptom | Go back to |
143
+ |---|---|
144
+ | `declares no team membership` / `shares no team` | §1.4 (same host) or §2.4 (cross-host canvas ref on the target) |
145
+ | `caller_session is required` | the caller is not a joined session (§1.3) or an external client omitted it |
146
+ | HTTP 401 `X-API-Key required` | §2.2: keys differ between the hosts |
147
+ | `a2a_probe` → `rejected` | §2.2 key, or §0 clock; also card signature/expiry |
148
+ | `a2a_probe` → `unreachable` | §2.1 reachability (bind address, firewall, tunnel) |
149
+ | peer reachable but its sessions never appear | §2.3: seeding is one-sided, or the peer did not `announce` |
150
+ | `No live DSH session node accepts team X` | X is on another host and nothing forwards (§3), or X is not joined/live there |
151
+ | `[PATTERN_NO_MINT]` / `[ALLOWLIST_DENY]` | §1.4: add the EXACT team name to the allowlist |
152
+ | `[CANVAS_TEAM]` from `a2a_team_join` | canvas teams are built in the workbench, not joined by tool |
@@ -272,6 +272,7 @@ export class TaskLedger {
272
272
  outcome: 'abandoned',
273
273
  endedBy: 'abandoned',
274
274
  ...(record.direction !== undefined ? { direction: record.direction } : {}),
275
+ ...(record.receiptExpected === false ? { receiptExpected: false } : {}),
275
276
  });
276
277
  this.persist();
277
278
  return { outcome: 'cleared', taskId, archivedAt: resolvedAt };
@@ -313,6 +314,7 @@ export class TaskLedger {
313
314
  outcome: 'abandoned',
314
315
  endedBy: 'cancelled',
315
316
  ...(record.direction !== undefined ? { direction: record.direction } : {}),
317
+ ...(record.receiptExpected === false ? { receiptExpected: false } : {}),
316
318
  });
317
319
  this.persist();
318
320
  return {
@@ -363,6 +365,7 @@ export class TaskLedger {
363
365
  ...(elapsedMs !== undefined ? { elapsedMs } : {}),
364
366
  endedBy: 'receipt',
365
367
  ...(record.direction !== undefined ? { direction: record.direction } : {}),
368
+ ...(record.receiptExpected === false ? { receiptExpected: false } : {}),
366
369
  });
367
370
  this.persist();
368
371
  return {
@@ -445,6 +448,7 @@ export class TaskLedger {
445
448
  summary: ECHO_SUMMARY,
446
449
  endedBy: 'echo',
447
450
  ...(record.direction !== undefined ? { direction: record.direction } : {}),
451
+ ...(record.receiptExpected === false ? { receiptExpected: false } : {}),
448
452
  });
449
453
  settled.push(record.taskId);
450
454
  }
@@ -505,6 +509,7 @@ export class TaskLedger {
505
509
  ...(typeof entry.lateReceiptAt === 'number' && Number.isFinite(entry.lateReceiptAt) ? { lateReceiptAt: entry.lateReceiptAt } : {}),
506
510
  ...(typeof entry.endedBy === 'string' && ARCHIVE_ENDS.includes(entry.endedBy) ? { endedBy: entry.endedBy } : {}),
507
511
  ...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
512
+ ...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
508
513
  });
509
514
  continue;
510
515
  }
@@ -550,6 +555,7 @@ export class TaskLedger {
550
555
  ...(typeof entry.lateReceiptAt === 'number' && Number.isFinite(entry.lateReceiptAt) ? { lateReceiptAt: entry.lateReceiptAt } : {}),
551
556
  ...(typeof entry.endedBy === 'string' && ARCHIVE_ENDS.includes(entry.endedBy) ? { endedBy: entry.endedBy } : {}),
552
557
  ...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
558
+ ...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
553
559
  });
554
560
  }
555
561
  }
@@ -0,0 +1,132 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { Context } from '@deepseek-ai/cordis';
3
+ import type { Agent } from '@deepseek-ai/dsh-agent';
4
+ import type WebServer from '@deepseek-ai/dsh-host-webserver';
5
+ import type { Config } from '../index.ts';
6
+ import type { CardCacheEntry, InFlightRoute, RouteActivityEntry } from '../runtime.ts';
7
+ import { CanvasStore } from '../canvas-store.ts';
8
+ import type { CliDepositStore } from '../cli-deposit.ts';
9
+ import type { ExposureStore } from '../exposure-store.ts';
10
+ import type { GroupStore } from '../group-store.ts';
11
+ import type { IdempotencyStore } from '../idempotency-store.ts';
12
+ import type { JoinedSessions } from '../joined-store.ts';
13
+ import type { LayoutStore } from '../layout-store.ts';
14
+ import type { PeerStore } from '../peer-store.ts';
15
+ import { type TaskLedger } from '../task-ledger.ts';
16
+ import type { TeamMembershipStore } from '../team-store.ts';
17
+ /** Remote-team rows cached for the state face (see refreshRemoteRows). */
18
+ export type RemoteRowsCache = {
19
+ at: number;
20
+ rows: {
21
+ team: string;
22
+ name: string;
23
+ origin?: string;
24
+ via?: string;
25
+ workspace?: string;
26
+ legacy?: boolean;
27
+ teams?: readonly string[];
28
+ }[];
29
+ } | undefined;
30
+ /** Everything the admin faces used to capture from `apply()`. */
31
+ export interface AdminFaceDeps {
32
+ readonly ctx: Context;
33
+ readonly config: Config;
34
+ readonly logger: ReturnType<Context['logger']>;
35
+ readonly home: string;
36
+ readonly session: string;
37
+ readonly lanIp: string;
38
+ readonly PLUGIN_VERSION: string;
39
+ /** index.ts's `import.meta.url` — the base the asset roots resolve against. */
40
+ readonly indexUrl: string;
41
+ readonly controlRoute: (handler: (req: IncomingMessage, res: ServerResponse) => void) => (req: IncomingMessage, res: ServerResponse) => void;
42
+ readonly controlTrustedWithoutKey: (req: IncomingMessage) => boolean;
43
+ readonly readJsonBody: (req: IncomingMessage, res: ServerResponse, use: (body: {
44
+ readonly id?: unknown;
45
+ readonly name?: unknown;
46
+ readonly action?: unknown;
47
+ }) => void, maxBytes?: number) => void;
48
+ readonly peerStore: PeerStore;
49
+ readonly peerLinkState: (peerUrl: string) => {
50
+ link: 'linked' | 'stranger';
51
+ verified: boolean;
52
+ };
53
+ readonly originClaimHeaders: () => Record<string, string>;
54
+ readonly refreshRemoteRows: () => void;
55
+ readonly joinedSessions: JoinedSessions;
56
+ readonly exposureStore: ExposureStore;
57
+ readonly teamMemberships: TeamMembershipStore;
58
+ readonly cliDeposits: CliDepositStore;
59
+ readonly groupStore: GroupStore;
60
+ readonly canvasStore: CanvasStore;
61
+ readonly layoutStore: LayoutStore;
62
+ readonly taskLedger: TaskLedger;
63
+ readonly idempotencyStore: IdempotencyStore;
64
+ readonly inFlightRoutes: Map<number, InFlightRoute>;
65
+ readonly recentActivity: RouteActivityEntry[];
66
+ readonly teamAuditRing: {
67
+ at: number;
68
+ action: 'join' | 'leave' | 'archive-prune' | 'agent-less';
69
+ team: string;
70
+ id: string;
71
+ ok: boolean;
72
+ reason?: string;
73
+ }[];
74
+ readonly reportRing: {
75
+ at: number;
76
+ level: 'info' | 'warning' | 'error';
77
+ message: string;
78
+ session: string;
79
+ task?: string;
80
+ }[];
81
+ readonly sessionNodes: Map<string, Agent>;
82
+ readonly liveRoots: Map<string, Agent>;
83
+ readonly mountSessionNode: (agent: Agent) => void;
84
+ readonly coldJoinedIds: () => Promise<Set<string>>;
85
+ readonly prewarmStatus: {
86
+ state: 'off' | 'skipped:no-wake-face' | 'draining' | 'done' | 'cancelled';
87
+ attempted: number;
88
+ woken: number;
89
+ failed: Array<{
90
+ id: string;
91
+ error: string;
92
+ }>;
93
+ };
94
+ readonly reconcileStatus: {
95
+ state: 'off' | 'idle' | 'draining';
96
+ lastTickAt: number;
97
+ lastChecked: number;
98
+ woken: number;
99
+ rows: Array<{
100
+ id: string;
101
+ error: string;
102
+ attempts: number;
103
+ nextRetryAt: number;
104
+ needsRepair: boolean;
105
+ }>;
106
+ };
107
+ readonly id8: (id: string) => string;
108
+ readonly sessionLabelOf: (agent: Agent) => string;
109
+ readonly sessionTeamOf: (agent: Agent) => string;
110
+ readonly nodeMetadataOf: (agent: Agent) => {
111
+ name: string;
112
+ description: string;
113
+ workspace?: string;
114
+ };
115
+ readonly canvasTeamOf: (name: string) => string;
116
+ readonly archivedSessionFilter: () => ((id: string) => boolean) | undefined;
117
+ readonly pruneArchivedJoins: () => void;
118
+ readonly pruneCanvasMemberships: () => void;
119
+ readonly cardCache: Map<string, CardCacheEntry>;
120
+ readonly remoteRowsCache: RemoteRowsCache;
121
+ readonly legacyInboundCount: number;
122
+ readonly ownCardUrl: string;
123
+ ownerAllowlist: string[];
124
+ ownerTeamScopeInfo: boolean;
125
+ ownerAnnounceRestartPending: boolean;
126
+ }
127
+ /**
128
+ * Register the admin faces on a settled web server.
129
+ * @param webServer - the settled host web server.
130
+ * @param d - the captures listed on {@link AdminFaceDeps}.
131
+ */
132
+ export declare function registerAdminFaces(webServer: WebServer, d: AdminFaceDeps): void;
@@ -191,11 +191,19 @@ export interface Config {
191
191
  /** Backoff ceiling; corrupt-log rows park as needs-repair and never retry. */
192
192
  readonly wakeReconcileMaxBackoffMs: number;
193
193
  /**
194
- * S4: joinable-team patterns for a2a_team_join — exact names or
195
- * trailing-`*` prefixes. Empty list denies every join (default).
194
+ * S4 (gh#11 N7, operator directive: team creation is a human act): the
195
+ * joinable-team curation list for a2a_team_join. ONLY an EXACT entry
196
+ * authorizes a join; trailing-`*` pattern entries stay readable here
197
+ * (whoami shows them) but carry no mint rights — a pattern that
198
+ * pre-authorized every future name under it would let a model mint teams
199
+ * nobody created. Empty list denies every join (default).
196
200
  */
197
201
  readonly teamJoinAllowlist: string[];
198
- /** S3 (queued): team-scoped routing admission. Enforcement off by default. */
202
+ /**
203
+ * S3 (phase-2 default-ON, owner ruling 2026-09-01): route dispatch checks
204
+ * caller↔target shared team membership before addressing; set false to
205
+ * restore the open outbound seam. (Kept in sync with the schema comment.)
206
+ */
199
207
  readonly teamScopeRouting: boolean;
200
208
  /**
201
209
  * S5 (owner directive 2026-10-20): information-plane scoping — the