@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 0.6.42

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 (61) hide show
  1. package/README.md +1 -1
  2. package/README_zh.md +1 -1
  3. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
  4. package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
  5. package/assets/stageDist/index.html +12 -12
  6. package/lib/client.js +64 -64
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +57 -9
  9. package/lib/origin-claim.js +4 -0
  10. package/lib/skill/SKILL.md +117 -117
  11. package/lib/skill/references/collaboration.md +428 -428
  12. package/lib/skill/references/discovery.md +78 -78
  13. package/lib/skill/references/intake.md +107 -107
  14. package/lib/skill/references/membership.md +73 -73
  15. package/lib/skill/references/pitfalls.md +51 -51
  16. package/lib/skill/references/router.md +96 -96
  17. package/lib/skill/references/routing.md +65 -65
  18. package/lib/types/origin-claim.d.ts +2 -0
  19. package/nexus-stage/package.json +21 -21
  20. package/nexus-stage/scripts/mockhost.py +261 -261
  21. package/nexus-stage/src/canvas-ops.ts +191 -191
  22. package/nexus-stage/src/canvas-wire.ts +164 -164
  23. package/nexus-stage/src/cli-home-picker.ts +42 -42
  24. package/nexus-stage/src/fault.ts +56 -56
  25. package/nexus-stage/src/federation-overlay.ts +103 -103
  26. package/nexus-stage/src/federation.ts +241 -241
  27. package/nexus-stage/src/layout-doc.ts +145 -145
  28. package/nexus-stage/src/main.ts +513 -513
  29. package/nexus-stage/src/planning-card-text.ts +73 -73
  30. package/nexus-stage/src/planning-card.ts +63 -63
  31. package/nexus-stage/src/planning-edges.ts +26 -26
  32. package/nexus-stage/src/planning-fit.ts +45 -45
  33. package/nexus-stage/src/planning-frame-hue.ts +13 -13
  34. package/nexus-stage/src/planning-legend.ts +39 -39
  35. package/nexus-stage/src/planning-member-handles.ts +61 -61
  36. package/nexus-stage/src/planning-menus.ts +422 -422
  37. package/nexus-stage/src/planning-mirror.ts +75 -75
  38. package/nexus-stage/src/planning-nodelist.ts +294 -294
  39. package/nexus-stage/src/planning-overlap.ts +159 -159
  40. package/nexus-stage/src/planning-reachability.ts +28 -28
  41. package/nexus-stage/src/planning-relayout.ts +121 -121
  42. package/nexus-stage/src/planning-roster.ts +15 -15
  43. package/nexus-stage/src/planning-search.ts +168 -168
  44. package/nexus-stage/src/planning-types.ts +115 -115
  45. package/nexus-stage/src/planning-undo.ts +68 -68
  46. package/nexus-stage/src/planning-view.ts +1499 -1499
  47. package/nexus-stage/src/planning.css +340 -340
  48. package/nexus-stage/src/seat-name-editor.ts +31 -31
  49. package/nexus-stage/src/session-drag.ts +64 -64
  50. package/nexus-stage/src/viewport.ts +106 -106
  51. package/nexus-stage/src/wb-chat.ts +684 -684
  52. package/nexus-stage/src/wb-directory.ts +237 -237
  53. package/nexus-stage/src/wb-ledger.ts +279 -279
  54. package/nexus-stage/src/wb-names.ts +43 -43
  55. package/nexus-stage/src/wb-rail.ts +147 -147
  56. package/nexus-stage/src/workbench.css +388 -388
  57. package/nexus-stage/src/workbench.ts +1272 -1272
  58. package/nexus-stage/src/world.ts +509 -509
  59. package/nexus-stage/tsconfig.json +24 -24
  60. package/nexus-stage/vite.config.ts +15 -15
  61. package/package.json +1 -1
