@nelsonlongxiang/dsh-open-a2a-net 0.6.28 → 0.6.30

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 (69) hide show
  1. package/README.md +87 -14
  2. package/README_zh.md +83 -7
  3. package/assets/nexusDist/assets/index-C_x3iAth.js +9 -0
  4. package/assets/nexusDist/assets/index-Dz8N80tI.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +0 -0
  7. package/cordis.patch.yml +21 -21
  8. package/lib/canvas-store.js +21 -1
  9. package/lib/client.js +64 -64
  10. package/lib/client.js.map +1 -1
  11. package/lib/faces/admin.js +755 -0
  12. package/lib/faces/canvas-notify.js +138 -0
  13. package/lib/faces/wire.js +669 -0
  14. package/lib/flood-guard.js +106 -0
  15. package/lib/index.js +200 -1392
  16. package/lib/recursion-guard.js +78 -0
  17. package/lib/runtime.js +1 -0
  18. package/lib/skill/SKILL.md +35 -9
  19. package/lib/skill/references/cli.md +24 -7
  20. package/lib/skill/references/collaboration.md +355 -0
  21. package/lib/skill/references/discovery.md +5 -2
  22. package/lib/skill/references/pitfalls.md +2 -1
  23. package/lib/skill/references/routing.md +18 -8
  24. package/lib/skill/references/setup.md +152 -0
  25. package/lib/task-ledger.js +40 -4
  26. package/lib/types/canvas-store.d.ts +10 -1
  27. package/lib/types/faces/admin.d.ts +133 -0
  28. package/lib/types/faces/canvas-notify.d.ts +74 -0
  29. package/lib/types/faces/wire.d.ts +171 -0
  30. package/lib/types/flood-guard.d.ts +29 -0
  31. package/lib/types/index.d.ts +11 -3
  32. package/lib/types/recursion-guard.d.ts +56 -0
  33. package/lib/types/runtime.d.ts +35 -0
  34. package/lib/types/task-ledger.d.ts +15 -1
  35. package/nexus-stage/package-lock.json +2262 -2262
  36. package/nexus-stage/src/canvas-ops.ts +175 -175
  37. package/nexus-stage/src/canvas-wire.ts +156 -156
  38. package/nexus-stage/src/edges.ts +72 -72
  39. package/nexus-stage/src/federation-overlay.ts +103 -103
  40. package/nexus-stage/src/federation.ts +240 -240
  41. package/nexus-stage/src/layout-doc.ts +143 -143
  42. package/nexus-stage/src/layout-wire.ts +158 -158
  43. package/nexus-stage/src/main.ts +5 -1
  44. package/nexus-stage/src/planning-card-text.ts +73 -0
  45. package/nexus-stage/src/planning-edges.ts +26 -0
  46. package/nexus-stage/src/planning-fit.ts +45 -0
  47. package/nexus-stage/src/planning-legend.ts +38 -0
  48. package/nexus-stage/src/planning-mirror.ts +31 -0
  49. package/nexus-stage/src/planning-overlap.ts +159 -0
  50. package/nexus-stage/src/planning-relayout.ts +121 -0
  51. package/nexus-stage/src/planning-search.ts +168 -0
  52. package/nexus-stage/src/planning-types.ts +1 -0
  53. package/nexus-stage/src/planning-undo.ts +68 -0
  54. package/nexus-stage/src/planning-view.ts +120 -121
  55. package/nexus-stage/src/planning.css +41 -8
  56. package/nexus-stage/src/seat.ts +35 -35
  57. package/nexus-stage/src/stage-name.ts +13 -13
  58. package/nexus-stage/src/tokens.ts +31 -31
  59. package/nexus-stage/src/viewport.ts +24 -1
  60. package/nexus-stage/src/wb-chat.ts +20 -6
  61. package/nexus-stage/src/wb-ledger.ts +20 -7
  62. package/nexus-stage/src/wb-names.ts +43 -0
  63. package/nexus-stage/src/workbench.css +8 -2
  64. package/nexus-stage/src/workbench.ts +55 -17
  65. package/nexus-stage/src/world.ts +13 -21
  66. package/package.json +216 -214
  67. package/assets/nexusDist/assets/index-1niOBYxp.js +0 -5
  68. package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
  69. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
