@nelsonlongxiang/dsh-open-a2a-net 0.6.43 → 0.7.0

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 (73) hide show
  1. package/assets/nexusDist/assets/index-CA_YZTWG.js +9 -0
  2. package/assets/nexusDist/index.html +1 -1
  3. package/cli/a2a.mjs +19 -3
  4. package/lib/a2a-client.js +10 -2
  5. package/lib/admitted-bookkeeping.js +7 -0
  6. package/lib/canvas-discovery.js +11 -1
  7. package/lib/canvas-member.js +43 -0
  8. package/lib/canvas-store.js +70 -4
  9. package/lib/direct-forward.js +9 -7
  10. package/lib/faces/admin.js +50 -7
  11. package/lib/faces/files.js +2 -1
  12. package/lib/faces/wire.js +566 -468
  13. package/lib/followup-delivery.js +89 -0
  14. package/lib/idempotency-store.js +8 -0
  15. package/lib/inbound-verification.js +23 -0
  16. package/lib/index.js +478 -156
  17. package/lib/nudge-observability.js +34 -0
  18. package/lib/origin-claim.js +36 -9
  19. package/lib/outbound-scope.js +4 -2
  20. package/lib/peer-directory.js +15 -5
  21. package/lib/peer-store.js +31 -17
  22. package/lib/queue-guard.js +4 -3
  23. package/lib/receipt-ladder.js +1 -1
  24. package/lib/receipt-retry.js +58 -0
  25. package/lib/reconciliation-reminders.js +148 -0
  26. package/lib/reconciliation-state.js +27 -0
  27. package/lib/session-transcript.js +46 -18
  28. package/lib/skill/SKILL.md +5 -0
  29. package/lib/skill/references/cli.md +4 -1
  30. package/lib/skill/references/routing.md +31 -1
  31. package/lib/task-auth.js +3 -3
  32. package/lib/task-delivery.js +23 -0
  33. package/lib/task-ledger-persistence.js +51 -0
  34. package/lib/task-ledger-rows.js +5 -0
  35. package/lib/task-ledger.js +219 -20
  36. package/lib/tools/info.js +37 -7
  37. package/lib/types/a2a-client.d.ts +1 -1
  38. package/lib/types/admitted-bookkeeping.d.ts +5 -0
  39. package/lib/types/canvas-member.d.ts +10 -0
  40. package/lib/types/canvas-store.d.ts +8 -0
  41. package/lib/types/faces/admin.d.ts +19 -0
  42. package/lib/types/faces/files.d.ts +1 -1
  43. package/lib/types/faces/wire.d.ts +21 -14
  44. package/lib/types/followup-delivery.d.ts +25 -0
  45. package/lib/types/idempotency-store.d.ts +2 -0
  46. package/lib/types/inbound-verification.d.ts +14 -0
  47. package/lib/types/index.d.ts +5 -0
  48. package/lib/types/nudge-observability.d.ts +17 -0
  49. package/lib/types/origin-claim.d.ts +7 -1
  50. package/lib/types/outbound-scope.d.ts +1 -0
  51. package/lib/types/peer-store.d.ts +4 -0
  52. package/lib/types/queue-guard.d.ts +9 -2
  53. package/lib/types/receipt-ladder.d.ts +2 -0
  54. package/lib/types/receipt-retry.d.ts +22 -0
  55. package/lib/types/reconciliation-reminders.d.ts +41 -0
  56. package/lib/types/reconciliation-state.d.ts +32 -0
  57. package/lib/types/session-transcript.d.ts +6 -1
  58. package/lib/types/task-auth.d.ts +7 -0
  59. package/lib/types/task-delivery.d.ts +17 -0
  60. package/lib/types/task-ledger-persistence.d.ts +3 -0
  61. package/lib/types/task-ledger-rows.d.ts +19 -2
  62. package/lib/types/task-ledger.d.ts +48 -1
  63. package/lib/types/tools/info.d.ts +3 -0
  64. package/lib/types/types.d.ts +3 -0
  65. package/nexus-stage/src/federation.ts +1 -0
  66. package/nexus-stage/src/main.ts +10 -2
  67. package/nexus-stage/src/member-provenance-picker.ts +13 -0
  68. package/nexus-stage/src/planning-member-handles.ts +4 -1
  69. package/nexus-stage/src/planning-view.ts +3 -3
  70. package/nexus-stage/src/workbench.ts +28 -6
  71. package/nexus-stage/src/world.ts +1 -0
  72. package/package.json +3 -2
  73. package/assets/nexusDist/assets/index-CaA76j0F.js +0 -9
@@ -26,7 +26,12 @@ export declare function scanZstdFrames(buffer: Buffer): FrameRange[];
26
26
  * artifacts are split into frames and decoded frame by frame (node's public
27
27
  * decoder is single-frame).
