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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +85 -14
  2. package/README_zh.md +81 -7
  3. package/assets/nexusDist/assets/index-BqepxJ7E.css +1 -0
  4. package/assets/nexusDist/assets/index-CTWH6QRh.js +9 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +624 -612
  7. package/cordis.patch.yml +21 -21
  8. package/lib/canvas-store.js +5 -1
  9. package/lib/cli-deposit.js +61 -12
  10. package/lib/client.js +64 -64
  11. package/lib/client.js.map +1 -1
  12. package/lib/faces/admin.js +744 -0
  13. package/lib/index.js +222 -756
  14. package/lib/peer-store.js +60 -4
  15. package/lib/recursion-guard.js +78 -0
  16. package/lib/runtime.js +1 -0
  17. package/lib/skill/SKILL.md +48 -11
  18. package/lib/skill/references/cli.md +109 -0
  19. package/lib/skill/references/discovery.md +15 -4
  20. package/lib/skill/references/membership.md +29 -6
  21. package/lib/skill/references/pitfalls.md +12 -4
  22. package/lib/skill/references/routing.md +18 -8
  23. package/lib/skill/references/setup.md +152 -0
  24. package/lib/task-ledger.js +6 -0
  25. package/lib/types/canvas-store.d.ts +5 -1
  26. package/lib/types/cli-deposit.d.ts +30 -1
  27. package/lib/types/faces/admin.d.ts +132 -0
  28. package/lib/types/index.d.ts +11 -3
  29. package/lib/types/peer-store.d.ts +24 -1
  30. package/lib/types/recursion-guard.d.ts +56 -0
  31. package/lib/types/runtime.d.ts +34 -0
  32. package/lib/types/task-ledger.d.ts +6 -0
  33. package/nexus-stage/package-lock.json +2262 -2262
  34. package/nexus-stage/src/canvas-ops.ts +175 -175
  35. package/nexus-stage/src/canvas-wire.ts +156 -156
  36. package/nexus-stage/src/edges.ts +72 -72
  37. package/nexus-stage/src/federation-overlay.ts +103 -0
  38. package/nexus-stage/src/federation.ts +240 -240
  39. package/nexus-stage/src/layout-doc.ts +143 -143
  40. package/nexus-stage/src/layout-wire.ts +158 -158
  41. package/nexus-stage/src/planning-card-text.ts +73 -0
  42. package/nexus-stage/src/planning-fit.ts +45 -0
  43. package/nexus-stage/src/planning-legend.ts +38 -0
  44. package/nexus-stage/src/planning-overlap.ts +134 -0
  45. package/nexus-stage/src/planning-relayout.ts +121 -0
  46. package/nexus-stage/src/planning-search.ts +168 -0
  47. package/nexus-stage/src/planning-view.ts +94 -119
  48. package/nexus-stage/src/planning.css +39 -8
  49. package/nexus-stage/src/seat.ts +35 -35
  50. package/nexus-stage/src/stage-name.ts +13 -13
  51. package/nexus-stage/src/tokens.ts +31 -31
  52. package/nexus-stage/src/viewport.ts +24 -1
  53. package/nexus-stage/src/wb-chat.ts +20 -6
  54. package/nexus-stage/src/wb-ledger.ts +20 -7
  55. package/nexus-stage/src/wb-names.ts +43 -0
  56. package/nexus-stage/src/workbench.css +8 -2
  57. package/nexus-stage/src/workbench.ts +55 -17
  58. package/nexus-stage/src/world.ts +5 -1
  59. package/package.json +216 -214
  60. package/assets/nexusDist/assets/index-DszoLz8i.js +0 -5
  61. package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
  62. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
@@ -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
  }
@@ -7,7 +7,11 @@
7
7
  * `<team>/canvas/<name>` resolves the first live local member, wakes the
8
8
  * first cold local one, then falls through to the remote members in order
9
9
  * (the directory resolves their handles; remote nodes never wake from
10
- * here). Membership order is the routing priority (member list order),
10
+ * here). The dispatching session itself is exempt from the local tiers
11
+ * (issue #41: the self-route rule extends to canvas — an initiator
12
+ * occupying a member slot never captures its own dispatch, which would
13
+ * only echo home; the seam lives in the dispatch layer, the store stays
14
+ * pure). Membership order is the routing priority (member list order),
11
15
  * preserved verbatim on disk. Plain JSON, one writer (the control routes),
12
16
  * read fresh by the state route.
13
17
  * @module @nelsonlongxiang/dsh-open-a2a-net/canvas-store
@@ -1,5 +1,11 @@
1
1
  /** Owner ruling ③: the default AND maximum online window. */
2
2
  export declare const CLI_DEPOSIT_TTL_MS: number;
3
+ /**
4
+ * gh#47 tombstone bound: expired deposits stay visible as OFFLINE rows
5
+ * (newest TOMBSTONE_CAP keep their handle/name so team refs never dangle
6
+ * silently) — the owner manages CLI nodes like session nodes.
7
+ */
8
+ export declare const CLI_TOMBSTONE_CAP = 16;
3
9
  /** Owner ruling ①: <agent-name>/<uuid·8B>. The name is [a-z0-9-]{1,32}. */