@@ -1,513 +1,513 @@
1
- /**
2
- * The A2A workbench boot: fetches /__dsh_a2a/state on a poll, feeds the
3
- * merged workbench (the 2D planning board as the stage + directory/ledger/
4
- * activity/plan rail, 0.6.2) and keeps the fault discipline
5
- * (a fetch failure must never masquerade as an empty fleet — the badge is
6
- * the honesty surface).
7
- * @module nexus-stage/main
8
- */
9
- import { createFaultReporter } from './fault'
10
- import { createWorkbench, type StateBody } from './workbench'
11
- import { createPlanningView } from './planning-view'
12
- import { rosterFrames, rosterNameOf } from './planning-roster'
13
- import { createSaveLoop, type LampState } from './layout-wire'
14
- import { createCanvasWire } from './canvas-wire'
15
- import { actionTeam, type CanvasAction } from './canvas-ops'
16
- import './workbench.css'
17
-
18
- const app = document.getElementById('app')!
19
-
20
- // gh#9 §10: the host injects the control-plane key into the shell when
21
- // apiKey is configured — stamp it on every /__dsh_a2a write so the GUI
22
- // survives a keyed host (same-origin disclosure only, see the serve site).
23
- // gh#40: a REMOTE face (LAN-bound test host, tunnel) serves the shell
24
- // WITHOUT the injected key by design — the credential must not leak to any
25
- // network fetcher. The operator still needs the panel there, so the key
26
- // can be typed once: sessionStorage is browser-local (never transmitted in
27
- // the page), stamped on the same X-API-Key header thereafter.
28
- const storedKey = (() => { try { return window.sessionStorage.getItem('dsh_a2a_key') ?? '' } catch { return '' } })()
29
- const CONTROL_KEY = (window as unknown as { __DSH_A2A_KEY__?: string }).__DSH_A2A_KEY__ ?? storedKey
30
- const controlFetch = (path: string, init: RequestInit = {}): Promise<Response> =>
31
- fetch(path, { ...init, headers: { ...(init.headers as Record<string, string> | undefined ?? {}), ...(CONTROL_KEY !== '' ? { 'X-API-Key': CONTROL_KEY } : {}) } })
32
-
33
- // gh#40: key-entry affordance — mounts BEFORE the panel when the shell came
34
- // keyless (remote face) and the control plane answers 401. One input, one
35
- // submit; the stored key survives until the tab closes.
36
- if (CONTROL_KEY === '') {
37
- void controlFetch('/__dsh_a2a/owner-settings').then(res => {
38
- if (res.status !== 401) return
39
- const gate = document.createElement('div')
40
- gate.className = 'nexus-keygate'
41
- gate.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:#fff;z-index:9999;font:14px/1.5 system-ui,sans-serif;color:#000'
42
- const label = document.createElement('div')
43
- label.textContent = '此面板经远程面打开(密钥不随页面下发)——输入控制密钥'
44
- const input = document.createElement('input')
45
- input.type = 'password'
46
- input.placeholder = 'X-API-Key'
47
- input.setAttribute('aria-label', '控制密钥')
48
- const submit = document.createElement('button')
49
- submit.type = 'button'
50
- submit.textContent = '进入面板'
51
- const err = document.createElement('div')
52
- err.style.color = '#b00'
53
- const tryKey = (): void => {
54
- const v = input.value.trim()
55
- if (v === '') return
56
- void fetch('/__dsh_a2a/owner-settings', { headers: { 'X-API-Key': v } }).then(r => {
57
- if (r.status === 401) { err.textContent = '密钥不对,再试一次'; return }
58
- try { window.sessionStorage.setItem('dsh_a2a_key', v) } catch { /* blocked site data: keep in-page */ }
59
- window.location.reload()
60
- })
61
- }
62
- submit.addEventListener('click', tryKey)
63
- input.addEventListener('keydown', ev => { if (ev.key === 'Enter') tryKey() })
64
- gate.append(label, input, submit, err)
65
- document.body.appendChild(gate)
66
- })
67
- }
68
-
69
- const faultBadge = document.createElement('div')
70
- faultBadge.className = 'nexus-fault'
71
- app.appendChild(faultBadge)
72
- const { fault, clear: clearFault } = createFaultReporter(
73
- {
74
- show(message) {
75
- faultBadge.textContent = message
76
- faultBadge.style.display = 'block'
77
- },
78
- hide() {
79
- faultBadge.style.display = 'none'
80
- },
81
- },
82
- message => console.error(message),
83
- )
84
-
85
- // ─── Spatial planning board (restored 0.6.1; wiring from the pre-0.6.0 stage) ───
86
- // 0.6.13: board node picks forward to the workbench chat drawer. The bench
87
- // is created later (it embeds the board), so the dep is a late-bound slot.
88
- let openNodeChat: ((id: string) => void) | undefined
89
- // Per-device viewport memory: browser-local, one slot per screen bucket.
90
- // Storage can be absent or throw (private windows, blocked site data) —
91
- // every access is guarded and the board falls back to the host/fit path.
92
- const VIEWPORT_KEY = 'dsh-a2a.viewport.'
93
- const viewportStore = {
94
- get(bucket: string): unknown {
95
- try {
96
- const raw = window.localStorage.getItem(VIEWPORT_KEY + bucket)
97
- return raw === null ? undefined : JSON.parse(raw)
98
- } catch {
99
- return undefined
100
- }
101
- },
102
- set(bucket: string, vp: { x: number; y: number; scale: number }): void {
103
- try {
104
- window.localStorage.setItem(VIEWPORT_KEY + bucket, JSON.stringify({ x: vp.x, y: vp.y, scale: vp.scale }))
105
- } catch { /* presentation memory only */ }
106
- },
107
- }
108
-
109
- const planning = createPlanningView({
110
- viewportStore,
111
- onDirty: () => saveLoop.markDirty(),
112
- onLampClick: () => saveLoop.retry(),
113
- onNodePick: (id) => { openNodeChat?.(id) },
114
- onCanvasAction: async (a) => {
115
- const ok = await runCanvasAction(a)
116
- // Settled: converge on truth either way — success absorbs the host's
117
- // rich state; failure re-pulls so the rollback matches reality.
118
- void poll()
119
- return ok
120
- },
121
- })
122
-
123
- const saveLoop = createSaveLoop({
124
- snapshot: () => planning.snapshotDoc(),
125
- send: (doc, opts) => controlFetch('/__dsh_a2a/canvas-layout', {
126
- method: 'POST',
127
- headers: { 'Content-Type': 'application/json' },
128
- body: JSON.stringify({ action: 'save', layout: doc }),
129
- cache: 'no-store',
130
- keepalive: opts.keepalive,
131
- }).then(async (r) => await r.json() as { ok: boolean; layout?: unknown; error?: string }),
132
- schedule: (fn, ms) => { const t = setTimeout(fn, ms); return () => { clearTimeout(t) } },
133
- now: Date.now,
134
- onLamp: (state) => {
135
- lampState = state
136
- const now = new Date()
137
- const hhmm = String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0')
138
- planning.setLamp(state, hhmm)
139
- },
140
- onAdopt: (normalized) => { planning.adoptExternalLayout(normalized) },
141
- onHttpError: (message) => fault(message),
142
- })
143
- window.addEventListener('pagehide', () => saveLoop.flush())
144
-
145
- const canvasWire = createCanvasWire({
146
- send: async (body) => {
147
- const r = await controlFetch('/__dsh_a2a/canvas', {
148
- method: 'POST',
149
- headers: { 'Content-Type': 'application/json' },
150
- body: JSON.stringify(body),
151
- cache: 'no-store',
152
- })
153
- return { status: r.status, body: await r.json() as { ok?: boolean; error?: string } }
154
- },
155
- onNotice: (kind, text) => planning.notice(kind, text),
156
- })
157
-
158
- /** One relayed op on a REMOTE peer's team. THIS host signs the envelope
159
- * and forwards; the peer's link gate authorizes the write. */
160
- async function fleetOp(peer: string, op: 'add-member' | 'remove-member' | 'remove', name: string, id?: string): Promise<boolean> {
161
- const r = await controlFetch('/__dsh_a2a/fleet-canvas-op', {
162
- method: 'POST',
163
- headers: { 'Content-Type': 'application/json' },
164
- body: JSON.stringify({ peer, op, name, ...(id !== undefined ? { id } : {}) }),
165
- cache: 'no-store',
166
- }).catch(() => undefined)
167
- const body = r !== undefined ? await r.json().catch(() => undefined) : undefined
168
- if (body?.ok === true && op === 'remove' && body.removed === false) planning.notice('info', `属主那边已没有「${name}」,无需解散`)
169
- if (body?.ok === true) return true
170
- // Say WHY the peer refused (not linked, untrusted origin, unknown member…), not just that it did.
171
- planning.notice('error', `跨主机团队操作失败:${String(body?.error ?? '对端无响应')}`)
172
- return false
173
- }
174
-
175
- async function rosterAction(a: CanvasAction, name: string): Promise<boolean> {
176
- const post = async (body: Record<string, unknown>): Promise<boolean> => {
177
- const reply = await controlFetch('/__dsh_a2a/roster-op', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, ...body }) }).catch(() => undefined)
178
- const result = reply !== undefined ? await reply.json().catch(() => undefined) as { ok?: boolean; error?: string } | undefined : undefined
179
- if (reply?.ok && result?.ok === true) return true
180
- planning.notice('error', `roster 操作失败:${String(result?.error ?? (reply ? `HTTP ${reply.status}` : '无响应'))}`)
181
- return false
182
- }
183
- if (a.type === 'create-team') return post({ op: 'create', ids: a.ids })
184
- if (a.type === 'remove-team') return post({ op: 'dissolve' })
185
- if (a.type === 'add-member' || a.type === 'remove-member') {
186
- for (const id of a.ids) if (!(await post({ op: a.type, id }))) return false
187
- return true
188
- }
189
- planning.notice('error', 'roster 不支持该操作')
190
- return false
191
- }
192
-
193
- function runCanvasAction(a: CanvasAction): Promise<boolean> {
194
- // gh#41: a remote-frame drop forks to the fleet relay; so does dissolving
195
- // a mirror frame (single owner + mirror: the owner's store is the truth).
196
- if ((a.type === 'add-member' || a.type === 'remove-member') && a.fleetPeer !== undefined) {
197
- const peer = a.fleetPeer
198
- return (async () => {
199
- for (const id of a.ids) if (!(await fleetOp(peer, a.type, a.team, id))) return false
200
- return true
201
- })()
202
- }
203
- if (a.type === 'remove-team' && a.fleetPeer !== undefined) return fleetOp(a.fleetPeer, 'remove', a.name)
204
- // Only a trusted registry roster frame or an explicit create choice may trigger a local roster write.
205
- if (a.roster === true) {
206
- const roster = rosterNameOf(actionTeam(a))
207
- if (roster !== undefined) return rosterAction(a, roster)
208
- planning.notice('error', '无效的 roster 框名'); return Promise.resolve(false)
209
- }
210
- switch (a.type) {
211
- case 'create-team': return canvasWire.createTeam(a.name, a.ids, a.created)
212
- case 'add-member': return canvasWire.addMembers(a.team, a.ids)
213
- case 'remove-member': return canvasWire.removeMembers(a.team, a.ids)
214
- case 'remove-team': return canvasWire.removeTeam(a.name)
215
- case 'reorder': return canvasWire.runRosterOps(a.team, a.ops)
216
- case 'join-network':
217
- case 'leave-network': return postNetworkJoin(a.type === 'join-network' ? 'join' : 'leave', a.id)
218
- }
219
- }
220
-
221
- /** Network join/leave for one session (the panel-slim migration: this
222
- * affordance lives on the planning view's node list). */
223
- async function postNetworkJoin(action: 'join' | 'leave', id: string): Promise<boolean> {
224
- const r = await controlFetch(`/__dsh_a2a/${action}`, {
225
- method: 'POST',
226
- headers: { 'Content-Type': 'application/json' },
227
- body: JSON.stringify({ id }),
228
- cache: 'no-store',
229
- }).catch(() => undefined)
230
- return r !== undefined && r.ok
231
- }
232
-
233
- let lampState: LampState = 'idle'
234
- // The nodes face reports effective scoped assemblies; a mounted registration alone does not prove visibility.
235
- let nodePromptMissing = new Set<string>()
236
- let nodePromptCheckedAt = 0
237
- let canvasFace = false
238
- let lastLayoutJson: string | undefined
239
- /** True once any layout answer (saved doc or an explicit null) has been
240
- * seen; the board build waits for that answer on the very first poll. */
241
- let layoutSeen = false
242
-
243
- /** Fetch the persisted layout; throws on transport failure so the poll can
244
- * hold the board build instead of silently building on ring seats. */
245
- async function fetchLayout(): Promise<unknown> {
246
- const res = await controlFetch('/__dsh_a2a/canvas-layout', { cache: 'no-store' })
247
- if (!res.ok) throw new Error(`/__dsh_a2a/canvas-layout ${res.status}`)
248
- return ((await res.json()) as { layout?: unknown }).layout ?? null
249
- }
250
-
251
- // ─── The workbench ───
252
- const bench = createWorkbench({
253
- now: () => Date.now(),
254
- // gh#34: the owner three-key panel rides the control face.
255
- async ownerSettingsRead() {
256
- try {
257
- const res = await controlFetch('/__dsh_a2a/owner-settings')
258
- return await res.json()
259
- } catch {
260
- return { ok: false, error: 'unreachable' }
261
- }
262
- },
263
- async ownerSettingsWrite(changes) {
264
- try {
265
- const res = await controlFetch('/__dsh_a2a/owner-settings', {
266
- method: 'POST',
267
- headers: { 'content-type': 'application/json' },
268
- body: JSON.stringify(changes),
269
- })
270
- return await res.json()
271
- } catch {
272
- return { ok: false, error: 'unreachable' }
273
- }
274
- },
275
- async abandon(taskId, reason) {
276
- try {
277
- const res = await controlFetch('/__dsh_a2a/tasks/abandon', {
278
- method: 'POST',
279
- headers: { 'content-type': 'application/json' },
280
- body: JSON.stringify({ task_id: taskId, reason }),
281
- })
282
- return res.ok
283
- } catch {
284
- return false
285
- }
286
- },
287
- copy(text) {
288
- void navigator.clipboard?.writeText(text).catch(() => {})
289
- },
290
- // 0.6.10: the ↗ jump affordances (0.6.7 L1 broadcast / 0.6.8 L2 deep
291
- // link) are REMOVED by ruling — 禁止任何跳转,全部使用响应式布局.
292
- // The only reach primitive is the inline 快派 form: it works for local
293
- // session teams, canvas teams and remote rows alike, and never leaves
294
- // this page.
295
- async cliHomeOptions(name, id) {
296
- try {
297
- const res = await controlFetch('/__dsh_a2a/canvas', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'cli-home-options', name, id }) })
298
- const body = await res.json() as { ok: boolean; homes?: string[]; unreadable?: number; held?: boolean; error?: string }
299
- return { ...body, ok: res.ok && body.ok === true }
300
- } catch (error) { return { ok: false, error: String(error) } }
301
- },
302
- async canvasOp(action, name, id, home) {
303
- // Same control route the planning board's wire uses; the inspector's
304
- // member writes are one id at a time, so no serial fan-out needed.
305
- try {
306
- const res = await controlFetch('/__dsh_a2a/canvas', {
307
- method: 'POST',
308
- headers: { 'content-type': 'application/json' },
309
- body: JSON.stringify({ action, name, id, ...(home === undefined ? {} : { home }) }),
310
- })
311
- const body = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
312
- if (res.ok && body.ok !== false) return true
313
- console.warn(`canvas ${action} ${name}: ${body.error ?? `HTTP ${res.status}`}`)
314
- return false
315
- } catch {
316
- return false
317
- }
318
- },
319
- async dispatchQuick(team, message) {
320
- // Quick dispatch (0.6.9): host-face POST; the trunk answers with the
321
- // canonical route shape ({ok, task_id, task_status, team, ...} on
322
- // delivery) or {ok:false, error}.
323
- try {
324
- const res = await controlFetch('/__dsh_a2a/dispatch', {
325
- method: 'POST',
326
- headers: { 'content-type': 'application/json' },
327
- body: JSON.stringify({ team, message }),
328
- })
329
- const body = (await res.json().catch(() => ({}))) as { ok?: boolean; task_id?: string; reply?: string; error?: string }
330
- return {
331
- ok: res.ok && body.ok === true,
332
- task_id: body.task_id,
333
- reply: body.reply,
334
- error: body.error ?? `HTTP ${res.status}`,
335
- }
336
- } catch (error) {
337
- return { ok: false, error: error instanceof Error ? error.message : String(error) }
338
- }
339
- },
340
- async sessionLog(id) {
341
- // Inline transcript (0.6.11): read-only tail of a local session's own
342
- // log from the host control face. Caps live server-side.
343
- try {
344
- const res = await controlFetch(`/__dsh_a2a/session-log?id=${encodeURIComponent(id)}`)
345
- const body = (await res.json().catch(() => ({}))) as { ok?: boolean; messages?: Array<{ role: string; text: string }>; error?: string }
346
- // 0.6.16: error stays undefined on success — an unconditional
347
- // `body.error ?? HTTP ${status}` painted "HTTP 200" on listing-shaped
348
- // successes that lacked a messages field.
349
- return { ok: res.ok && body.ok === true, messages: body.messages, error: body.error ?? (res.ok ? undefined : `HTTP ${res.status}`) }
350
- } catch (error) {
351
- return { ok: false, error: error instanceof Error ? error.message : String(error) }
352
- }
353
- },
354
- async sessionLogList() {
355
- // 0.6.12: host-scope readable-log listing — archived and never-joined
356
- // sessions included; the network directory is not the reading gate.
357
- try {
358
- const res = await controlFetch('/__dsh_a2a/session-log')
359
- const body = (await res.json().catch(() => ({}))) as { ok?: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; count?: number; error?: string }
360
- // count: how many logs the host holds — the list face caps at 50, so the
361
- // directory can say it is showing 50 of N instead of silently truncating.
362
- return { ok: res.ok && body.ok === true, sessions: body.sessions, count: typeof body.count === 'number' ? body.count : undefined, error: body.error ?? (res.ok ? undefined : `HTTP ${res.status}`) }
363
- } catch (error) {
364
- return { ok: false, error: error instanceof Error ? error.message : String(error) }
365
- }
366
- },
367
- async remoteSessionLog(query) {
368
- // 0.6.15: proxy read of a remote host's /a2a/log — by tracked peer url or
369
- // by team handle (directory-resolved). The proxy is the only reader; the
370
- // browser never dials the peer directly.
371
- try {
372
- const params = new URLSearchParams()
373
- if (query.team !== undefined) params.set('team', query.team)
374
- if (query.peer !== undefined) params.set('peer', query.peer)
375
- if (query.id !== undefined) params.set('id', query.id)
376
- if (query.limit !== undefined) params.set('limit', String(query.limit))
377
- const res = await controlFetch(`/__dsh_a2a/remote-session-log?${params.toString()}`)
378
- const body = (await res.json().catch(() => ({}))) as {
379
- ok?: boolean
380
- messages?: Array<{ role: string; text: string }>
381
- sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>
382
- error?: string
383
- via?: string
384
- }
385
- return { ok: res.ok && body.ok === true, messages: body.messages, sessions: body.sessions, error: body.error ?? (res.ok ? undefined : `HTTP ${res.status}`), via: body.via }
386
- } catch (error) {
387
- return { ok: false, error: error instanceof Error ? error.message : String(error) }
388
- }
389
- },
390
- board: {
391
- root: planning.root,
392
- activate: () => planning.activate(),
393
- deactivate: () => planning.deactivate(),
394
- available: () => canvasFace,
395
- },
396
- })
397
- app.appendChild(bench.root)
398
- // 0.6.13: board node picks now resolve against the live bench state.
399
- openNodeChat = (id) => { bench.openNodeChat(id) }
400
-
401
- // Legacy entry compatibility: the old stage link ?mode=plan converges on the
402
- // board stage — in the merged shell canvas mode is already the default while
403
- // the host serves the canvas face, so this only guards the first poll.
404
- if (new URLSearchParams(window.location.search).get('mode') === 'plan') {
405
- const tryBoard = window.setInterval(() => {
406
- if (canvasFace) {
407
- bench.selectTab('board')
408
- window.clearInterval(tryBoard)
409
- }
410
- }, 300)
411
- window.setTimeout(() => window.clearInterval(tryBoard), 30_000)
412
- }
413
-
414
- async function poll(): Promise<void> {
415
- try {
416
- const res = await controlFetch('/__dsh_a2a/state', { cache: 'no-store' })
417
- if (!res.ok) throw new Error(`/__dsh_a2a/state ${res.status}`)
418
- const body = (await res.json()) as StateBody
419
- if (Date.now() - nodePromptCheckedAt >= 15_000) {
420
- nodePromptCheckedAt = Date.now()
421
- try {
422
- const nodes = await controlFetch('/__dsh_a2a/nodes', { cache: 'no-store' })
423
- if (!nodes.ok) throw new Error(`nodes ${nodes.status}`)
424
- const view = await nodes.json() as { bindings?: Array<{ session: string; mounted: unknown; effective: { core: boolean; mutable: boolean } | null }> }
425
- nodePromptMissing = new Set((view.bindings ?? [])
426
- .filter(b => b.mounted !== null && b.effective !== null && (b.effective.core === false || b.effective.mutable === false))
427
- .map(b => b.session))
428
- } catch { nodePromptMissing.clear() } // unavailable is unknown, not confirmed missing
429
- }
430
- const shown = { ...body, sessions: (body.sessions ?? []).map(s => ({ ...s, ...(nodePromptMissing.has(s.id) ? { nodePromptMissing: true } : {}) })) }
431
- clearFault()
432
- // canvasFace first: bench.update reads board.available() to pick the
433
- // mode, so assigning it afterwards held the very first paint in data
434
- // mode (board display:none, 0px wide) until the next 3s poll — and the
435
- // board's first reconcile measured a zero-width view.
436
- canvasFace = body.canvas !== undefined
437
- bench.update(shown)
438
-
439
- // ── The layout answer precedes the first reconcile on purpose ──
440
- // The board's first build reads `layoutDoc` for saved positions and
441
- // frame rects; every later pass keeps live geometry ("live rect wins").
442
- // A layout landing AFTER the build therefore never displaces the ring
443
- // seats — the refresh-reset-to-middle bug. Until any answer (doc or
444
- // null) has been seen, a failed GET holds the board one poll rather
445
- // than building on seats; after that, failures leave the last doc in
446
- // place and the poll continues.
447
- if (canvasFace) {
448
- try {
449
- const layout = await fetchLayout()
450
- const layoutJson = JSON.stringify(layout ?? null)
451
- if (layoutJson !== lastLayoutJson) {
452
- lastLayoutJson = layoutJson
453
- // Adopt poll-sourced layouts only while nothing is unsaved/in
454
- // flight — otherwise a slow GET would revert positions the user
455
- // is still dragging.
456
- if ((lampState === 'idle' || lampState === 'saved') && !saveLoop.isBusy()) {
457
- planning.adoptExternalLayout(layout)
458
- }
459
- }
460
- layoutSeen = true
461
- } catch (err) {
462
- if (!layoutSeen) {
463
- fault(`canvas layout 获取失败:${String(err)}`)
464
- return
465
- }
466
- }
467
- } else {
468
- layoutSeen = true
469
- }
470
-
471
- // ── Planning board shares this poll: same state payload. ──
472
- // SessionLite needs `joined: boolean` (workbench rows carry `joined?`):
473
- // tri-state → boolean without losing the unjoined node-list population.
474
- const allSessions = (body.sessions ?? []).map(s => ({ ...s, joined: s.joined === true }))
475
- const teams = [...(body.canvas?.teams ?? []), ...rosterFrames(body.registry?.teams ?? [], body.zone ?? '')]
476
- const peers = (body.peers ?? []).filter(p => p !== null && typeof p === 'object' && typeof (p as { url?: unknown }).url === 'string')
477
- const inFlight = (body.inFlight ?? []).filter(r => r !== null && typeof r === 'object' && typeof (r as { team?: unknown }).team === 'string' && typeof (r as { peer?: unknown }).peer === 'string' && Number.isFinite((r as { startedAt?: unknown }).startedAt))
478
- const remoteTeams = (body.remote ?? [])
479
- .filter(r => r !== null && typeof r === 'object' && typeof (r as { team?: unknown }).team === 'string' && typeof (r as { via?: unknown }).via === 'string')
480
- .map(r => ({
481
- team: r.team,
482
- name: r.name,
483
- via: r.via,
484
- workspace: r.workspace,
485
- ...(r.unreachable === true ? { unreachable: true } : {}),
486
- ...(typeof r.origin === 'string' && r.origin !== '' ? { origin: r.origin } : {}),
487
- ...(Array.isArray(r.teams) && r.teams.length > 0 ? { teams: r.teams.filter((t): t is string => typeof t === 'string' && t !== '') } : {}),
488
- }))
489
- // gh#41: the peers' published canvas org rides the board (remote frames).
490
- const peerOrg = (body.peerOrg as unknown[] ?? [])
491
- .filter((r): r is { url?: unknown; org?: unknown; unreachable?: unknown } => r !== null && typeof r === 'object')
492
- .filter(r => typeof r.url === 'string' && Array.isArray(r.org))
493
- .map(r => ({
494
- url: r.url as string,
495
- ...(r.unreachable === true ? { unreachable: true } : {}),
496
- org: (r.org as unknown[])
497
- .filter((e): e is { name?: unknown; team?: unknown; members?: unknown } => e !== null && typeof e === 'object')
498
- .filter(e => typeof e.team === 'string' && e.team !== '')
499
- .map(e => ({ name: typeof e.name === 'string' ? e.name : '', team: e.team as string, members: Array.isArray(e.members) ? e.members.filter((m): m is string => typeof m === 'string') : [] })),
500
- }))
501
- planning.reconcile({ sessions: allSessions, teams, peerCount: peers.length, peers, inFlight, remoteTeams, peerOrg })
502
- } catch (err) {
503
- fault(`state 轮询失败:${String(err)}`)
504
- }
505
- }
506
-
507
- void poll()
508
- window.setInterval(() => {
509
- if (!document.hidden) void poll()
510
- }, 3000)
511
- document.addEventListener('visibilitychange', () => {
512
- if (!document.hidden) void poll()
513
- })
1
+ /**
2
+ * The A2A workbench boot: fetches /__dsh_a2a/state on a poll, feeds the
3
+ * merged workbench (the 2D planning board as the stage + directory/ledger/
4
+ * activity/plan rail, 0.6.2) and keeps the fault discipline
5
+ * (a fetch failure must never masquerade as an empty fleet — the badge is
6
+ * the honesty surface).
7
+ * @module nexus-stage/main
8
+ */
9
+ import { createFaultReporter } from './fault'
10
+ import { createWorkbench, type StateBody } from './workbench'
11
+ import { createPlanningView } from './planning-view'
12
+ import { rosterFrames, rosterNameOf } from './planning-roster'
13
+ import { createSaveLoop, type LampState } from './layout-wire'
14
+ import { createCanvasWire } from './canvas-wire'
15
+ import { actionTeam, type CanvasAction } from './canvas-ops'
16
+ import './workbench.css'
17
+
18
+ const app = document.getElementById('app')!
19
+
20
+ // gh#9 §10: the host injects the control-plane key into the shell when
21
+ // apiKey is configured — stamp it on every /__dsh_a2a write so the GUI
22
+ // survives a keyed host (same-origin disclosure only, see the serve site).
23
+ // gh#40: a REMOTE face (LAN-bound test host, tunnel) serves the shell
24
+ // WITHOUT the injected key by design — the credential must not leak to any
25
+ // network fetcher. The operator still needs the panel there, so the key
26
+ // can be typed once: sessionStorage is browser-local (never transmitted in
27
+ // the page), stamped on the same X-API-Key header thereafter.
28
+ const storedKey = (() => { try { return window.sessionStorage.getItem('dsh_a2a_key') ?? '' } catch { return '' } })()
29
+ const CONTROL_KEY = (window as unknown as { __DSH_A2A_KEY__?: string }).__DSH_A2A_KEY__ ?? storedKey
30
+ const controlFetch = (path: string, init: RequestInit = {}): Promise<Response> =>
31
+ fetch(path, { ...init, headers: { ...(init.headers as Record<string, string> | undefined ?? {}), ...(CONTROL_KEY !== '' ? { 'X-API-Key': CONTROL_KEY } : {}) } })
32
+
33
+ // gh#40: key-entry affordance — mounts BEFORE the panel when the shell came
34
+ // keyless (remote face) and the control plane answers 401. One input, one
35
+ // submit; the stored key survives until the tab closes.
36
+ if (CONTROL_KEY === '') {
37
+ void controlFetch('/__dsh_a2a/owner-settings').then(res => {
38
+ if (res.status !== 401) return
39
+ const gate = document.createElement('div')
40
+ gate.className = 'nexus-keygate'
41
+ gate.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:#fff;z-index:9999;font:14px/1.5 system-ui,sans-serif;color:#000'
42
+ const label = document.createElement('div')
43
+ label.textContent = '此面板经远程面打开(密钥不随页面下发)——输入控制密钥'
44
+ const input = document.createElement('input')
45
+ input.type = 'password'
46
+ input.placeholder = 'X-API-Key'
47
+ input.setAttribute('aria-label', '控制密钥')
48
+ const submit = document.createElement('button')
49
+ submit.type = 'button'
50
+ submit.textContent = '进入面板'
51
+ const err = document.createElement('div')
52
+ err.style.color = '#b00'
53
+ const tryKey = (): void => {
54
+ const v = input.value.trim()
55
+ if (v === '') return
56
+ void fetch('/__dsh_a2a/owner-settings', { headers: { 'X-API-Key': v } }).then(r => {
57
+ if (r.status === 401) { err.textContent = '密钥不对,再试一次'; return }
58
+ try { window.sessionStorage.setItem('dsh_a2a_key', v) } catch { /* blocked site data: keep in-page */ }
59
+ window.location.reload()
60
+ })
61
+ }
62
+ submit.addEventListener('click', tryKey)
63
+ input.addEventListener('keydown', ev => { if (ev.key === 'Enter') tryKey() })
64
+ gate.append(label, input, submit, err)
65
+ document.body.appendChild(gate)
66
+ })
67
+ }
68
+
69
+ const faultBadge = document.createElement('div')
70
+ faultBadge.className = 'nexus-fault'
71
+ app.appendChild(faultBadge)
72
+ const { fault, clear: clearFault } = createFaultReporter(
73
+ {
74
+ show(message) {
75
+ faultBadge.textContent = message
76
+ faultBadge.style.display = 'block'
77
+ },
78
+ hide() {
79
+ faultBadge.style.display = 'none'
80
+ },
81
+ },
82
+ message => console.error(message),
83
+ )
84
+
85
+ // ─── Spatial planning board (restored 0.6.1; wiring from the pre-0.6.0 stage) ───
86
+ // 0.6.13: board node picks forward to the workbench chat drawer. The bench
87
+ // is created later (it embeds the board), so the dep is a late-bound slot.
88
+ let openNodeChat: ((id: string) => void) | undefined
89
+ // Per-device viewport memory: browser-local, one slot per screen bucket.
90
+ // Storage can be absent or throw (private windows, blocked site data) —
91
+ // every access is guarded and the board falls back to the host/fit path.
92
+ const VIEWPORT_KEY = 'dsh-a2a.viewport.'
93
+ const viewportStore = {
94
+ get(bucket: string): unknown {
95
+ try {
96
+ const raw = window.localStorage.getItem(VIEWPORT_KEY + bucket)
97
+ return raw === null ? undefined : JSON.parse(raw)
98
+ } catch {
99
+ return undefined
100
+ }
101
+ },
102
+ set(bucket: string, vp: { x: number; y: number; scale: number }): void {
103
+ try {
104
+ window.localStorage.setItem(VIEWPORT_KEY + bucket, JSON.stringify({ x: vp.x, y: vp.y, scale: vp.scale }))
105
+ } catch { /* presentation memory only */ }
106
+ },
107
+ }
108
+
109
+ const planning = createPlanningView({
110
+ viewportStore,
111
+ onDirty: () => saveLoop.markDirty(),
112
+ onLampClick: () => saveLoop.retry(),
113
+ onNodePick: (id) => { openNodeChat?.(id) },
114
+ onCanvasAction: async (a) => {
115
+ const ok = await runCanvasAction(a)
116
+ // Settled: converge on truth either way — success absorbs the host's
117
+ // rich state; failure re-pulls so the rollback matches reality.
118
+ void poll()
119
+ return ok
120
+ },
121
+ })
122
+
123
+ const saveLoop = createSaveLoop({
124
+ snapshot: () => planning.snapshotDoc(),
125
+ send: (doc, opts) => controlFetch('/__dsh_a2a/canvas-layout', {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/json' },
128
+ body: JSON.stringify({ action: 'save', layout: doc }),
129
+ cache: 'no-store',
130
+ keepalive: opts.keepalive,
131
+ }).then(async (r) => await r.json() as { ok: boolean; layout?: unknown; error?: string }),
132
+ schedule: (fn, ms) => { const t = setTimeout(fn, ms); return () => { clearTimeout(t) } },
133
+ now: Date.now,
134
+ onLamp: (state) => {
135
+ lampState = state
136
+ const now = new Date()
137
+ const hhmm = String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0')
138
+ planning.setLamp(state, hhmm)
139
+ },
140
+ onAdopt: (normalized) => { planning.adoptExternalLayout(normalized) },
141
+ onHttpError: (message) => fault(message),
142
+ })
143
+ window.addEventListener('pagehide', () => saveLoop.flush())
144
+
145
+ const canvasWire = createCanvasWire({
146
+ send: async (body) => {
147
+ const r = await controlFetch('/__dsh_a2a/canvas', {
148
+ method: 'POST',
149
+ headers: { 'Content-Type': 'application/json' },
150
+ body: JSON.stringify(body),
151
+ cache: 'no-store',
152
+ })
153
+ return { status: r.status, body: await r.json() as { ok?: boolean; error?: string } }
154
+ },
155
+ onNotice: (kind, text) => planning.notice(kind, text),
156
+ })
157
+
158
+ /** One relayed op on a REMOTE peer's team. THIS host signs the envelope
159
+ * and forwards; the peer's link gate authorizes the write. */
160
+ async function fleetOp(peer: string, op: 'add-member' | 'remove-member' | 'remove', name: string, id?: string): Promise<boolean> {
161
+ const r = await controlFetch('/__dsh_a2a/fleet-canvas-op', {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/json' },
164
+ body: JSON.stringify({ peer, op, name, ...(id !== undefined ? { id } : {}) }),
165
+ cache: 'no-store',
166
+ }).catch(() => undefined)
167
+ const body = r !== undefined ? await r.json().catch(() => undefined) : undefined
168
+ if (body?.ok === true && op === 'remove' && body.removed === false) planning.notice('info', `属主那边已没有「${name}」,无需解散`)
169
+ if (body?.ok === true) return true
170
+ // Say WHY the peer refused (not linked, untrusted origin, unknown member…), not just that it did.
171
+ planning.notice('error', `跨主机团队操作失败:${String(body?.error ?? '对端无响应')}`)
172
+ return false
173
+ }
174
+
175
+ async function rosterAction(a: CanvasAction, name: string): Promise<boolean> {
176
+ const post = async (body: Record<string, unknown>): Promise<boolean> => {
177
+ const reply = await controlFetch('/__dsh_a2a/roster-op', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, ...body }) }).catch(() => undefined)
178
+ const result = reply !== undefined ? await reply.json().catch(() => undefined) as { ok?: boolean; error?: string } | undefined : undefined
179
+ if (reply?.ok && result?.ok === true) return true
180
+ planning.notice('error', `roster 操作失败:${String(result?.error ?? (reply ? `HTTP ${reply.status}` : '无响应'))}`)
181
+ return false
182
+ }
183
+ if (a.type === 'create-team') return post({ op: 'create', ids: a.ids })
184
+ if (a.type === 'remove-team') return post({ op: 'dissolve' })
185
+ if (a.type === 'add-member' || a.type === 'remove-member') {
186
+ for (const id of a.ids) if (!(await post({ op: a.type, id }))) return false
187
+ return true
188
+ }
189
+ planning.notice('error', 'roster 不支持该操作')
190
+ return false
191
+ }
192
+
193
+ function runCanvasAction(a: CanvasAction): Promise<boolean> {
194
+ // gh#41: a remote-frame drop forks to the fleet relay; so does dissolving
195
+ // a mirror frame (single owner + mirror: the owner's store is the truth).
196
+ if ((a.type === 'add-member' || a.type === 'remove-member') && a.fleetPeer !== undefined) {
197
+ const peer = a.fleetPeer
198
+ return (async () => {
199
+ for (const id of a.ids) if (!(await fleetOp(peer, a.type, a.team, id))) return false
200
+ return true
201
+ })()
202
+ }
203
+ if (a.type === 'remove-team' && a.fleetPeer !== undefined) return fleetOp(a.fleetPeer, 'remove', a.name)
204
+ // Only a trusted registry roster frame or an explicit create choice may trigger a local roster write.
205
+ if (a.roster === true) {
206
+ const roster = rosterNameOf(actionTeam(a))
207
+ if (roster !== undefined) return rosterAction(a, roster)
208
+ planning.notice('error', '无效的 roster 框名'); return Promise.resolve(false)
209
+ }
210
+ switch (a.type) {
211
+ case 'create-team': return canvasWire.createTeam(a.name, a.ids, a.created)
212
+ case 'add-member': return canvasWire.addMembers(a.team, a.ids)
213
+ case 'remove-member': return canvasWire.removeMembers(a.team, a.ids)
214
+ case 'remove-team': return canvasWire.removeTeam(a.name)
215
+ case 'reorder': return canvasWire.runRosterOps(a.team, a.ops)
216
+ case 'join-network':
217
+ case 'leave-network': return postNetworkJoin(a.type === 'join-network' ? 'join' : 'leave', a.id)
218
+ }
219
+ }
220
+
221
+ /** Network join/leave for one session (the panel-slim migration: this
222
+ * affordance lives on the planning view's node list). */
223
+ async function postNetworkJoin(action: 'join' | 'leave', id: string): Promise<boolean> {
224
+ const r = await controlFetch(`/__dsh_a2a/${action}`, {
225
+ method: 'POST',
226
+ headers: { 'Content-Type': 'application/json' },
227
+ body: JSON.stringify({ id }),
228
+ cache: 'no-store',
229
+ }).catch(() => undefined)
230
+ return r !== undefined && r.ok
231
+ }
232
+
233
+ let lampState: LampState = 'idle'
234
+ // The nodes face reports effective scoped assemblies; a mounted registration alone does not prove visibility.
235
+ let nodePromptMissing = new Set<string>()
236
+ let nodePromptCheckedAt = 0
237
+ let canvasFace = false
238
+ let lastLayoutJson: string | undefined
239
+ /** True once any layout answer (saved doc or an explicit null) has been
240
+ * seen; the board build waits for that answer on the very first poll. */
241
+ let layoutSeen = false
242
+
243
+ /** Fetch the persisted layout; throws on transport failure so the poll can
244
+ * hold the board build instead of silently building on ring seats. */
245
+ async function fetchLayout(): Promise<unknown> {
246
+ const res = await controlFetch('/__dsh_a2a/canvas-layout', { cache: 'no-store' })
247
+ if (!res.ok) throw new Error(`/__dsh_a2a/canvas-layout ${res.status}`)
248
+ return ((await res.json()) as { layout?: unknown }).layout ?? null
249
+ }
250
+
251
+ // ─── The workbench ───
252
+ const bench = createWorkbench({
253
+ now: () => Date.now(),
254
+ // gh#34: the owner three-key panel rides the control face.
255
+ async ownerSettingsRead() {
256
+ try {
257
+ const res = await controlFetch('/__dsh_a2a/owner-settings')
258
+ return await res.json()
259
+ } catch {
260
+ return { ok: false, error: 'unreachable' }
261
+ }
262
+ },
263
+ async ownerSettingsWrite(changes) {
264
+ try {
265
+ const res = await controlFetch('/__dsh_a2a/owner-settings', {
266
+ method: 'POST',
267
+ headers: { 'content-type': 'application/json' },
268
+ body: JSON.stringify(changes),
269
+ })
270
+ return await res.json()
271
+ } catch {
272
+ return { ok: false, error: 'unreachable' }
273
+ }
274
+ },
275
+ async abandon(taskId, reason) {
276
+ try {
277
+ const res = await controlFetch('/__dsh_a2a/tasks/abandon', {
278
+ method: 'POST',
279
+ headers: { 'content-type': 'application/json' },
280
+ body: JSON.stringify({ task_id: taskId, reason }),
281
+ })
282
+ return res.ok
283
+ } catch {
284
+ return false
285
+ }
286
+ },
287
+ copy(text) {
288
+ void navigator.clipboard?.writeText(text).catch(() => {})
289
+ },
290
+ // 0.6.10: the ↗ jump affordances (0.6.7 L1 broadcast / 0.6.8 L2 deep
291
+ // link) are REMOVED by ruling — 禁止任何跳转,全部使用响应式布局.
292
+ // The only reach primitive is the inline 快派 form: it works for local
293
+ // session teams, canvas teams and remote rows alike, and never leaves
294
+ // this page.
295
+ async cliHomeOptions(name, id) {
296
+ try {
297
+ const res = await controlFetch('/__dsh_a2a/canvas', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'cli-home-options', name, id }) })
298
+ const body = await res.json() as { ok: boolean; homes?: string[]; unreadable?: number; held?: boolean; error?: string }
299
+ return { ...body, ok: res.ok && body.ok === true }
300
+ } catch (error) { return { ok: false, error: String(error) } }
301
+ },
302
+ async canvasOp(action, name, id, home) {
303
+ // Same control route the planning board's wire uses; the inspector's
304
+ // member writes are one id at a time, so no serial fan-out needed.
305
+ try {
306
+ const res = await controlFetch('/__dsh_a2a/canvas', {
307
+ method: 'POST',
308
+ headers: { 'content-type': 'application/json' },
309
+ body: JSON.stringify({ action, name, id, ...(home === undefined ? {} : { home }) }),
310
+ })
311
+ const body = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
312
+ if (res.ok && body.ok !== false) return true
313
+ console.warn(`canvas ${action} ${name}: ${body.error ?? `HTTP ${res.status}`}`)
314
+ return false
315
+ } catch {
316
+ return false
317
+ }
318
+ },
319
+ async dispatchQuick(team, message) {
320
+ // Quick dispatch (0.6.9): host-face POST; the trunk answers with the
321
+ // canonical route shape ({ok, task_id, task_status, team, ...} on
322
+ // delivery) or {ok:false, error}.
323
+ try {
324
+ const res = await controlFetch('/__dsh_a2a/dispatch', {
325
+ method: 'POST',
326
+ headers: { 'content-type': 'application/json' },
327
+ body: JSON.stringify({ team, message }),
328
+ })
329
+ const body = (await res.json().catch(() => ({}))) as { ok?: boolean; task_id?: string; reply?: string; error?: string }
330
+ return {
331
+ ok: res.ok && body.ok === true,
332
+ task_id: body.task_id,
333
+ reply: body.reply,
334
+ error: body.error ?? `HTTP ${res.status}`,
335
+ }
336
+ } catch (error) {
337
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
338
+ }
339
+ },
340
+ async sessionLog(id) {
341
+ // Inline transcript (0.6.11): read-only tail of a local session's own
342
+ // log from the host control face. Caps live server-side.
343
+ try {
344
+ const res = await controlFetch(`/__dsh_a2a/session-log?id=${encodeURIComponent(id)}`)
345
+ const body = (await res.json().catch(() => ({}))) as { ok?: boolean; messages?: Array<{ role: string; text: string }>; error?: string }
346
+ // 0.6.16: error stays undefined on success — an unconditional
347
+ // `body.error ?? HTTP ${status}` painted "HTTP 200" on listing-shaped
348
+ // successes that lacked a messages field.
349
+ return { ok: res.ok && body.ok === true, messages: body.messages, error: body.error ?? (res.ok ? undefined : `HTTP ${res.status}`) }
350
+ } catch (error) {
351
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
352
+ }
353
+ },
354
+ async sessionLogList() {
355
+ // 0.6.12: host-scope readable-log listing — archived and never-joined
356
+ // sessions included; the network directory is not the reading gate.
357
+ try {
358
+ const res = await controlFetch('/__dsh_a2a/session-log')
359
+ const body = (await res.json().catch(() => ({}))) as { ok?: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; count?: number; error?: string }
360
+ // count: how many logs the host holds — the list face caps at 50, so the
361
+ // directory can say it is showing 50 of N instead of silently truncating.
362
+ return { ok: res.ok && body.ok === true, sessions: body.sessions, count: typeof body.count === 'number' ? body.count : undefined, error: body.error ?? (res.ok ? undefined : `HTTP ${res.status}`) }
363
+ } catch (error) {
364
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
365
+ }
366
+ },
367
+ async remoteSessionLog(query) {
368
+ // 0.6.15: proxy read of a remote host's /a2a/log — by tracked peer url or
369
+ // by team handle (directory-resolved). The proxy is the only reader; the
370
+ // browser never dials the peer directly.
371
+ try {
372
+ const params = new URLSearchParams()
373
+ if (query.team !== undefined) params.set('team', query.team)
374
+ if (query.peer !== undefined) params.set('peer', query.peer)
375
+ if (query.id !== undefined) params.set('id', query.id)
376
+ if (query.limit !== undefined) params.set('limit', String(query.limit))
377
+ const res = await controlFetch(`/__dsh_a2a/remote-session-log?${params.toString()}`)
378
+ const body = (await res.json().catch(() => ({}))) as {
379
+ ok?: boolean
380
+ messages?: Array<{ role: string; text: string }>
381
+ sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>
382
+ error?: string
383
+ via?: string
384
+ }
385
+ return { ok: res.ok && body.ok === true, messages: body.messages, sessions: body.sessions, error: body.error ?? (res.ok ? undefined : `HTTP ${res.status}`), via: body.via }
386
+ } catch (error) {
387
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
388
+ }
389
+ },
390
+ board: {
391
+ root: planning.root,
392
+ activate: () => planning.activate(),
393
+ deactivate: () => planning.deactivate(),
394
+ available: () => canvasFace,
395
+ },
396
+ })
397
+ app.appendChild(bench.root)
398
+ // 0.6.13: board node picks now resolve against the live bench state.
399
+ openNodeChat = (id) => { bench.openNodeChat(id) }
400
+
401
+ // Legacy entry compatibility: the old stage link ?mode=plan converges on the
402
+ // board stage — in the merged shell canvas mode is already the default while
403
+ // the host serves the canvas face, so this only guards the first poll.
404
+ if (new URLSearchParams(window.location.search).get('mode') === 'plan') {
405
+ const tryBoard = window.setInterval(() => {
406
+ if (canvasFace) {
407
+ bench.selectTab('board')
408
+ window.clearInterval(tryBoard)
409
+ }
410
+ }, 300)
411
+ window.setTimeout(() => window.clearInterval(tryBoard), 30_000)
412
+ }
413
+
414
+ async function poll(): Promise<void> {
415
+ try {
416
+ const res = await controlFetch('/__dsh_a2a/state', { cache: 'no-store' })
417
+ if (!res.ok) throw new Error(`/__dsh_a2a/state ${res.status}`)
418
+ const body = (await res.json()) as StateBody
419
+ if (Date.now() - nodePromptCheckedAt >= 15_000) {
420
+ nodePromptCheckedAt = Date.now()
421
+ try {
422
+ const nodes = await controlFetch('/__dsh_a2a/nodes', { cache: 'no-store' })
423
+ if (!nodes.ok) throw new Error(`nodes ${nodes.status}`)
424
+ const view = await nodes.json() as { bindings?: Array<{ session: string; mounted: unknown; effective: { core: boolean; mutable: boolean } | null }> }
425
+ nodePromptMissing = new Set((view.bindings ?? [])
426
+ .filter(b => b.mounted !== null && b.effective !== null && (b.effective.core === false || b.effective.mutable === false))
427
+ .map(b => b.session))
428
+ } catch { nodePromptMissing.clear() } // unavailable is unknown, not confirmed missing
429
+ }
430
+ const shown = { ...body, sessions: (body.sessions ?? []).map(s => ({ ...s, ...(nodePromptMissing.has(s.id) ? { nodePromptMissing: true } : {}) })) }
431
+ clearFault()
432
+ // canvasFace first: bench.update reads board.available() to pick the
433
+ // mode, so assigning it afterwards held the very first paint in data
434
+ // mode (board display:none, 0px wide) until the next 3s poll — and the
435
+ // board's first reconcile measured a zero-width view.
436
+ canvasFace = body.canvas !== undefined
437
+ bench.update(shown)
438
+
439
+ // ── The layout answer precedes the first reconcile on purpose ──
440
+ // The board's first build reads `layoutDoc` for saved positions and
441
+ // frame rects; every later pass keeps live geometry ("live rect wins").
442
+ // A layout landing AFTER the build therefore never displaces the ring
443
+ // seats — the refresh-reset-to-middle bug. Until any answer (doc or
444
+ // null) has been seen, a failed GET holds the board one poll rather
445
+ // than building on seats; after that, failures leave the last doc in
446
+ // place and the poll continues.
447
+ if (canvasFace) {
448
+ try {
449
+ const layout = await fetchLayout()
450
+ const layoutJson = JSON.stringify(layout ?? null)
451
+ if (layoutJson !== lastLayoutJson) {
452
+ lastLayoutJson = layoutJson
453
+ // Adopt poll-sourced layouts only while nothing is unsaved/in
454
+ // flight — otherwise a slow GET would revert positions the user
455
+ // is still dragging.
456
+ if ((lampState === 'idle' || lampState === 'saved') && !saveLoop.isBusy()) {
457
+ planning.adoptExternalLayout(layout)
458
+ }
459
+ }
460
+ layoutSeen = true
461
+ } catch (err) {
462
+ if (!layoutSeen) {
463
+ fault(`canvas layout 获取失败:${String(err)}`)
464
+ return
465
+ }
466
+ }
467
+ } else {
468
+ layoutSeen = true
469
+ }
470
+
471
+ // ── Planning board shares this poll: same state payload. ──
472
+ // SessionLite needs `joined: boolean` (workbench rows carry `joined?`):
473
+ // tri-state → boolean without losing the unjoined node-list population.
474
+ const allSessions = (body.sessions ?? []).map(s => ({ ...s, joined: s.joined === true }))
475
+ const teams = [...(body.canvas?.teams ?? []), ...rosterFrames(body.registry?.teams ?? [], body.zone ?? '')]
476
+ const peers = (body.peers ?? []).filter(p => p !== null && typeof p === 'object' && typeof (p as { url?: unknown }).url === 'string')
477
+ const inFlight = (body.inFlight ?? []).filter(r => r !== null && typeof r === 'object' && typeof (r as { team?: unknown }).team === 'string' && typeof (r as { peer?: unknown }).peer === 'string' && Number.isFinite((r as { startedAt?: unknown }).startedAt))
478
+ const remoteTeams = (body.remote ?? [])
479
+ .filter(r => r !== null && typeof r === 'object' && typeof (r as { team?: unknown }).team === 'string' && typeof (r as { via?: unknown }).via === 'string')
480
+ .map(r => ({
481
+ team: r.team,
482
+ name: r.name,
483
+ via: r.via,
484
+ workspace: r.workspace,
485
+ ...(r.unreachable === true ? { unreachable: true } : {}),
486
+ ...(typeof r.origin === 'string' && r.origin !== '' ? { origin: r.origin } : {}),
487
+ ...(Array.isArray(r.teams) && r.teams.length > 0 ? { teams: r.teams.filter((t): t is string => typeof t === 'string' && t !== '') } : {}),
488
+ }))
489
+ // gh#41: the peers' published canvas org rides the board (remote frames).
490
+ const peerOrg = (body.peerOrg as unknown[] ?? [])
491
+ .filter((r): r is { url?: unknown; org?: unknown; unreachable?: unknown } => r !== null && typeof r === 'object')
492
+ .filter(r => typeof r.url === 'string' && Array.isArray(r.org))
493
+ .map(r => ({
494
+ url: r.url as string,
495
+ ...(r.unreachable === true ? { unreachable: true } : {}),
496
+ org: (r.org as unknown[])
497
+ .filter((e): e is { name?: unknown; team?: unknown; members?: unknown } => e !== null && typeof e === 'object')
498
+ .filter(e => typeof e.team === 'string' && e.team !== '')
499
+ .map(e => ({ name: typeof e.name === 'string' ? e.name : '', team: e.team as string, members: Array.isArray(e.members) ? e.members.filter((m): m is string => typeof m === 'string') : [] })),
500
+ }))
501
+ planning.reconcile({ sessions: allSessions, teams, peerCount: peers.length, peers, inFlight, remoteTeams, peerOrg })
502
+ } catch (err) {
503
+ fault(`state 轮询失败:${String(err)}`)
504
+ }
505
+ }
506
+
507
+ void poll()
508
+ window.setInterval(() => {
509
+ if (!document.hidden) void poll()
510
+ }, 3000)
511
+ document.addEventListener('visibilitychange', () => {
512
+ if (!document.hidden) void poll()
513
+ })