@@ -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 |
@@ -60,7 +60,7 @@ export function briefOf(message) {
60
60
  * lazy — every read/write entry point sweeps first.
61
61
  */
62
62
  export const TASK_STALE_TTL_MS = 24 * 60 * 60_000;
63
- const ARCHIVE_ENDS = ['receipt', 'echo', 'abandoned', 'cancelled'];
63
+ const ARCHIVE_ENDS = ['receipt', 'echo', 'abandoned', 'cancelled', 'evicted'];
64
64
  /** Summary marker {@link TaskLedger.resolveEcho} writes on echo-settled rows. */
65
65
  const ECHO_SUMMARY = '(settled by content echo)';
66
66
  /**
@@ -165,7 +165,8 @@ export class TaskLedger {
165
165
  ...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
166
166
  ...(brief !== undefined && brief !== '' ? { brief } : {}),
167
167
  status: 'pending',
168
- }, ...this.tasks].slice(0, TASK_CAP);
168
+ }, ...this.tasks];
169
+ this.trimOwed();
169
170
  this.persist();
170
171
  }
171
172
  track(taskId, team, peer, contextId, brief) {
@@ -185,7 +186,8 @@ export class TaskLedger {
185
186
  ...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
186
187
  ...(brief !== undefined && brief !== '' ? { brief } : {}),
187
188
  status: 'pending',
188
- }, ...this.tasks].slice(0, TASK_CAP);
189
+ }, ...this.tasks];
190
+ this.trimOwed();
189
191
  this.persist();
190
192
  }
191
193
  /**
@@ -219,7 +221,8 @@ export class TaskLedger {
219
221
  ...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
220
222
  ...(brief !== undefined && brief !== '' ? { brief } : {}),
221
223
  status: 'pending',
222
- }, ...this.tasks].slice(0, TASK_CAP);
224
+ }, ...this.tasks];
225
+ this.trimOwed();
223
226
  this.persist();
224
227
  }
225
228
  /**
@@ -272,6 +275,7 @@ export class TaskLedger {
272
275
  outcome: 'abandoned',
273
276
  endedBy: 'abandoned',
274
277
  ...(record.direction !== undefined ? { direction: record.direction } : {}),
278
+ ...(record.receiptExpected === false ? { receiptExpected: false } : {}),
275
279
  });
276
280
  this.persist();
277
281
  return { outcome: 'cleared', taskId, archivedAt: resolvedAt };
@@ -313,6 +317,7 @@ export class TaskLedger {
313
317
  outcome: 'abandoned',
314
318
  endedBy: 'cancelled',
315
319
  ...(record.direction !== undefined ? { direction: record.direction } : {}),
320
+ ...(record.receiptExpected === false ? { receiptExpected: false } : {}),
316
321
  });
317
322
  this.persist();
318
323
  return {
@@ -363,6 +368,7 @@ export class TaskLedger {
363
368
  ...(elapsedMs !== undefined ? { elapsedMs } : {}),
364
369
  endedBy: 'receipt',
365
370
  ...(record.direction !== undefined ? { direction: record.direction } : {}),
371
+ ...(record.receiptExpected === false ? { receiptExpected: false } : {}),
366
372
  });
367
373
  this.persist();
368
374
  return {
@@ -445,6 +451,7 @@ export class TaskLedger {
445
451
  summary: ECHO_SUMMARY,
446
452
  endedBy: 'echo',
447
453
  ...(record.direction !== undefined ? { direction: record.direction } : {}),
454
+ ...(record.receiptExpected === false ? { receiptExpected: false } : {}),
448
455
  });
449
456
  settled.push(record.taskId);
450
457
  }
@@ -456,6 +463,33 @@ export class TaskLedger {
456
463
  archiveSettled(record) {
457
464
  this.archived = [record, ...this.archived].slice(0, this.archiveCap);
458
465
  }
466
+ /**
467
+ * gh#86 (chief order direct-4f596524): when the owed book exceeds
468
+ * TASK_CAP, the overflow rows go to the ARCHIVE as evicted - visible
469
+ * for audit, never silently dropped. The review seat's recommendation,
470
+ * chief-endorsed: the silent cap slice lost the oldest pending rows
471
+ * with no trace. L8 criterion: zero silently-dropped.
472
+ */
473
+ trimOwed() {
474
+ if (this.tasks.length <= TASK_CAP)
475
+ return;
476
+ const overflow = this.tasks.slice(TASK_CAP);
477
+ this.tasks = this.tasks.slice(0, TASK_CAP);
478
+ const now = this.now();
479
+ for (const row of overflow) {
480
+ this.archiveSettled({
481
+ taskId: row.taskId,
482
+ team: row.team,
483
+ peer: row.peer,
484
+ startedAt: row.startedAt,
485
+ ...(row.contextId !== undefined ? { contextId: row.contextId } : {}),
486
+ resolvedAt: now,
487
+ ...(row.brief !== undefined ? { brief: row.brief } : {}),
488
+ endedBy: 'evicted',
489
+ ...(row.direction !== undefined ? { direction: row.direction } : {}),
490
+ });
491
+ }
492
+ }
459
493
  /** Lazy dead-letter sweep: overdue pending rows flip status, at most once. */
460
494
  sweep(now) {
461
495
  let changed = false;
@@ -505,6 +539,7 @@ export class TaskLedger {
505
539
  ...(typeof entry.lateReceiptAt === 'number' && Number.isFinite(entry.lateReceiptAt) ? { lateReceiptAt: entry.lateReceiptAt } : {}),
506
540
  ...(typeof entry.endedBy === 'string' && ARCHIVE_ENDS.includes(entry.endedBy) ? { endedBy: entry.endedBy } : {}),
507
541
  ...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
542
+ ...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
508
543
  });
509
544
  continue;
510
545
  }
