@nelsonlongxiang/dsh-open-a2a-net 0.6.33 → 0.6.35

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 (57) hide show
  1. package/README.md +5 -2
  2. package/README_zh.md +5 -2
  3. package/assets/nexusDist/assets/index-Du2E6DJL.js +9 -0
  4. package/assets/nexusDist/assets/index-z33lHtLw.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +3 -1
  7. package/lib/a2a-client.js +9 -2
  8. package/lib/auth-observer.js +1 -1
  9. package/lib/canvas-store.js +64 -6
  10. package/lib/card.js +2 -1
  11. package/lib/cli-home.js +44 -0
  12. package/lib/client/A2aControl.js +1 -1
  13. package/lib/client.js +65 -65
  14. package/lib/client.js.map +1 -1
  15. package/lib/direct-forward.js +61 -0
  16. package/lib/faces/admin.js +43 -6
  17. package/lib/faces/wire.js +57 -40
  18. package/lib/flood-guard.js +7 -1
  19. package/lib/index.js +117 -65
  20. package/lib/outbound-scope.js +25 -0
  21. package/lib/queue-guard.js +37 -0
  22. package/lib/response-fields.js +27 -0
  23. package/lib/skill/references/cli.md +11 -4
  24. package/lib/skill/references/pitfalls.md +5 -4
  25. package/lib/skill/references/routing.md +4 -0
  26. package/lib/task-auth.js +52 -3
  27. package/lib/task-ledger.js +4 -1
  28. package/lib/tools/info.js +19 -3
  29. package/lib/types/a2a-client.d.ts +1 -1
  30. package/lib/types/auth-observer.d.ts +2 -2
  31. package/lib/types/canvas-store.d.ts +15 -1
  32. package/lib/types/cli-home.d.ts +24 -0
  33. package/lib/types/client/A2aControl.d.ts +1 -1
  34. package/lib/types/direct-forward.d.ts +32 -0
  35. package/lib/types/faces/admin.d.ts +8 -1
  36. package/lib/types/faces/wire.d.ts +12 -4
  37. package/lib/types/flood-guard.d.ts +5 -0
  38. package/lib/types/index.d.ts +4 -0
  39. package/lib/types/outbound-scope.d.ts +21 -0
  40. package/lib/types/queue-guard.d.ts +32 -0
  41. package/lib/types/response-fields.d.ts +3 -0
  42. package/lib/types/runtime.d.ts +1 -1
  43. package/lib/types/task-auth.d.ts +17 -2
  44. package/lib/types/task-ledger.d.ts +6 -1
  45. package/lib/types/types.d.ts +8 -0
  46. package/nexus-stage/src/cli-home-picker.ts +42 -0
  47. package/nexus-stage/src/main.ts +9 -2
  48. package/nexus-stage/src/planning-card.ts +61 -0
  49. package/nexus-stage/src/planning-menus.ts +42 -7
  50. package/nexus-stage/src/planning-view.ts +14 -32
  51. package/nexus-stage/src/planning.css +17 -0
  52. package/nexus-stage/src/workbench.css +4 -0
  53. package/nexus-stage/src/workbench.ts +28 -5
  54. package/nexus-stage/src/world.ts +5 -1
  55. package/package.json +2 -1
  56. package/assets/nexusDist/assets/index-BACVZe0j.css +0 -1
  57. package/assets/nexusDist/assets/index-Bbxnpf2p.js +0 -9
@@ -63,7 +63,7 @@ export type ArchiveEnd = 'receipt' | 'echo' | 'abandoned' | 'cancelled' | 'evict
63
63
  * same-host seats could not be told apart.
64
64
  */
