@nelsonlongxiang/dsh-open-a2a-net 0.6.42 → 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 (79) hide show
  1. package/README.md +1 -0
  2. package/README_zh.md +1 -0
  3. package/assets/nexusDist/assets/index-CA_YZTWG.js +9 -0
  4. package/assets/nexusDist/index.html +1 -1
  5. package/cli/a2a.mjs +43 -7
  6. package/lib/a2a-client.js +10 -2
  7. package/lib/admitted-bookkeeping.js +7 -0
  8. package/lib/canvas-discovery.js +11 -1
  9. package/lib/canvas-member.js +43 -0
  10. package/lib/canvas-store.js +315 -17
  11. package/lib/client.js +62 -72
  12. package/lib/client.js.map +1 -1
  13. package/lib/config.js +1 -0
  14. package/lib/direct-forward.js +9 -7
  15. package/lib/faces/admin.js +150 -67
  16. package/lib/faces/files.js +2 -1
  17. package/lib/faces/wire.js +569 -469
  18. package/lib/followup-delivery.js +89 -0
  19. package/lib/idempotency-store.js +8 -0
  20. package/lib/inbound-verification.js +23 -0
  21. package/lib/index.js +534 -166
  22. package/lib/nudge-observability.js +34 -0
  23. package/lib/origin-claim.js +36 -9
  24. package/lib/outbound-scope.js +4 -2
  25. package/lib/peer-directory.js +15 -5
  26. package/lib/peer-store.js +31 -17
  27. package/lib/queue-guard.js +4 -3
  28. package/lib/receipt-ladder.js +1 -1
  29. package/lib/receipt-retry.js +58 -0
  30. package/lib/reconciliation-reminders.js +148 -0
  31. package/lib/reconciliation-state.js +27 -0
  32. package/lib/session-transcript.js +46 -18
  33. package/lib/skill/SKILL.md +5 -0
  34. package/lib/skill/references/cli.md +4 -1
  35. package/lib/skill/references/routing.md +31 -1
  36. package/lib/task-auth.js +3 -3
  37. package/lib/task-delivery.js +23 -0
  38. package/lib/task-ledger-persistence.js +51 -0
  39. package/lib/task-ledger-rows.js +5 -0
  40. package/lib/task-ledger.js +219 -20
  41. package/lib/tools/info.js +37 -7
  42. package/lib/types/a2a-client.d.ts +1 -1
  43. package/lib/types/admitted-bookkeeping.d.ts +5 -0
  44. package/lib/types/canvas-member.d.ts +10 -0
  45. package/lib/types/canvas-store.d.ts +56 -1
  46. package/lib/types/config.d.ts +2 -0
  47. package/lib/types/faces/admin.d.ts +21 -0
  48. package/lib/types/faces/files.d.ts +1 -1
  49. package/lib/types/faces/wire.d.ts +21 -14
  50. package/lib/types/followup-delivery.d.ts +25 -0
  51. package/lib/types/idempotency-store.d.ts +2 -0
  52. package/lib/types/inbound-verification.d.ts +14 -0
  53. package/lib/types/index.d.ts +5 -0
  54. package/lib/types/nudge-observability.d.ts +17 -0
  55. package/lib/types/origin-claim.d.ts +7 -1
  56. package/lib/types/outbound-scope.d.ts +1 -0
  57. package/lib/types/peer-store.d.ts +4 -0
  58. package/lib/types/queue-guard.d.ts +9 -2
  59. package/lib/types/receipt-ladder.d.ts +2 -0
  60. package/lib/types/receipt-retry.d.ts +22 -0
  61. package/lib/types/reconciliation-reminders.d.ts +41 -0
  62. package/lib/types/reconciliation-state.d.ts +32 -0
  63. package/lib/types/session-transcript.d.ts +6 -1
  64. package/lib/types/task-auth.d.ts +7 -0
  65. package/lib/types/task-delivery.d.ts +17 -0
  66. package/lib/types/task-ledger-persistence.d.ts +3 -0
  67. package/lib/types/task-ledger-rows.d.ts +19 -2
  68. package/lib/types/task-ledger.d.ts +48 -1
  69. package/lib/types/tools/info.d.ts +3 -0
  70. package/lib/types/types.d.ts +3 -0
  71. package/nexus-stage/src/federation.ts +1 -0
  72. package/nexus-stage/src/main.ts +17 -2
  73. package/nexus-stage/src/member-provenance-picker.ts +13 -0
  74. package/nexus-stage/src/planning-member-handles.ts +4 -1
  75. package/nexus-stage/src/planning-view.ts +3 -3
  76. package/nexus-stage/src/workbench.ts +51 -7
  77. package/nexus-stage/src/world.ts +1 -0
  78. package/package.json +3 -2
  79. package/assets/nexusDist/assets/index-NkNSeIx6.js +0 -9
