@nelsonlongxiang/dsh-open-a2a-net 0.6.38 → 0.6.40

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 (48) hide show
  1. package/README.md +342 -335
  2. package/README_zh.md +243 -236
  3. package/assets/nexusDist/assets/index-C7HagaGy.css +1 -1
  4. package/assets/nexusDist/assets/index-NkNSeIx6.js +9 -9
  5. package/cli/a2a.mjs +20 -5
  6. package/cli/serve.mjs +132 -0
  7. package/cordis.patch.yml +21 -21
  8. package/lib/a2a-client.js +1 -0
  9. package/lib/canvas-id.js +27 -0
  10. package/lib/canvas-store.js +7 -1
  11. package/lib/cli-deposit.js +2 -2
  12. package/lib/client.js +63 -63
  13. package/lib/config.js +125 -0
  14. package/lib/direct-forward.js +64 -18
  15. package/lib/faces/admin.js +4 -1
  16. package/lib/faces/wire.js +77 -7
  17. package/lib/flood-guard.js +3 -1
  18. package/lib/index.js +84 -172
  19. package/lib/lan-ip.js +20 -0
  20. package/lib/live-roots.js +39 -0
  21. package/lib/origin-claim.js +3 -2
  22. package/lib/peer-link-display.js +3 -2
  23. package/lib/queue-guard.js +3 -1
  24. package/lib/skill/references/cli.md +215 -203
  25. package/lib/skill/references/routing.md +4 -0
  26. package/lib/skill/references/setup.md +156 -153
  27. package/lib/task-auth.js +14 -10
  28. package/lib/types/canvas-id.d.ts +9 -0
  29. package/lib/types/cli-deposit.d.ts +6 -1
  30. package/lib/types/config.d.ts +278 -0
  31. package/lib/types/direct-forward.d.ts +25 -3
  32. package/lib/types/faces/admin.d.ts +2 -0
  33. package/lib/types/faces/wire.d.ts +26 -0
  34. package/lib/types/index.d.ts +3 -277
  35. package/lib/types/lan-ip.d.ts +7 -0
  36. package/lib/types/live-roots.d.ts +35 -0
  37. package/lib/types/origin-claim.d.ts +1 -0
  38. package/lib/types/peer-link-display.d.ts +2 -0
  39. package/lib/types/queue-guard.d.ts +2 -0
  40. package/lib/types/task-auth.d.ts +3 -0
  41. package/lib/types/types.d.ts +7 -0
  42. package/nexus-stage/package-lock.json +2262 -2262
  43. package/nexus-stage/src/edges.ts +72 -72
  44. package/nexus-stage/src/layout-wire.ts +158 -158
  45. package/nexus-stage/src/seat.ts +35 -35
  46. package/nexus-stage/src/stage-name.ts +13 -13
  47. package/nexus-stage/src/tokens.ts +31 -31
  48. package/package.json +219 -218