65
65
  export interface Sender {
66
- readonly kind: 'session' | 'host' | 'bridge';
66
+ readonly kind: 'session' | 'host' | 'bridge' | 'cli';
67
67
  /**
68
68
  * A session's `<zone>/<id8>` node team, the host process label, or the
69
69
  * bridge submitter. Group senders by `kind:id`, never by `id` alone: a host
@@ -89,6 +89,7 @@ export declare const SENDER_ID_CAP = 256;
89
89
  export declare function parseSender(raw: unknown): Sender | undefined;
90
90
  /** #139: what an outbound row is born with besides its sender. */
91
91
  export interface OutboundBirth {
92
+ readonly forwarded?: true;
92
93
  /** The local-candidate admission (ingress local); kept only on a row whose peer is 'local'. */
93
94
  readonly auth?: TaskAuthorization;
94
95
  readonly peerKey?: string;
@@ -124,6 +125,8 @@ export interface TaskRecord {
124
125
  readonly brief?: string;
125
126
  /** #114 S3: the real sender of an outbound row (absent on inbound rows and rows persisted before the field). */
126
127
  readonly sender?: Sender;
128
+ /** Home host forwarded an online CLI request to a peer. */
129
+ readonly forwarded?: true;
127
130
  /**
128
131
  * #114 S2: the immutable authorization the row was born with — an inbound
129
132
  * row's wire admission, or (#139) an outbound row's local-candidate
@@ -183,6 +186,8 @@ export interface ArchivedRecord {
183
186
  readonly receiptExpected?: false;
184
187
  /** #114 S3: copied from the owed row (absent on inbound rows and rows archived before the field). */
185
188
  readonly sender?: Sender;
189
+ /** Home host forwarded an online CLI request to a peer. */
190
+ readonly forwarded?: true;
186
191
  /** #114 S2: carried over from the live row unchanged. */
187
192
  readonly auth?: TaskAuthorization;
188
193
  /** #139: carried over from the live row unchanged (a late receipt is judged against them). */
@@ -76,6 +76,8 @@ export interface A2aSessionTeamInfo {
76
76
  readonly name: string;
77
77
  /** One-line excerpt of the session's newest user or assistant text. */
78
78
  readonly description: string;
79
+ /** CLI deposit status, explicitly online/offline on full host cards; absent on ordinary sessions. */
80
+ readonly offline?: boolean;
79
81
  /** The session's working directory, when the publishing node shares it. */
80
82
  readonly workspace?: string;
81
83
  }
@@ -105,6 +107,8 @@ export interface ZoneRecord {
105
107
  }
106
108
  /** Canonical `a2a_route` success value. */
107
109
  export interface A2aRouteOk {
110
+ /** Actual member face returned by a forwarded peer delivery. */
111
+ readonly deliveredTo?: string;
108
112
  readonly ok: true;
109
113
  readonly team: string;
110
114
  /** Reply text the remote team produced. */
@@ -129,6 +133,8 @@ export interface A2aRouteOk {
129
133
  * receipt-owed (`trackOwedTask` skips it).
130
134
  */
131
135
  readonly bridge?: 'native-teams';
136
+ /** The peer's explicit receipt contract, when provided. */
137
+ readonly receipt?: 'none' | 'routed' | 'mailbox';
132
138
  /**
133
139
  * gh#141: the peer refused the settlement the delivered message carried
134
140
  * (its enforce verdict, `settlement: {status: 'refused'}` on the wire,
@@ -138,6 +144,8 @@ export interface A2aRouteOk {
138
144
  }
139
145
  /** Canonical `a2a_route` failure value. */
140
146
  export interface A2aRouteError {
147
+ /** A forwarded receipt never promises another receipt, including uncertain waits. */
148
+ readonly receipt?: 'none';
141
149
  readonly ok: false;
142
150
  /** Peer or transport failure text. */
143
151
  readonly error: string;
@@ -0,0 +1,42 @@
1
+ /** Explicit operator choice only; this form never auto-selects or relaxes home admission. */
2
+ export interface CliHomeOptions { ok: boolean; homes?: string[]; unreadable?: number; held?: boolean; error?: string }
3
+ export function createCliHomePicker(read: () => Promise<CliHomeOptions>, save: (home: string) => Promise<boolean>): HTMLFormElement {
4
+ const form = document.createElement('form')
5
+ form.className = 'wb-cli-home'
6
+ const readButton = document.createElement('button')
7
+ readButton.type = 'button'; readButton.textContent = '指定 home'; readButton.className = 'wb-member-submit'
8
+ const select = document.createElement('select')
9
+ select.className = 'wb-member-pick'; select.setAttribute('aria-label', '选择 CLI home 主机'); select.hidden = true
10
+ const submit = document.createElement('button')
11
+ submit.type = 'submit'; submit.textContent = '确认 home'; submit.className = 'wb-member-submit'; submit.hidden = true; submit.disabled = true
12
+ const note = document.createElement('div')
13
+ note.className = 'wb-hint'; note.setAttribute('role', 'status'); note.setAttribute('aria-live', 'polite')
14
+ note.textContent = 'home 未确定;点击指定 home 现读已链接主机名片。'
15
+ let busy = false
16
+ readButton.addEventListener('click', async () => {
17
+ if (busy) return
18
+ busy = true; readButton.disabled = true; submit.disabled = true; note.textContent = '正在现读主机名片…'
19
+ try {
20
+ const result = await read()
21
+ if (!result.ok) throw Error(result.error ?? '候选读取失败')
22
+ if (result.held) { readButton.hidden = true; select.hidden = true; submit.hidden = true; select.replaceChildren(); note.textContent = '本机持有,无需指定'; return }
23
+ select.replaceChildren(new Option('请选择 home 主机…', '', true, true))
24
+ for (const home of result.homes ?? []) select.appendChild(new Option(home, home))
25
+ select.hidden = false; submit.hidden = false; readButton.textContent = '刷新候选'
26
+ const count = result.homes?.length ?? 0
27
+ note.textContent = (result.unreadable ? '有 ' + result.unreadable + ' 台对端名片读不到;' : '') + (count > 1 ? '多台已链接主机列出该句柄,请显式选择。' : count === 1 ? '找到 1 台已链接主机;仍需你显式确认。' : '当前没有已链接且列出该句柄的可选主机。')
28
+ } catch (error) { note.textContent = '读取失败:' + String(error); select.hidden = true; submit.hidden = true }
29
+ finally { busy = false; readButton.disabled = false }
30
+ })
31
+ select.addEventListener('change', () => { submit.disabled = busy || select.value === '' })
32
+ form.addEventListener('submit', async event => {
33
+ event.preventDefault()
34
+ if (busy || select.value === '') return
35
+ busy = true; readButton.disabled = true; select.disabled = true; submit.disabled = true
36
+ try { note.textContent = await save(select.value) ? 'home 已指定:' + select.value : '指定失败:主机可能已离线或不再列出该句柄,请刷新后重选。' }
37
+ catch (error) { note.textContent = '指定失败:' + String(error) }
38
+ finally { busy = false; readButton.disabled = false; select.disabled = false; submit.disabled = select.value === '' }
39
+ })
40
+ form.append(readButton, select, submit, note)
41
+ return form
42
+ }
@@ -292,14 +292,21 @@ const bench = createWorkbench({
292
292
  // The only reach primitive is the inline 快派 form: it works for local
293
293
  // session teams, canvas teams and remote rows alike, and never leaves
294
294
  // this page.
295
- async canvasOp(action, name, id) {
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) {
296
303
  // Same control route the planning board's wire uses; the inspector's
297
304
  // member writes are one id at a time, so no serial fan-out needed.
298
305
  try {
299
306
  const res = await controlFetch('/__dsh_a2a/canvas', {
300
307
  method: 'POST',
301
308
  headers: { 'content-type': 'application/json' },
302
- body: JSON.stringify({ action, name, id }),
309
+ body: JSON.stringify({ action, name, id, ...(home === undefined ? {} : { home }) }),
303
310
  })
304
311
  const body = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
305
312
  if (res.ok && body.ok !== false) return true
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Board node card: the DOM skeleton and the right-hand chip. Extracted from planning-view so the card's static
3
+ * structure reads in one place and the view stays under its M1 module-size cap (1507 lines; it reached 1529 with
4
+ * the 工作中/空闲 chip). Pure DOM helpers — no view state; renderNodes still decides what each card shows.
5
+ * @module nexus-stage/planning-card
6
+ */
7
+ import type { PlanningSession } from './planning-types'
8
+ import type { WorldNode } from './world'
9
+
10
+ /** The card's 工作中/空闲 chip text; empty when the host reported no status (older host, cold or remote node). */
11
+ export function workLabel(work: Pick<PlanningSession, 'status' | 'queued'> | undefined): string {
12
+ if (work?.status === undefined) return ''
13
+ const base = work.status === 'running' ? '工作中' : '空闲'
14
+ return typeof work.queued === 'number' && work.queued > 0 ? `${base} · 排队 ${String(work.queued)}` : base
15
+ }
16
+
17
+ /** A fresh `.p-node` card: title row (dot + text), second row (description + 工作中/空闲 chip), and the right-hand chip. */
18
+ export function createCardElement(id: string): HTMLDivElement {
19
+ const el = document.createElement('div')
20
+ el.className = 'p-node'
21
+ el.dataset.id = id
22
+ el.tabIndex = 0
23
+ const nm = document.createElement('div')
24
+ nm.className = 'nm'
25
+ const dot = document.createElement('span')
26
+ dot.className = 'dot'
27
+ const name = document.createElement('span')
28
+ name.className = 'nm-text'
29
+ nm.append(dot, name)
30
+ const sub = document.createElement('div')
31
+ sub.className = 'sub mono'
32
+ const prio = document.createElement('span')
33
+ prio.className = 'prio mono'
34
+ // Second line: the description, with the 工作中/空闲 chip at its right end — the title keeps its full width.
35
+ const work = document.createElement('span')
36
+ work.className = 'work'
37
+ const subRow = document.createElement('div')
38
+ subRow.className = 'sub-row'
39
+ subRow.append(sub, work)
40
+ el.append(nm, subRow, prio)
41
+ return el
42
+ }
43
+
44
+ /**
45
+ * The right-hand chip. Peer cards show the link badge (gh#20 W-A: linked green / stranger gray, then the
46
+ * verified tick/cross; an absent link field from an older host renders the neutral 'peer'); session cards show
47
+ * their first membership priority `P<n>`. Writes only on change.
48
+ */
49
+ export function paintPrio(prioEl: HTMLElement, n: WorldNode, info: { link?: 'linked' | 'stranger'; verified?: boolean } | undefined): void {
50
+ if (n.remote === true) {
51
+ const cls = info?.link === 'linked' ? 'lnk-linked' : info?.link === 'stranger' ? 'lnk-stranger' : ''
52
+ if (prioEl.className !== `prio mono ${cls}`.trim()) prioEl.className = `prio mono ${cls}`.trim()
53
+ const mark = info === undefined ? '' : info.verified === true ? '✓' : '✗'
54
+ const badge = (info?.link ?? 'peer') + mark
55
+ if (prioEl.textContent !== badge) prioEl.textContent = badge
56
+ } else {
57
+ if (prioEl.className !== 'prio mono') prioEl.className = 'prio mono'
58
+ const prioText = n.memberships[0] !== undefined ? `P${n.memberships[0]!.index}` : ''
59
+ if (prioEl.textContent !== prioText) prioEl.textContent = prioText
60
+ }
61
+ }
@@ -91,12 +91,46 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
91
91
  title.id = 'p-dialog-title'
92
92
  title.className = 'p-dialog-title'
93
93
  title.textContent = `组建团队(${ids.length} 个节点,自上而下即优先级)`
94
- const kind = document.createElement('select')
95
- kind.className = 'p-dialog-kind'
96
- kind.setAttribute('aria-label', '团队类型')
97
- for (const [value, label] of [['canvas', 'canvas 队'], ['roster', 'roster 网络队']]) {
98
- const option = document.createElement('option'); option.value = value; option.textContent = label; kind.appendChild(option)
94
+ // Team type: a visible two-way toggle (a bare <select> read as a label and roster went unnoticed).
95
+ const kind = { value: 'canvas' as 'canvas' | 'roster' }
96
+ const kindGroup = document.createElement('div')
97
+ kindGroup.className = 'p-dialog-kind'
98
+ kindGroup.setAttribute('role', 'radiogroup')
99
+ kindGroup.setAttribute('aria-label', '团队类型')
100
+ const kindHint = document.createElement('div')
101
+ kindHint.className = 'p-dialog-hint'
102
+ const KIND_HINT = {
103
+ canvas: 'canvas 队:画布上的协作组,可以加入远端成员。',
104
+ roster: 'roster 网络队:只收本机已入网的会话;队名会写进属主的精确允许名单。',
105
+ } as const
106
+ const kindButtons = (['canvas', 'roster'] as const).map(value => {
107
+ const b = document.createElement('button')
108
+ b.type = 'button'
109
+ b.dataset.kind = value
110
+ b.setAttribute('role', 'radio')
111
+ b.textContent = value === 'canvas' ? 'canvas 队' : 'roster 网络队'
112
+ b.addEventListener('click', () => setKind(value))
113
+ kindGroup.appendChild(b)
114
+ return b
115
+ })
116
+ function setKind(value: 'canvas' | 'roster'): void {
117
+ kind.value = value
118
+ for (const b of kindButtons) {
119
+ const on = b.dataset.kind === value
120
+ b.setAttribute('aria-checked', String(on))
121
+ b.tabIndex = on ? 0 : -1
122
+ }
123
+ kindHint.textContent = KIND_HINT[value]
124
+ err.textContent = ''
99
125
  }
126
+ // Arrow keys move between the two options (radiogroup pattern).
127
+ kindGroup.addEventListener('keydown', (ev) => {
128
+ const e = ev as KeyboardEvent
129
+ if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return
130
+ e.preventDefault()
131
+ setKind(kind.value === 'canvas' ? 'roster' : 'canvas')
132
+ kindButtons.find(b => b.dataset.kind === kind.value)?.focus()
133
+ })
100
134
  const input = document.createElement('input')
101
135
  input.className = 'p-dialog-input'
102
136
  input.maxLength = 40
@@ -113,7 +147,8 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
113
147
  cancelBtn.type = 'button'
114
148
  cancelBtn.textContent = '取消'
115
149
  row.append(okBtn, cancelBtn)
116
- panel.append(title, kind, input, err, row)
150
+ setKind('canvas')
151
+ panel.append(title, kindGroup, kindHint, input, err, row)
117
152
  wrap.appendChild(panel)
118
153
  root.appendChild(wrap)
119
154
 
@@ -161,7 +196,7 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
161
196
  if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return }
162
197
  if (e.key !== 'Tab') return
163
198
  e.preventDefault()
164
- const focusables = Array.from(panel.querySelectorAll<HTMLElement>('input, select, button'))
199
+ const focusables = Array.from(panel.querySelectorAll<HTMLElement>('input, select, button:not([tabindex="-1"])'))
165
200
  const idx = focusables.indexOf(document.activeElement as HTMLElement)
166
201
  const next = e.shiftKey
167
202
  ? focusables[(idx - 1 + focusables.length) % focusables.length]
@@ -51,6 +51,7 @@ import { settleBoard, tidyMembers } from './planning-overlap'
51
51
  import { createLayoutUndo, type LayoutSnap } from './planning-undo'
52
52
  import { createNodeList } from './planning-nodelist'
53
53
  import { cardText } from './planning-card-text'
54
+ import { createCardElement, paintPrio, workLabel } from './planning-card'
54
55
  import { createLegend } from './planning-legend'
55
56
  import { UNREACHABLE_HINT, showFrameReachability, unreachableRefs } from './planning-reachability'
56
57
  import { viewportBucket, type PlanningDeps, type PlanningInput, type PlanningSession, type PlanningTeam, type PlanningView, type SeamKey, type SeamPointer, type ViewportBucket } from './planning-types'
@@ -68,6 +69,9 @@ type Gesture =
68
69
  | { readonly kind: 'pan'; last: { x: number; y: number }; moved: boolean }
69
70
  | { readonly kind: 'marquee'; origin: { x: number; y: number }; last: { x: number; y: number }; additive: boolean }
70
71
 
72
+ // The card skeleton, its 工作中/空闲 chip and the right-hand chip live in planning-card (module-size cap).
73
+ export { workLabel } from './planning-card'
74
+
71
75
  export function createPlanningView(deps: PlanningDeps): PlanningView {
72
76
  const model = new WorldModel()
73
77
  let layoutDoc: LayoutDoc | undefined
@@ -398,22 +402,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
398
402
  seen.add(n.id)
399
403
  let el = nodeEls.get(n.id)
400
404
  if (el === undefined) {
401
- el = document.createElement('div')
402
- el.className = 'p-node'
403
- el.dataset.id = n.id
404
- el.tabIndex = 0
405
- const nm = document.createElement('div')
406
- nm.className = 'nm'
407
- const dot = document.createElement('span')
408
- dot.className = 'dot'
409
- const name = document.createElement('span')
410
- name.className = 'nm-text'
411
- nm.append(dot, name)
412
- const sub = document.createElement('div')
413
- sub.className = 'sub mono'
414
- const prio = document.createElement('span')
415
- prio.className = 'prio mono'
416
- el.append(nm, sub, prio)
405
+ el = createCardElement(n.id)
417
406
  nodeLayer.appendChild(el)
418
407
  nodeEls.set(n.id, el)
419
408
  }
@@ -422,6 +411,12 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
422
411
  el.style.top = `${r.y}px`
423
412
  el.classList.toggle('cold', !n.live)
424
413
  el.classList.toggle('remote', n.remote === true)
414
+ // 工作中/空闲: live local sessions only; the host reports nothing for cold or remote nodes.
415
+ const work = n.live && n.remote !== true ? lastWork.get(n.id) : undefined
416
+ el.classList.toggle('is-working', work?.status === 'running')
417
+ const workText = workLabel(work)
418
+ const workEl = el.querySelector<HTMLElement>('.work')!
419
+ if (workEl.textContent !== workText) workEl.textContent = workText
425
420
  el.classList.toggle('selected', model.isSelected(n.id))
426
421
  el.setAttribute('aria-selected', String(model.isSelected(n.id)))
427
422
  const host = n.id.slice('peer-'.length)
@@ -436,22 +431,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
436
431
  if (subEl.textContent !== subText) subEl.textContent = subText
437
432
  const tip = unavailable ? `${txt.tip} · ${UNREACHABLE_HINT}` : txt.tip
438
433
  if (el.title !== tip) el.title = tip
439
- const prioEl = el.querySelector<HTMLElement>('.prio')!
440
- if (n.remote === true) {
441
- // gh#20 W-A: the peer badge — link class colors the chip (linked
442
- // green / stranger gray), the verified tick/cross rides after it.
443
- // An absent link field (older host) renders the neutral 'peer'.
444
- const info = peerInfoByHost.get(host)
445
- const cls = info?.link === 'linked' ? 'lnk-linked' : info?.link === 'stranger' ? 'lnk-stranger' : ''
446
- if (prioEl.className !== `prio mono ${cls}`.trim()) prioEl.className = `prio mono ${cls}`.trim()
447
- const mark = info === undefined ? '' : info.verified === true ? '✓' : '✗'
448
- const badge = (info?.link ?? 'peer') + mark
449
- if (prioEl.textContent !== badge) prioEl.textContent = badge
450
- } else {
451
- if (prioEl.className !== 'prio mono') prioEl.className = 'prio mono'
452
- const prioText = n.memberships[0] !== undefined ? `P${n.memberships[0]!.index}` : ''
453
- if (prioEl.textContent !== prioText) prioEl.textContent = prioText
454
- }
434
+ paintPrio(el.querySelector<HTMLElement>('.prio')!, n, n.remote === true ? peerInfoByHost.get(host) : undefined)
455
435
  }
456
436
  for (const [id, el] of [...nodeEls]) {
457
437
  if (!seen.has(id)) { el.remove(); nodeEls.delete(id) }
@@ -470,6 +450,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
470
450
  const federation = createFederationOverlay(svg, model)
471
451
  let lastPeers: ReadonlyArray<PeerRow> = []
472
452
  let lastRemoteTeams: ReadonlyMap<string, RemoteTeamRow[]> = new Map()
453
+ let lastWork: ReadonlyMap<string, Pick<PlanningSession, 'status' | 'queued'>> = new Map()
473
454
  const peerScoreByText = new Map<string, number | undefined>()
474
455
  /** gh#20 W-A: per-host link/verified info for the peer badges. */
475
456
  const peerInfoByHost = new Map<string, { link?: 'linked' | 'stranger'; verified?: boolean }>()
@@ -1221,6 +1202,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1221
1202
  // excluded from bounds, so the column cannot feed back into itself).
1222
1203
  const peerSeen = new Set<string>()
1223
1204
  lastRemoteTeams = groupRemoteTeams(input.remoteTeams ?? [])
1205
+ lastWork = new Map(input.sessions.map(s => [s.id, { status: s.status, queued: s.queued }]))
1224
1206
  reachability = unreachableRefs(input)
1225
1207
  peerScoreByText.clear()
1226
1208
  for (const p of input.peers ?? []) {
@@ -116,6 +116,14 @@
116
116
  #dsh-plan .p-node.remote .nm-text{overflow-wrap:anywhere}
117
117
  #dsh-plan .p-node .dot{width:8px; height:8px; border-radius:50%; background:var(--edge-live); flex:none}
118
118
  #dsh-plan .p-node.cold .dot{background:transparent; border:1.5px solid var(--edge-cold)}
119
+ #dsh-plan .p-node .sub-row{display:flex; align-items:baseline; gap:6px; margin-top:2px; min-width:0}
120
+ #dsh-plan .p-node .sub-row .sub{flex:1 1 auto; min-width:0; margin-top:0}
121
+ #dsh-plan .p-node .work{flex:none; font-size:10.5px; font-weight:500; color:var(--ink-muted); white-space:nowrap}
122
+ #dsh-plan .p-node .work:empty{display:none}
123
+ #dsh-plan .p-node.is-working .work{color:var(--edge-live)}
124
+ #dsh-plan .p-node.is-working .dot{animation:p-working 1.4s ease-in-out infinite}
125
+ @keyframes p-working{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--edge-live) 55%,transparent)}50%{box-shadow:0 0 0 4px transparent}}
126
+ @media (prefers-reduced-motion: reduce){#dsh-plan .p-node.is-working .dot{animation:none}}
119
127
  #dsh-plan .p-node .sub{font-size:11px; color:var(--ink-muted); margin-top:2px;
120
128
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
121
129
  /* W1: a picked search hit pulses its card for 1.2s (class removed on timer). */
@@ -252,6 +260,15 @@
252
260
  #dsh-plan .p-dialog-input:hover{border-color:color-mix(in srgb,var(--uupm-ink-1) 55%,transparent)}
253
261
  #dsh-plan .p-dialog-input:focus-visible{outline:2px solid var(--accent); outline-offset:1px; border-color:var(--accent)}
254
262
  #dsh-plan .p-dialog-err{min-height:1.2em; font-size:11px; color:var(--uupm-error)}
263
+ #dsh-plan .p-dialog-kind{display:grid; grid-template-columns:1fr 1fr; gap:4px; padding:3px;
264
+ background:var(--uupm-bg-0); border:1px solid var(--card-line); border-radius:8px}
265
+ #dsh-plan .p-dialog-kind button{font:inherit; color:var(--ink-muted); background:transparent; border:1px solid transparent;
266
+ border-radius:6px; padding:6px 10px; cursor:pointer}
267
+ #dsh-plan .p-dialog-kind button:hover{color:var(--ink); background:var(--uupm-wash-8)}
268
+ #dsh-plan .p-dialog-kind button[aria-checked=true]{color:var(--ink); font-weight:600; background:var(--uupm-wash-15);
269
+ border-color:var(--accent)}
270
+ #dsh-plan .p-dialog-kind button:focus-visible{outline:2px solid var(--accent); outline-offset:1px}
271
+ #dsh-plan .p-dialog-hint{font-size:11px; color:var(--ink-muted); max-width:340px}
255
272
  #dsh-plan .p-dialog-row{display:flex; gap:8px; justify-content:flex-end}
256
273
  #dsh-plan .p-dialog-row button{background:transparent; color:var(--ink); border:1px solid var(--card-line);
257
274
  border-radius:6px; padding:5px 12px; font:inherit; cursor:pointer}
@@ -93,6 +93,10 @@ body{background:var(--n-bg);color:var(--n-ink);font:13px/1.45 system-ui,"Segoe U
93
93
  .wb-member-remove:hover:not(:disabled),.wb-member-submit:hover:not(:disabled){border-color:var(--n-danger)}
94
94
  .wb-member-submit:hover:not(:disabled){border-color:var(--n-accent)}
95
95
  .wb-member-add{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
96
+ .wb-cli-home{display:flex;flex-wrap:wrap;gap:6px;padding:8px;margin:4px 0 10px;border:1px solid var(--n-border);border-radius:6px}
97
+ .wb-cli-home [hidden]{display:none}
98
+ .wb-cli-home .wb-hint{flex-basis:100%;margin-top:0}
99
+ .wb-cli-home .wb-member-pick{min-width:0;max-width:100%}
96
100
  .wb-member-pick,.wb-member-manual{background:var(--n-panel);border:1px solid var(--n-border);color:var(--n-ink);
97
101
  border-radius:6px;padding:3px 8px;font:inherit;font-size:12px;min-width:0}
98
102
  .wb-member-pick{flex:1 1 150px}
@@ -19,6 +19,7 @@
19
19
  * @module nexus-stage/workbench
20
20
  */
21
21
  import { createChatDrawer, type ChatTarget } from './wb-chat'
22
+ import { createCliHomePicker, type CliHomeOptions } from './cli-home-picker'
22
23
  import { createRailTabs } from './wb-rail'
23
24
  import { DIRECTORY_FILTERS, learnLogTitle, loadLogTitles, renderDirectory, type DirFilter } from './wb-directory'
24
25
  import { ledgerFacts, renderLedger as paintLedger } from './wb-ledger'
@@ -130,6 +131,10 @@ export interface CanvasMemberRow {
130
131
  readonly joined?: boolean
131
132
  readonly live?: boolean
132
133
  readonly remote?: boolean
134
+ /** Present only for CLI refs: null means the linked home is ambiguous or unknown. */
135
+ readonly cliHome?: string | null
136
+ readonly cliHeld?: boolean
137
+ readonly cliHomeCleared?: boolean
133
138
  }
134
139
 
135
140
  /** One canvas team from /state `canvas.teams`. */
@@ -142,7 +147,7 @@ export interface CanvasTeamRow {
142
147
  /** One activity-ring entry. */
143
148
  export interface ActivityRow {
144
149
  readonly ts: number
145
- readonly dir: 'in' | 'out'
150
+ readonly dir: 'in' | 'out' | 'fwd'
146
151
  readonly team: string
147
152
  readonly peer: string
148
153
  readonly ok: boolean
@@ -212,7 +217,8 @@ export interface WorkbenchDeps {
212
217
  abandon(taskId: string, reason: string): Promise<boolean>
213
218
  /** Canvas team member change via the control route (0.6.6): `id` is a
214
219
  * local session id or a zone-qualified remote handle. True on success. */
215
- canvasOp(action: 'add-member' | 'remove-member', name: string, id: string): Promise<boolean>
220
+ canvasOp(action: 'add-member' | 'remove-member', name: string, id: string, home?: string): Promise<boolean>
221
+ cliHomeOptions?(name: string, id: string): Promise<CliHomeOptions>
216
222
  /** Quick dispatch (0.6.9): POST /__dsh_a2a/dispatch as a human gesture —
217
223
  * one message to a routing team, async delivery, receipt lands in the
218
224
  * ledger. Optional — without it the 快派 form hides. */
@@ -333,6 +339,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
333
339
  // Canvas add-member form cache (static DOM re-mounted across polls; keyed
334
340
  // by team name — same trick as draftForm so inputs survive the 3s poll).
335
341
  let canvasForm: { name: string; form: HTMLFormElement; pick: HTMLSelectElement; manual: HTMLInputElement } | null = null
342
+ const cliHomeForms = new Map<string, HTMLFormElement>()
336
343
  // Quick-dispatch form (0.6.9), cached per target routing team the same
337
344
  // draftForm way: a textarea's draft must survive the 3s poll remount.
338
345
  let dispatchForm: { team: string; form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } | null = null
@@ -690,7 +697,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
690
697
  for (const row of ring) {
691
698
  const line = el('div', 'wb-row')
692
699
  const dir = el('span', 'wb-row-dir')
693
- dir.textContent = row.dir === 'out' ? '→' : '←'
700
+ dir.textContent = row.dir === 'fwd' ? '⇢' : row.dir === 'out' ? '→' : '←'
694
701
  if (row.ok !== true) dir.classList.add('is-fail')
695
702
  line.appendChild(dir)
696
703
  teamCell(line, last, row.team)
@@ -962,12 +969,12 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
962
969
  }
963
970
  for (const member of fresh.members) {
964
971
  const row = el('div', 'wb-member-row')
965
- const dot = member.remote === true ? '◇ 远端' : member.live === true ? '● 现场' : member.joined === true ? '○ 冷启' : '· 未入网'
972
+ const dot = member.cliHeld === true ? '本机持有,无需指定' : member.remote === true ? (member.cliHome === null ? '◇ 远端 · home 未确定' : '◇ 远端') : member.live === true ? '● 现场' : member.joined === true ? '○ 冷启' : '· 未入网'
966
973
  row.appendChild(el('span', 'wb-member-dot')).textContent = dot
967
974
  const label = el('span', 'wb-member-label')
968
975
  const session = (last?.sessions ?? []).find(s => s.id === member.id)
969
976
  label.textContent = member.remote === true ? member.id : (session !== undefined ? displayName(session) : member.id)
970
- label.title = member.team ?? member.id
977
+ label.title = member.cliHeld === true ? '本机持有,无需指定' : member.cliHome !== undefined ? (member.cliHome ?? 'home 未确定') : (member.team ?? member.id)
971
978
  row.appendChild(label)
972
979
  const remove = el('button', 'wb-member-remove')
973
980
  remove.type = 'button'
@@ -978,6 +985,20 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
978
985
  })
979
986
  row.appendChild(remove)
980
987
  card.appendChild(row)
988
+ if (member.cliHomeCleared === true) {
989
+ const notice = el('div', 'wb-hint')
990
+ notice.textContent = '本机落户已清除旧 home 钉;户口离线后也不会恢复旧钉。'
991
+ card.appendChild(notice)
992
+ }
993
+ if (member.cliHome === null && member.cliHeld !== true && deps.cliHomeOptions !== undefined) {
994
+ const key = JSON.stringify([fresh.name, member.id])
995
+ let form = cliHomeForms.get(key)
996
+ if (form === undefined) {
997
+ form = createCliHomePicker(() => deps.cliHomeOptions!(fresh.name, member.id), home => deps.canvasOp('add-member', fresh.name, member.id, home))
998
+ cliHomeForms.set(key, form)
999
+ }
1000
+ card.appendChild(form)
1001
+ }
981
1002
  }
982
1003
  // Add-member form: static DOM re-mounted per poll (draftForm trick) —
983
1004
  // rebuilt only when the inspected team changes.
@@ -1196,6 +1217,8 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1196
1217
  }
1197
1218
 
1198
1219
  function update(state: StateBody | null): void {
1220
+ const unknown = new Set((state?.canvas?.teams ?? []).flatMap(team => team.members.filter(member => member.cliHome === null && member.cliHeld !== true).map(member => JSON.stringify([team.name, member.id]))))
1221
+ for (const key of cliHomeForms.keys()) if (!unknown.has(key)) cliHomeForms.delete(key)
1199
1222
  last = state
1200
1223
  // 0.6.12: refresh the readable-log list opportunistically (single flight;
1201
1224
  // the sidebar re-renders when rows land, mid-poll remount is idempotent).
@@ -153,7 +153,11 @@ export function planSeatClear(sid: string, occupied: ReadonlyArray<{ readonly x:
153
153
  }
154
154
 
155
155
  /** Minimal session row shape the model needs (topology.SessionRow satisfies it). */
156
- export interface SessionLite { id: string; label: string; team: string; name?: string; description?: string; joined: boolean; live?: boolean; teams?: readonly string[] }
156
+ export interface SessionLite {
157
+ id: string; label: string; team: string; name?: string; description?: string; joined: boolean; live?: boolean; teams?: readonly string[]
158
+ /** Live local sessions only: mid-turn ('running') or idle, and how many receipt-owing tasks wait on it. */
159
+ status?: 'running' | 'idle'; queued?: number
160
+ }
157
161
 
158
162
  /** Minimal team shape (topology.CanvasTeam satisfies it). `team` is the
159
163
  * mono route alias `<host>/canvas/<name>` when the payload carries it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nelsonlongxiang/dsh-open-a2a-net",
3
- "version": "0.6.33",
3
+ "version": "0.6.35",
4
4
  "description": "Open A2A network plugin for DeepSeek Harness: signed agent cards, decentralized peer/zone discovery, direct routing model tools, and joinable session nodes with a sidebar control",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -85,6 +85,7 @@
85
85
  "test:live": "vitest run tests/live",
86
86
  "test:build-layout": "vitest run tests/gates/build-layout.spec.ts",
87
87
  "verify:docs": "node scripts/doc-drift.mjs",
88
+ "reanchor:arch": "node scripts/reanchor-architecture.mjs",
88
89
  "verify:nexus": "node scripts/verify-nexus-ci.mjs && npm --prefix nexus-stage run build && npm --prefix nexus-stage run test && git diff --exit-code -- assets/nexusDist/assets && git diff --ignore-cr-at-eol --exit-code -- assets/nexusDist/index.html",
89
90
  "verify": "pnpm typecheck && pnpm verify:docs && pnpm build && pnpm test && pnpm test:build-layout && pnpm verify:nexus"
90
91
  },