28
28
  */
29
- export declare function decodeSessionLog(buffer: Buffer): string;
29
+ type BoundedZstdDecoder = (input: Uint8Array, options: {
30
+ maxOutputLength: number;
31
+ }) => Buffer;
32
+ /** Codec seam for tests; budgets may only tighten the shipped hard caps. */
33
+ export declare function createSessionLogDecoder(decoder?: BoundedZstdDecoder | undefined, outputCap?: number): (buffer: Buffer) => string;
34
+ export declare const decodeSessionLog: (buffer: Buffer) => string;
30
35
  /**
31
36
  * Extract the last `limit` human-visible turns from a decoded session log.
32
37
  * User lines come from user/message data.content[].text; assistant lines from
@@ -43,10 +43,17 @@ export interface AdmissionRecords {
43
43
  readonly teams: readonly string[];
44
44
  }[];
45
45
  joined(): readonly string[];
46
+ /** Caller-only proof projection; targets still use the complete local registry. */
47
+ callerJoined?(): readonly string[];
48
+ callerMemberships?(): readonly {
49
+ readonly session: string;
50
+ readonly teams: readonly string[];
51
+ }[];
46
52
  canvasNames(): readonly string[];
47
53
  /** Canvas names the session sits in. */
48
54
  canvasTeamsOf(sessionId: string): readonly string[];
49
55
  canvasMembersOf(name: string): readonly string[];
56
+ callerCanvasMembersOf?(name: string): readonly string[];
50
57
  canvasRemoteMembersOf(name: string): readonly string[];
51
58
  /** The canvasOrg lists of the warm peer-card cache. */
52
59
  peerOrgs(): Iterable<readonly PeerOrgEntry[]>;
