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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +85 -14
  2. package/README_zh.md +81 -7
  3. package/assets/nexusDist/assets/index-BqepxJ7E.css +1 -0
  4. package/assets/nexusDist/assets/index-CTWH6QRh.js +9 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +624 -624
  7. package/cordis.patch.yml +21 -21
  8. package/lib/client.js +64 -64
  9. package/lib/client.js.map +1 -1
  10. package/lib/faces/admin.js +744 -0
  11. package/lib/index.js +139 -737
  12. package/lib/recursion-guard.js +78 -0
  13. package/lib/runtime.js +1 -0
  14. package/lib/skill/SKILL.md +34 -9
  15. package/lib/skill/references/cli.md +24 -7
  16. package/lib/skill/references/discovery.md +5 -2
  17. package/lib/skill/references/pitfalls.md +2 -1
  18. package/lib/skill/references/routing.md +18 -8
  19. package/lib/skill/references/setup.md +152 -0
  20. package/lib/task-ledger.js +6 -0
  21. package/lib/types/faces/admin.d.ts +132 -0
  22. package/lib/types/index.d.ts +11 -3
  23. package/lib/types/recursion-guard.d.ts +56 -0
  24. package/lib/types/runtime.d.ts +34 -0
  25. package/lib/types/task-ledger.d.ts +6 -0
  26. package/nexus-stage/package-lock.json +2262 -2262
  27. package/nexus-stage/src/canvas-ops.ts +175 -175
  28. package/nexus-stage/src/canvas-wire.ts +156 -156
  29. package/nexus-stage/src/edges.ts +72 -72
  30. package/nexus-stage/src/federation-overlay.ts +103 -103
  31. package/nexus-stage/src/federation.ts +240 -240
  32. package/nexus-stage/src/layout-doc.ts +143 -143
  33. package/nexus-stage/src/layout-wire.ts +158 -158
  34. package/nexus-stage/src/planning-card-text.ts +73 -0
  35. package/nexus-stage/src/planning-fit.ts +45 -0
  36. package/nexus-stage/src/planning-legend.ts +38 -0
  37. package/nexus-stage/src/planning-overlap.ts +134 -0
  38. package/nexus-stage/src/planning-relayout.ts +121 -0
  39. package/nexus-stage/src/planning-search.ts +168 -0
  40. package/nexus-stage/src/planning-view.ts +63 -88
  41. package/nexus-stage/src/planning.css +39 -8
  42. package/nexus-stage/src/seat.ts +35 -35
  43. package/nexus-stage/src/stage-name.ts +13 -13
  44. package/nexus-stage/src/tokens.ts +31 -31
  45. package/nexus-stage/src/viewport.ts +24 -1
  46. package/nexus-stage/src/wb-chat.ts +20 -6
  47. package/nexus-stage/src/wb-ledger.ts +20 -7
  48. package/nexus-stage/src/wb-names.ts +43 -0
  49. package/nexus-stage/src/workbench.css +8 -2
  50. package/nexus-stage/src/workbench.ts +55 -17
  51. package/nexus-stage/src/world.ts +5 -1
  52. package/package.json +216 -214
  53. package/assets/nexusDist/assets/index-1niOBYxp.js +0 -5
  54. package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
  55. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