@@ -550,6 +585,7 @@ export class TaskLedger {
550
585
  ...(typeof entry.lateReceiptAt === 'number' && Number.isFinite(entry.lateReceiptAt) ? { lateReceiptAt: entry.lateReceiptAt } : {}),
551
586
  ...(typeof entry.endedBy === 'string' && ARCHIVE_ENDS.includes(entry.endedBy) ? { endedBy: entry.endedBy } : {}),
552
587
  ...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
588
+ ...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
553
589
  });
554
590
  }
555
591
  }
@@ -51,14 +51,23 @@ export declare class CanvasStore {
51
51
  /** Team entries in creation order; members in add order (routing priority). */
52
52
  private teams;
53
53
  private readonly path;
54
+ private readonly listeners;
54
55
  /**
55
56
  * @param path - the snapshot file; an empty path keeps the store in memory.
56
57
  */
57
58
  constructor(path: string);
59
+ /**
60
+ * Observe changes: the listener runs after every write that changed the
61
+ * store (never on restore). A throwing listener cannot break the write.
62
+ * @param listener - called with no arguments.
63
+ * @returns the unsubscribe function.
64
+ */
65
+ onChange(listener: () => void): () => void;
58
66
  /** Load a persisted snapshot, if present and well-formed. */
59
67
  private restore;
60
- /** Persist the current snapshot (no-op without a path). */
68
+ /** Persist the current snapshot (no-op without a path), then tell the listeners. */
61
69
  private persist;
70
+ private write;
62
71
  /** Every team name, in creation order. */
63
72
  list(): readonly string[];
64
73
  /** Whether the named team exists. */
@@ -0,0 +1,133 @@
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 CanvasNotifyDeps } from './canvas-notify.ts';
9
+ import type { CliDepositStore } from '../cli-deposit.ts';
10
+ import type { ExposureStore } from '../exposure-store.ts';
11
+ import type { GroupStore } from '../group-store.ts';
12
+ import type { IdempotencyStore } from '../idempotency-store.ts';
13
+ import type { JoinedSessions } from '../joined-store.ts';
14
+ import type { LayoutStore } from '../layout-store.ts';
15
+ import type { PeerStore } from '../peer-store.ts';
16
+ import { type TaskLedger } from '../task-ledger.ts';
17
+ import type { TeamMembershipStore } from '../team-store.ts';
18
+ /** Remote-team rows cached for the state face (see refreshRemoteRows). */
19
+ export type RemoteRowsCache = {
20
+ at: number;
21
+ rows: {
22
+ team: string;
23
+ name: string;
24
+ origin?: string;
25
+ via?: string;
26
+ workspace?: string;
27
+ legacy?: boolean;
28
+ teams?: readonly string[];
29
+ }[];
30
+ } | undefined;
31
+ /** Everything the admin faces used to capture from `apply()`. */
32
+ export interface AdminFaceDeps extends Pick<CanvasNotifyDeps, 'verifyOriginClaim' | 'callerLinkedToThisHost' | 'cachedCardFetch'> {
33
+ readonly ctx: Context;
34
+ readonly config: Config;
35
+ readonly logger: ReturnType<Context['logger']>;
36
+ readonly home: string;
37
+ readonly session: string;
38
+ readonly lanIp: string;
39
+ readonly PLUGIN_VERSION: string;
40
+ /** index.ts's `import.meta.url` — the base the asset roots resolve against. */
41
+ readonly indexUrl: string;
42
+ readonly controlRoute: (handler: (req: IncomingMessage, res: ServerResponse) => void) => (req: IncomingMessage, res: ServerResponse) => void;
43
+ readonly controlTrustedWithoutKey: (req: IncomingMessage) => boolean;
44
+ readonly readJsonBody: (req: IncomingMessage, res: ServerResponse, use: (body: {
45
+ readonly id?: unknown;
46
+ readonly name?: unknown;
47
+ readonly action?: unknown;
48
+ }) => void, maxBytes?: number) => void;
49
+ readonly peerStore: PeerStore;
50
+ readonly peerLinkState: (peerUrl: string) => {
51
+ link: 'linked' | 'stranger';
52
+ verified: boolean;
53
+ };
54
+ readonly originClaimHeaders: () => Record<string, string>;
55
+ readonly refreshRemoteRows: () => void;
56
+ readonly joinedSessions: JoinedSessions;
57
+ readonly exposureStore: ExposureStore;
58
+ readonly teamMemberships: TeamMembershipStore;
59
+ readonly cliDeposits: CliDepositStore;
60
+ readonly groupStore: GroupStore;
61
+ readonly canvasStore: CanvasStore;
62
+ readonly layoutStore: LayoutStore;
63
+ readonly taskLedger: TaskLedger;
64
+ readonly idempotencyStore: IdempotencyStore;
65
+ readonly inFlightRoutes: Map<number, InFlightRoute>;
66
+ readonly recentActivity: RouteActivityEntry[];
67
+ readonly teamAuditRing: {
68
+ at: number;
69
+ action: 'join' | 'leave' | 'archive-prune' | 'agent-less';
70
+ team: string;
71
+ id: string;
72
+ ok: boolean;
73
+ reason?: string;
74
+ }[];
75
+ readonly reportRing: {
76
+ at: number;
77
+ level: 'info' | 'warning' | 'error';
78
+ message: string;
79
+ session: string;
80
+ task?: string;
81
+ }[];
82
+ readonly sessionNodes: Map<string, Agent>;
83
+ readonly liveRoots: Map<string, Agent>;
84
+ readonly mountSessionNode: (agent: Agent) => void;
85
+ readonly coldJoinedIds: () => Promise<Set<string>>;
86
+ readonly prewarmStatus: {
87
+ state: 'off' | 'skipped:no-wake-face' | 'draining' | 'done' | 'cancelled';
88
+ attempted: number;
89
+ woken: number;
90
+ failed: Array<{
91
+ id: string;
92
+ error: string;
93
+ }>;
94
+ };
95
+ readonly reconcileStatus: {
96
+ state: 'off' | 'idle' | 'draining';
97
+ lastTickAt: number;
98
+ lastChecked: number;
99
+ woken: number;
100
+ rows: Array<{
101
+ id: string;
102
+ error: string;
103
+ attempts: number;
104
+ nextRetryAt: number;
105
+ needsRepair: boolean;
106
+ }>;
107
+ };
108
+ readonly id8: (id: string) => string;
109
+ readonly sessionLabelOf: (agent: Agent) => string;
110
+ readonly sessionTeamOf: (agent: Agent) => string;
111
+ readonly nodeMetadataOf: (agent: Agent) => {
112
+ name: string;
113
+ description: string;
114
+ workspace?: string;
115
+ };
116
+ readonly canvasTeamOf: (name: string) => string;
117
+ readonly archivedSessionFilter: () => ((id: string) => boolean) | undefined;
118
+ readonly pruneArchivedJoins: () => void;
119
+ readonly pruneCanvasMemberships: () => void;
120
+ readonly cardCache: Map<string, CardCacheEntry>;
121
+ readonly remoteRowsCache: RemoteRowsCache;
122
+ readonly legacyInboundCount: number;
123
+ readonly ownCardUrl: string;
124
+ ownerAllowlist: string[];
125
+ ownerTeamScopeInfo: boolean;
126
+ ownerAnnounceRestartPending: boolean;
127
+ }
128
+ /**
129
+ * Register the admin faces on a settled web server.
130
+ * @param webServer - the settled host web server.
131
+ * @param d - the captures listed on {@link AdminFaceDeps}.
132
+ */
133
+ export declare function registerAdminFaces(webServer: WebServer, d: AdminFaceDeps): void;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Canvas change notification (owner ruling 2026-10-29: single owner + mirror).
3
+ *
4
+ * A canvas team belongs to the host that created it. Peers MIRROR it from the
5
+ * owner's full card (`canvasOrg`, rendered through the state face's
6
+ * `peerOrg`) and EDIT it through the fleet relay (`/__dsh_a2a/fleet-canvas-op`
7
+ * → the owner's `/a2a/canvas-op`). Nothing is replicated, so there is no
8
+ * merge: the owner's store is the single truth. What was missing is
9
+ * freshness — a mirror only moved when the 60s card-cache window lapsed.
10
+ *
11
+ * This face closes that gap with a content-free ping:
12
+ * - SEND: any change to this host's canvas store (local board, a peer's
13
+ * canvas-op, a leave/archive prune) schedules one debounced
14
+ * `POST /a2a/canvas-changed` to every tracked peer that is linked to this
15
+ * host by the same predicate the card face uses to serve the full card —
16
+ * exactly the peers that can see `canvasOrg` at all. The body is empty:
17
+ * the ping says "re-read my card", never what changed.
18
+ * - RECEIVE: a verified OriginClaim from a TRACKED peer marks that peer's
19
+ * cached card stale (the card stays readable — no frame flicker) and
20
+ * refetches it through the shared single-flight cache; the board's next
21
+ * 3s poll renders the new org. Per-peer rate limit; a caller whose claimed
22
+ * URL is not exactly a tracked peer with a cached card is refused before
23
+ * its claim is verified (nothing is fetched on its say-so), an unsigned one
24
+ * right after.
25
+ * @module src/faces/canvas-notify
26
+ */
27
+ import type { IncomingMessage } from 'node:http';
28
+ import type { Context } from '@deepseek-ai/cordis';
29
+ import type WebServer from '@deepseek-ai/dsh-host-webserver';
30
+ import type { A2aPeerCard } from '../types.ts';
31
+ import type { CanvasStore } from '../canvas-store.ts';
32
+ import type { PeerStore } from '../peer-store.ts';
33
+ import type { CardCacheEntry } from '../runtime.ts';
34
+ /** Coalesce a burst of store writes (a drag that adds three members) into one ping. */
35
+ export declare const CANVAS_NOTIFY_DEBOUNCE_MS = 400;
36
+ /** A peer's pings refresh its card at most this often. */
37
+ export declare const CANVAS_REFRESH_MIN_GAP_MS = 1000;
38
+ /** The captures this face needs from `apply()`. */
39
+ export interface CanvasNotifyDeps {
40
+ readonly ctx: Context;
41
+ readonly logger: ReturnType<Context['logger']>;
42
+ readonly peerStore: PeerStore;
43
+ readonly canvasStore: CanvasStore;
44
+ readonly cardCache: Map<string, CardCacheEntry>;
45
+ readonly originClaimHeaders: () => Record<string, string>;
46
+ readonly verifyOriginClaim: (req: IncomingMessage) => Promise<{
47
+ ok: true;
48
+ caller: string;
49
+ callerUrl: string;
50
+ card: A2aPeerCard;
51
+ } | {
52
+ ok: false;
53
+ reason: string;
54
+ }>;
55
+ readonly callerLinkedToThisHost: (caller: string, callerUrl: string, callerCard: A2aPeerCard | undefined) => boolean;
56
+ readonly cachedCardFetch: (url: string) => Promise<A2aPeerCard | undefined>;
57
+ }
58
+ /**
59
+ * Re-read one peer's card now. Each pass pre-stales the entry (the literal
60
+ * carries no missAt) so it is a GENUINE refetch: pass 1 may join a flight
61
+ * that started before the peer's change; pass 2 always starts after the
62
+ * first settled — that one is the answer. A miss keeps the last good card
63
+ * inside cachedCardFetch itself (the missAt window); there is nothing to
64
+ * restore here.
65
+ * @param d - the card cache and its single-flight fetch.
66
+ * @param url - the tracked peer URL.
67
+ */
68
+ export declare function refreshPeerCard(d: Pick<CanvasNotifyDeps, 'cardCache' | 'cachedCardFetch'>, url: string): Promise<void>;
69
+ /**
70
+ * Register the receive face and subscribe the send side to the canvas store.
71
+ * @param webServer - the settled host web server.
72
+ * @param d - the captures listed on {@link CanvasNotifyDeps}.
73
+ */
74
+ export declare function registerCanvasNotify(webServer: WebServer, d: CanvasNotifyDeps): void;