@@ -0,0 +1,17 @@
1
+ export interface TaskDelivery {
2
+ version: 1;
3
+ taskId: string;
4
+ messageId: string;
5
+ sessionId: string;
6
+ fingerprint: string;
7
+ callerKey?: string;
8
+ /** Distinguishes task births from receipt-only or synchronous receiptless occurrences. */
9
+ taskExpected?: true;
10
+ createdAt: number;
11
+ state: 'reserved' | 'enqueued' | 'claimed' | 'cancelled' | 'failed';
12
+ }
13
+ export declare const TASK_DELIVERY_CAP = 512;
14
+ /** Keep completed/cancelled occurrence identities through the server idempotency window. */
15
+ export declare const TASK_DELIVERY_RETENTION_MS: number;
16
+ export declare function deliveryFingerprint(sessionId: string, text: string, sender?: string, localFrom?: string): string;
17
+ export declare function parseTaskDelivery(raw: unknown): TaskDelivery | undefined;
@@ -0,0 +1,3 @@
1
+ /** Brief AV/indexer contention must not turn into an unbounded synchronous stall. */
2
+ export declare const LEDGER_RENAME_RETRY_MS: readonly [2, 4];
3
+ export declare function writeTaskLedger(path: string, bytes: string): void;
@@ -105,6 +105,9 @@ export declare function withAuth(raw: unknown, entry: {
105
105
  export declare function routingOf(row: Pick<TaskRecord, 'peerKey' | 'peerHost' | 'deliveredTo' | 'forwarded' | 'answeredAt' | 'receiverMembers'>): Pick<TaskRecord, 'peerKey' | 'peerHost' | 'deliveredTo' | 'forwarded' | 'answeredAt' | 'receiverMembers'>;
106
106
  /** #139: what an outbound row is born with besides its sender. */
107
107
  export interface OutboundBirth {
108
+ readonly consumed?: boolean;
109
+ /** Exact receiver selected at local delivery, never inferred from a later canvas roster. */
110
+ readonly receiverSessionId?: string;
108
111
  readonly forwarded?: true;
109
112
  /** Local-candidate admission, or a forwarded online CLI's verified wire admission. */
110
113
  readonly auth?: TaskAuthorization;
@@ -115,7 +118,7 @@ export interface OutboundBirth {
115
118
  readonly receiverMembers?: readonly string[];
116
119
  }
117
120
  /** The facts of one outbound birth that fit the row (see {@link withRouting} for the same shape on restore). */
118
- export declare function birthFacts(peer: string, birth: OutboundBirth | undefined, team?: string, sender?: Sender, now?: number, warn?: (message: string) => void): Pick<TaskRecord, 'auth' | 'peerKey' | 'peerHost' | 'deliveredTo' | 'forwarded' | 'receiverMembers'>;
121
+ export declare function birthFacts(peer: string, birth: OutboundBirth | undefined, team?: string, sender?: Sender, now?: number, warn?: (message: string) => void): Pick<TaskRecord, 'auth' | 'peerKey' | 'peerHost' | 'deliveredTo' | 'forwarded' | 'receiverMembers' | 'receiverSessionId' | 'consumed'>;
119
122
  /** Bound on a persisted routing fact; anything longer (or empty, or not a string) is dropped on restore. */
120
123
  export declare const ROUTING_FACT_CAP = 256;
121
124
  export declare function parseReceiverMembers(value: unknown, warn?: (message: string) => void): readonly string[] | undefined;
@@ -125,9 +128,14 @@ export declare function parseReceiverMembers(value: unknown, warn?: (message: st
125
128
  * a remote outbound row; anything else is dropped (the row survives as one
126
129
  * born before the fields, judged by current membership and origin).
127
130
  */
128
- export declare function withRouting(entry: Record<string, unknown>, warn?: (message: string) => void): Pick<TaskRecord, 'peerKey' | 'peerHost' | 'deliveredTo' | 'forwarded' | 'answeredAt' | 'receiverMembers'>;
131
+ export declare function withRouting(entry: Record<string, unknown>, warn?: (message: string) => void): Pick<TaskRecord, 'peerKey' | 'peerHost' | 'deliveredTo' | 'forwarded' | 'answeredAt' | 'receiverMembers' | 'receiptDelivery'>;
129
132
  /** One tracked outbound task still in the owed book (unsettled). */
130
133
  export interface TaskRecord {
134
+ /** Local work answered, but successful receipt delivery is not confirmed; never a settlement. */
135
+ receiptDelivery?: 'receipt-undelivered';
136
+ /** Observation from exact inbox claim or peer response, never an acknowledgement. */
137
+ consumed?: boolean;
138
+ readonly receiverSessionId?: string;
131
139
  readonly taskId: string;
132
140
  readonly team: string;
133
141
  readonly peer: string;
@@ -184,8 +192,13 @@ export interface TaskRecord {
184
192
  */
185
193
  reason?: DeadReason;
186
194
  }
195
+ export declare const WITHDRAWAL_RESULTS: readonly ["removed", "claimed-steered", "withdraw-failed-steered", "no-agent"];
196
+ export type WithdrawalResult = typeof WITHDRAWAL_RESULTS[number];
197
+ export declare function isWithdrawalResult(value: unknown): value is WithdrawalResult;
187
198
  /** One settled task kept for audit, with its receipt-correlated outcome. */
188
199
  export interface ArchivedRecord {
200
+ /** Cancellation notification observation; independent of the terminal task outcome. */
201
+ readonly withdrawalResult?: WithdrawalResult;
189
202
  readonly taskId: string;
190
203
  readonly team: string;
191
204
  readonly peer: string;
@@ -298,6 +311,10 @@ export interface ReceiptResolvedInfo {
298
311
  }
299
312
  /** The persisted ledger document: the owed book plus its settled archive. */
300
313
  export interface TaskLedgerSnapshot {
314
+ readonly taskDeliveryRestoreError?: true;
315
+ readonly taskDeliveries?: readonly import('./task-delivery.ts').TaskDelivery[];
316
+ readonly reconciliationSessions?: Record<string, number>;
317
+ readonly reconciliationReminders?: Record<string, import('./reconciliation-state.ts').ReminderState>;
301
318
  readonly tasks: readonly TaskRecord[];
302
319
  readonly archived?: readonly ArchivedRecord[];
303
320
  /** #114 S4: the bounded ring of refused / would-be-refused settlements. */
@@ -19,6 +19,8 @@
19
19
  * @module @nelsonlongxiang/dsh-open-a2a-net/task-ledger
20
20
  */
21
21
  import type { TaskAuthorization } from './task-auth.ts';
22
+ import { type TaskDelivery } from './task-delivery.ts';
23
+ import { type ReminderState } from './reconciliation-state.ts';
22
24
  import { type ConservationVerdict } from './ledger-conservation.ts';
23
25
  import type { TaskRecord, ArchivedRecord, Sender, OutboundBirth, TaskLedgerOptions, ReceiptResolvedInfo, SettlementRefusalRecord, SettlementJudge, AbandonResult, CancelledResult } from './task-ledger-rows.ts';
24
26
  export * from './task-ledger-rows.ts';
@@ -45,6 +47,49 @@ export declare class TaskLedger {
45
47
  /** Injectable clock (option `now`). */
46
48
  private readonly now;
47
49
  private readonly warn;
50
+ /** Last successful commit, for rollback when a mutation cannot persist. */
51
+ private committed;
52
+ private deliveries;
53
+ private deliveryRestoreError;
54
+ markConsumed(taskId: string, consumed: boolean): void;
55
+ deliveryFor(taskId: string): TaskDelivery | undefined;
56
+ deliveryByMessage(sessionId: string, messageId: string): TaskDelivery | undefined;
57
+ reserveDelivery(record: TaskDelivery): TaskDelivery;
58
+ updateDelivery(taskId: string, state: TaskDelivery['state']): void;
59
+ private reminderRecoveryPending;
60
+ private reminders;
61
+ private reminderSessions;
62
+ reminderSessionAt(id: string): number | undefined;
63
+ reminderState(taskId: string): ReminderState | undefined;
64
+ /** Atomic observation update; no task status, answer, or conservation mutation. */
65
+ saveReminders(updates: readonly {
66
+ taskId: string;
67
+ state: ReminderState | undefined;
68
+ }[], session?: {
69
+ id: string;
70
+ at: number | undefined;
71
+ }): void;
72
+ private admittedFailure;
73
+ private admittedFailures;
74
+ noteAdmittedBookkeepingFailure(error: unknown): void;
75
+ orphanDelivery(taskId: string): boolean;
76
+ deliveryBookkeepingState(): {
77
+ orphaned: number;
78
+ failures: number;
79
+ lastError?: {
80
+ at: number;
81
+ code: string;
82
+ };
83
+ };
84
+ private writeFailure;
85
+ /** Diagnostic-only; never implies that a failed mutation committed. No paths or payloads exposed. */
86
+ persistenceState(): {
87
+ ok: boolean;
88
+ lastError?: {
89
+ at: number;
90
+ code: string;
91
+ };
92
+ };
48
93
  /**
49
94
  * @param path - persistence file (`<dsh-home>/a2a/tasks.json`); empty = no persistence.
50
95
  * @param options - TTL/capacity overrides; defaults come from the exported constants.
@@ -99,7 +144,7 @@ export declare class TaskLedger {
99
144
  * @param from - the caller's receipt target (label or callback address).
100
145
  * @param contextId - the delivery's conversation id, kept for follow-up routes (empty omits it).
101
146
  */
102
- trackInbound(taskId: string, team: string, from: string, contextId?: string, receiptExpected?: boolean, brief?: string, auth?: TaskAuthorization, deliveredTo?: string): void;
147
+ trackInbound(taskId: string, team: string, from: string, contextId?: string, receiptExpected?: boolean, brief?: string, auth?: TaskAuthorization, deliveredTo?: string, receiverSessionId?: string, consumed?: boolean): void;
103
148
  /** Record an answer only from the local member that actually received this row; never settle or change counters. */
104
149
  markAnswered(taskId: string, target: string): boolean;
105
150
  /**
@@ -138,6 +183,7 @@ export declare class TaskLedger {
138
183
  * the route layer reads team/contextId from here for its best-effort
139
184
  * steer, which is the documented boundary until origin-auth lands.
140
185
  */
186
+ recordWithdrawalResult(taskId: string, result: import('./task-ledger-rows.ts').WithdrawalResult): void;
141
187
  cancel(taskId: string, reason?: string): CancelledResult;
142
188
  /**
143
189
  * Correlate one message against the ledger: a receipt (message starting
@@ -204,4 +250,5 @@ export declare class TaskLedger {
204
250
  private restore;
205
251
  /** Persist the ledger (no-op when no path was configured). */
206
252
  private persist;
253
+ private snapshot;
207
254
  }
@@ -14,6 +14,7 @@ import type { TaskLedger } from '../task-ledger.ts';
14
14
  import type { TeamMembershipStore } from '../team-store.ts';
15
15
  /** One directory row shared with listDirectoryTeams (which stays in index.ts). */
16
16
  export type DirectoryTeamRow = {
17
+ memberRef?: string;
17
18
  team: string;
18
19
  session: string;
19
20
  name: string;
@@ -41,9 +42,11 @@ export interface InfoToolsDeps {
41
42
  readonly ownCardUrl: string;
42
43
  readonly peerStore: PeerStore;
43
44
  readonly taskLedger: TaskLedger;
45
+ readonly nudgeEscalations?: () => import('../nudge-observability.ts').NudgeEscalations;
44
46
  readonly joinedSessions: JoinedSessions;
45
47
  readonly teamMemberships: TeamMembershipStore;
46
48
  readonly canvasStore: CanvasStore;
49
+ readonly canvasMigrationState: () => Record<string, unknown>;
47
50
  readonly exposureStore: ExposureStore;
48
51
  readonly idempotencyStore: IdempotencyStore;
49
52
  readonly cardCache: Map<string, CardCacheEntry>;
@@ -2,6 +2,7 @@
2
2
  * Wire and configuration vocabulary for the A2A client extension.
3
3
  * @module @nelsonlongxiang/dsh-open-a2a-net/types
4
4
  */
5
+ import type { RECEIPT_RETRYABLE } from './receipt-retry.ts';
5
6
  /** One peer node's agent card — the decentralized discovery record. */
6
7
  export interface A2aPeerCard {
7
8
  /** Human-facing peer name. */
@@ -108,6 +109,7 @@ export interface ZoneRecord {
108
109
  }
109
110
  /** Canonical `a2a_route` success value. */
110
111
  export interface A2aRouteOk {
112
+ readonly receiverBookkeeping?: 'failed';
111
113
  /** Actual member face returned by a forwarded peer delivery. */
112
114
  readonly deliveredTo?: string;
113
115
  /**
@@ -150,6 +152,7 @@ export interface A2aRouteOk {
150
152
  }
151
153
  /** Canonical `a2a_route` failure value. */
152
154
  export interface A2aRouteError {
155
+ readonly [RECEIPT_RETRYABLE]?: true;
153
156
  /** A forwarded receipt never promises another receipt, including uncertain waits. */
154
157
  readonly receipt?: 'none';
155
158
  readonly ok: false;
@@ -69,6 +69,7 @@ export function peerHostOf(url: string): string {
69
69
 
70
70
  /** One remote team row as the directory reports it (state.remote). */
71
71
  export interface RemoteTeamRow {
72
+ memberRef?: string
72
73
  team: string
73
74
  unreachable?: boolean
74
75
  name?: string
@@ -299,6 +299,13 @@ const bench = createWorkbench({
299
299
  return { ...body, ok: res.ok && body.ok === true }
300
300
  } catch (error) { return { ok: false, error: String(error) } }
301
301
  },
302
+ async confirmMember(name, id, replacement, revision, expectedOwner, peer) {
303
+ try {
304
+ const response=await controlFetch('/__dsh_a2a/canvas',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'confirm-member',name,id,replacement,revision,expectedOwner,...(peer?{peer}:{})})})
305
+ const body=await response.json() as {ok?:boolean;error?:string}
306
+ return {ok:response.ok&&body.ok===true,error:body.error}
307
+ }catch(error){return{ok:false,error:String(error)}}
308
+ },
302
309
  async regulateSuspended(action, name, id, revision, expected) {
303
310
  try {
304
311
  const response = await controlFetch('/__dsh_a2a/canvas', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action, name, id, revision, expected }) })
@@ -306,14 +313,14 @@ const bench = createWorkbench({
306
313
  return { ok: response.ok && body.ok === true, error: body.error }
307
314
  } catch (error) { return { ok: false, error: String(error) } }
308
315
  },
309
- async canvasOp(action, name, id, home) {
316
+ async canvasOp(action, name, id, home, peer) {
310
317
  // Same control route the planning board's wire uses; the inspector's
311
318
  // member writes are one id at a time, so no serial fan-out needed.
312
319
  try {
313
320
  const res = await controlFetch('/__dsh_a2a/canvas', {
314
321
  method: 'POST',
315
322
  headers: { 'content-type': 'application/json' },
316
- body: JSON.stringify({ action, name, id, ...(home === undefined ? {} : { home }) }),
323
+ body: JSON.stringify({ action, name, id, ...(home === undefined ? {} : { home }), ...(peer === undefined ? {} : { peer }) }),
317
324
  })
318
325
  const body = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
319
326
  if (res.ok && body.ok !== false) return true
@@ -486,6 +493,7 @@ async function poll(): Promise<void> {
486
493
  .filter(r => r !== null && typeof r === 'object' && typeof (r as { team?: unknown }).team === 'string' && typeof (r as { via?: unknown }).via === 'string')
487
494
  .map(r => ({
488
495
  team: r.team,
496
+ memberRef:r.memberRef,
489
497
  name: r.name,
490
498
  via: r.via,
491
499
  workspace: r.workspace,
@@ -0,0 +1,13 @@
1
+ /** Explicit slot provenance confirmation; never clears suspension or infers owner from a label. */
2
+ export interface ProvenanceChoice { replacement: string; label: string; peer?: string }
3
+ export function createMemberProvenancePicker(choices: readonly ProvenanceChoice[], confirm: (choice: ProvenanceChoice) => Promise<{ ok: boolean; error?: string }>): HTMLElement {
4
+ const root=document.createElement('form');root.className='wb-member-provenance'
5
+ const select=document.createElement('select');select.setAttribute('aria-label','确认成员来源')
6
+ select.appendChild(new Option('明确选择来源…',''))
7
+ choices.forEach((choice,index)=>select.appendChild(new Option(choice.label,String(index))))
8
+ const submit=document.createElement('button');submit.type='submit';submit.textContent='确认来源'
9
+ const hint=document.createElement('span');hint.textContent='确认不等于恢复;已暂停槽位仍保持暂停'
10
+ root.append(select,submit,hint)
11
+ root.addEventListener('submit',async event=>{event.preventDefault();if(select.value==='')return;const choice=choices[Number(select.value)];if(!choice)return;submit.disabled=true;const out=await confirm(choice);if(!out.ok){submit.disabled=false;hint.textContent=out.error??'来源无法验证,原槽位保留'}else hint.textContent='来源已确认;等待状态刷新(暂停状态不变)'})
12
+ return root
13
+ }
@@ -2,7 +2,7 @@
2
2
  import type { RemoteTeamRow } from './federation'
3
3
  import type { SessionLite } from './world'
4
4
 
5
- type HandleSession = Pick<SessionLite, 'id' | 'team' | 'joined'>
5
+ type HandleSession = Pick<SessionLite, 'id' | 'team' | 'joined' | 'memberRef'>
6
6
 
7
7
  /** Map only unique joined id8 handles that no remote directory row already owns. */
8
8
  export function joinedLocalIdsByHandle(sessions: ReadonlyArray<HandleSession>, publishedHandles: ReadonlySet<string>): Map<string, string> {
@@ -21,6 +21,9 @@ export function joinedLocalIdsByHandle(sessions: ReadonlyArray<HandleSession>, p
21
21
  localIds.set(handle, session.id)
22
22
  }
23
23
  }
24
+ const ownerAliases=new Map<string,string[]>()
25
+ for(const session of sessions)if(session.joined===true && session.memberRef){const ids=ownerAliases.get(session.memberRef)??[];ids.push(session.id);ownerAliases.set(session.memberRef,ids)}
26
+ for(const [ref,ids]of ownerAliases)if(ids.length===1)localIds.set(ref,ids[0]!)
24
27
  return localIds
25
28
  }
26
29
 
@@ -1236,7 +1236,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1236
1236
  const host = peerHostOf(p.url)
1237
1237
  // Owner report 2026-11-04: the host's rows now lead with its bare process team (e.g. 'dsh'), which the
1238
1238
  // server reads as a local session id and refuses — take the first zone/id8 SESSION handle instead.
1239
- const handle = lastRemoteTeams.get(host)?.find(row => row.team.includes('/'))?.team
1239
+ const first = lastRemoteTeams.get(host)?.find(row => row.team.includes('/')); const handle = first?.memberRef ?? first?.team
1240
1240
  model.upsertNode({
1241
1241
  id, x: base.x, y: base.y,
1242
1242
  label: p.url, team: '', name: p.url, peerUrl: p.url,
@@ -1262,7 +1262,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1262
1262
  let y = hostNode?.y ?? 0
1263
1263
  for (const row of rows) {
1264
1264
  if (!row.team.includes('/')) continue
1265
- const id = `member-${row.team.replace(/[^a-z0-9-]/gi, '-')}`
1265
+ const id = `member-${(row.memberRef ?? row.team).replace(/[^a-z0-9-]/gi, '-')}`
1266
1266
  if (memberSeen.has(id)) continue
1267
1267
  memberSeen.add(id)
1268
1268
  const saved = layoutDoc?.nodes[id]
@@ -1272,7 +1272,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1272
1272
  model.upsertNode({
1273
1273
  id, x: pos.x, y: pos.y,
1274
1274
  label: row.team, team: row.team, name: row.name !== undefined && row.name !== '' ? row.name : row.team,
1275
- remoteHandle: row.team,
1275
+ remoteHandle: row.memberRef ?? row.team,
1276
1276
  live: true, remote: true, memberships: [],
1277
1277
  })
1278
1278
  }
@@ -21,6 +21,7 @@
21
21
  import { createChatDrawer, type ChatTarget } from './wb-chat'
22
22
  import { createSeatNameEditor } from './seat-name-editor'
23
23
  import { createCliHomePicker, type CliHomeOptions } from './cli-home-picker'
24
+ import { createMemberProvenancePicker, type ProvenanceChoice } from './member-provenance-picker'
24
25
  import { createRailTabs } from './wb-rail'
25
26
  import { DIRECTORY_FILTERS, learnLogTitle, loadLogTitles, renderDirectory, type DirFilter } from './wb-directory'
26
27
  import { ledgerFacts, renderLedger as paintLedger } from './wb-ledger'
@@ -92,6 +93,7 @@ export interface PeerOrgRow {
92
93
 
93
94
  /** One session row from /state `sessions` (live or cold joined). */
94
95
  export interface SessionRow {
96
+ readonly memberRef?: string
95
97
  readonly id: string
96
98
  readonly label: string
97
99
  readonly team: string
@@ -112,6 +114,7 @@ export interface SessionRow {
112
114
 
113
115
  /** One remote team row from /state `remote`. */
114
116
  export interface RemoteRow {
117
+ readonly memberRef?: string
115
118
  readonly team: string
116
119
  readonly unreachable?: boolean
117
120
  readonly name?: string
@@ -161,7 +164,7 @@ export interface StateBody {
161
164
  readonly zone?: string
162
165
  readonly version?: string
163
166
  readonly sessions?: readonly SessionRow[]
164
- readonly canvas?: { teams?: readonly CanvasTeamRow[]; revision?: number; retainedMembers?: ReadonlyArray<{ team: string; member: string; suspended: boolean; records: readonly unknown[] }> }
167
+ readonly canvas?: { teams?: readonly CanvasTeamRow[]; revision?: number; memberSlots?: ReadonlyArray<{ name: string; member: string; ownerKey: string | null; provenance: string; suspended: boolean; compatibilityInference: boolean }>; retainedMembers?: ReadonlyArray<{ team: string; member: string; suspended: boolean; records: readonly unknown[] }> }
165
168
  /** gh#20 W-A/W-C: the peer row carries the link badge source, the host
166
169
  * rail's label/version/last-contact facts (absent on older hosts). */
167
170
  readonly peers?: ReadonlyArray<{
@@ -220,8 +223,9 @@ export interface WorkbenchDeps {
220
223
  abandon(taskId: string, reason: string): Promise<boolean>
221
224
  /** Canvas team member change via the control route (0.6.6): `id` is a
222
225
  * local session id or a zone-qualified remote handle. True on success. */
223
- canvasOp(action: 'add-member' | 'remove-member', name: string, id: string, home?: string): Promise<boolean>
226
+ canvasOp(action: 'add-member' | 'remove-member', name: string, id: string, home?: string, peer?: string): Promise<boolean>
224
227
  cliHomeOptions?(name: string, id: string): Promise<CliHomeOptions>
228
+ confirmMember?(name: string, id: string, replacement: string, revision: number, expectedOwner: string | null, peer?: string): Promise<{ ok: boolean; error?: string }>
225
229
  regulateSuspended?(action: 'restore-suspended' | 'delete-suspended', name: string, id: string, revision: number, expected: readonly unknown[]): Promise<{ ok: boolean; error?: string }>
226
230
  /** Quick dispatch (0.6.9): POST /__dsh_a2a/dispatch as a human gesture —
227
231
  * one message to a routing team, async delivery, receipt lands in the
@@ -344,6 +348,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
344
348
  // by team name — same trick as draftForm so inputs survive the 3s poll).
345
349
  let canvasForm: { name: string; form: HTMLFormElement; pick: HTMLSelectElement; manual: HTMLInputElement } | null = null
346
350
  const cliHomeForms = new Map<string, HTMLFormElement>()
351
+ const provenanceForms = new Map<string, HTMLElement>()
347
352
  // Quick-dispatch form (0.6.9), cached per target routing team the same
348
353
  // draftForm way: a textarea's draft must survive the 3s poll remount.
349
354
  let dispatchForm: { team: string; form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } | null = null
@@ -1033,6 +1038,17 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1033
1038
  }
1034
1039
  card.appendChild(row)
1035
1040
  }
1041
+ for (const slot of last?.canvas?.memberSlots ?? []) {
1042
+ if (slot.name !== fresh.name || slot.provenance !== 'unknown' || !deps.confirmMember || last?.canvas?.revision === undefined) continue
1043
+ const badge=el('div','wb-hint');badge.textContent=slot.member+' · 来源未确认'+(slot.compatibilityInference?' · 兼容推断中':'')+(slot.suspended?' · 已暂停':'');card.appendChild(badge)
1044
+ const choices: ProvenanceChoice[]=(last.sessions??[]).filter(s=>s.joined===true).map(s=>({replacement:s.id,label:displayName(s)+' — 本机完整会话'}))
1045
+ for(const remote of last.remote??[])if(remote.team.includes('/') && remote.via)choices.push({replacement:remote.team,label:displayName(remote)+' — '+remote.via,peer:remote.via})
1046
+ const revision=last.canvas.revision
1047
+ const formKey=JSON.stringify([fresh.name,slot.member,revision,slot.ownerKey,choices])
1048
+ let form=provenanceForms.get(formKey)
1049
+ if(!form){form=createMemberProvenancePicker(choices,choice=>deps.confirmMember!(fresh.name,slot.member,choice.replacement,revision,slot.ownerKey,choice.peer));provenanceForms.set(formKey,form)}
1050
+ card.appendChild(form)
1051
+ }
1036
1052
  // Add-member form: static DOM re-mounted per poll (draftForm trick) —
1037
1053
  // rebuilt only when the inspected team changes.
1038
1054
  if (canvasForm?.name !== fresh.name) {
@@ -1054,10 +1070,11 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1054
1070
  form.addEventListener('submit', async ev => {
1055
1071
  ev.preventDefault()
1056
1072
  const handle = manual.value.trim()
1057
- const id = handle !== '' ? handle : pick.value
1073
+ const selected=handle!==''?undefined:pick.selectedOptions[0]
1074
+ const id = handle !== '' ? handle : selected?.dataset.member ?? pick.value
1058
1075
  if (id === '') return
1059
1076
  submit.disabled = true
1060
- const ok = await deps.canvasOp('add-member', fresh.name, id)
1077
+ const ok = await deps.canvasOp('add-member', fresh.name, id, undefined, selected?.dataset.peer)
1061
1078
  submit.disabled = false
1062
1079
  if (ok) manual.value = ''
1063
1080
  })
@@ -1089,7 +1106,10 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1089
1106
  pick.appendChild(new Option('远端会话队(目录卡片)…', '', true, false))
1090
1107
  remoteLabeled = true
1091
1108
  }
1092
- pick.appendChild(new Option(`${displayName(remote)} — 远端`, remote.team))
1109
+ const option=new Option(`${displayName(remote)} — ${remote.via ?? '来源未确认'}`, JSON.stringify([remote.team,remote.via]))
1110
+ option.dataset.member=remote.team
1111
+ if(remote.via)option.dataset.peer=remote.via
1112
+ pick.appendChild(option)
1093
1113
  }
1094
1114
  if (!remoteLabeled) {
1095
1115
  pick.appendChild(new Option('(暂无可拉入的远端会话队——见下方说明)', '', true, false))
@@ -1098,7 +1118,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1098
1118
  card.appendChild(canvasForm.form)
1099
1119
  const hint = el('div', 'wb-hint')
1100
1120
  hint.textContent =
1101
- '成员由人类在此手工分配(agent 不可自组队)。远端成员走对端目录路由,不做本机冷唤醒;地址由目录在派发时重解析。远端下拉只列 zone/id8 会话句柄——若为空,说明目录里还没有已链接对端的会话队(未链接的对端只给最小卡);此时可在输入框手输句柄(如 dsh/xxxxxxxx)先把人拉进来,句柄本身即链接手势。'
1121
+ '成员由人类明确分配。本机写完整会话;远端必须通过已链接主机的有效名片核实并记录完整公钥来源。最小卡、离线或第三方无法验证时保留待确认,不能手输裸句柄绕过来源校验。来源确认不恢复暂停;默认兼容窗口不自动关闭。'
1102
1122
  card.appendChild(hint)
1103
1123
  // Quick dispatch targets the canvas ROUTING team (0.6.9): local tiers
1104
1124
  // first, then the remote members in order — the seam's own priority.
@@ -1252,6 +1272,8 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1252
1272
  function update(state: StateBody | null): void {
1253
1273
  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]))))
1254
1274
  for (const key of cliHomeForms.keys()) if (!unknown.has(key)) cliHomeForms.delete(key)
1275
+ const slots=state?.canvas?.memberSlots??[]
1276
+ for(const key of provenanceForms.keys()){const [name,member,revision]=JSON.parse(key) as [string,string,number];if(revision!==state?.canvas?.revision||!slots.some(slot=>slot.name===name&&slot.member===member&&slot.provenance==='unknown'))provenanceForms.delete(key)}
1255
1277
  last = state
1256
1278
  // 0.6.12: refresh the readable-log list opportunistically (single flight;
1257
1279
  // the sidebar re-renders when rows land, mid-poll remount is idempotent).
@@ -154,6 +154,7 @@ export function planSeatClear(sid: string, occupied: ReadonlyArray<{ readonly x:
154
154
 
155
155
  /** Minimal session row shape the model needs (topology.SessionRow satisfies it). */
156
156
  export interface SessionLite {
157
+ memberRef?: string
157
158
  id: string; label: string; team: string; name?: string; description?: string; joined: boolean; live?: boolean; teams?: readonly string[]
158
159
  /** Live local sessions only: mid-turn ('running') or idle, and how many receipt-owing tasks wait on it. */
159
160
  status?: 'running' | 'idle'; queued?: number
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nelsonlongxiang/dsh-open-a2a-net",
3
- "version": "0.6.43",
3
+ "version": "0.7.0",
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",
@@ -88,7 +88,8 @@
88
88
  "verify:docs": "node scripts/doc-drift.mjs",
89
89
  "reanchor:arch": "node scripts/reanchor-architecture.mjs",
90
90
  "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",
91
- "verify": "pnpm typecheck && pnpm verify:docs && pnpm build && pnpm test && pnpm test:build-layout && pnpm verify:nexus"
91
+ "verify": "node scripts/verify-temp-clean.mjs",
92
+ "verify:core": "pnpm typecheck && pnpm verify:docs && pnpm build && pnpm test && pnpm test:build-layout && pnpm verify:nexus"
92
93
  },
93
94
  "peerDependencies": {
94
95
  "@deepseek-ai/cordis": ">=4.0.1",