4
10
  export declare const CLI_HANDLE_RE: RegExp;
5
11
  /** One deposited CLI identity. */
@@ -11,6 +17,12 @@ export interface CliDeposit {
11
17
  readonly cardJson: string;
12
18
  readonly depositedAt: number;
13
19
  readonly expiresAt: number;
20
+ /**
21
+ * gh#47 tombstone: set when the deposit expired — the row stays (offline)
22
+ * so the directory/board keeps the member visible and team refs never
23
+ * dangle silently. A re-publish (same handle, same key) un-tombstones.
24
+ */
25
+ expiredAt?: number;
14
26
  }
15
27
  /** One mailbox entry for a deposited handle. */
16
28
  export interface CliMail {
@@ -37,17 +49,34 @@ export declare class CliDepositStore {
37
49
  };
38
50
  revoke(handle: string): boolean;
39
51
  /** Active deposits (sweeping expired ones — ruling ③ auto-deregister). */
52
+ /** LIVE deposits only (tombstones excluded — the control list is the
53
+ * online roster; offline rows ride directoryRows for the board). */
40
54
  list(): readonly CliDeposit[];
41
55
  get(handle: string): CliDeposit | undefined;
42
- /** Card-serve rows: the handle + display name, marked cli (no wake). */
56
+ /** Card-serve rows: LIVE deposits only (an expired card 404s). */
43
57
  cardRows(): Array<{
44
58
  team: string;
45
59
  name: string;
46
60
  description: string;
47
61
  }>;
62
+ /**
63
+ * gh#47 directory rows: LIVE + TOMBSTONE (offline) — the board renders
64
+ * offline member cards grayed but PRESENT, so a canvas ref to an expired
65
+ * CLI never dangles silently. Re-publish un-tombstones (same handle).
66
+ */
67
+ directoryRows(): Array<{
68
+ team: string;
69
+ name: string;
70
+ description: string;
71
+ offline: boolean;
72
+ }>;
48
73
  mail(handle: string, from: string, text: string): void;
49
74
  readMail(handle: string): readonly CliMail[];
50
75
  private sweep;
76
+ /** Mark one row expired (tombstone) and persist. */
77
+ private markExpired;
78
+ /** Keep at most TOMBSTONE_CAP offline rows — oldest evictions fall off. */
79
+ private pruneTombstones;
51
80
  private persist;
52
81
  private restore;
53
82
  }
@@ -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
@@ -18,6 +18,22 @@ export declare const FAILURE_PENALTY = 100;
18
18
  export declare const SUCCESS_REWARD = 100;
19
19
  /** Score floor: at or below this a non-seed peer is evicted. */
20
20
  export declare const EVICTION_FLOOR = 0;
21
+ /**
22
+ * Eviction cooldown (the zombie-referral breaker): an evicted dead address
23
+ * is refused by {@link PeerStore.offer} for this long — without it the
24
+ * address comes straight back through a linked peer's card advertising
25
+ * (evict → learn → fail → evict forever; observed grinding .156:3081 down
26
+ * 3400 points). v1 keeps the window FIXED at 24h (dead-letter TTL
27
+ * convention); progressive backoff is a future note, not this slice.
28
+ */
29
+ export declare const EVICTION_COOLDOWN_MS: number;
30
+ /** Ledger bound: the newest 64 evictions survive (LRU by evictedAt). */
31
+ export declare const EVICTION_LEDGER_CAP = 64;
32
+ /** One eviction-ledger row: when a URL was evicted (epoch ms). */
33
+ export interface EvictionRecord {
34
+ readonly url: string;
35
+ readonly evictedAt: number;
36
+ }
21
37
  /** One tracked peer. */
