@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,7 @@
1
+ /** Observation of accepted input whose task birth did not commit; not a task status. */
2
+ import { safeObservationCode } from "./nudge-observability.js";
3
+ export const ADMITTED_BOOKKEEPING_NOTE = '已送达、接收方记账失败,请对账,不要重派。Delivered, but receiver bookkeeping failed; reconcile the original task, do not redispatch.';
4
+ export class AdmittedBookkeepingError extends Error {
5
+ code;
6
+ constructor(error) { super(ADMITTED_BOOKKEEPING_NOTE); this.code = safeObservationCode(error, 'LEDGER_WRITE_FAILED'); }
7
+ }
@@ -1,5 +1,6 @@
1
1
  /** Read-only canvas ownership projection; never authorizes or selects a dispatch candidate. */
2
2
  import { advertisedCanvasTeams } from "./peer-directory.js";
3
+ import { splitQualifiedMember } from "./canvas-member.js";
3
4
  export function canvasOwners(local, ownUrl, peers) {
4
5
  const rows = local.map(row => ({ ...row, owner: ownUrl, memberCount: row.members.length, local: true }));
5
6
  const seen = new Set();
@@ -14,7 +15,16 @@ export function canvasOwners(local, ownUrl, peers) {
14
15
  return rows;
15
16
  }
16
17
  export function canvasMemberships(owners, handle, localId, resolveLocal) {
17
- return owners.filter(row => ((row.scopeMembers ?? row.members).includes(handle) && (row.local || resolveLocal === undefined || resolveLocal(handle) === localId)) || (row.local && localId !== undefined && row.members.includes(localId))).map(({ members: _members, scopeMembers: _scopeMembers, ...row }) => row);
18
+ return owners.flatMap(row => {
19
+ const matched = (row.scopeMembers ?? row.members).filter(ref => (row.local && localId !== undefined && ref === localId) ||
20
+ (resolveLocal !== undefined && localId !== undefined && (ref === handle || splitQualifiedMember(ref)?.handle === handle) && resolveLocal(ref) === localId) ||
21
+ (resolveLocal === undefined && ref === handle));
22
+ if (!matched.length)
23
+ return [];
24
+ const inferred = matched.some(ref => ref === handle && splitQualifiedMember(ref) === undefined);
25
+ const { members: _members, scopeMembers: _scopeMembers, ...detail } = row;
26
+ return [{ ...detail, provenance: inferred ? 'unknown' : 'confirmed', ...(inferred ? { compatibilityInference: true } : {}) }];
27
+ });
18
28
  }
19
29
  export function canvasFailure(team, owners, reason) {
20
30
  if (!new RegExp('^.+/canvas/[^/]+$').test(team))
@@ -0,0 +1,43 @@
1
+ /** Session-member provenance (#334). fp8 selects an owner; only a full key anchors it. */
2
+ import { createPublicKey } from 'node:crypto';
3
+ import { canvasOwnerFingerprint } from "./canvas-id.js";
4
+ import { CLI_HANDLE_RE } from "./cli-deposit.js";
5
+ /** CLI handles keep their existing home/deposit rules and are never parsed as session members. */
6
+ export function splitQualifiedMember(ref) {
7
+ if (CLI_HANDLE_RE.test(ref))
8
+ return undefined;
9
+ const match = /^([^/\s@]+\/[0-9a-f]{8})@([0-9a-f]{8})$/.exec(ref);
10
+ return match ? { handle: match[1], fingerprint: match[2] } : undefined;
11
+ }
12
+ export function validMemberOwner(ref, key) {
13
+ const member = splitQualifiedMember(ref);
14
+ if (!member || !key || canvasOwnerFingerprint(key) !== member.fingerprint)
15
+ return false;
16
+ try {
17
+ const bytes = Buffer.from(key, 'base64'), publicKey = createPublicKey({ key: bytes, format: 'der', type: 'spki' });
18
+ return publicKey.asymmetricKeyType === 'ed25519' && publicKey.export({ format: 'der', type: 'spki' }).equals(bytes) && bytes.toString('base64') === key;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ export function qualifiedSessionMember(handle, key) {
25
+ const ref = handle + '@' + canvasOwnerFingerprint(key);
26
+ return validMemberOwner(ref, key) ? ref : undefined;
27
+ }
28
+ export function memberProvenance(ref, ownerKey, ownKey) {
29
+ if (CLI_HANDLE_RE.test(ref))
30
+ return 'cli';
31
+ if (!ref.includes('/'))
32
+ return 'local-id';
33
+ const qualified = splitQualifiedMember(ref);
34
+ if (!qualified)
35
+ return ref.includes('@') ? 'conflict' : 'unknown';
36
+ if (ownerKey === undefined)
37
+ return qualified.fingerprint === canvasOwnerFingerprint(ownKey) ? 'confirmed-local' : 'unknown';
38
+ if (!validMemberOwner(ref, ownerKey))
39
+ return 'conflict';
40
+ if (qualified.fingerprint === canvasOwnerFingerprint(ownKey) && ownerKey !== ownKey)
41
+ return 'conflict';
42
+ return ownerKey === ownKey ? 'confirmed-local' : 'confirmed-remote';
43
+ }
@@ -16,9 +16,16 @@
16
16
  * read fresh by the state route.
17
17
  * @module @nelsonlongxiang/dsh-open-a2a-net/canvas-store
18
18
  */
19
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync, openSync, closeSync, fsyncSync } from 'node:fs';
20
+ import { randomUUID } from 'node:crypto';
21
+ import { hostname } from 'node:os';
20
22
  import { dirname } from 'node:path';
21
23
  import { CLI_HANDLE_RE } from "./cli-deposit.js";
24
+ import { splitQualifiedMember, validMemberOwner, memberProvenance } from "./canvas-member.js";
25
+ /** A bare session handle is not a CLI identity or a qualified owner selector. */
26
+ export function legacyBareMember(id) {
27
+ return isRemoteMemberRef(id) && !CLI_HANDLE_RE.test(id) && !/@[0-9a-f]{8}$/.test(id);
28
+ }
22
29
  /** Upper bounds: a canvas taxonomy, not a database. */
23
30
  export const CANVAS_TEAM_CAP = 64;
24
31
  export const CANVAS_MEMBER_CAP = 32;
@@ -67,18 +74,29 @@ function validNewName(raw) {
67
74
  * registry knowledge: callers validate join state and resolve members.
68
75
  */
69
76
  export class CanvasStore {
77
+ options;
70
78
  /** Team entries in creation order; members in add order (routing priority). */
71
79
  teams = [];
72
80
  cliHome = {};
73
81
  cliHomeCleared = new Set();
82
+ suspended = [];
83
+ revision = 0;
84
+ committed = '';
85
+ memberOwners = {};
86
+ disk;
87
+ readOnlyReason = '';
88
+ lastWriteError = '';
89
+ failedSuspension = false;
74
90
  path;
75
91
  listeners = new Set();
76
92
  /**
77
93
  * @param path - the snapshot file; an empty path keeps the store in memory.
78
94
  */
79
- constructor(path) {
95
+ constructor(path, options = {}) {
96
+ this.options = options;
80
97
  this.path = path;
81
98
  this.restore();
99
+ this.committed = this.serialize();
82
100
  }
83
101
  /**
84
102
  * Observe changes: the listener runs after every write that changed the
@@ -95,7 +113,60 @@ export class CanvasStore {
95
113
  if (this.path === '' || !existsSync(this.path))
96
114
  return;
97
115
  try {
98
- const snapshot = JSON.parse(readFileSync(this.path, 'utf8'));
116
+ this.disk = readFileSync(this.path, 'utf8');
117
+ const snapshot = JSON.parse(this.disk);
118
+ if (snapshot?.formatVersion !== undefined && snapshot.formatVersion !== 2) {
119
+ this.readOnlyReason = 'unsupported canvas formatVersion';
120
+ return;
121
+ }
122
+ if (snapshot?.memberOwners && typeof snapshot.memberOwners === 'object' && !Array.isArray(snapshot.memberOwners))
123
+ this.memberOwners = { ...snapshot.memberOwners };
124
+ if (snapshot?.formatVersion === 2) {
125
+ if (!Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0 || !Array.isArray(snapshot.suspendedMembers) || !Array.isArray(snapshot.teams) || snapshot.teams.length > CANVAS_TEAM_CAP)
126
+ throw Error('incomplete or oversized canvas v2 snapshot');
127
+ const names = new Set();
128
+ for (const entry of snapshot.teams) {
129
+ if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string' || validName(entry.name) !== entry.name || names.has(entry.name) || !Array.isArray(entry.members) || entry.members.length > CANVAS_MEMBER_CAP)
130
+ throw Error('invalid canvas v2 team');
131
+ names.add(entry.name);
132
+ const members = new Set();
133
+ for (const member of entry.members) {
134
+ if (typeof member !== 'string' || member === '' || /[\x00-\x1f\x7f]/.test(member) || members.has(member))
135
+ throw Error('invalid canvas v2 member');
136
+ members.add(member);
137
+ }
138
+ }
139
+ if (snapshot.memberOwners !== undefined && (snapshot.memberOwners === null || typeof snapshot.memberOwners !== 'object' || Array.isArray(snapshot.memberOwners) || Object.entries(snapshot.memberOwners).some(([ref, key]) => typeof key !== 'string' || !validMemberOwner(ref, key))))
140
+ throw Error('invalid canvas v2 owners');
141
+ const fpKeys = new Map();
142
+ for (const [ref, key] of Object.entries(snapshot.memberOwners ?? {})) {
143
+ const fp = splitQualifiedMember(ref).fingerprint;
144
+ if (fpKeys.has(fp) && fpKeys.get(fp) !== key)
145
+ throw Error('conflicting canvas member fingerprints');
146
+ fpKeys.set(fp, key);
147
+ }
148
+ if (snapshot.cliHome !== undefined && (snapshot.cliHome === null || typeof snapshot.cliHome !== 'object' || Array.isArray(snapshot.cliHome) || Object.entries(snapshot.cliHome).some(([handle, home]) => !CLI_HANDLE_RE.test(handle) || typeof home !== 'string' || !validHomeOrigin(home) || !snapshot.teams.some(t => t.members.includes(handle)))))
149
+ throw Error('invalid canvas v2 CLI homes');
150
+ if (snapshot.cliHomeCleared !== undefined && (!Array.isArray(snapshot.cliHomeCleared) || new Set(snapshot.cliHomeCleared).size !== snapshot.cliHomeCleared.length || snapshot.cliHomeCleared.some(handle => typeof handle !== 'string' || !CLI_HANDLE_RE.test(handle) || !snapshot.teams.some(t => t.members.includes(handle)))))
151
+ throw Error('invalid canvas v2 cleared homes');
152
+ }
153
+ this.revision = Number.isSafeInteger(snapshot?.revision) && snapshot.revision >= 0 ? snapshot.revision : 0;
154
+ if (snapshot?.suspendedMembers !== undefined) {
155
+ if (!Array.isArray(snapshot.suspendedMembers))
156
+ throw Error('invalid suspension records');
157
+ const seen = new Set();
158
+ for (const row of snapshot.suspendedMembers) {
159
+ if (!row || typeof row.team !== 'string' || typeof row.member !== 'string' || typeof row.localSessionId !== 'string' || !['leave', 'archive', 'remove'].includes(row.reason) || typeof row.at !== 'string' || !Number.isFinite(Date.parse(row.at)) || typeof row.token !== 'string' || !row.token)
160
+ throw Error('invalid suspension record');
161
+ if (snapshot.formatVersion === 2 && !snapshot.teams?.some(t => t.name === row.team && t.members.includes(row.member)))
162
+ throw Error('orphan suspension record');
163
+ const key = JSON.stringify([row.team, row.member, row.reason, row.localSessionId]);
164
+ if (seen.has(key))
165
+ throw Error('duplicate suspension record');
166
+ seen.add(key);
167
+ this.suspended.push({ ...row });
168
+ }
169
+ }
99
170
  if (!Array.isArray(snapshot?.teams))
100
171
  return;
101
172
  const kept = [];
@@ -125,13 +196,31 @@ export class CanvasStore {
125
196
  }
126
197
  }
127
198
  catch {
128
- // A corrupt store is not fatal: the user re-creates teams; routing
129
- // names simply stop resolving until then.
199
+ this.teams = [];
200
+ this.suspended = [];
201
+ // A malformed v2 suspension must never silently reactivate members.
202
+ if (this.disk?.includes('suspendedMembers') || this.disk?.includes('formatVersion'))
203
+ this.readOnlyReason = 'invalid canvas v2 snapshot';
130
204
  }
131
205
  }
132
206
  /** Persist the current snapshot (no-op without a path), then tell the listeners. */
133
207
  persist() {
134
- this.write();
208
+ try {
209
+ this.write();
210
+ this.committed = this.serialize();
211
+ this.lastWriteError = '';
212
+ }
213
+ catch (error) {
214
+ this.lastWriteError = String(error);
215
+ const previous = JSON.parse(this.committed);
216
+ this.teams = previous.teams.map(t => ({ name: t.name, members: [...t.members] }));
217
+ this.suspended = [...previous.suspendedMembers ?? []];
218
+ this.memberOwners = { ...previous.memberOwners };
219
+ this.cliHome = { ...previous.cliHome };
220
+ this.cliHomeCleared = new Set(previous.cliHomeCleared);
221
+ this.revision = previous.revision ?? 0;
222
+ throw error;
223
+ }
135
224
  for (const listener of this.listeners) {
136
225
  try {
137
226
  listener();
@@ -139,17 +228,100 @@ export class CanvasStore {
139
228
  catch { /* a listener's failure is its own */ }
140
229
  }
141
230
  }
231
+ serialize() {
232
+ return JSON.stringify({ teams: this.teams, memberOwners: this.memberOwners, suspendedMembers: this.suspended, cliHome: this.cliHome, cliHomeCleared: [...this.cliHomeCleared], revision: this.revision });
233
+ }
142
234
  write() {
143
- if (this.path === '')
144
- return;
145
- try {
235
+ if (this.readOnlyReason)
236
+ throw Error(this.readOnlyReason);
237
+ const revision = this.revision + 1;
238
+ const snapshot = { formatVersion: 2, revision,
239
+ teams: this.teams.map(t => ({ name: t.name, members: [...t.members] })),
240
+ suspendedMembers: this.suspended.map(r => ({ ...r })),
241
+ ...(Object.keys(this.memberOwners).length ? { memberOwners: this.memberOwners } : {}),
242
+ ...(Object.keys(this.cliHome).length ? { cliHome: { ...this.cliHome } } : {}),
243
+ ...(this.cliHomeCleared.size ? { cliHomeCleared: [...this.cliHomeCleared] } : {}) };
244
+ const bytes = JSON.stringify(snapshot);
245
+ if (this.path !== '') {
146
246
  mkdirSync(dirname(this.path), { recursive: true });
147
- const snapshot = { teams: this.teams.map(t => ({ name: t.name, members: [...t.members] })), ...(Object.keys(this.cliHome).length > 0 ? { cliHome: { ...this.cliHome } } : {}), ...(this.cliHomeCleared.size > 0 ? { cliHomeCleared: [...this.cliHomeCleared] } : {}) };
148
- writeFileSync(this.path, JSON.stringify(snapshot), { mode: 0o600 });
247
+ const lock = this.path + '.lock';
248
+ // Never steal another writer's lock. Diagnostics do not authorize removal.
249
+ try {
250
+ mkdirSync(lock);
251
+ }
252
+ catch (error) {
253
+ throw Error('canvas lock held at ' + lock + ': ' + JSON.stringify(this.lockInfo()) + ' (' + String(error) + ')');
254
+ }
255
+ try {
256
+ writeFileSync(lock + '/owner.json', JSON.stringify({ pid: process.pid, host: hostname(), at: new Date().toISOString() }), { mode: 0o600, flag: 'wx' });
257
+ const current = existsSync(this.path) ? readFileSync(this.path, 'utf8') : undefined;
258
+ if (current !== this.disk)
259
+ throw Error('canvas snapshot changed externally; reload before writing');
260
+ let oldVersion;
261
+ try {
262
+ oldVersion = current === undefined ? undefined : JSON.parse(current)?.formatVersion;
263
+ }
264
+ catch { /* back up malformed legacy data too */ }
265
+ if (current !== undefined && oldVersion !== 2) {
266
+ const backup = this.path + '.pre-v2';
267
+ if (existsSync(backup)) {
268
+ if (readFileSync(backup, 'utf8') !== current)
269
+ throw Error('canvas pre-v2 backup differs; inspect before migration: ' + backup);
270
+ }
271
+ else
272
+ this.replaceFile(backup, current);
273
+ }
274
+ this.replaceFile(this.path, bytes);
275
+ this.disk = bytes;
276
+ }
277
+ finally {
278
+ rmSync(lock, { recursive: true });
279
+ }
149
280
  }
150
- catch {
151
- // An unwritable home must not break the canvas; the store degrades to
152
- // memory-only.
281
+ this.revision = revision;
282
+ }
283
+ replaceFile(target, bytes) {
284
+ const temporary = target + '.tmp-' + randomUUID();
285
+ try {
286
+ writeFileSync(temporary, bytes, { mode: 0o600, flag: 'wx' });
287
+ const fd = openSync(temporary, 'r+');
288
+ try {
289
+ fsyncSync(fd);
290
+ }
291
+ finally {
292
+ closeSync(fd);
293
+ }
294
+ renameSync(temporary, target);
295
+ if (process.platform !== 'win32') {
296
+ try {
297
+ const dir = openSync(dirname(target), 'r');
298
+ try {
299
+ fsyncSync(dir);
300
+ }
301
+ finally {
302
+ closeSync(dir);
303
+ }
304
+ }
305
+ catch (error) {
306
+ // Rename already committed: rolled-back memory must not be used as active state.
307
+ this.readOnlyReason = 'canvas directory fsync failed after rename: ' + String(error);
308
+ throw error;
309
+ }
310
+ }
311
+ }
312
+ finally {
313
+ rmSync(temporary, { force: true });
314
+ }
315
+ }
316
+ lockInfo() {
317
+ const path = this.path + '.lock';
318
+ if (this.path === '' || !existsSync(path))
319
+ return undefined;
320
+ try {
321
+ return { path, owner: JSON.parse(readFileSync(path + '/owner.json', 'utf8')) };
322
+ }
323
+ catch (error) {
324
+ return { path, error: 'owner metadata unavailable: ' + String(error) };
153
325
  }
154
326
  }
155
327
  /** Every team name, in creation order. */
@@ -163,7 +335,121 @@ export class CanvasStore {
163
335
  /** The named team's member ids in routing-priority order (a copy). */
164
336
  membersOf(name) {
165
337
  const entry = this.teams.find(t => t.name === name);
166
- return entry === undefined ? [] : [...entry.members];
338
+ if (this.readOnlyReason || this.failedSuspension)
339
+ return [];
340
+ return entry === undefined ? [] : entry.members.filter(id => {
341
+ if (this.isSuspended(name, id) || (this.options.legacyBareMembers === false && legacyBareMember(id)))
342
+ return false;
343
+ if (this.options.ownPublicKey && id.includes('@') && !CLI_HANDLE_RE.test(id)) {
344
+ const provenance = memberProvenance(id, this.memberOwners[id], this.options.ownPublicKey);
345
+ return provenance === 'confirmed-local' || provenance === 'confirmed-remote';
346
+ }
347
+ return true;
348
+ });
349
+ }
350
+ /** Inspection only: never use raw rows for routing, authorization or counts. */
351
+ rawMembersOf(name) { return [...(this.teams.find(t => t.name === name)?.members ?? [])]; }
352
+ isSuspended(name, member) { return this.suspended.some(r => r.team === name && r.member === member); }
353
+ suspensionOf(name, member) { return this.suspended.filter(r => r.team === name && r.member === member).map(r => ({ ...r })); }
354
+ state() {
355
+ return { revision: this.revision, suspendedMembers: this.suspended.map(r => ({ ...r })),
356
+ directoryFsync: process.platform === 'win32' ? 'unavailable on Windows; file fsync and atomic rename only' : 'required after rename',
357
+ ...(this.lockInfo() ? { lock: this.lockInfo() } : {}),
358
+ ...(this.readOnlyReason || this.failedSuspension || this.lastWriteError ? { error: this.readOnlyReason || (this.failedSuspension ? 'canvas suspension persistence failed; restart after repair' : this.lastWriteError) } : {}) };
359
+ }
360
+ ownerKeyOf(member) { return this.memberOwners[member]; }
361
+ ownerConflict(member, key) {
362
+ const fp = splitQualifiedMember(member)?.fingerprint;
363
+ return Object.entries(this.memberOwners).some(([ref, pinned]) => (ref === member || splitQualifiedMember(ref)?.fingerprint === fp) && pinned !== key);
364
+ }
365
+ /** Owner control only: CAS the complete slot and preserve suspension plus routing priority. */
366
+ confirmMember(team, member, replacement, ownerKey, revision, expectedOwner) {
367
+ if (this.readOnlyReason || this.failedSuspension)
368
+ return { ok: false, error: this.state().error };
369
+ const entry = this.teams.find(t => t.name === team), index = entry?.members.indexOf(member) ?? -1;
370
+ if (!entry || index < 0 || revision !== this.revision || expectedOwner !== (this.memberOwners[member] ?? null))
371
+ return { ok: false, error: 'canvas version, member slot or owner changed; refresh first' };
372
+ const sameId8 = (value) => value.replace(/^session-/, '').slice(0, 8);
373
+ const originalHandle = splitQualifiedMember(member)?.handle ?? member;
374
+ const replacementHandle = splitQualifiedMember(replacement)?.handle ?? replacement;
375
+ if (member.includes('/') && (replacement.includes('/') ? originalHandle !== replacementHandle : !originalHandle.endsWith('/' + sameId8(replacement))))
376
+ return { ok: false, error: 'confirmation must identify the same original session handle' };
377
+ if (!member.includes('/') || !replacement || CLI_HANDLE_RE.test(member) || CLI_HANDLE_RE.test(replacement))
378
+ return { ok: false, error: 'session provenance confirmation only; CLI homes use their own controls' };
379
+ const qualified = splitQualifiedMember(replacement);
380
+ if ((replacement.includes('/') && !qualified) || (qualified && (!ownerKey || !validMemberOwner(replacement, ownerKey) || this.ownerConflict(replacement, ownerKey))))
381
+ return { ok: false, error: 'owner key/fingerprint conflict or unproved member' };
382
+ if (this.memberOwners[member] !== undefined && (replacement !== member || ownerKey !== this.memberOwners[member]))
383
+ return { ok: false, error: 'confirmed owner cannot be silently replaced' };
384
+ const equivalent = entry.members.indexOf(replacement);
385
+ if (equivalent >= 0 && equivalent !== index && this.suspensionOf(team, member).some(row => this.suspensionOf(team, replacement).some(other => other.reason === row.reason && other.localSessionId === row.localSessionId)))
386
+ return { ok: false, error: 'both slots have independent matching suspension reasons; handle them explicitly before merging' };
387
+ if (equivalent >= 0 && equivalent !== index)
388
+ entry.members.splice(index, 1);
389
+ else
390
+ entry.members[index] = replacement;
391
+ this.suspended = this.suspended.map(row => row.team === team && row.member === member ? { ...row, member: replacement } : row)
392
+ .filter((row, i, all) => all.findIndex(other => other.team === row.team && other.member === row.member && other.reason === row.reason && other.localSessionId === row.localSessionId) === i);
393
+ if (qualified)
394
+ this.memberOwners[replacement] = ownerKey;
395
+ try {
396
+ this.persist();
397
+ return { ok: true };
398
+ }
399
+ catch (error) {
400
+ return { ok: false, error: String(error) };
401
+ }
402
+ }
403
+ /** Durably revoke canvas consent BEFORE join/agent/roster state is removed. */
404
+ revokeLocal(id, ownQualified, inferredBare, reason, team) {
405
+ if (this.readOnlyReason || this.failedSuspension)
406
+ throw Error(this.state().error);
407
+ let changed = false;
408
+ for (const entry of this.teams) {
409
+ if (team !== undefined && entry.name !== team)
410
+ continue;
411
+ if (inferredBare !== undefined && entry.members.includes(inferredBare) && !this.suspended.some(r => r.team === entry.name && r.member === inferredBare && r.localSessionId === id && r.reason === reason)) {
412
+ this.suspended.push({ team: entry.name, member: inferredBare, localSessionId: id, reason, at: new Date().toISOString(), token: randomUUID() });
413
+ changed = true;
414
+ }
415
+ const kept = entry.members.filter(m => (m !== id && m !== ownQualified) || this.isSuspended(entry.name, m));
416
+ if (kept.length !== entry.members.length) {
417
+ entry.members = kept;
418
+ changed = true;
419
+ }
420
+ }
421
+ if (changed) {
422
+ try {
423
+ this.persist();
424
+ }
425
+ catch (error) {
426
+ this.failedSuspension = true;
427
+ throw error;
428
+ }
429
+ }
430
+ return changed;
431
+ }
432
+ /** Owner-only CAS. The caller supplies the exact slot and the complete current suspension records. */
433
+ regulateSuspended(action, team, member, revision, expected) {
434
+ if (this.readOnlyReason || this.failedSuspension)
435
+ return { ok: false, error: this.state().error };
436
+ const records = this.suspensionOf(team, member);
437
+ if (revision !== this.revision || !records.length || !this.rawMembersOf(team).includes(member) || JSON.stringify(expected) !== JSON.stringify(records))
438
+ return { ok: false, error: 'canvas version, member slot or suspension records changed; refresh first' };
439
+ const old = this.suspended;
440
+ const entry = this.teams.find(t => t.name === team), members = entry.members;
441
+ this.suspended = this.suspended.filter(r => r.team !== team || r.member !== member);
442
+ if (action === 'delete')
443
+ entry.members = entry.members.filter(m => m !== member);
444
+ try {
445
+ this.persist();
446
+ return { ok: true };
447
+ }
448
+ catch (error) {
449
+ this.suspended = old;
450
+ entry.members = members;
451
+ return { ok: false, error: String(error) };
452
+ }
167
453
  }
168
454
  /** Frozen, operator-linked CLI home; undefined means unknown or ambiguous. */
169
455
  cliHomeOf(handle) { return this.cliHome[handle]; }
@@ -188,7 +474,7 @@ export class CanvasStore {
188
474
  }
189
475
  /** Every team name the id is a member of, in team creation order. */
190
476
  teamsOf(id) {
191
- return this.teams.filter(t => t.members.includes(id)).map(t => t.name);
477
+ return this.teams.filter(t => this.membersOf(t.name).includes(id)).map(t => t.name);
192
478
  }
193
479
  /**
194
480
  * Create a named team; whitespace trims, duplicates are idempotent, and
@@ -216,6 +502,8 @@ export class CanvasStore {
216
502
  const index = this.teams.findIndex(t => t.name === name);
217
503
  if (index < 0)
218
504
  return false;
505
+ if (this.suspended.some(r => r.team === name))
506
+ return false;
219
507
  const [removed] = this.teams.splice(index, 1);
220
508
  for (const id of removed.members)
221
509
  this.forgetUnusedHome(id);
@@ -233,10 +521,15 @@ export class CanvasStore {
233
521
  return false;
234
522
  if (home !== undefined && this.cliHome[id] !== undefined && this.cliHome[id] !== home && options.replaceHome !== true)
235
523
  return false;
524
+ const qualified = splitQualifiedMember(id);
525
+ if (qualified && (!options.ownerKey || !validMemberOwner(id, options.ownerKey) || this.ownerConflict(id, options.ownerKey)))
526
+ return false;
236
527
  const entry = this.teams.find(t => t.name === name);
237
528
  if (entry === undefined)
238
529
  return false;
239
530
  if (entry.members.includes(id)) {
531
+ if (qualified && this.memberOwners[id] === undefined)
532
+ return false; // Existing unknown slots require explicit confirmation, not re-add.
240
533
  if (home !== undefined && this.cliHome[id] !== home) {
241
534
  this.cliHome[id] = home;
242
535
  this.cliHomeCleared.delete(id);
@@ -247,6 +540,8 @@ export class CanvasStore {
247
540
  if (entry.members.length >= CANVAS_MEMBER_CAP)
248
541
  return false;
249
542
  entry.members.push(id);
543
+ if (qualified)
544
+ this.memberOwners[id] = options.ownerKey;
250
545
  if (home !== undefined) {
251
546
  this.cliHome[id] = home;
252
547
  this.cliHomeCleared.delete(id);
@@ -261,6 +556,9 @@ export class CanvasStore {
261
556
  const entry = this.teams.find(t => t.name === name);
262
557
  if (entry === undefined)
263
558
  return false;
559
+ // Suspended slots require owner CAS, never a peer remove/readd round trip.
560
+ if (this.isSuspended(name, id))
561
+ return false;
264
562
  const index = entry.members.indexOf(id);
265
563
  if (index < 0)
266
564
  return false;
@@ -284,7 +582,7 @@ export class CanvasStore {
284
582
  let dirty = false;
285
583
  for (const entry of this.teams) {
286
584
  const index = entry.members.indexOf(id);
287
- if (index >= 0) {
585
+ if (index >= 0 && !this.isSuspended(entry.name, id)) {
288
586
  entry.members.splice(index, 1);
289
587
  dirty = true;
290
588
  }