@@ -1,153 +1,156 @@
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
- ## Human seat names
25
-
26
- The owner can open the workbench header ⚙, select a local session under “会话席位名”, and explicitly save a stable display name (up to 64 characters; a name shaped like a route address such as `dsh/66abc2cf` is refused so it cannot pass for another session). Empty input clears the label. The `seatNames` map is stored in the home patch layer and hot-applies; cards and the board prefer it over the session title, and `a2a_whoami` / `a2a-net teams <name>` expose it. It never changes the route handle, join state or admission. Agents must ask the owner rather than self-name: A2A tool arguments containing `seatName`/`seatNames` are refused; no naming model tool exists. The owner control API keeps its existing apiKey/loopback trust model, not a sandbox against local shell or config-file writers.
27
-
28
- ## 1. One host: sessions talking to each other
29
-
30
- **Owner:**
31
- 1. Install, then restart `dsh web`:
32
- `dsh plugin --profile web add @nelsonlongxiang/dsh-open-a2a-net`
33
- 2. Leave the config at its defaults. `sessionNodes: true` exposes main
34
- sessions as joinable nodes; no `apiKey` is needed while the listener is
35
- bound to loopback.
36
- 3. Join each session that should collaborate: sidebar → A2A 网络 → toggle.
37
- An unjoined session is invisible and refused (see
38
- [membership.md](membership.md)).
39
- 4. Give the sessions a shared team (`teamScopeRouting` is on by default, so a
40
- teamless session has no network). Pick ONE of:
41
- - **Canvas team (recommended):** workbench `/__dsh_a2a_nexus/` → select
42
- the session cards → 建队. Canvas membership is assigned by hand.
43
- - **Roster team:** add the EXACT name (for example `dsh/review`) to
44
- `teamJoinAllowlist` in the workbench owner panel (header ⚙). Each
45
- session then declares it with `a2a_team_join { team: "dsh/review" }`.
46
- A `*`/prefix entry authorizes nothing (`[PATTERN_NO_MINT]`).
47
-
48
- **Agent, verify:**
49
- - `a2a_whoami`: `node.joined: true`, the handle `<zone>/<id8>`, and the
50
- team under `teams.roster` or `teams.canvas`.
51
- - `a2a_teams`: the other session's handle is listed.
52
- - One sync round:
53
- `a2a_route { team: "<other handle>", message: "ping: reply 'pong'" }`.
54
- A local target gets up to 180 s. Continue the conversation with the
55
- returned `context_id`.
56
-
57
- ## 2. Two hosts that can reach each other (peers)
58
-
59
- Do section 1 on both hosts first. Then the **owner**, on BOTH hosts:
60
-
61
- 1. **Reachability, both ways.** Each host must open the other's web
62
- listener (LAN, VPN or tunnel). There is no built-in NAT traversal.
63
- 2. **One shared `apiKey`.** Bind beyond loopback only with a key. The
64
- delivery face `/a2a/direct` and the log face require `X-API-Key` when a
65
- key is set, and a host sends ITS OWN `apiKey` on every peer request. So
66
- keyed hosts that talk to each other must carry the SAME key (or both
67
- none, loopback-only). A mismatch shows up as HTTP 401
68
- `X-API-Key required on the delivery face`.
69
- 3. **Announce and seed each other.** Seeding BOTH ways is what makes the
70
- hosts linked: each side then reads the other's full card. One-sided
71
- seeding does not link.
72
-
73
- ```yaml
74
- - id: a2a
75
- name: '@nelsonlongxiang/dsh-open-a2a-net'
76
- config:
77
- announce: true
78
- apiKey: '<same secret on every linked host>'
79
- peers: ['http://<other-host>:<port>'] # the other side lists this host
80
- ```
81
-
82
- Restart. `announce` toggled from the owner panel applies on the next
83
- restart.
84
- 4. **Link the sessions across hosts with a canvas team.** Hosts default to
85
- the same zone name (`team: 'dsh'`), so a remote caller `dsh/<id8>`
86
- resolves to no local session. A roster declaration cannot admit it,
87
- because the roster holds this host's own sessions only. The record that
88
- admits it is a **canvas team naming both exact handles**. Build it in the
89
- workbench (建队 with the local session, then add the remote handle):
90
- - on the host that receives rounds, or
91
- - on either host, once they are linked (step 3).
92
-
93
- The other side then reads that board's `canvasOrg` from the peer's full
94
- card it has cached (gh#71, recognized both ways). `a2a_probe` warms that
95
- cache. Receipts flowing back are admitted the same way.
96
-
97
- Admission is target-scoped (F5): the remote member reaches only the
98
- sessions inside that canvas team. A linked peer can also edit your canvas
99
- (add or remove members, dissolve a team) through
100
- `/__dsh_a2a/fleet-canvas-op`; your host's link gate decides.
101
-
102
- **Agent, verify on each host:**
103
- - `a2a_probe`: the peer is reachable. A miss is classified as `unreachable`
104
- (network, port, tunnel) or `rejected` (key, signature, clock).
105
- - State face `remote` rows: the peer's sessions appear, with `via` set to
106
- the peer URL. `a2a_teams` shows peer rows only for teams in this host's
107
- joined scope (S5, `teamScopeInfo`), so an empty list is not proof that the
108
- link failed.
109
- - One async round with a receipt:
110
- `a2a_route { team: "<remote handle>", message: "...", async: true }`,
111
- then check `a2a_tasks` until the row lands in `archived` with
112
- `endedBy: receipt`. A peer delivery rides a 15 s transport budget:
113
- `-32005` means it was delivered and is still running. Reconcile; never
114
- resend.
115
-
116
- ## 3. Beyond direct peers (A cannot reach C)
117
-
118
- Home-host one-hop forwarding shipped in 0.6.34 and defaults **on from 0.6.38**. Explicit `forwardDirect: false` restores local-only CLI delivery. It only follows a local miss for a bound-signed, online CLI deposited on that home, inside the unchanged sender scope. A must directly link B's receiving member (all advertised potential receivers for a canvas), and B must link the CLI and pin A as home. The home must not be isolated and any inbound Guard prevents a second forward. The outbound hop budget remains DEFAULT-1. No links are auto-created or relaxed.
119
-
120
- Long forwarded work should use async `route` plus `inbox --wait --renew`, not expect a 15s `--sync` transport window to cover an entire model turn. A late final still produces the original task's receipt, and the forwarded row keeps selected-peer receiver facts for enforced settlement.
121
-
122
- Other connectivity options (not authority bypasses):
123
-
124
- 1. **Make it a peer pair.** Open a tunnel or port-forward so A reaches C,
125
- then do section 2 for A↔C. This is the simplest and keeps receipts
126
- end-to-end.
127
- 2. **Delegate through B.** Route the task to a session on B, the host both
128
- sides reach, and have that session route on to C and report back. Each
129
- hop is its own task with its own receipt: B's ledger owns the B→C leg.
130
- Keep messages self-contained and use `async: true` per hop.
131
- 3. **Names across zones.** `delegates` publishes signed name records, so a
132
- name can resolve through other zones (≤5 hops, loops fail closed). That
133
- is name resolution, not message forwarding: the final delivery still
134
- goes straight to the resolved host, so that host must be reachable.
135
-
136
- ## 4. Callers outside a session
137
-
138
- Scripts and terminals use the `a2a-net` CLI: deposit on ONE home host, read
139
- a mailbox, no wake. The home can forward eligible requests one hop under section 3; an online deposit alone grants no remote scope. See [cli.md](cli.md).
140
-
141
- ## 5. Symptom → step
142
-
143
- | Symptom | Go back to |
144
- |---|---|
145
- | `declares no team membership` / `shares no team` | §1.4 (same host) or §2.4 (cross-host canvas ref on the target) |
146
- | `caller_session is required` | the caller is not a joined session (§1.3) or an external client omitted it |
147
- | HTTP 401 `X-API-Key required` | §2.2: keys differ between the hosts |
148
- | `a2a_probe` → `rejected` | §2.2 key, or §0 clock; also card signature/expiry |
149
- | `a2a_probe` → `unreachable` | §2.1 reachability (bind address, firewall, tunnel) |
150
- | peer reachable but its sessions never appear | §2.3: seeding is one-sided, or the peer did not `announce` |
151
- | `No live DSH session node accepts team X` | X is on another host and nothing forwards (§3), or X is not joined/live there |
152
- | `[PATTERN_NO_MINT]` / `[ALLOWLIST_DENY]` | §1.4: add the EXACT team name to the allowlist |
153
- | `[CANVAS_TEAM]` from `a2a_team_join` | canvas teams are built in the workbench, not joined by tool |
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
+ ## Human seat names
25
+
26
+ The owner can open the workbench header ⚙, select a local session under “会话席位名”, and explicitly save a stable display name (up to 64 characters; a name shaped like a route address such as `dsh/66abc2cf` is refused so it cannot pass for another session). Empty input clears the label. The `seatNames` map is stored in the home patch layer and hot-applies; cards and the board prefer it over the session title, and `a2a_whoami` / `a2a-net teams <name>` expose it. It never changes the route handle, join state or admission. Agents must ask the owner rather than self-name: A2A tool arguments containing `seatName`/`seatNames` are refused; no naming model tool exists. The owner control API keeps its existing apiKey/loopback trust model, not a sandbox against local shell or config-file writers.
27
+
28
+ ## 1. One host: sessions talking to each other
29
+
30
+ **Owner:**
31
+ 1. Install, then restart `dsh web`:
32
+ `dsh plugin --profile web add @nelsonlongxiang/dsh-open-a2a-net`
33
+ 2. Leave the config at its defaults. `sessionNodes: true` exposes main
34
+ sessions as joinable nodes; no `apiKey` is needed while the listener is
35
+ bound to loopback.
36
+ 3. Join each session that should collaborate: sidebar → A2A 网络 → toggle.
37
+ An unjoined session is invisible and refused (see
38
+ [membership.md](membership.md)).
39
+ 4. Give the sessions a shared team (`teamScopeRouting` is on by default, so a
40
+ teamless session has no network). Pick ONE of:
41
+ - **Canvas team (recommended):** workbench `/__dsh_a2a_nexus/` → select
42
+ the session cards → 建队. Canvas membership is assigned by hand.
43
+ - **Roster team:** add the EXACT name (for example `dsh/review`) to
44
+ `teamJoinAllowlist` in the workbench owner panel (header ⚙). Each
45
+ session then declares it with `a2a_team_join { team: "dsh/review" }`.
46
+ A `*`/prefix entry authorizes nothing (`[PATTERN_NO_MINT]`).
47
+
48
+ **Agent, verify:**
49
+ - `a2a_whoami`: `node.joined: true`, the handle `<zone>/<id8>`, and the
50
+ team under `teams.roster` or `teams.canvas`.
51
+ - `a2a_teams`: the other session's handle is listed.
52
+ - One sync round:
53
+ `a2a_route { team: "<other handle>", message: "ping: reply 'pong'" }`.
54
+ A local target gets up to 180 s. Continue the conversation with the
55
+ returned `context_id`.
56
+
57
+ ## 2. Two hosts that can reach each other (peers)
58
+
59
+ Do section 1 on both hosts first. Then the **owner**, on BOTH hosts:
60
+
61
+ 1. **Reachability, both ways.** Each host must open the other's web
62
+ listener (LAN, VPN or tunnel). There is no built-in NAT traversal.
63
+ 2. **One shared `apiKey`.** Bind beyond loopback only with a key. The
64
+ delivery face `/a2a/direct` and the log face require `X-API-Key` when a
65
+ key is set, and a host sends ITS OWN `apiKey` on every peer request. So
66
+ keyed hosts that talk to each other must carry the SAME key (or both
67
+ none, loopback-only). A mismatch shows up as HTTP 401
68
+ `X-API-Key required on the delivery face`.
69
+ 3. **Announce and seed each other.** Seeding BOTH ways is what makes the
70
+ hosts linked: each side then reads the other's full card. One-sided
71
+ seeding does not link.
72
+
73
+ ```yaml
74
+ - id: a2a
75
+ name: '@nelsonlongxiang/dsh-open-a2a-net'
76
+ config:
77
+ announce: true
78
+ apiKey: '<same secret on every linked host>'
79
+ peers: ['http://<other-host>:<port>'] # the other side lists this host
80
+ ```
81
+
82
+ Restart. `announce` toggled from the owner panel applies on the next
83
+ restart.
84
+ 4. **Link the sessions across hosts with a canvas team.** Hosts default to
85
+ the same zone name (`team: 'dsh'`), so a remote caller `dsh/<id8>`
86
+ resolves to no local session. A roster declaration cannot admit it,
87
+ because the roster holds this host's own sessions only. The record that
88
+ admits it is a **canvas team naming both exact handles**. Build it in the
89
+ workbench (建队 with the local session, then add the remote handle):
90
+ - on the host that receives rounds, or
91
+ - on either host, once they are linked (step 3).
92
+
93
+ The other side then reads that board's `canvasOrg` from the peer's full
94
+ card it has cached (gh#71, recognized both ways). `a2a_probe` warms that
95
+ cache. Receipts flowing back are admitted the same way.
96
+
97
+ Admission is target-scoped (F5): the remote member reaches only the
98
+ sessions inside that canvas team. A linked peer can also edit your canvas
99
+ (add or remove members, dissolve a team) through
100
+ `/__dsh_a2a/fleet-canvas-op`; your host's link gate decides.
101
+
102
+ **Agent, verify on each host:**
103
+ - `a2a_probe`: the peer is reachable. A miss is classified as `unreachable`
104
+ (network, port, tunnel) or `rejected` (key, signature, clock).
105
+ - State face `remote` rows: the peer's sessions appear, with `via` set to
106
+ the peer URL. `a2a_teams` shows peer rows only for teams in this host's
107
+ joined scope (S5, `teamScopeInfo`), so an empty list is not proof that the
108
+ link failed.
109
+ - One async round with a receipt:
110
+ `a2a_route { team: "<remote handle>", message: "...", async: true }`,
111
+ then check `a2a_tasks` until the row lands in `archived` with
112
+ `endedBy: receipt`. A peer delivery rides a 15 s transport budget:
113
+ `-32005` means it was delivered and is still running. Reconcile; never
114
+ resend.
115
+
116
+ ## 3. Beyond direct peers (A cannot reach C)
117
+
118
+ Home-host one-hop forwarding shipped in 0.6.34 and defaults **on from 0.6.38**. Explicit `forwardDirect: false` restores local-only CLI delivery. It only follows a local miss for a bound-signed, online CLI deposited on that home, inside the unchanged sender scope. A must directly link B's receiving member (all advertised potential receivers for a canvas), and B must link the CLI and pin A as home. The home must not be isolated and any inbound Guard prevents a second forward. The outbound hop budget remains DEFAULT-1. No links are auto-created or relaxed.
119
+
120
+ Long forwarded work should use async `route` plus `inbox --wait --renew`, not expect a 15s `--sync` transport window to cover an entire model turn. A late final still produces the original task's receipt, and the forwarded row keeps selected-peer receiver facts for enforced settlement.
121
+
122
+ Other connectivity options (not authority bypasses):
123
+
124
+ 1. **Make it a peer pair.** Open a tunnel or port-forward so A reaches C,
125
+ then do section 2 for A↔C. This is the simplest and keeps receipts
126
+ end-to-end.
127
+ 2. **Delegate through B.** Route the task to a session on B, the host both
128
+ sides reach, and have that session route on to C and report back. Each
129
+ hop is its own task with its own receipt: B's ledger owns the B→C leg.
130
+ Keep messages self-contained and use `async: true` per hop.
131
+ 3. **Names across zones.** `delegates` publishes signed name records, so a
132
+ name can resolve through other zones (≤5 hops, loops fail closed). That
133
+ is name resolution, not message forwarding: the final delivery still
134
+ goes straight to the resolved host, so that host must be reachable.
135
+
136
+ ## 4. Callers outside a session
137
+
138
+ For an always-on CLI home that survives DSH restarts, run the network layer without a DSH instance: `a2a-net serve` — deployment, service files, linking and the public-internet caveats are in `docs/guides/standalone-node.md` of the repository.
139
+
140
+
141
+ Scripts and terminals use the `a2a-net` CLI: deposit on ONE home host, read
142
+ a mailbox, no wake. The home can forward eligible requests one hop under section 3; an online deposit alone grants no remote scope. See [cli.md](cli.md).
143
+
144
+ ## 5. Symptom → step
145
+
146
+ | Symptom | Go back to |
147
+ |---|---|
148
+ | `declares no team membership` / `shares no team` | §1.4 (same host) or §2.4 (cross-host canvas ref on the target) |
149
+ | `caller_session is required` | the caller is not a joined session (§1.3) or an external client omitted it |
150
+ | HTTP 401 `X-API-Key required` | §2.2: keys differ between the hosts |
151
+ | `a2a_probe` → `rejected` | §2.2 key, or §0 clock; also card signature/expiry |
152
+ | `a2a_probe` → `unreachable` | §2.1 reachability (bind address, firewall, tunnel) |
153
+ | peer reachable but its sessions never appear | §2.3: seeding is one-sided, or the peer did not `announce` |
154
+ | `No live DSH session node accepts team X` | X is on another host and nothing forwards (§3), or X is not joined/live there |
155
+ | `[PATTERN_NO_MINT]` / `[ALLOWLIST_DENY]` | §1.4: add the EXACT team name to the allowlist |
156
+ | `[CANVAS_TEAM]` from `a2a_team_join` | canvas teams are built in the workbench, not joined by tool |
package/lib/task-auth.js CHANGED
@@ -32,7 +32,11 @@ export const VIA_OF = {
32
32
  /** The refusal text of a decision, or undefined when admitted — the shape the faces have always read. */
33
33
  export const refusalOf = (admission) => (admission.ok ? undefined : admission.refusal);
34
34
  const admitted = (rule, basis = []) => ({ ok: true, via: VIA_OF[rule], rule, basis });
35
- const refused = (refusal) => ({ ok: false, refusal });
35
+ /** #282 S1: a refusal may carry the one step the caller (or this host's operator) can take next. */
36
+ const refused = (refusal, next) => ({ ok: false, refusal, ...(next !== undefined ? { next } : {}) });
37
+ /** The next step of a refused decision, or undefined. */
38
+ export const nextOf = (a) => (a.ok ? undefined : a.next);
39
+ const linkNext = (caller, target) => `ask this host's operator to put ${caller} in a canvas team${target !== undefined ? ` with ${target}` : ''} in the A2A workbench, or route through a member of a team you already share`;
36
40
  const ZONE_ID8 = /^[a-z0-9-]+\/[0-9a-f]{8}$/i;
37
41
  const teamlessText = (caller) => `caller "${caller}" declares no team membership on this host's roster — a teamless node has no network (S3 phase-3 admission). Declare a shared team on both ends, or route through a teamed member.`;
38
42
  /**
@@ -178,7 +182,7 @@ export function sameZoneAdmission(r, caller, targetTeam) {
178
182
  return hit;
179
183
  return refused(declared.size === 0
180
184
  ? teamlessText(caller)
181
- : `caller "${caller}" shares no team with target "${targetTeam}" — declared [${[...declared.keys()].join(', ')}], target links [${[...links].join(', ')}] (F5 target-level admission: a host-level link alone does not open every session team).`);
185
+ : `caller "${caller}" shares no team with target "${targetTeam}" — declared [${[...declared.keys()].join(', ')}], target links [${[...links].join(', ')}] (F5 target-level admission: a host-level link alone does not open every session team).`, linkNext(caller, targetTeam));
182
186
  }
183
187
  }
184
188
  const hit = pass(t => routable.has(t) || otherLocalDeclarations.has(t));
@@ -186,7 +190,7 @@ export function sameZoneAdmission(r, caller, targetTeam) {
186
190
  return hit;
187
191
  return refused(declared.size === 0
188
192
  ? teamlessText(caller)
189
- : `caller "${caller}" declares teams [${[...declared.keys()].join(', ')}] — none is routable on this node (shared-team admission). Declare a shared team on both ends, or route through a teamed member.`);
193
+ : `caller "${caller}" declares teams [${[...declared.keys()].join(', ')}] — none is routable on this node (shared-team admission). Declare a shared team on both ends, or route through a teamed member.`, linkNext(caller, targetTeam));
190
194
  }
191
195
  /**
192
196
  * W1: the joined-face gate for the LOCAL dispatch candidate — only a
@@ -216,7 +220,7 @@ export function callerAdmission(r, caller, targetTeam, cliProof) {
216
220
  if (!r.teamScopeRouting)
217
221
  return admitted('scope-off');
218
222
  if (caller === '') {
219
- return refused('caller_session is required — anonymous deliveries are refused (S3 phase-3b). Pass caller_session as <zone>/<id8>.');
223
+ return refused('caller_session is required — anonymous deliveries are refused (S3 phase-3b). Pass caller_session as <zone>/<id8>.', 'send caller_session as your <zone>/<id8> handle (a2a-net does this for you)');
220
224
  }
221
225
  if (caller === r.zone)
222
226
  return admitted('host-face');
@@ -228,21 +232,21 @@ export function callerAdmission(r, caller, targetTeam, cliProof) {
228
232
  const basis = r.canvasNames().filter(name => r.canvasRemoteMembersOf(name).includes(caller)).map(name => r.canvasTeamOf(name));
229
233
  if (basis.length > 0)
230
234
  return admitted('remote-ref-foreign', basis);
231
- return refused(`caller "${caller}" has no operator-established link on this node — cross-host admission requires a canvas remote membership this host's operator added (N3/N4: the caller's own card claims are self-description, not links). Ask this host's operator to add the member in the A2A workbench, or route through a linked member.`);
235
+ return refused(`caller "${caller}" has no operator-established link on this node — cross-host admission requires a canvas remote membership this host's operator added (N3/N4: the caller's own card claims are self-description, not links). Ask this host's operator to add the member in the A2A workbench, or route through a linked member.`, linkNext(caller, targetTeam));
232
236
  }
233
237
  // A foreign CLI handle is never a free-form node fact: its operator link,
234
238
  // pinned home, bound signer, and fresh online row must ALL hold.
235
239
  if (CLI_HANDLE_RE.test(caller) && !r.cliDeposited(caller)) {
236
240
  const basis = r.canvasNames().filter(name => r.canvasRemoteMembersOf(name).includes(caller)).map(name => r.canvasTeamOf(name));
237
241
  if (basis.length === 0)
238
- return refused(`remote CLI ${caller} has no operator-established canvas link`);
242
+ return refused(`remote CLI ${caller} has no operator-established canvas link`, linkNext(caller, targetTeam));
239
243
  const home = r.cliHomeOf?.(caller);
240
244
  if (home === undefined)
241
- return refused(`remote CLI ${caller} home is not pinned`);
245
+ return refused(`remote CLI ${caller} home is not pinned`, `ask this host's operator to pin ${caller}'s home host when adding it to the canvas team (workbench member dialog)`);
242
246
  if (cliProof?.bound !== true || cliHomeOrigin(cliProof.origin) !== home || cliHomeOrigin(cliProof.viaUrl) !== home)
243
- return refused(`remote CLI ${caller} signer does not match pinned home`);
247
+ return refused(`remote CLI ${caller} signer does not match pinned home`, `send through ${caller}'s pinned home host only`);
244
248
  if (cliProof.online !== true)
245
- return refused(`remote CLI ${caller} is offline or home card could not be read`);
249
+ return refused(`remote CLI ${caller} is offline or home card could not be read`, 'run `a2a-net publish` on the home host, then send again with a new task id');
246
250
  return admitted('remote-ref-cli', basis);
247
251
  }
248
252
  // Free-form label: only this host's own node facts (V3).
@@ -257,7 +261,7 @@ export function callerAdmission(r, caller, targetTeam, cliProof) {
257
261
  // gh#45: a deposited CLI handle is a published node fact of this host.
258
262
  if (r.cliDeposited(caller))
259
263
  return admitted('cli-deposit');
260
- return refused(`caller_session "${caller}" matches no node fact on this host (joined session id, live node label, or deposited CLI handle) — free-form caller labels are self-description and are not admissible (N3). Pass caller_session as <zone>/<id8> with an operator-established link.`);
264
+ return refused(`caller_session "${caller}" matches no node fact on this host (joined session id, live node label, or deposited CLI handle) — free-form caller labels are self-description and are not admissible (N3). Pass caller_session as <zone>/<id8> with an operator-established link.`, 'send caller_session as a <zone>/<id8> handle this host has linked; free-form labels are never admitted');
261
265
  }
262
266
  /** Revalidate only mutable operator authority, not already-verified birth-time identity or liveness. */
263
267
  export function recheckRemoteCliAuthorization(r, auth) {
@@ -0,0 +1,9 @@
1
+ /** First 8 hex of sha256 over the base64 SPKI DER public key; '' when there is no key. */
2
+ export declare function canvasOwnerFingerprint(publicKey: string | undefined): string;
3
+ /** Split `<zone>/canvas/<name>@<fp8>` into the plain team and the owner fingerprint. */
4
+ export declare function splitCanvasOwner(team: string): {
5
+ team: string;
6
+ owner?: string;
7
+ };
8
+ /** The display/routing id of a canvas team owned by the card holding `publicKey`. */
9
+ export declare function qualifiedCanvasId(team: string, publicKey: string | undefined): string;
@@ -39,6 +39,11 @@ export interface CliMail {
39
39
  /** gh#113: set when `text` was cut to MAILBOX_TEXT_CAP; `chars` is the original length. */
40
40
  readonly truncated?: true;
41
41
  readonly chars?: number;
42
+ /**
43
+ * #298: a receipt for a forward this host already answered synchronously — the CLI got the reply on the
44
+ * HTTP answer, so this copy is a duplicate. Kept in full (never dropped); readers may fold it.
45
+ */
46
+ readonly dup?: 'sync-answered';
42
47
  }
43
48
  /** gh#113: one mailbox read — the rows plus what a reader needs to notice loss. */
44
49
  export interface CliMailbox {
@@ -101,7 +106,7 @@ export declare class CliDepositStore {
101
106
  description: string;
102
107
  offline: boolean;
103
108
  }>;
104
- mail(handle: string, from: string, text: string): void;
109
+ mail(handle: string, from: string, text: string, dup?: 'sync-answered'): void;
105
110
  readMail(handle: string): readonly CliMail[];
106
111
  /** gh#113: the mailbox with its seq bounds and loss counter; `afterSeq` keeps only newer rows. */
107
112
  readMailbox(handle: string, afterSeq?: number): CliMailbox;