@@ -0,0 +1,22 @@
1
+ /** Host-owned bounded retry policy; never changes the receipt text. */
2
+ import type { Context } from '@deepseek-ai/cordis';
3
+ export declare function receiptRetryWait(ctx: Pick<Context, 'effect'>, ms: number): Promise<boolean>;
4
+ export declare const RECEIPT_RETRYABLE: unique symbol;
5
+ export declare const RECEIPT_RETRY_DELAYS_MS: readonly [1000, 2000, 4000, 8000, 16000, 30000];
6
+ export declare class ReceiptDeliveryError extends Error {
7
+ readonly transient: boolean;
8
+ constructor(message: string, transient: boolean);
9
+ }
10
+ export declare function transientReceiptFailure(error: unknown): boolean;
11
+ export declare function receiptFailure(result: {
12
+ error: string;
13
+ code?: number;
14
+ [RECEIPT_RETRYABLE]?: true;
15
+ }): ReceiptDeliveryError;
16
+ export declare function retryReceiptDelivery(d: {
17
+ attempt: () => Promise<void>;
18
+ notify: () => Promise<void>;
19
+ pending: () => boolean;
20
+ wait: (ms: number) => Promise<boolean>;
21
+ log: (error: unknown) => void;
22
+ }, delays?: readonly number[]): Promise<void>;
@@ -0,0 +1,41 @@
1
+ import type { TaskLedger, TaskRecord } from './task-ledger.ts';
2
+ export interface ReminderAgent {
3
+ id: unknown;
4
+ status: string;
5
+ }
6
+ export interface ReminderDeps {
7
+ ledger: TaskLedger;
8
+ agents: () => readonly ReminderAgent[];
9
+ resolve: (row: TaskRecord) => ReminderAgent | undefined;
10
+ sender: (row: TaskRecord) => ReminderAgent | undefined;
11
+ followup: (agent: ReminderAgent, text: string) => void;
12
+ now?: () => number;
13
+ warn: (code: string) => void;
14
+ }
15
+ export declare class ReconciliationReminders {
16
+ private readonly d;
17
+ private stopped;
18
+ private active;
19
+ private failedUntil;
20
+ private nudgeAt;
21
+ readonly diagnostics: {
22
+ lastCheckedAt: number;
23
+ unresolved: number;
24
+ lastError?: {
25
+ at: number;
26
+ code: string;
27
+ };
28
+ };
29
+ constructor(d: ReminderDeps);
30
+ stop(): void;
31
+ /** Shared session gate with the existing unread nudge; no budget/consumption changes. */
32
+ nudgeAllowed(id: string): boolean;
33
+ noteNudge(id: string): void;
34
+ private now;
35
+ private idle;
36
+ private throttled;
37
+ scan(): void;
38
+ private report;
39
+ private remind;
40
+ private notify;
41
+ }
@@ -0,0 +1,32 @@
1
+ /** #351 reminder observations only: never a task lifecycle or acknowledgement. */
2
+ export declare const REMINDER_SCAN_MS = 60000;
3
+ export declare const REMINDER_MIN_MS = 120000;
4
+ export declare const REMINDER_LONG_MS: number;
5
+ export declare const REMINDER_WARNING_MS: number;
6
+ export interface ReminderState {
7
+ version: 1;
8
+ targetId: string;
9
+ attempts: number;
10
+ deliveredCount: number;
11
+ lastAttemptAt: number;
12
+ nextAt: number;
13
+ requestId: string;
14
+ lastResult: 'reserved' | 'delivered' | 'failed' | 'unknown';
15
+ lastDeliveredAt?: number;
16
+ noticeAttemptedAt?: number;
17
+ notifiedAt?: number;
18
+ escalationAt?: number;
19
+ diagnostic?: 'metadata-reset';
20
+ }
21
+ export declare function parseReminder(raw: unknown): ReminderState | undefined;
22
+ export declare function nextReminderDelay(attempts: number): number;
23
+ export interface ReminderView {
24
+ reminderAttempts: number;
25
+ reminderDelivered: number;
26
+ reminderLastAt: number;
27
+ reminderNextAt: number;
28
+ repeatedlyUnreconciled: boolean;
29
+ reminderResult: string;
30
+ reminderDiagnostic?: 'metadata-reset';
31
+ }
32
+ export declare function reminderView(state: ReminderState | undefined, now?: number): Partial<ReminderView>;
@@ -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,14 +299,28 @@ 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 canvasOp(action, name, id, home) {
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
+ },
309
+ async regulateSuspended(action, name, id, revision, expected) {
310
+ try {
311
+ const response = await controlFetch('/__dsh_a2a/canvas', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action, name, id, revision, expected }) })
312
+ const body = await response.json() as { ok?: boolean; error?: string }
313
+ return { ok: response.ok && body.ok === true, error: body.error }
314
+ } catch (error) { return { ok: false, error: String(error) } }
315
+ },
316
+ async canvasOp(action, name, id, home, peer) {
303
317
  // Same control route the planning board's wire uses; the inspector's
304
318
  // member writes are one id at a time, so no serial fan-out needed.
305
319
  try {
306
320
  const res = await controlFetch('/__dsh_a2a/canvas', {
307
321
  method: 'POST',
308
322
  headers: { 'content-type': 'application/json' },
309
- body: JSON.stringify({ action, name, id, ...(home === undefined ? {} : { home }) }),
323
+ body: JSON.stringify({ action, name, id, ...(home === undefined ? {} : { home }), ...(peer === undefined ? {} : { peer }) }),
310
324
  })
311
325
  const body = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
312
326
  if (res.ok && body.ok !== false) return true
@@ -479,6 +493,7 @@ async function poll(): Promise<void> {
479
493
  .filter(r => r !== null && typeof r === 'object' && typeof (r as { team?: unknown }).team === 'string' && typeof (r as { via?: unknown }).via === 'string')
480
494
  .map(r => ({
481
495
  team: r.team,
496
+ memberRef:r.memberRef,
482
497
  name: r.name,
483
498
  via: r.via,
484
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
@@ -128,6 +131,7 @@ export interface RemoteRow {
128
131
  * handle (`zone/id8`) — remote members are routed by the directory, never
129
132
  * woken locally (0.6.6 unified team model). */
130
133
  export interface CanvasMemberRow {
134
+ readonly provenance?: 'legacy-inferred' | 'unknown'
131
135
  readonly id: string
132
136
  readonly team?: string
133
137
  readonly joined?: boolean
@@ -160,7 +164,7 @@ export interface StateBody {
160
164
  readonly zone?: string
161
165
  readonly version?: string
162
166
  readonly sessions?: readonly SessionRow[]
163
- readonly canvas?: { teams?: readonly CanvasTeamRow[] }
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[] }> }
164
168
  /** gh#20 W-A/W-C: the peer row carries the link badge source, the host
165
169
  * rail's label/version/last-contact facts (absent on older hosts). */
166
170
  readonly peers?: ReadonlyArray<{
@@ -219,8 +223,10 @@ export interface WorkbenchDeps {
219
223
  abandon(taskId: string, reason: string): Promise<boolean>
220
224
  /** Canvas team member change via the control route (0.6.6): `id` is a
221
225
  * local session id or a zone-qualified remote handle. True on success. */
222
- 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>
223
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 }>
229
+ regulateSuspended?(action: 'restore-suspended' | 'delete-suspended', name: string, id: string, revision: number, expected: readonly unknown[]): Promise<{ ok: boolean; error?: string }>
224
230
  /** Quick dispatch (0.6.9): POST /__dsh_a2a/dispatch as a human gesture —
225
231
  * one message to a routing team, async delivery, receipt lands in the
226
232
  * ledger. Optional — without it the 快派 form hides. */
@@ -342,6 +348,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
342
348
  // by team name — same trick as draftForm so inputs survive the 3s poll).
343
349
  let canvasForm: { name: string; form: HTMLFormElement; pick: HTMLSelectElement; manual: HTMLInputElement } | null = null
344
350
  const cliHomeForms = new Map<string, HTMLFormElement>()
351
+ const provenanceForms = new Map<string, HTMLElement>()
345
352
  // Quick-dispatch form (0.6.9), cached per target routing team the same
346
353
  // draftForm way: a textarea's draft must survive the 3s poll remount.
347
354
  let dispatchForm: { team: string; form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } | null = null
@@ -981,7 +988,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
981
988
  for (const member of fresh.members) {
982
989
  const row = el('div', 'wb-member-row')
983
990
  const dot = member.cliHeld === true ? '本机持有,无需指定' : member.remote === true ? (member.cliHome === null ? '◇ 远端 · home 未确定' : '◇ 远端') : member.live === true ? '● 现场' : member.joined === true ? '○ 冷启' : '· 未入网'
984
- row.appendChild(el('span', 'wb-member-dot')).textContent = dot
991
+ row.appendChild(el('span', 'wb-member-dot')).textContent = dot + (member.provenance ? ' · 来源未确认' + (member.provenance === 'legacy-inferred' ? '(兼容推断)' : '') : '')
985
992
  const label = el('span', 'wb-member-label')
986
993
  const session = (last?.sessions ?? []).find(s => s.id === member.id)
987
994
  label.textContent = member.remote === true ? member.id : (session !== undefined ? displayName(session) : member.id)
@@ -1011,6 +1018,37 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1011
1018
  card.appendChild(form)
1012
1019
  }
1013
1020
  }
1021
+ const retained = (last?.canvas?.retainedMembers ?? []).filter(r => r.team === fresh.name)
1022
+ for (const slot of retained) {
1023
+ const row = el('div', 'wb-member-row')
1024
+ row.appendChild(el('span', 'wb-member-dot')).textContent = slot.suspended ? '已暂停 · 不参与路由' : '来源未确认 · 窗口关闭'
1025
+ row.appendChild(el('span', 'wb-member-label')).textContent = slot.member
1026
+ if (slot.suspended && deps.regulateSuspended && last?.canvas?.revision !== undefined) {
1027
+ for (const action of ['restore-suspended', 'delete-suspended'] as const) {
1028
+ const button = el('button', 'wb-member-remove'); button.type = 'button'
1029
+ button.textContent = action === 'restore-suspended' ? '显式恢复' : '删除暂停位'
1030
+ const revision = last.canvas.revision
1031
+ button.addEventListener('click', async () => {
1032
+ button.disabled = true
1033
+ const result = await deps.regulateSuspended!(action, fresh.name, slot.member, revision, slot.records)
1034
+ if (!result.ok) { button.disabled = false; button.title = result.error ?? '状态已变,请刷新后重试' }
1035
+ })
1036
+ row.appendChild(button)
1037
+ }
1038
+ }
1039
+ card.appendChild(row)
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
+ }
1014
1052
  // Add-member form: static DOM re-mounted per poll (draftForm trick) —
1015
1053
  // rebuilt only when the inspected team changes.
1016
1054
  if (canvasForm?.name !== fresh.name) {
@@ -1032,10 +1070,11 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1032
1070
  form.addEventListener('submit', async ev => {
1033
1071
  ev.preventDefault()
1034
1072
  const handle = manual.value.trim()
1035
- const id = handle !== '' ? handle : pick.value
1073
+ const selected=handle!==''?undefined:pick.selectedOptions[0]
1074
+ const id = handle !== '' ? handle : selected?.dataset.member ?? pick.value
1036
1075
  if (id === '') return
1037
1076
  submit.disabled = true
1038
- 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)
1039
1078
  submit.disabled = false
1040
1079
  if (ok) manual.value = ''
1041
1080
  })
@@ -1067,7 +1106,10 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1067
1106
  pick.appendChild(new Option('远端会话队(目录卡片)…', '', true, false))
1068
1107
  remoteLabeled = true
1069
1108
  }
1070
- 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)
1071
1113
  }
1072
1114
  if (!remoteLabeled) {
1073
1115
  pick.appendChild(new Option('(暂无可拉入的远端会话队——见下方说明)', '', true, false))
@@ -1076,7 +1118,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1076
1118
  card.appendChild(canvasForm.form)
1077
1119
  const hint = el('div', 'wb-hint')
1078
1120
  hint.textContent =
1079
- '成员由人类在此手工分配(agent 不可自组队)。远端成员走对端目录路由,不做本机冷唤醒;地址由目录在派发时重解析。远端下拉只列 zone/id8 会话句柄——若为空,说明目录里还没有已链接对端的会话队(未链接的对端只给最小卡);此时可在输入框手输句柄(如 dsh/xxxxxxxx)先把人拉进来,句柄本身即链接手势。'
1121
+ '成员由人类明确分配。本机写完整会话;远端必须通过已链接主机的有效名片核实并记录完整公钥来源。最小卡、离线或第三方无法验证时保留待确认,不能手输裸句柄绕过来源校验。来源确认不恢复暂停;默认兼容窗口不自动关闭。'
1080
1122
  card.appendChild(hint)
1081
1123
  // Quick dispatch targets the canvas ROUTING team (0.6.9): local tiers
1082
1124
  // first, then the remote members in order — the seam's own priority.
@@ -1230,6 +1272,8 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1230
1272
  function update(state: StateBody | null): void {
1231
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]))))
1232
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)}
1233
1277
  last = state
1234
1278
  // 0.6.12: refresh the readable-log list opportunistically (single flight;
1235
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.42",
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",