22
38
  export interface PeerRecord {
23
39
  readonly url: string;
@@ -36,6 +52,8 @@ export interface PeerRecord {
36
52
  /** The persisted store document (whole-file read/write, small by design). */
37
53
  export interface PeerStoreSnapshot {
38
54
  readonly peers: readonly PeerRecord[];
55
+ /** Eviction ledger (gh#46): absent on pre-cooldown files = empty. */
56
+ readonly evictions?: readonly EvictionRecord[];
39
57
  }
40
58
  /**
41
59
  * Bounded, quality-scored peer collection with whole-file persistence.
@@ -48,12 +66,15 @@ export declare class PeerStore {
48
66
  private readonly cap;
49
67
  private readonly persistDebounceMs;
50
68
  private readonly peers;
69
+ /** Eviction ledger (gh#46): url → when it was evicted. Seeds never enter. */
70
+ private readonly evictions;
71
+ private readonly now;
51
72
  /**
52
73
  * @param seeds - the configured seed URLs; always retained (never evicted).
53
74
  * @param path - persistence file (`<dsh-home>/a2a/peers.json`); empty = no persistence.
54
75
  * @param cap - peer bound, seeds included (default {@link PEER_CAP}).
55
76
  */
56
- constructor(seeds: readonly string[], path: string, cap?: number, persistDebounceMs?: number);
77
+ constructor(seeds: readonly string[], path: string, cap?: number, persistDebounceMs?: number, now?: () => number);
57
78
  /**
58
79
  * Every tracked peer URL: seeds first (in first-tracked order), then
59
80
  * offered peers by score descending (equal scores keep first-tracked order).
@@ -131,6 +152,8 @@ export declare class PeerStore {
131
152
  bySession(session: string): string | undefined;
132
153
  /** Load a persisted snapshot on construction, if present. */
133
154
  private restore;
155
+ /** Keep the ledger at its LRU bound: the oldest evictions fall off. */
156
+ private pruneLedger;
134
157
  /**
135
158
  * Persist the current peer set (no-op when no path was configured).
136
159
  * Writes are debounced and asynchronous: a sweep that settles dozens of
@@ -0,0 +1,56 @@
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 declare const DEFAULT_HOP_BUDGET = 4;
17
+ /** Wire code for a zero-budget arrival (cluster-C registry, after -32006). */
18
+ export declare const WIRE_ERROR_BUDGET_EXHAUSTED = -32007;
19
+ /** The guard payload as it travels on the wire. */
20
+ export interface RecursionGuard {
21
+ readonly visitToken: string;
22
+ readonly hopBudget: number;
23
+ readonly deadlineAt?: number;
24
+ }
25
+ export type GuardVerdict = {
26
+ readonly kind: 'ok';
27
+ readonly guard: RecursionGuard;
28
+ readonly mode: 'explicit' | 'default';
29
+ } | {
30
+ readonly kind: 'budget-exhausted';
31
+ };
32
+ /**
33
+ * Ingress check: parse the header, validate the budget, fail closed. A
34
+ * malformed payload falls back to the default budget (a guard that cannot
35
+ * be evaluated must not reject honest first-hop traffic; the budget bound
36
+ * still applies). hopBudget <= 0 on arrival ⇒ budget-exhausted.
37
+ */
38
+ export declare function checkGuardAtIngress(header: string | undefined): GuardVerdict;
39
+ /** Mint the outbound guard for a first-hop dispatch (full budget at origin). */
40
+ export declare function mintGuard(): RecursionGuard;
41
+ /** Serialize for the X-A2A-Guard header. */
42
+ export declare function guardHeader(guard: RecursionGuard): string;
43
+ /**
44
+ * The ingress refusal path (gh#60): budget-exhausted answers the structured
45
+ * error, records the receiptless ledger row, and reports true (the caller
46
+ * returns). The whole response lives here so the ingress seam stays ONE
47
+ * line (the index.ts ratchet pays-as-it-goes: no net line growth).
48
+ */
49
+ export declare function refuseIfBudgetExhausted(req: {
50
+ headers: Record<string, unknown>;
51
+ }, res: {
52
+ writeHead: (status: number, headers: Record<string, string | number>) => void;
53
+ end: (body: string) => void;
54
+ }, team: string, caller: string, taskId: string, ledger: {
55
+ trackReceiptless: (taskId: string, team: string, peer: string, contextId?: string, brief?: string) => void;
56
+ }): boolean;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared A2A runtime types (gh#77): shapes that used to be declared inside
3
+ * `apply()` and are now read by the face modules as well.
4
+ * @module src/runtime
5
+ */
6
+ import type { A2aPeerCard } from './types.ts';
7
+ /**
8
+ * Recent routing activity for the network panel: a bounded ring of the
9
+ * latest inbound and outbound route outcomes, newest last. The state route
10
+ * serves a copy; the panel derives unread badges from new entries.
11
+ */
12
+ export interface RouteActivityEntry {
13
+ readonly ts: number;
14
+ readonly dir: 'in' | 'out';
15
+ readonly team: string;
16
+ readonly peer: string;
17
+ readonly ok: boolean;
18
+ }
19
+ /**
20
+ * In-flight outbound routes: a route registers on dial and unregisters on
21
+ * settlement, so the panel can show collaboration in progress ("who is
22
+ * waiting on whom") — the state route serves a snapshot.
23
+ */
24
+ export interface InFlightRoute {
25
+ readonly id: number;
26
+ readonly team: string;
27
+ readonly peer: string;
28
+ readonly startedAt: number;
29
+ }
30
+ /** One peer-card cache entry: when it was fetched and what came back. */
31
+ export interface CardCacheEntry {
32
+ at: number;
33
+ card: A2aPeerCard | undefined;
34
+ }
@@ -120,6 +120,12 @@ export interface ArchivedRecord {
120
120
  readonly endedBy?: ArchiveEnd;
121
121
  /** gh#31: which side of the wire the row served, copied from the owed row (absent on rows archived before the field). */
122
122
  readonly direction?: 'inbound' | 'outbound';
123
+ /**
124
+ * gh#68: copied from the owed row when it awaited no receipt, so a tidied
125
+ * fire-forget expiry is not read as a send we gave up on. Only `false` is
126
+ * ever stored; absent means a receipt was owed (or the row predates the field).
127
+ */
128
+ readonly receiptExpected?: false;
123
129
  }
124
130
  /**
125
131
  * How one archived row ended (gh#31). The recorded field wins; legacy rows