@@ -0,0 +1,744 @@
1
+ /**
2
+ * The `/__dsh_a2a/*` admin faces and the two workbench pages (gh#77 batch B1).
3
+ *
4
+ * Moved verbatim out of `apply()` in `src/index.ts`: the routes register
5
+ * when the web server settles, exactly as before. What used to be closure
6
+ * captures now arrives through {@link AdminFaceDeps}:
7
+ * - stable bindings (stores, helpers, config) are destructured once below, so
8
+ * the route bodies read as they did in index.ts;
9
+ * - the six `let` bindings the routes read or write go through accessors on
10
+ * `d` (`d.ownerAllowlist = …` writes back into index.ts), keeping the live
11
+ * binding semantics of the closure; so does `cardCache`, which apply()
12
+ * declares after this registration (a destructure would hit its TDZ);
13
+ * - `indexUrl` is index.ts's `import.meta.url`: the asset roots
14
+ * (`../assets/stageDist`, `../assets/nexusDist`) resolve against the
15
+ * entry module, not this file (tsc mirrors src/ under lib/).
16
+ * @module src/faces/admin
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { isRemoteMemberRef, validRemoteMemberRef } from "../canvas-store.js";
22
+ import { applyOwnerSettings } from "../owner-settings.js";
23
+ import { resolveStageMount } from "../stage-mount.js";
24
+ import { ARCHIVE_CAP, archivedEndedBy } from "../task-ledger.js";
25
+ import { MAX_LAYOUT_BODY_BYTES } from "../transport-caps.js";
26
+ /**
27
+ * Register the admin faces on a settled web server.
28
+ * @param webServer - the settled host web server.
29
+ * @param d - the captures listed on {@link AdminFaceDeps}.
30
+ */
31
+ export function registerAdminFaces(webServer, d) {
32
+ const { ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, indexUrl, controlRoute, controlTrustedWithoutKey, readJsonBody, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, } = d;
33
+ ctx.effect(() => webServer.register({
34
+ kind: 'exact',
35
+ path: '/__dsh_a2a/state',
36
+ handler: controlRoute((_req, res) => {
37
+ void (async () => {
38
+ const t0 = Date.now();
39
+ // The panel polls this route: pruning here makes a mid-session
40
+ // archive leave the network within one poll interval, no restart
41
+ // required.
42
+ pruneArchivedJoins();
43
+ pruneCanvasMemberships();
44
+ const tPrune = Date.now();
45
+ const assignments = groupStore.all();
46
+ const groupOf = (id) => {
47
+ const name = assignments[id];
48
+ return name === undefined ? undefined : name;
49
+ };
50
+ // Archived sessions are closed: the GUI hides them everywhere, so
51
+ // the panel must not re-list them as join surface rows — joined
52
+ // or not. (The prune above already took their join intents; this
53
+ // covers never-joined live roots and any intent a restart left.)
54
+ const isArchived = archivedSessionFilter();
55
+ const tFacts0 = Date.now();
56
+ const sessions = [...liveRoots.values()]
57
+ .filter(agent => isArchived?.(String(agent.id)) !== true)
58
+ .map(agent => ({
59
+ id: String(agent.id),
60
+ label: sessionLabelOf(agent),
61
+ team: sessionTeamOf(agent),
62
+ ...nodeMetadataOf(agent),
63
+ joined: sessionNodes.has(String(agent.id)),
64
+ live: true,
65
+ teams: [...teamMemberships.teamsOf(String(agent.id))],
66
+ // #19 D3: the session's exposure tier (linked floor /
67
+ // fleet opt-up) — readable on the state face.
68
+ exposure: exposureStore.tierOf(String(agent.id)),
69
+ ...(canvasStore.teamsOf(String(agent.id)).length > 0 ? { canvasTeams: [...canvasStore.teamsOf(String(agent.id))] } : {}),
70
+ ...(groupOf(String(agent.id)) !== undefined ? { group: groupOf(String(agent.id)) } : {}),
71
+ }));
72
+ // Cold joined sessions: a remembered intent whose Agent is not
73
+ // back yet (host restarted, session not opened). The row carries
74
+ // no facts — the Web client merges the session title from its
75
+ // own list — and wakes through the normal session-open path,
76
+ // after which the remount above joins it automatically.
77
+ // Cold rows only exist when a joined intent lacks a live agent;
78
+ // with all intents live (or none) the persistence layer is never
79
+ // asked and the handler stays synchronous to its response.
80
+ const tFacts1 = Date.now();
81
+ const coldCandidates = joinedSessions.list().filter(id => !liveRoots.has(id));
82
+ const persistence = ctx.get('sessionPersistence');
83
+ const persisted = persistence !== undefined && coldCandidates.length > 0 ? await coldJoinedIds() : new Set();
84
+ const tCold = Date.now();
85
+ for (const id of coldCandidates) {
86
+ // Archived intents are retained on disk (an archived window is
87
+ // transient; the 3081 wake defect came from deleting them), so
88
+ // the join surface must skip them here instead.
89
+ if (!persisted.has(id) || isArchived?.(id) === true)
90
+ continue;
91
+ sessions.push({
92
+ id,
93
+ label: `${session}-${id8(id)}`,
94
+ team: `${config.team}/${id8(id)}`,
95
+ joined: true,
96
+ live: false,
97
+ teams: [...teamMemberships.teamsOf(id)],
98
+ ...(canvasStore.teamsOf(id).length > 0 ? { canvasTeams: [...canvasStore.teamsOf(id)] } : {}),
99
+ ...(groupOf(id) !== undefined ? { group: groupOf(id) } : {}),
100
+ });
101
+ }
102
+ const body = JSON.stringify({
103
+ nodes: true,
104
+ version: PLUGIN_VERSION,
105
+ sessions,
106
+ groups: groupStore.list(),
107
+ canvas: {
108
+ teams: canvasStore.list().map(name => ({
109
+ name,
110
+ team: canvasTeamOf(name),
111
+ members: canvasStore.membersOf(name).map(id => {
112
+ // Dual shape: local members report this zone's join/live
113
+ // facts; remote refs are opaque team handles — the state
114
+ // face passes them through with remote:true for the
115
+ // workbench's member chips and pickers.
116
+ if (isRemoteMemberRef(id)) {
117
+ return { id, team: id, remote: true };
118
+ }
119
+ return {
120
+ id,
121
+ team: `${config.team}/${id8(id)}`,
122
+ joined: sessionNodes.has(id) || joinedSessions.has(id),
123
+ live: sessionNodes.has(id),
124
+ };
125
+ }),
126
+ })),
127
+ },
128
+ host: lanIp === '' ? {} : { lanIp },
129
+ // gh#20 W-C (host rail): each peer row also carries its display
130
+ // name and version from the warm card (absent until a fetch),
131
+ // and the last-contact timestamp for the health lamp. W-A's
132
+ // link/verified ride along for the badge.
133
+ peers: peerStore.list().map(url => {
134
+ const card = d.cardCache.get(url)?.card;
135
+ const lastContact = peerStore.lastContact(url);
136
+ const lastContactOk = peerStore.lastContactOk(url);
137
+ return {
138
+ url,
139
+ score: peerStore.score(url),
140
+ ...(card?.name !== undefined && card.name !== '' ? { name: card.name } : {}),
141
+ ...(typeof card?.version === 'string' && card.version !== '' ? { version: card.version } : {}),
142
+ ...(lastContact !== undefined ? { lastContactAt: lastContact } : {}),
143
+ ...(lastContactOk !== undefined ? { lastContactOk } : {}),
144
+ ...peerLinkState(url),
145
+ };
146
+ }),
147
+ remote: (refreshRemoteRows(), (d.remoteRowsCache?.rows ?? []).map(row => {
148
+ const canvasTeams = canvasStore.teamsOf(row.team);
149
+ return canvasTeams.length > 0 ? { ...row, canvasTeams: [...canvasTeams] } : row;
150
+ })),
151
+ activity: recentActivity.slice(),
152
+ inFlight: [...inFlightRoutes.values()].map(route => ({
153
+ team: route.team,
154
+ peer: route.peer,
155
+ startedAt: route.startedAt,
156
+ })),
157
+ // Owed receipts: pending rows only — the cross-turn waits the
158
+ // in-flight ring cannot show. Dead-lettered rows ride their own
159
+ // list and settled ones just a count, all additive so the panel
160
+ // keeps rendering plain owed rows until it learns the tiers.
161
+ tasks: taskLedger.list()
162
+ .filter(task => task.status === 'pending')
163
+ .map(task => ({
164
+ taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, status: task.status,
165
+ direction: task.direction ?? 'outbound', receiptExpected: task.receiptExpected !== false,
166
+ // The dispatch brief rides every row: a task id and a team
167
+ // say nothing about WHAT is owed — supervision cannot tell
168
+ // a nudge from an abandon without it.
169
+ ...(task.brief !== undefined ? { brief: task.brief } : {}),
170
+ // C-ruling bucket split: a pending row is settleable while
171
+ // it is young, expected, and answerable; past the stale
172
+ // hour it is conversational noise waiting on the dead TTL.
173
+ settleable: task.receiptExpected !== false && Date.now() - task.startedAt < 60 * 60_000,
174
+ })),
175
+ // C face: the stale/conversational tail as a count, so the
176
+ // panel can render only truly settleable debt.
177
+ tasksStaleCount: taskLedger.list().filter(task => task.status === 'pending' && (task.receiptExpected === false || Date.now() - task.startedAt >= 60 * 60_000)).length,
178
+ tasksDead: taskLedger.list()
179
+ .filter(task => task.status === 'dead')
180
+ .map(task => ({ taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, deadAt: task.deadAt ?? task.startedAt, reason: task.reason ?? 'unspecified', ...(task.brief !== undefined ? { brief: task.brief } : {}), ...(task.direction !== undefined ? { direction: task.direction } : {}) })),
181
+ // gh#31: per-task outcomes for rows that LEFT the ledger — the
182
+ // chat drawer used to guess (settled, abandoned or cancelled).
183
+ // Bounded to the archive (≤64), newest first, with the
184
+ // controlled-vocabulary verdict each row settled by.
185
+ // gh#31 follow-up: archive() is already newest first (settles
186
+ // prepend), so the projection keeps that order — the earlier
187
+ // .reverse() served it oldest first. Each row also says HOW it
188
+ // ended (endedBy: abandon and cancel both record outcome
189
+ // 'abandoned', and so does a peer's own 'abandoned' receipt),
190
+ // whether a receipt came after the caller ended it (late), and
191
+ // its direction when known.
192
+ archived: taskLedger.archive().slice(0, ARCHIVE_CAP).map(row => ({
193
+ taskId: row.taskId,
194
+ team: row.team,
195
+ peer: row.peer,
196
+ startedAt: row.startedAt,
197
+ resolvedAt: row.resolvedAt,
198
+ outcome: row.outcome ?? 'unclassified',
199
+ endedBy: archivedEndedBy(row),
200
+ ...(row.direction !== undefined ? { direction: row.direction } : {}),
201
+ // gh#68: present only when the row awaited no receipt.
202
+ ...(row.receiptExpected === false ? { receiptExpected: false } : {}),
203
+ ...(row.lateReceiptAt !== undefined ? { late: true } : {}),
204
+ ...(row.brief !== undefined ? { brief: row.brief } : {}),
205
+ ...(row.summary !== undefined && row.summary !== '' ? { summary: row.summary.slice(0, 160) } : {}),
206
+ })),
207
+ archivedCount: taskLedger.archive().length,
208
+ // Ledger density face: the settled archive split by verdict, so
209
+ // the workbench can say WHICH way those rows settled instead of
210
+ // just how many. Additive and bounded (outcome vocabulary is a
211
+ // closed set); rows without an envelope outcome count as
212
+ // 'unclassified' rather than being dropped.
213
+ archivedSplit: (() => {
214
+ const split = {};
215
+ for (const row of taskLedger.archive()) {
216
+ const key = row.outcome ?? 'unclassified';
217
+ split[key] = (split[key] ?? 0) + 1;
218
+ }
219
+ return split;
220
+ })(),
221
+ // 0.5.36 observability: the idempotency window aggregate —
222
+ // window occupancy, outcome split, and cumulative claim-verdict
223
+ // counters (the data the UNIQUE-ization decision waits on).
224
+ // Read-only; no fingerprints cross here (they are /a2a/query's
225
+ // only auth material).
226
+ idempotency: idempotencyStore.stats(),
227
+ // gh#41 fleet planning: each tracked peer's published canvas
228
+ // org (full-card canvasOrg) from the warm card cache — the
229
+ // board renders REMOTE team frames from this. Absent until a
230
+ // linked fetch warmed the card.
231
+ peerOrg: peerStore.list().map(url => {
232
+ const card = d.cardCache.get(url)?.card;
233
+ const org = card?.canvasOrg;
234
+ return Array.isArray(org)
235
+ ? { url, org: org.filter((e) => e !== null && typeof e === 'object')
236
+ .map(e => ({ name: typeof e.name === 'string' ? e.name : '', team: typeof e.team === 'string' ? e.team : '', members: Array.isArray(e.members) ? e.members.filter((m) => typeof m === 'string') : [] }))
237
+ .filter(e => e.team !== '') }
238
+ : { url, org: [] };
239
+ }),
240
+ // gh#9+gh#10 M1: envelope-absent deliveries during the
241
+ // OriginClaim window — the operator watches this drain to
242
+ // zero before closing the window.
243
+ legacyInbound: d.legacyInboundCount,
244
+ // F-observability: the boot prewarm's lifecycle — a dead or
245
+ // skipped prewarm is the root shape behind "cold rows pile up
246
+ // after restart" and must be readable, not a console rumor.
247
+ prewarm: { ...prewarmStatus, failed: [...prewarmStatus.failed] },
248
+ // F8 observability: reconciliation cadence, wake outcomes,
249
+ // and per-row failure reasons with backoff and the parked
250
+ // needs-repair state — the cold row's "why am I still cold".
251
+ reconcile: { ...reconcileStatus, rows: [...reconcileStatus.rows] },
252
+ // S1: flat node registry — local sessions and remote card
253
+ // nodes in one table, zone-qualified team handles as the
254
+ // stable cross-host addresses. Local ids are the session
255
+ // UUIDs; remote ids are the publishing team handles. (The
256
+ // `nodes` boolean above is the capability flag the panel
257
+ // already consumes; this face rides its own key.)
258
+ registry: {
259
+ nodes: [
260
+ ...sessions.map(row => ({
261
+ id: String(row.id),
262
+ label: String(row.label ?? ''),
263
+ team: String(row.team ?? ''),
264
+ zone: config.team,
265
+ live: row.live === true,
266
+ joined: row.joined === true,
267
+ ...(typeof row.workspace === 'string' && row.workspace !== '' ? { workspace: row.workspace } : {}),
268
+ teams: [...teamMemberships.teamsOf(String(row.id))],
269
+ })),
270
+ ...(d.remoteRowsCache?.rows ?? []).map(row => ({
271
+ id: String(row.team),
272
+ label: String(row.name ?? ''),
273
+ team: String(row.team),
274
+ zone: String(row.team).split('/')[0] ?? config.team,
275
+ host: String(row.origin ?? ''),
276
+ ...(row.workspace !== undefined && row.workspace !== '' ? { workspace: row.workspace } : {}),
277
+ ...(row.legacy === true ? { legacy: true } : {}),
278
+ teams: [...(row.teams ?? [])],
279
+ remote: true,
280
+ })),
281
+ ],
282
+ // S2: local team rosters rebuilt from member declarations.
283
+ teams: (() => {
284
+ const roster = new Map();
285
+ for (const entry of teamMemberships.list()) {
286
+ for (const team of entry.teams) {
287
+ const members = roster.get(team) ?? [];
288
+ members.push(entry.session);
289
+ roster.set(team, members);
290
+ }
291
+ }
292
+ return [...roster.entries()].map(([team, members]) => ({
293
+ team,
294
+ members,
295
+ live: members.filter(member => liveRoots.has(member)).length,
296
+ }));
297
+ })(),
298
+ // S4 audit face: join/leave attempts with outcomes — the
299
+ // queryable half that rotating log lines cannot be.
300
+ teamAudit: [...teamAuditRing],
301
+ },
302
+ // Unjoined-node reports: the one-way signal ring (design:
303
+ // docs/design/unjoined-report-ingress.md), newest first.
304
+ reports: [...reportRing].reverse(),
305
+ });
306
+ res.writeHead(200, {
307
+ 'Content-Type': 'application/json',
308
+ 'Content-Length': Buffer.byteLength(body),
309
+ // Phase timing (ms since request start): prune / live-row facts /
310
+ // cold-rows enumeration. Diagnostic for the poll-latency question.
311
+ 'X-A2A-Timing': `prune=${tPrune - t0};facts=${tFacts1 - tFacts0};cold=${tCold - tFacts1};total=${Date.now() - t0}`,
312
+ });
313
+ res.end(body);
314
+ })();
315
+ }),
316
+ }), 'a2a: session-node state route');
317
+ ctx.effect(() => webServer.register({
318
+ kind: 'exact',
319
+ path: '/__dsh_a2a/join',
320
+ handler: controlRoute((req, res) => {
321
+ readJsonBody(req, res, (body) => {
322
+ const agent = typeof body.id === 'string' ? liveRoots.get(body.id) : undefined;
323
+ if (agent === undefined) {
324
+ const payload = JSON.stringify({ error: 'no live session with that id' });
325
+ res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
326
+ res.end(payload);
327
+ return;
328
+ }
329
+ mountSessionNode(agent);
330
+ joinedSessions.add(String(agent.id));
331
+ const payload = JSON.stringify({ id: body.id, label: sessionLabelOf(agent), team: sessionTeamOf(agent) });
332
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
333
+ res.end(payload);
334
+ });
335
+ }),
336
+ }), 'a2a: session-node join route');
337
+ ctx.effect(() => webServer.register({
338
+ kind: 'exact',
339
+ path: '/__dsh_a2a/leave',
340
+ handler: controlRoute((req, res) => {
341
+ readJsonBody(req, res, (body) => {
342
+ const id = typeof body.id === 'string' ? body.id : '';
343
+ if (id !== '')
344
+ sessionNodes.delete(id);
345
+ joinedSessions.remove(id);
346
+ // Leaving the network leaves every canvas team too: membership
347
+ // without join consent would be a routing backdoor. Team
348
+ // roster declarations die the same way (S2 consistency).
349
+ canvasStore.dropMember(id);
350
+ teamMemberships.dropSession(id);
351
+ const payload = JSON.stringify({ id });
352
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
353
+ res.end(payload);
354
+ });
355
+ }),
356
+ }), 'a2a: session-node leave route');
357
+ // gh#45 CLI 户口 control face (loopback/key gated like every write):
358
+ // publish (deposit a signed CLI card, ≤4h by ruling), revoke, list,
359
+ // and mailbox readout — the CLI polls this while alive.
360
+ ctx.effect(() => webServer.register({
361
+ kind: 'exact',
362
+ path: '/__dsh_a2a/cli-deposit',
363
+ handler: controlRoute((req, res) => {
364
+ readJsonBody(req, res, (body) => {
365
+ const action = body.action;
366
+ let payload;
367
+ if (action === 'publish') {
368
+ const card = typeof body.card === 'string' ? body.card : '';
369
+ const out = cliDeposits.deposit(card);
370
+ payload = JSON.stringify(out.ok
371
+ ? { ok: true, handle: out.handle, expiresAt: out.expiresAt, cardUrl: `${d.ownCardUrl}/a2a/cli/${encodeURIComponent(out.handle)}` }
372
+ : { ok: false, error: out.error });
373
+ }
374
+ else if (action === 'revoke') {
375
+ payload = JSON.stringify({ ok: cliDeposits.revoke(typeof body.handle === 'string' ? body.handle : '') });
376
+ }
377
+ else if (action === 'inbox') {
378
+ const handle = typeof body.handle === 'string' ? body.handle : '';
379
+ payload = JSON.stringify({ ok: true, mail: cliDeposits.readMail(handle) });
380
+ }
381
+ else {
382
+ payload = JSON.stringify({ ok: true, deposits: cliDeposits.list().map(row => ({ handle: row.handle, name: row.name, depositedAt: row.depositedAt, expiresAt: row.expiresAt })) });
383
+ }
384
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
385
+ res.end(payload);
386
+ });
387
+ }),
388
+ }), 'a2a: cli-deposit route');
389
+ // gh#41 fleet planning relay: the GUI asks THIS host to regulate a
390
+ // REMOTE peer's canvas team. The node key never leaves the server —
391
+ // the relay stamps the OriginClaim envelope and forwards to the
392
+ // peer's /a2a/canvas-op face; the peer's link gate authorizes.
393
+ ctx.effect(() => webServer.register({
394
+ kind: 'exact',
395
+ path: '/__dsh_a2a/fleet-canvas-op',
396
+ handler: controlRoute((req, res) => {
397
+ readJsonBody(req, res, async (body) => {
398
+ const { peer, op, name, id } = body;
399
+ const peerUrl = typeof peer === 'string' ? peer : '';
400
+ const opV = typeof op === 'string' ? op : '';
401
+ const nameV = typeof name === 'string' ? name : '';
402
+ const idV = typeof id === 'string' ? id : '';
403
+ const send = (payload) => {
404
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
405
+ res.end(payload);
406
+ };
407
+ if (peerUrl === '' || !/^https?:\/\//.test(peerUrl) || !['create', 'add-member', 'remove-member'].includes(opV) || (opV !== 'create' && nameV === '')) {
408
+ send(JSON.stringify({ ok: false, error: 'peer (http url), op (create | add-member | remove-member), and name are required' }));
409
+ return;
410
+ }
411
+ if (!peerStore.list().includes(peerUrl)) {
412
+ send(JSON.stringify({ ok: false, error: `peer ${peerUrl} is not tracked — regulate tracked peers only` }));
413
+ return;
414
+ }
415
+ try {
416
+ const target = peerUrl.replace(/\/$/, '') + '/a2a/canvas-op';
417
+ const reply = await globalThis.fetch(target, {
418
+ method: 'POST',
419
+ headers: { 'Content-Type': 'application/json', ...originClaimHeaders() },
420
+ body: JSON.stringify({ op: opV, name: nameV, ...(opV !== 'create' ? { id: idV } : {}) }),
421
+ signal: AbortSignal.timeout(15_000),
422
+ });
423
+ const raw = await reply.text();
424
+ try {
425
+ const out = JSON.parse(raw);
426
+ send(JSON.stringify(out));
427
+ }
428
+ catch {
429
+ // A non-JSON reply is a peer WITHOUT the canvas-op face (a
430
+ // pre-gh41 build) or a proxy page — name the shape honestly.
431
+ send(JSON.stringify({ ok: false, error: `peer ${peerUrl} answered HTTP ${String(reply.status)} without JSON — it likely runs a build without the fleet canvas-op face (pre gh#41); upgrade it, or regulate its teams on that host directly` }));
432
+ }
433
+ }
434
+ catch (error) {
435
+ send(JSON.stringify({ ok: false, error: `fleet op failed: ${error instanceof Error ? error.message : String(error)}` }));
436
+ }
437
+ });
438
+ }),
439
+ }), 'a2a: fleet-canvas-op relay route');
440
+ // gh#34 owner three-key panel: the remote build-out unlock knobs,
441
+ // surfaced as a control face instead of "翻配置文件". GET reads the
442
+ // effective values; POST writes the HOME patch layer (the
443
+ // authoritative surface) AND hot-applies the two call-time keys
444
+ // (teamJoinAllowlist / teamScopeInfo); announce is boot-time
445
+ // publishing, so its change lands on the next host restart and the
446
+ // reply says so. Same key gate as every write face.
447
+ ctx.effect(() => webServer.register({
448
+ kind: 'exact',
449
+ path: '/__dsh_a2a/owner-settings',
450
+ handler: controlRoute((req, res) => {
451
+ if (req.method === 'GET') {
452
+ const payload = JSON.stringify({
453
+ ok: true,
454
+ teamJoinAllowlist: [...d.ownerAllowlist],
455
+ teamScopeInfo: d.ownerTeamScopeInfo,
456
+ announce: config.announce === true,
457
+ announceRestartPending: d.ownerAnnounceRestartPending,
458
+ });
459
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
460
+ res.end(payload);
461
+ return;
462
+ }
463
+ readJsonBody(req, res, (rawBody) => {
464
+ const body = rawBody;
465
+ const changes = {};
466
+ if (Array.isArray(body.teamJoinAllowlist)) {
467
+ const list = body.teamJoinAllowlist.filter((name) => typeof name === 'string' && name !== '' && name.length <= 128);
468
+ changes.teamJoinAllowlist = [...new Set(list)];
469
+ }
470
+ if (typeof body.teamScopeInfo === 'boolean')
471
+ changes.teamScopeInfo = body.teamScopeInfo;
472
+ if (typeof body.announce === 'boolean')
473
+ changes.announce = body.announce;
474
+ if (Object.keys(changes).length === 0) {
475
+ const payload = JSON.stringify({ ok: false, error: 'no recognized keys: teamJoinAllowlist (string[]), teamScopeInfo (bool), announce (bool)' });
476
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
477
+ res.end(payload);
478
+ return;
479
+ }
480
+ try {
481
+ const patchPath = join(home, 'cordis.patch.yml');
482
+ const current = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '';
483
+ const { yaml, restartNeeded } = applyOwnerSettings(current, changes);
484
+ mkdirSync(home, { recursive: true });
485
+ writeFileSync(patchPath, yaml, 'utf8');
486
+ // Hot-apply the two call-time keys.
487
+ if (changes.teamJoinAllowlist !== undefined)
488
+ d.ownerAllowlist = [...changes.teamJoinAllowlist];
489
+ if (changes.teamScopeInfo !== undefined)
490
+ d.ownerTeamScopeInfo = changes.teamScopeInfo;
491
+ if (changes.announce !== undefined && changes.announce !== (config.announce === true))
492
+ d.ownerAnnounceRestartPending = true;
493
+ if (changes.announce !== undefined && changes.announce === (config.announce === true))
494
+ d.ownerAnnounceRestartPending = false;
495
+ logger.info(`a2a: owner settings updated (${Object.keys(changes).join(', ')}${restartNeeded ? ' — restart pending for announce' : ''})`);
496
+ const payload = JSON.stringify({ ok: true, restartNeeded, keys: Object.keys(changes) });
497
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
498
+ res.end(payload);
499
+ }
500
+ catch (error) {
501
+ const payload = JSON.stringify({ ok: false, error: `patch-layer write failed: ${error instanceof Error ? error.message : String(error)}` });
502
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
503
+ res.end(payload);
504
+ }
505
+ });
506
+ }),
507
+ }), 'a2a: owner-settings route');
508
+ // Groups control: create/remove named groups and assign sessions to
509
+ // them (an empty name unassigns). Same guard as join/leave — the
510
+ // group taxonomy enumerates session ids, so it stays behind the key.
511
+ // Deprecated as of 0.6.6 (canvas teams supersede manual grouping);
512
+ // kept working for old panels.
513
+ ctx.effect(() => webServer.register({
514
+ kind: 'exact',
515
+ path: '/__dsh_a2a/groups',
516
+ handler: controlRoute((req, res) => {
517
+ readJsonBody(req, res, (body) => {
518
+ const action = body.action;
519
+ let payload;
520
+ if (action === 'create') {
521
+ const name = typeof body.name === 'string' ? body.name : '';
522
+ const stored = groupStore.create(name);
523
+ payload = JSON.stringify(stored === undefined ? { ok: false, error: 'invalid name or group cap reached' } : { ok: true, name: stored, groups: groupStore.list() });
524
+ }
525
+ else if (action === 'remove') {
526
+ const name = typeof body.name === 'string' ? body.name : '';
527
+ payload = JSON.stringify({ ok: groupStore.remove(name), groups: groupStore.list() });
528
+ }
529
+ else if (action === 'assign') {
530
+ const id = typeof body.id === 'string' ? body.id : '';
531
+ const name = typeof body.name === 'string' ? body.name : undefined;
532
+ if (id === '' || name === undefined) {
533
+ payload = JSON.stringify({ ok: false, error: 'id and name are required' });
534
+ }
535
+ else {
536
+ payload = JSON.stringify({ ok: groupStore.assign(id, name), groups: groupStore.list() });
537
+ }
538
+ }
539
+ else {
540
+ payload = JSON.stringify({ ok: false, error: 'unknown action' });
541
+ }
542
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
543
+ res.end(payload);
544
+ });
545
+ }),
546
+ }), 'a2a: session-group route');
547
+ // Canvas control: create/remove named multi-member teams and manage
548
+ // membership. Same key guard as join/leave — membership enumerates
549
+ // session ids. A member must be a joined session (live node or
550
+ // remembered intent): canvas routing only reaches sessions the user
551
+ // put on the network by gesture — no routing backdoor over unjoined
552
+ // sessions.
553
+ ctx.effect(() => webServer.register({
554
+ kind: 'exact',
555
+ path: '/__dsh_a2a/canvas',
556
+ handler: controlRoute((req, res) => {
557
+ readJsonBody(req, res, (body) => {
558
+ const action = body.action;
559
+ let payload;
560
+ if (action === 'create') {
561
+ const name = typeof body.name === 'string' ? body.name : '';
562
+ const stored = canvasStore.create(name);
563
+ payload = JSON.stringify(stored === undefined ? { ok: false, error: 'invalid name or team cap reached' } : { ok: true, name: stored, teams: canvasStore.list() });
564
+ }
565
+ else if (action === 'remove') {
566
+ const name = typeof body.name === 'string' ? body.name : '';
567
+ payload = JSON.stringify({ ok: canvasStore.remove(name), teams: canvasStore.list() });
568
+ }
569
+ else if (action === 'add-member') {
570
+ const name = typeof body.name === 'string' ? body.name : '';
571
+ const id = typeof body.id === 'string' ? body.id : '';
572
+ // Dual member shape: a local session id must be a joined node;
573
+ // a remote ref is a zone-qualified team handle whose shape this
574
+ // gate checks and whose existence the UI picker (directory
575
+ // rows) vouches for. Humans compose both via the workbench.
576
+ const memberValid = isRemoteMemberRef(id)
577
+ ? validRemoteMemberRef(id)
578
+ : id !== '' && (sessionNodes.has(id) || joinedSessions.has(id));
579
+ if (name === '' || !memberValid) {
580
+ payload = JSON.stringify({ ok: false, error: 'name and a member (joined session id, or a zone-qualified remote team handle like zone/a1b2c3d4) are required' });
581
+ }
582
+ else {
583
+ canvasStore.create(name);
584
+ payload = JSON.stringify({ ok: canvasStore.addMember(name, id), teams: canvasStore.list(), members: canvasStore.membersOf(name) });
585
+ }
586
+ }
587
+ else if (action === 'remove-member') {
588
+ const name = typeof body.name === 'string' ? body.name : '';
589
+ const id = typeof body.id === 'string' ? body.id : '';
590
+ if (name === '' || id === '') {
591
+ payload = JSON.stringify({ ok: false, error: 'name and id are required' });
592
+ }
593
+ else {
594
+ payload = JSON.stringify({ ok: canvasStore.removeMember(name, id), teams: canvasStore.list(), members: canvasStore.membersOf(name) });
595
+ }
596
+ }
597
+ else {
598
+ payload = JSON.stringify({ ok: false, error: 'unknown action' });
599
+ }
600
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
601
+ res.end(payload);
602
+ });
603
+ }),
604
+ }), 'a2a: session-canvas route');
605
+ // Layout face (contract v2): whole-document GET + save/reset for the
606
+ // stage's spatial presentation state. Validated/clamped server-side by
607
+ // LayoutStore so a buggy client cannot poison persisted geometry.
608
+ ctx.effect(() => webServer.register({
609
+ kind: 'exact',
610
+ path: '/__dsh_a2a/canvas-layout',
611
+ handler: controlRoute((req, res) => {
612
+ if (req.method === 'GET') {
613
+ const payload = JSON.stringify({ ok: true, layout: layoutStore.get() });
614
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
615
+ res.end(payload);
616
+ return;
617
+ }
618
+ readJsonBody(req, res, (body) => {
619
+ const action = body.action;
620
+ if (action === 'reset') {
621
+ layoutStore.reset();
622
+ const payload = JSON.stringify({ ok: true, layout: null });
623
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
624
+ res.end(payload);
625
+ return;
626
+ }
627
+ if (action === 'save') {
628
+ const accepted = layoutStore.save(body.layout);
629
+ const payload = JSON.stringify(accepted ? { ok: true, layout: layoutStore.get() } : { ok: false, error: 'payload is not a version-1 layout document' });
630
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
631
+ res.end(payload);
632
+ return;
633
+ }
634
+ const payload = JSON.stringify({ ok: false, error: 'unknown action' });
635
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
636
+ res.end(payload);
637
+ }, MAX_LAYOUT_BODY_BYTES);
638
+ }),
639
+ }), 'a2a: canvas-layout route');
640
+ // The dsh-a2a-munder-difflin floor stage: a same-origin full-page
641
+ // surface adapted from the Munder Difflin office renderer (stage/,
642
+ // NOTICE). Serves the built Vite tree with a traversal-guarded map.
643
+ ctx.effect(() => webServer.register({
644
+ kind: 'prefix',
645
+ path: '/__dsh_a2a_canvas',
646
+ handler: (req, res) => {
647
+ const rootDir = fileURLToPath(new URL('../assets/stageDist', indexUrl));
648
+ // Directory-classic resolution: the bare mount 301s onto itself
649
+ // with a slash, so the shell's `./assets` references resolve
650
+ // inside this tree instead of 404ing against the web root.
651
+ const MOUNT = '/__dsh_a2a_canvas';
652
+ const resolved = resolveStageMount(req.url ?? '/', MOUNT);
653
+ if (resolved.redirectTo !== undefined) {
654
+ res.writeHead(301, { Location: resolved.redirectTo, 'Cache-Control': 'no-store', 'X-A2A-Stage': 'redirect' });
655
+ res.end();
656
+ return;
657
+ }
658
+ const rel = decodeURIComponent(resolved.rel).replace(/\\/g, '/');
659
+ if (rel.includes('..')) {
660
+ res.writeHead(403, { 'Content-Type': 'text/plain', 'X-A2A-Stage': 'traversal' });
661
+ res.end();
662
+ return;
663
+ }
664
+ try {
665
+ const file = readFileSync(join(rootDir, rel));
666
+ const ext = rel.slice(rel.lastIndexOf('.') + 1);
667
+ const types = {
668
+ html: 'text/html; charset=utf-8',
669
+ js: 'text/javascript; charset=utf-8',
670
+ mjs: 'text/javascript; charset=utf-8',
671
+ css: 'text/css; charset=utf-8',
672
+ png: 'image/png',
673
+ json: 'application/json; charset=utf-8',
674
+ svg: 'image/svg+xml',
675
+ };
676
+ res.writeHead(200, { 'Content-Type': types[ext] ?? 'application/octet-stream', 'Cache-Control': 'no-store', 'X-A2A-Stage': 'ok:' + ext });
677
+ res.end(file);
678
+ }
679
+ catch (err) {
680
+ res.writeHead(404, { 'Content-Type': 'text/plain', 'X-A2A-Stage': 'miss' });
681
+ res.end('stage-miss rel=' + rel + ' rootDir=' + rootDir + ' err=' + String(err));
682
+ }
683
+ },
684
+ }), 'a2a: canvas-stage page');
685
+ // The dsh-a2a-nexus stage: Three.js infinite-canvas topology viewer.
686
+ // Serves the built Vite tree from assets/nexusDist with a traversal-
687
+ // guarded file map (same discipline as canvas-stage).
688
+ ctx.effect(() => webServer.register({
689
+ kind: 'prefix',
690
+ path: '/__dsh_a2a_nexus',
691
+ handler: (req, res) => {
692
+ const rootDirNexus = fileURLToPath(new URL('../assets/nexusDist', indexUrl));
693
+ // Same directory-classic resolution as the canvas mount: the bare
694
+ // `/__dsh_a2a_nexus` form used to serve the shell verbatim, whose
695
+ // `./assets` then 404'd at the web root — a silent black viewer.
696
+ const MOUNT = '/__dsh_a2a_nexus';
697
+ const resolved = resolveStageMount(req.url ?? '/', MOUNT);
698
+ if (resolved.redirectTo !== undefined) {
699
+ res.writeHead(301, { Location: resolved.redirectTo, 'Cache-Control': 'no-store' });
700
+ res.end();
701
+ return;
702
+ }
703
+ const rel = decodeURIComponent(resolved.rel).replace(/\\/g, '/');
704
+ if (rel.includes('..')) {
705
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
706
+ res.end();
707
+ return;
708
+ }
709
+ try {
710
+ const file = readFileSync(join(rootDirNexus, rel));
711
+ const ext = rel.slice(rel.lastIndexOf('.') + 1);
712
+ const types = {
713
+ html: 'text/html; charset=utf-8',
714
+ js: 'text/javascript; charset=utf-8',
715
+ mjs: 'text/javascript; charset=utf-8',
716
+ css: 'text/css; charset=utf-8',
717
+ png: 'image/png',
718
+ json: 'application/json; charset=utf-8',
719
+ svg: 'image/svg+xml',
720
+ };
721
+ res.writeHead(200, { 'Content-Type': types[ext] ?? 'application/octet-stream', 'Cache-Control': 'no-store' });
722
+ // gh#9 §10 GUI key plumbing (REVIEW M3 fix): the shell learns
723
+ // the control-plane key ONLY on the loopback leniency face —
724
+ // a remote bare GET of the page serves the shell WITHOUT the
725
+ // key (the reviewer proved the pre-fix shape leaked the
726
+ // credential to any network-reachable fetcher on a
727
+ // 0.0.0.0-bound host, contradicting §10's purpose). Loopback
728
+ // localhost is exactly §10.2's stated leniency posture; keyed
729
+ // hosts behind tunnels use originClaimUrl-style operator
730
+ // plumbing instead.
731
+ if (ext === 'html' && config.apiKey !== '' && controlTrustedWithoutKey(req)) {
732
+ const injected = file.toString('utf8').replace('</head>', `<script>window.__DSH_A2A_KEY__=${JSON.stringify(config.apiKey)};</script></head>`);
733
+ res.end(Buffer.from(injected, 'utf8'));
734
+ return;
735
+ }
736
+ res.end(file);
737
+ }
738
+ catch {
739
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
740
+ res.end();
741
+ }
742
+ },
743
+ }), 'a2a: nexus-stage page');
744
+ }