@zktx.io/sui-mcp 0.7.0 → 0.8.1

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 (71) hide show
  1. package/README.md +89 -42
  2. package/dist/core/activity/localDataService.js +8 -21
  3. package/dist/core/activity/localDataValidation.js +22 -46
  4. package/dist/core/activity/sqliteAccountStore.js +46 -0
  5. package/dist/core/activity/sqliteActivityStore.js +65 -147
  6. package/dist/core/activity/sqliteActivityStoreSchema.js +44 -28
  7. package/dist/core/activity/sqliteActivityStoreTypes.js +9 -0
  8. package/dist/core/activity/transactionActivityService.js +2 -2
  9. package/dist/core/preferences/preferencesStore.js +2 -2
  10. package/dist/core/preferences/sqlitePreferencesRepository.js +10 -0
  11. package/dist/core/session/cardResult.js +41 -0
  12. package/dist/core/session/cardSession.js +1 -1
  13. package/dist/core/session/cardSessionStore.js +51 -19
  14. package/dist/core/session/chainAccounts.js +120 -0
  15. package/dist/core/session/sessionStore.js +4 -8
  16. package/dist/core/session/settingsFileStore.js +3 -30
  17. package/dist/core/session/settingsOperation.js +27 -19
  18. package/dist/core/session/settingsWorkflow.js +161 -87
  19. package/dist/core/session/sqliteCardStore.js +34 -14
  20. package/dist/core/session/sqliteSessionStore.js +5 -5
  21. package/dist/core/session/sqliteSettingsWorkflowStore.js +135 -41
  22. package/dist/core/session/sqliteTransactionRequest.js +18 -0
  23. package/dist/core/session/sqliteWalletConnectionOperation.js +13 -0
  24. package/dist/core/session/sqliteWalletWorkflowStore.js +569 -333
  25. package/dist/core/session/status.js +2 -2
  26. package/dist/core/session/wait.js +6 -11
  27. package/dist/core/session/walletConnection.js +71 -46
  28. package/dist/core/session/walletConnectionOperations.js +24 -0
  29. package/dist/core/session/walletRuntime.js +7 -13
  30. package/dist/core/session/walletWorkflow.js +248 -99
  31. package/dist/core/session/workflowState.js +104 -38
  32. package/dist/core/session/workflowView.js +92 -101
  33. package/dist/mcp/activeAccountResponse.js +3 -0
  34. package/dist/mcp/responseGuidance.js +7 -9
  35. package/dist/mcp/serverInfo.js +11 -10
  36. package/dist/mcp/toolErrors.js +5 -3
  37. package/dist/mcp/toolNames.js +1 -0
  38. package/dist/mcp/tools/action/prepareSuiActionReview.js +9 -16
  39. package/dist/mcp/tools/read/readToolHelpers.js +9 -7
  40. package/dist/mcp/tools/session/executionResultTools.js +2 -2
  41. package/dist/mcp/tools/session/shared.js +2 -2
  42. package/dist/mcp/tools/session/statusTools.js +12 -10
  43. package/dist/mcp/tools/session/walletConnectionTools.js +26 -14
  44. package/dist/mcp/tools/settings/index.js +7 -4
  45. package/dist/mcp-app/account.html +7 -7
  46. package/dist/mcp-app/chart.html +3 -3
  47. package/dist/mcp-app/connect.html +13 -13
  48. package/dist/mcp-app/disconnect.html +3637 -0
  49. package/dist/mcp-app/disconnect.notices.txt +1480 -0
  50. package/dist/mcp-app/receipt.html +393 -393
  51. package/dist/mcp-app/review.html +459 -459
  52. package/dist/mcp-app/settings.html +7 -7
  53. package/dist/mcp-ui/contracts.js +3 -1
  54. package/dist/mcp-ui/tools.js +4 -4
  55. package/dist/runtime/application.js +1 -1
  56. package/dist/runtime/localSettingsService.js +7 -4
  57. package/dist/runtime/runtimeTermination.js +3 -0
  58. package/dist/runtime/smokeMainnetRead.js +5 -4
  59. package/dist/runtime/walletConnectTransport.js +16 -16
  60. package/dist/runtime/walletSdkChildRuntime.js +29 -12
  61. package/dist/runtime/walletSdkIpc.js +5 -4
  62. package/dist/runtime/walletSdkProcess.js +24 -8
  63. package/docs/AGENT_BEHAVIOR.md +82 -54
  64. package/docs/LOCAL_DB_ARCHITECTURE.md +100 -70
  65. package/docs/MCP_SETUP.md +384 -347
  66. package/docs/MCP_TOOLS.md +292 -177
  67. package/docs/SDK_API.md +15 -6
  68. package/docs/TRANSACTION_ACTIVITY_LOG.md +4 -0
  69. package/docs/UTILITY_INDEX.md +9 -7
  70. package/docs/WALLET_CONNECTION.md +162 -167
  71. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { basename, dirname } from "node:path";
3
- import { TextDecoder } from "node:util";
3
+ import { TextDecoder, isDeepStrictEqual } from "node:util";
4
4
  import { LocalDataError } from "../activity/localDataTypes.js";
5
5
  import { settingsInputError, SETTINGS_REPLACEMENT_BLOCKED_MESSAGE } from "./settingsInputError.js";
6
6
  import { SETTINGS_APPLIES_AFTER_RESTART } from "../preferences/preferencesStore.js";
@@ -10,7 +10,7 @@ import { DEFAULT_SESSION_TTL_MS } from "./localSession.js";
10
10
  import { isSettingsDownloadFile, settingsDownloadPath } from "./settingsFileIdentity.js";
11
11
  import { SettingsDownloadPathExists, settingsFileDigest, decodeSettingsChunk } from "./settingsFileStore.js";
12
12
  import { settingsIdentity } from "./sqliteSettingsWorkflowStore.js";
13
- import { SettingsConflict, settingsDownload, isSettingsDownloadFileAction, settingsViewSchema, endpointPreparationSchema, storedRestoreCheckSchema, retainedBackupRejection, permitsBackupRecheck } from "./settingsOperation.js";
13
+ import { SettingsConflict, settingsCardInputSchema, settingsDownload, isSettingsDownloadFileAction, settingsViewSchema, endpointPreparationSchema, storedRestoreCheckSchema, retainedBackupRejection, permitsBackupRecheck } from "./settingsOperation.js";
14
14
  export const DOWNLOAD_RETRY_WINDOW_MS = DEFAULT_SESSION_TTL_MS;
15
15
  export class SettingsWorkflow {
16
16
  options;
@@ -29,7 +29,29 @@ export class SettingsWorkflow {
29
29
  runtimeEvent: (event) => this.runtimeEvent(event), readyPublished: (runId) => this.readyPublished(runId) });
30
30
  }
31
31
  get records() { this.options.assertCurrent(); return this.options.records; }
32
- create(record) { this.records.create(record); }
32
+ async create(record) {
33
+ const input = settingsCardInputSchema.parse(record.state.input);
34
+ if (!input.operationStatus) {
35
+ this.records.create(record);
36
+ return;
37
+ }
38
+ const operation = this.records.get(input.operationStatus.operationId);
39
+ if (!operation || operation.dataEpoch !== this.records.control().data_epoch) {
40
+ throw new SettingsConflict("This Settings request is not available in the current profile and server run.");
41
+ }
42
+ const original = this.records.cards.get(operation.cardId);
43
+ if (!original)
44
+ throw new SettingsConflict("This Settings request is unavailable.");
45
+ const download = settingsDownload(operation);
46
+ const targetId = download?.localSave?.attempts.find((a) => a.saveAttemptId === download.localSave?.currentSaveAttemptId)?.cardId ??
47
+ download?.attempts.find((a) => a.attemptId === download.currentAttemptId)?.cardId;
48
+ const target = targetId ? this.records.cards.get(targetId) ?? original : original;
49
+ const current = await this.describe(target, true);
50
+ const { restoreCheck: _restoreCheck, ...status } = current.data;
51
+ const data = { ...status, allowedActions: [], observe: false,
52
+ ...(current.data.download ? { download: { ...current.data.download, allowedFileActions: [] } } : {}) };
53
+ this.records.cards.create({ ...record, state: { ...record.state, state: "closed", reason: "completed", data } });
54
+ }
33
55
  operationKind(record) { return this.records.forCard(record).draft?.kind; }
34
56
  recoverOwner() {
35
57
  this.records.transaction(() => {
@@ -51,25 +73,37 @@ export class SettingsWorkflow {
51
73
  const card = this.records.cards.get(operation.cardId);
52
74
  if (!card)
53
75
  continue;
54
- const verifyExport = operation.draft?.kind === "export_local_data" && file?.role === "export" && !!operation.admittedAt;
55
76
  const result = { outcome: "interrupted", recordedAt: this.now().toISOString(),
56
77
  message: operation.admittedAt
57
78
  ? "Sui MCP restarted before this confirmed change finished. It will not be repeated. Check the result of the original request separately."
58
79
  : "Sui MCP restarted before you confirmed this change. It will not be repeated." };
59
- this.records.db.prepare(`UPDATE settings_operations SET owner_id=?,phase=?,result_json=?,checkpoint=? WHERE operation_id=?`)
60
- .run(this.records.ownerId, verifyExport ? "executing" : "terminal", verifyExport ? null : JSON.stringify(result), verifyExport ? "verify_export_after_owner_change" : null, operation.operationId);
80
+ this.records.db.prepare(`UPDATE settings_operations SET owner_id=?,phase='terminal',result_json=? WHERE operation_id=?`)
81
+ .run(this.records.ownerId, JSON.stringify(result), operation.operationId);
61
82
  const reason = card.state.reason ?? (card.state.state === "ready" && Date.parse(card.state.expiresAt) <= this.now().getTime() ? "expired" : "server_restarted");
62
- if (this.records.db.prepare(`UPDATE live_read_cards SET owner_id=?,state=?,reason=?,revision=revision+1
63
- WHERE id=? AND owner_id=? AND token_hash=? AND revision=?`).run(this.records.ownerId, verifyExport ? "running" : "closed", verifyExport ? null : reason, card.state.cardId, card.ownerId, card.tokenHash, card.state.revision).changes !== 1) {
83
+ this.records.invalidateUpload(operation);
84
+ if (card.state.state !== "closed" && !this.records.cards.replace(card, { ...card, ownerId: this.records.ownerId,
85
+ state: { ...card.state, state: "closed", reason, revision: card.state.revision + 1,
86
+ data: this.records.resultView(operation, result) } }, this.now())) {
64
87
  throw new SettingsConflict("The interrupted Settings card changed while its status was being updated.");
65
88
  }
66
- if (file?.role === "upload")
67
- this.records.writeFile({ ...file, invalidated: true });
68
89
  this.records.db.prepare(`UPDATE settings_control SET active_operation_id=NULL,wallet_input_blocked=0,wallet_block_operation_id=NULL
69
90
  WHERE id=1 AND active_operation_id=?`).run(operation.operationId);
70
91
  if (file)
71
92
  this.records.releaseFileIO(file.fileId);
72
93
  }
94
+ const pendingCards = this.records.db.prepare("SELECT id FROM live_read_cards WHERE kind='settings' AND state!='closed' AND owner_id!=?")
95
+ .all(this.records.ownerId);
96
+ for (const { id } of pendingCards) {
97
+ const card = this.records.cards.get(id), operation = this.records.get(card.operationId);
98
+ if (!operation)
99
+ throw new SettingsConflict("The interrupted Settings operation is unavailable.");
100
+ const result = { outcome: "interrupted", recordedAt: this.now().toISOString(),
101
+ message: "This card ended when the server restarted. Check the original operation's current result in a new status card; no effect will be repeated." };
102
+ if (!this.records.cards.replace(card, { ...card, ownerId: this.records.ownerId,
103
+ state: { ...card.state, state: "closed", reason: "server_restarted", revision: card.state.revision + 1,
104
+ data: this.records.resultView(operation, result) } }, this.now()))
105
+ throw new SettingsConflict("The interrupted Settings card changed during recovery.");
106
+ }
73
107
  const ioId = this.records.control().file_io_id;
74
108
  if (ioId) {
75
109
  const file = this.records.file(ioId);
@@ -159,23 +193,8 @@ export class SettingsWorkflow {
159
193
  await this.cleanupTemporaryFiles();
160
194
  });
161
195
  }
162
- async verifyPriorExport(operation) {
163
- const file = operation.fileId ? this.records.file(operation.fileId) : undefined;
164
- let result = { outcome: "interrupted", recordedAt: this.now().toISOString(),
165
- message: "Sui MCP restarted and the backup file could not be checked. Open a new Settings card to create another backup." };
166
- if (file) {
167
- try {
168
- await this.options.files.verifyExport(file);
169
- result = { outcome: "completed", recordedAt: this.now().toISOString(), message: "The previously created local backup was verified.",
170
- details: { path: this.options.files.exportPath(file), filename: file.displayName, fileId: file.fileId, sha256: file.sha256, totalSize: file.totalSize } };
171
- }
172
- catch { }
173
- }
174
- this.options.assertCurrent();
175
- this.retainResult(operation.operationId, result, false, file && result.outcome === "completed" ? { ...file, sealed: true, receivedSize: file.totalSize } : undefined);
176
- }
177
196
  async readFile(record, fileId, offset, limit, attemptId) {
178
- const operation = this.records.forCard(record), file = this.records.file(fileId);
197
+ const operation = this.records.forCard(record);
179
198
  if (operation.draft?.kind === "download_backup") {
180
199
  if (this.closing)
181
200
  throw new SettingsConflict("This server is closing; the download cannot continue.");
@@ -204,13 +223,7 @@ export class SettingsWorkflow {
204
223
  await this.cleanupTemporaryFiles(operation.operationId);
205
224
  }
206
225
  }
207
- if (operation.result?.outcome !== "completed" || operation.draft?.kind !== "export_local_data" || operation.fileId !== fileId ||
208
- !file || file.operationId !== operation.operationId || !file.sealed || file.invalidated || attemptId !== undefined)
209
- throw new SettingsConflict("This completed backup is not available for download.");
210
- const length = Math.min(limit, file.totalSize - offset);
211
- const data = await this.options.files.readExportRange(file, offset, length);
212
- this.options.assertCurrent();
213
- return { file, offset, chunkBase64: data.toString("base64") };
226
+ throw new SettingsConflict("This Settings request does not provide a download.");
214
227
  }
215
228
  async startDownload(expected, input) {
216
229
  let bytes;
@@ -231,7 +244,7 @@ export class SettingsWorkflow {
231
244
  await this.trackDownloadIO(file.fileId, () => this.options.files.createDownload(file, data));
232
245
  this.options.assertCurrent();
233
246
  readyAt = this.now().toISOString();
234
- result = { outcome: "completed", completionScope: "backup_snapshot", recordedAt: readyAt,
247
+ result = { outcome: "completed", completionScope: "backup_delivery", recordedAt: readyAt,
235
248
  message: "The backup is ready for download. This does not confirm that a file was saved.",
236
249
  details: { filename: file.displayName, fileId: file.fileId, sha256: file.sha256, totalSize: file.totalSize } };
237
250
  }
@@ -243,7 +256,7 @@ export class SettingsWorkflow {
243
256
  const existing = this.records.file(file.fileId);
244
257
  this.records.writeFile({ ...existing, invalidated: true, cleanup: "complete" });
245
258
  });
246
- result = { outcome: "failed", completionScope: "backup_snapshot", recordedAt: this.now().toISOString(),
259
+ result = { outcome: "failed", completionScope: "backup_delivery", recordedAt: this.now().toISOString(),
247
260
  message: "The backup could not be prepared. Open a new Settings card to try again." };
248
261
  }
249
262
  this.finishedFileIO.add(file.fileId);
@@ -304,7 +317,8 @@ export class SettingsWorkflow {
304
317
  retained = { saveAttemptId, outcome };
305
318
  this.localSaveResults.set(file.operationId, retained);
306
319
  }
307
- this.records.settleLocalSave(file.operationId, retained.saveAttemptId, retained.outcome);
320
+ const path = retained.outcome.status === "saved" ? this.options.files.localBackupPath(file) : undefined;
321
+ this.records.settleLocalSave(file.operationId, retained.saveAttemptId, retained.outcome, path ? { directory: dirname(path), filename: basename(path) } : undefined);
308
322
  this.localSaveResults.delete(file.operationId);
309
323
  this.scheduleDownloadExpiry();
310
324
  await this.cleanupTemporaryFiles(file.operationId);
@@ -352,9 +366,9 @@ export class SettingsWorkflow {
352
366
  if (draft.kind === "set_endpoint" || draft.kind === "restore_endpoint")
353
367
  return settingsIdentity({ ...common, target: this.options.resolveEndpoint(draft),
354
368
  current: this.options.preferences.getLocalSettingSync(draft.endpoint === "grpc" ? "suiGrpcUrl" : "suiGraphqlUrl") });
355
- if (draft.kind === "clear_read_address")
369
+ if (draft.kind === "set_wallet_networks")
356
370
  return settingsIdentity({ ...common,
357
- current: this.records.db.prepare("SELECT * FROM active_account_context WHERE id=1").get() ?? null });
371
+ current: this.options.preferences.getLocalSettingSync("walletConnectionChains") });
358
372
  if (draft.kind === "import_local_data" || draft.kind === "reset_local_data")
359
373
  return settingsIdentity({ ...common,
360
374
  userDataRevision: control.user_data_revision, file: operation.fileId ? this.records.file(operation.fileId) : null });
@@ -387,8 +401,8 @@ export class SettingsWorkflow {
387
401
  return previous;
388
402
  }
389
403
  requireConfirmation(record, operation) {
390
- if (["export_local_data", "download_backup"].includes(operation.draft?.kind ?? ""))
391
- throw new SettingsConflict("Open a new Settings card and use Download backup.");
404
+ if (["set_wallet_networks", "clear_read_address", "download_backup"].includes(operation.draft?.kind ?? ""))
405
+ throw new SettingsConflict("This action is applied by its explicit save or download request.");
392
406
  const replacing = operation.draft?.kind === "restart_wallet_service" && typeof operation.preparation?.supersedesOperationId === "string"
393
407
  ? operation.preparation.supersedesOperationId : undefined;
394
408
  this.records.requireAdmission(record, { action: "settings_confirm", operationId: operation.operationId,
@@ -469,8 +483,25 @@ export class SettingsWorkflow {
469
483
  }
470
484
  return file.sealed ? required("Check this backup again before restoring.") : { status: "receiving" };
471
485
  }
472
- async describe(record) {
486
+ expireInput(record) {
487
+ if (record.state.state !== "ready" || Date.parse(record.state.expiresAt) > this.now().getTime())
488
+ return record;
489
+ const operation = this.records.forCard(record);
490
+ const result = { outcome: "interrupted", recordedAt: this.now().toISOString(),
491
+ message: record.state.input.backupDelivery ? "This delivery card expired. Ask in chat for a new delivery card if the backup is still available."
492
+ : "The time to confirm this Settings change has ended." };
493
+ if (record.state.input.backupDelivery) {
494
+ if (!this.records.cards.replace(record, { ...record, state: { ...record.state, state: "closed", reason: "expired",
495
+ revision: record.state.revision + 1, data: this.records.resultView(operation, result) } }, this.now()))
496
+ throw new SettingsConflict("This delivery card changed before it could expire.");
497
+ }
498
+ else
499
+ this.records.finish(operation.operationId, result, undefined, "expired");
500
+ return this.records.cards.get(record.state.cardId);
501
+ }
502
+ async describe(record, currentOperation = false) {
473
503
  this.options.assertCurrent();
504
+ record = this.expireInput(record);
474
505
  let operation = this.records.forCard(record);
475
506
  this.observeLocalSave(operation);
476
507
  let downloadExpiryPending = false;
@@ -485,17 +516,13 @@ export class SettingsWorkflow {
485
516
  if (operation.result === undefined) {
486
517
  this.synchronize(operation.operationId);
487
518
  operation = this.records.get(operation.operationId);
488
- if (operation.checkpoint === "verify_export_after_owner_change" && !operation.result)
489
- await this.verifyPriorExport(operation);
490
519
  if (operation.draft?.kind === "restart_wallet_service" && operation.admittedAt) {
491
520
  this.options.wallet.recoveryContext();
492
521
  this.assertRecoveryRecorded();
493
522
  }
494
523
  this.records.transaction(() => {
495
- record = this.records.cards.evaluate(record, () => this.now()).record;
496
- if (record.state.state === "closed" && record.state.reason === "expired") {
497
- if (operation.fileId)
498
- this.invalidateFile(operation.fileId);
524
+ record = this.expireInput(record);
525
+ if (!record.state.input.backupDelivery && record.state.state === "closed" && record.state.reason === "expired") {
499
526
  this.records.finish(operation.operationId, { outcome: "interrupted", recordedAt: this.now().toISOString(), message: "The time to confirm this Settings change has ended." });
500
527
  }
501
528
  });
@@ -506,7 +533,7 @@ export class SettingsWorkflow {
506
533
  }
507
534
  catch (error) {
508
535
  this.options.assertCurrent();
509
- if (!operation.result)
536
+ if (!operation.result && !settingsDownload(operation)?.readyAt)
510
537
  throw error;
511
538
  downloadExpiryPending = true;
512
539
  }
@@ -524,12 +551,14 @@ export class SettingsWorkflow {
524
551
  const restoreCheck = operation.draft?.kind === "import_local_data" && !operation.result
525
552
  ? this.restoreCheck(operation, record, file) : undefined;
526
553
  const allowed = [];
527
- if (record.state.state === "ready" && record.ownerId === this.records.ownerId && operation.dataEpoch === this.records.control().data_epoch && !operation.result) {
554
+ if (!record.state.input.backupDelivery && record.state.state === "ready" && record.ownerId === this.records.ownerId && operation.dataEpoch === this.records.control().data_epoch && !operation.result) {
528
555
  const fileIOAvailable = this.records.fileIOAvailable();
529
556
  if (fileIOAvailable) {
530
557
  allowed.push("settings_select", "settings_cancel");
531
558
  if (this.records.control().active_operation_id === null)
532
- allowed.push("settings_download_backup");
559
+ allowed.push("settings_download_backup", "settings_save_wallet_networks");
560
+ if (facts?.readAddress && facts.readConnectionId && this.records.control().active_operation_id === null)
561
+ allowed.push("settings_clear_read_address");
533
562
  if (operation.draft)
534
563
  allowed.push("settings_back");
535
564
  }
@@ -544,7 +573,7 @@ export class SettingsWorkflow {
544
573
  (file && !file.invalidated || this.records.canReserveFileIO(operation.operationId)))
545
574
  allowed.push("settings_begin_upload");
546
575
  }
547
- else if (operation.phase !== "prepared" && operation.draft && !["export_local_data", "download_backup"].includes(operation.draft.kind) &&
576
+ else if (operation.phase !== "prepared" && operation.draft && !["set_wallet_networks", "clear_read_address", "download_backup"].includes(operation.draft.kind) &&
548
577
  (operation.draft.kind !== "restart_wallet_service" || this.canPrepareRestart()))
549
578
  allowed.push("settings_prepare");
550
579
  if (operation.phase === "uploading" && file && !file.invalidated && !file.sealed) {
@@ -570,32 +599,49 @@ export class SettingsWorkflow {
570
599
  ...(localSave.failure ? { failure: localSave.failure } : {}), ...(localSave.savedAt ? { savedAt: localSave.savedAt } : {}) } } : {}),
571
600
  ...(downloadEnd ? { endReason: downloadEnd } : {}),
572
601
  ...(storedDownload.expiresAt ? { expiresAt: storedDownload.expiresAt, remainingMs: Math.max(0, Date.parse(storedDownload.expiresAt) - this.now().getTime()) } : {}),
573
- allowedFileActions: currentDownloadOwner && !this.closing ? [
574
- ...(storedDownload.readyAt ? ["settings_report_download"] : []),
575
- ...(!downloadEnd && file && !file.invalidated && localSave?.status !== "saving" ? ["settings_cancel_download",
576
- ...(!localSave && storedDownload.readyAt ? ["settings_retry_download"] : []),
577
- ...(localSave && (localSave.status === "needs_consent" || localSave.status === "failed" && localSave.failure === "not_published") &&
578
- this.records.canReserveFileIO(operation.operationId) ? ["settings_store_backup"] : [])] : [])
602
+ allowedFileActions: currentDownloadOwner && record.state.state !== "closed" && !this.closing ? [
603
+ ...(storedDownload.readyAt && currentAttempt?.cardId === record.state.cardId ? ["settings_report_download"] : []),
604
+ ...(!downloadEnd && file && !file.invalidated && localSave?.status !== "saving" ? [
605
+ ...(currentAttempt?.cardId === record.state.cardId ? ["settings_cancel_download"] : []),
606
+ ...(record.state.state === "ready" && record.state.input.backupDelivery && !localSave && storedDownload.readyAt ? ["settings_retry_download"] : []),
607
+ ...(record.state.state === "ready" && record.state.input.backupDelivery && localSave && (localSave.status === "needs_consent" || localSave.status === "failed" && localSave.failure === "not_published") &&
608
+ this.records.canReserveFileIO(operation.operationId) ? ["settings_store_backup"] : [])
609
+ ] : [])
579
610
  ] : []
580
611
  } : undefined;
581
612
  if (storedDownload && !downloadExpiryPending)
582
613
  this.scheduleDownloadExpiry();
583
- const cleanup = operation.result ? downloadExpiryPending ? "pending" : this.records.temporaryFileCleanup(operation.operationId) : undefined;
584
- const backupPath = localSave?.status === "saved" && file ? this.options.files.localBackupPath(file)
585
- : operation.draft?.kind === "export_local_data" && operation.result?.outcome === "completed" ? operation.result.details?.path : undefined;
586
- const data = { kind: "settings", operationId: operation.operationId, phase: operation.phase, allowedActions: allowed,
587
- observe: !operation.result && (["executing", "stopping", "starting"].includes(operation.phase) || restoreCheck?.status === "checking"),
614
+ const backupPath = localSave?.status === "saved" && file ? this.options.files.localBackupPath(file) : undefined;
615
+ const readingDownloadStatus = currentOperation && storedDownload !== undefined;
616
+ const activeDelivery = storedDownload && !currentOperation && record.state.state !== "closed";
617
+ const deliveryPending = storedDownload && (localSave?.status === "saving" || !downloadEnd &&
618
+ (!storedDownload.readyAt || !currentAttempt?.hostReport && !localSave));
619
+ const recordedResult = currentOperation && storedDownload
620
+ ? (deliveryPending ? undefined : this.records.downloadResult(operation))
621
+ : operation.result;
622
+ const cleanup = recordedResult ? downloadExpiryPending ? "pending" : this.records.temporaryFileCleanup(operation.operationId) : undefined;
623
+ const data = { kind: "settings", operationId: operation.operationId,
624
+ phase: readingDownloadStatus ? recordedResult ? "terminal" : "executing"
625
+ : activeDelivery ? record.state.state === "ready" ? "draft" : "executing" : operation.phase, allowedActions: readingDownloadStatus ? [] : allowed,
626
+ observe: !readingDownloadStatus && (activeDelivery ? !storedDownload.readyAt || localSave?.status === "saving" : !operation.result && (["executing", "stopping", "starting"].includes(operation.phase) || restoreCheck?.status === "checking")),
588
627
  ...(restoreCheck ? { restoreCheck } : {}), ...(download ? { download } : {}),
589
628
  ...(typeof backupPath === "string" ? { backupLocation: { directory: dirname(backupPath), filename: basename(backupPath) } } : {}),
590
629
  ...(operation.draft ? { draft: operation.draft } : {}), ...(operation.preparationId ? { preparationId: operation.preparationId } : {}),
591
- ...(operation.preparation && !storedDownload ? { preparation: operation.preparation } : {}), ...(operation.result ? { result: operation.result } : {}),
592
- ...(cleanup ? { temporaryFileCleanup: cleanup } : {}),
630
+ ...(operation.preparation && !storedDownload ? { preparation: operation.preparation } : {}), ...(recordedResult && !activeDelivery ? { result: recordedResult } : {}),
631
+ ...(cleanup && !activeDelivery ? { temporaryFileCleanup: cleanup } : {}),
593
632
  ...(file ? { file: { fileId: file.fileId, displayName: file.displayName, totalSize: file.totalSize, receivedSize: file.receivedSize, sha256: file.sha256, sealed: file.sealed, cleanup: file.cleanup } } : {}),
594
633
  ...(facts ? { facts } : {}), ...(operation.priorRunId ? { priorRunId: operation.priorRunId } : {}),
595
634
  ...(operation.nextRunId ? { nextRunId: operation.nextRunId } : {}),
596
635
  ...(operation.supersedesOperationId ? { supersedesOperationId: operation.supersedesOperationId } : {}),
597
636
  ...(operation.supersededByOperationId ? { supersededByOperationId: operation.supersededByOperationId } : {}) };
598
637
  assertNoForbiddenMcpFields(data);
638
+ if (!currentOperation && record.state.state === "closed" && record.state.data !== undefined)
639
+ return { record, data: settingsViewSchema.parse(record.state.data) };
640
+ if (!currentOperation && record.state.input.backupDelivery && downloadEnd) {
641
+ this.records.transaction(() => this.records.finishDownloadCard(operation.operationId, record.state.cardId));
642
+ record = this.records.cards.get(record.state.cardId);
643
+ return { record, data: settingsViewSchema.parse(record.state.data) };
644
+ }
599
645
  return { record, data: settingsViewSchema.parse(data) };
600
646
  }
601
647
  async act(expected, action) {
@@ -613,8 +659,39 @@ export class SettingsWorkflow {
613
659
  await this.continueDownload(expected, action);
614
660
  return;
615
661
  }
616
- if (action.action === "settings_select" && ["export_local_data", "download_backup"].includes(action.draft.kind)) {
617
- throw new SettingsConflict("Use Download backup to request a backup in one step.");
662
+ if (action.action === "settings_clear_read_address" || action.action === "settings_save_wallet_networks") {
663
+ const input = action;
664
+ if (stored.acceptedDigest === settingsIdentity(input))
665
+ return;
666
+ this.records.transaction(() => {
667
+ const { card, operation } = this.records.requireInput(expected, input.operationId);
668
+ this.records.requireFinalSlot();
669
+ const previous = input.action === "settings_save_wallet_networks" ? this.options.preferences.getWalletConnectionChainsSync() : undefined;
670
+ if (input.action === "settings_save_wallet_networks" && !isDeepStrictEqual(previous, input.expectedChains)) {
671
+ throw new SettingsConflict("The saved networks changed. Check the current settings before saving your selection.");
672
+ }
673
+ this.records.claimFinal(operation.operationId);
674
+ const next = { operationId: operation.operationId, cardId: operation.cardId, ownerId: operation.ownerId,
675
+ dataEpoch: operation.dataEpoch, phase: "executing", draft: input.action === "settings_clear_read_address"
676
+ ? { kind: "clear_read_address" } : { kind: "set_wallet_networks", chains: input.chains },
677
+ acceptedDigest: settingsIdentity(input), admittedAt: this.now().toISOString() };
678
+ this.records.write(operation, next, card, { ...card, acceptedInput: input,
679
+ state: { ...card.state, state: "running", revision: card.state.revision + 1 } });
680
+ if (input.action === "settings_clear_read_address") {
681
+ this.records.clearReadAddress(input.connectionId, input.account);
682
+ this.records.finish(operation.operationId, { outcome: "completed", recordedAt: this.now().toISOString(),
683
+ message: "The saved read address was cleared. Your wallet connection and transaction requests are unchanged." });
684
+ }
685
+ else {
686
+ this.options.preferences.setLocalSettingSync("walletConnectionChains", input.chains, this.now());
687
+ this.options.wallet.closeConnectionCardsWithChangedNetworks(input.chains);
688
+ this.records.finish(operation.operationId, { outcome: "completed", recordedAt: this.now().toISOString(),
689
+ message: "Wallet networks were saved. They apply to new connections; existing connections are unchanged.",
690
+ details: { walletConnectionChains: input.chains, previousWalletConnectionChains: previous, appliesTo: "new_connections" } });
691
+ }
692
+ });
693
+ await this.cleanupTemporaryFiles(input.operationId);
694
+ return;
618
695
  }
619
696
  if (action.action === "settings_confirm" && stored.acceptedDigest === settingsIdentity(action))
620
697
  return;
@@ -638,13 +715,12 @@ export class SettingsWorkflow {
638
715
  await this.seal(expected, action);
639
716
  return;
640
717
  }
718
+ let selected;
641
719
  this.records.transaction(() => {
642
720
  const { card, operation } = this.records.requireInput(expected, "operationId" in action ? action.operationId : undefined);
643
721
  if (!this.records.fileIOAvailable())
644
722
  throw new SettingsConflict("Wait for the file transfer to finish before changing your selection.");
645
723
  if (action.action === "settings_cancel") {
646
- if (operation.fileId)
647
- this.invalidateFile(operation.fileId);
648
724
  this.records.finish(operation.operationId, { outcome: "interrupted", recordedAt: this.now().toISOString(), message: "This Settings change was cancelled. Nothing was applied." });
649
725
  return;
650
726
  }
@@ -658,29 +734,26 @@ export class SettingsWorkflow {
658
734
  }
659
735
  const next = this.withoutPreparation(operation);
660
736
  if (action.action === "settings_select") {
661
- if (operation.fileId)
662
- this.invalidateFile(operation.fileId);
663
737
  delete next.fileId;
664
738
  delete next.preparation;
665
739
  next.draft = action.draft;
666
740
  next.phase = "draft";
667
741
  }
668
742
  this.records.write(operation, next, card);
743
+ if (action.action === "settings_select" && action.draft.kind !== "import_local_data")
744
+ selected = this.records.cards.get(card.state.cardId);
669
745
  });
670
746
  await this.cleanupTemporaryFiles(stored.operationId);
671
- }
672
- invalidateFile(id) {
673
- const file = this.records.file(id);
674
- if (file?.role === "upload")
675
- this.records.writeFile({ ...file, invalidated: true, cleanup: "pending" });
747
+ if (selected)
748
+ await this.prepare(selected, stored.operationId);
676
749
  }
677
750
  async prepare(expected, id) {
678
751
  const initial = this.records.requireInput(expected, id);
679
752
  const draft = initial.operation.draft;
680
753
  if (!draft)
681
754
  throw new SettingsConflict("Choose what you want to change in Settings first.");
682
- if (["export_local_data", "download_backup"].includes(draft.kind))
683
- throw new SettingsConflict("Open a new Settings card and use Download backup.");
755
+ if (["set_wallet_networks", "clear_read_address", "download_backup"].includes(draft.kind))
756
+ throw new SettingsConflict("This action is applied by its explicit save or download request.");
684
757
  let preparation;
685
758
  if (draft.kind === "import_local_data") {
686
759
  await this.checkImport(expected, id);
@@ -746,8 +819,6 @@ export class SettingsWorkflow {
746
819
  this.records.write(operation, { ...this.withoutPreparation(operation), phase: "uploading" }, card);
747
820
  return;
748
821
  }
749
- if (operation.fileId)
750
- this.invalidateFile(operation.fileId);
751
822
  const fileId = randomUUID();
752
823
  file = { fileId, operationId: operation.operationId, role: "upload", relativePath: `.settings-staging/${fileId}.upload`, displayName: action.name,
753
824
  totalSize: action.totalSize, receivedSize: 0, sha256: action.sha256, sealed: false, invalidated: false, cleanup: "pending" };
@@ -923,8 +994,8 @@ export class SettingsWorkflow {
923
994
  const initial = this.records.requireInput(expected, action.operationId), draft = initial.operation.draft;
924
995
  if (!draft || initial.operation.preparationId !== action.preparationId)
925
996
  throw new SettingsConflict("Review the current Settings change before confirming it.");
926
- if (["export_local_data", "download_backup"].includes(draft.kind))
927
- throw new SettingsConflict("Open a new Settings card and use Download backup.");
997
+ if (draft.kind === "download_backup" || draft.kind === "set_wallet_networks" || draft.kind === "clear_read_address")
998
+ throw new SettingsConflict("This action is applied by its explicit save or download request.");
928
999
  if (draft.kind === "restart_wallet_service") {
929
1000
  this.restart(expected, action);
930
1001
  return;
@@ -997,15 +1068,12 @@ export class SettingsWorkflow {
997
1068
  effectiveValueAtSave: prepared.effectiveValue, effectiveSourceAtSave: prepared.source,
998
1069
  appliesAfter: SETTINGS_APPLIES_AFTER_RESTART };
999
1070
  }
1000
- else if (draft.kind === "clear_read_address")
1001
- this.options.activity.clearActiveAccountSync(this.now());
1002
1071
  else if (replacement)
1003
1072
  details = this.options.localData.applyValidatedReplacement(imported, decisionTime, expected.state.cardId);
1004
1073
  this.records.finish(operation.operationId, { outcome: "completed", recordedAt: this.now().toISOString(),
1005
1074
  message: endpoint ? "The network address was saved. This result describes the settings at save time."
1006
- : draft.kind === "clear_read_address" ? "The default address for viewing assets was cleared. Your wallet connection and transaction requests are unchanged."
1007
- : draft.kind === "import_local_data" ? "Local records were replaced. Restart the apps using Sui MCP before continuing."
1008
- : "Local data was reset. This does not cancel a transaction on Sui or disconnect your wallet app.", details });
1075
+ : draft.kind === "import_local_data" ? "Local records were replaced. Restart the apps using Sui MCP before continuing."
1076
+ : "Local data was reset. This does not cancel a transaction on Sui or disconnect your wallet app.", details });
1009
1077
  });
1010
1078
  if (refused)
1011
1079
  throw refused;
@@ -1088,12 +1156,18 @@ export class SettingsWorkflow {
1088
1156
  if (retained.downloadReadyAt)
1089
1157
  this.records.recordDownloadReady(id, retained.downloadReadyAt, DOWNLOAD_RETRY_WINDOW_MS);
1090
1158
  else if (this.records.get(id)?.draft?.kind === "download_backup" && retained.result.outcome !== "completed")
1091
- this.records.endDownload(id, "source_unavailable");
1159
+ this.records.endDownload(id, "source_unavailable", retained.result);
1092
1160
  const fileId = retained.file?.fileId ?? this.records.get(id)?.fileId;
1093
1161
  if (fileId && this.finishedFileIO.has(fileId)) {
1094
1162
  this.records.releaseFileIO(fileId);
1095
1163
  settledFileId = fileId;
1096
1164
  }
1165
+ if (retained.downloadReadyAt) {
1166
+ this.records.releaseFinal(id);
1167
+ return true;
1168
+ }
1169
+ if (this.records.get(id)?.draft?.kind === "download_backup")
1170
+ return true;
1097
1171
  return this.records.finish(id, retained.result);
1098
1172
  });
1099
1173
  if (settledFileId)
@@ -30,11 +30,11 @@ export class SqliteCardRecordStore {
30
30
  let record = this.get(expected.state.cardId);
31
31
  if (!record || record.tokenHash !== expected.tokenHash || record.ownerId !== expected.ownerId)
32
32
  throw new Error("Saved card access is unavailable.");
33
- if (record.state.state === "ready" && Date.parse(record.state.expiresAt) <= now.getTime()) {
33
+ if (!["connect", "disconnect", "review", "settings"].includes(record.state.kind) && record.state.state === "ready" && Date.parse(record.state.expiresAt) <= now.getTime()) {
34
34
  const next = { ...record, state: { ...record.state, state: "closed", reason: "expired", revision: record.state.revision + 1 } };
35
- if (!this.replace(record, next))
35
+ if (!this.replace(record, next, now))
36
36
  throw new Error("Card expiry could not be committed.");
37
- record = next;
37
+ record = this.get(record.state.cardId);
38
38
  }
39
39
  return { record, evaluatedAt: now.toISOString() };
40
40
  }).immediate();
@@ -53,25 +53,45 @@ export class SqliteCardRecordStore {
53
53
  }).immediate();
54
54
  }
55
55
  create(record) {
56
+ if (record.state.state === "closed")
57
+ record = this.terminal(record);
56
58
  this.db.prepare(`INSERT INTO live_read_cards
57
59
  (id, owner_id, token_hash, kind, state, reason, error, revision, created_at, expires_at,
58
60
  input_json, accepted_input_json, result_json, receipt_display_json, scope, operation_id)
59
61
  VALUES (@id, @owner, @token, @kind, @state, @reason, @error, @revision, @created, @expires,
60
62
  @input, @accepted, @result, @display, @scope, @operation)`).run(values(record));
61
63
  }
62
- replace(expected, next) {
63
- return this.db.prepare(`UPDATE live_read_cards SET state=@state, reason=@reason, error=@error,
64
- revision=@revision, input_json=@input, accepted_input_json=@accepted, result_json=@result,
65
- receipt_display_json=@display, scope=@scope, operation_id=@operation
66
- WHERE id=@id AND owner_id=@owner AND token_hash=@token AND revision=@expectedRevision
67
- AND state=@expectedState`).run({ ...values(next), expectedRevision: expected.state.revision,
68
- expectedState: expected.state.state }).changes === 1;
64
+ terminal(record) {
65
+ if (["connect", "disconnect", "review", "settings"].includes(record.state.kind) && record.state.data === undefined) {
66
+ throw new Error("The owning domain must supply the card's committed result.");
67
+ }
68
+ return record;
69
+ }
70
+ replace(expected, next, _at) {
71
+ if (expected.state.state === "closed")
72
+ return false;
73
+ return this.db.transaction(() => {
74
+ if (next.state.state === "closed")
75
+ next = this.terminal(next);
76
+ return this.db.prepare(`UPDATE live_read_cards SET owner_id=@owner,state=@state,reason=@reason,error=@error,
77
+ revision=@revision,input_json=@input,accepted_input_json=@accepted,result_json=@result,
78
+ receipt_display_json=@display,scope=@scope,operation_id=@operation
79
+ WHERE id=@id AND owner_id=@expectedOwner AND token_hash=@token AND revision=@expectedRevision
80
+ AND state=@expectedState`).run({ ...values(next), expectedOwner: expected.ownerId,
81
+ expectedRevision: expected.state.revision, expectedState: expected.state.state }).changes === 1;
82
+ }).immediate();
69
83
  }
70
84
  recover(ownerId, now) {
71
- this.db.prepare(`UPDATE live_read_cards SET
72
- reason=CASE WHEN state='ready' AND expires_at<=@now THEN 'expired' ELSE 'server_restarted' END,
73
- state='closed', revision=revision+1, owner_id=@owner
74
- WHERE state!='closed' AND owner_id!=@owner`).run({ owner: ownerId, now: now.toISOString() });
85
+ this.db.transaction(() => {
86
+ const ids = this.db.prepare("SELECT id FROM live_read_cards WHERE state!='closed' AND kind NOT IN ('connect','disconnect','review','settings') AND owner_id!=?").all(ownerId);
87
+ for (const { id } of ids) {
88
+ const record = this.get(id);
89
+ const reason = record.state.state === "ready" && Date.parse(record.state.expiresAt) <= now.getTime() ? "expired" : "server_restarted";
90
+ if (!this.replace(record, { ...record, ownerId, state: { ...record.state, state: "closed", reason, revision: record.state.revision + 1 } }, now)) {
91
+ throw new Error("Card recovery could not be committed.");
92
+ }
93
+ }
94
+ }).immediate();
75
95
  }
76
96
  }
77
97
  function values(record) {
@@ -5,11 +5,11 @@ import { INITIAL_CHAIN_OBSERVATION_STATUSES } from "./transactionRequest.js";
5
5
  export function insertLiveReviewSessionRow(db, session) {
6
6
  const serialized = serializeSessionForLiveReviewSessionRow(session);
7
7
  db.prepare(`INSERT INTO live_review_sessions
8
- (id, token_hash, status, account, owner_id, review_revision, preparation_id, preparation_error, wallet_connection_id, wallet_connection_revision, current_attempt_id,
8
+ (id, token_hash, status, account, owner_id, review_revision, preparation_id, preparation_error, wallet_connection_id, wallet_grant_revision, current_attempt_id,
9
9
  plans_json, review_state_json,
10
10
  created_at, expires_at, last_activity_at, revision, write_contract_version)
11
11
  VALUES
12
- (@id, @tokenHash, @status, @account, @ownerId, @reviewRevision, @preparationId, @preparationError, @walletConnectionId, @walletConnectionRevision, @currentAttemptId,
12
+ (@id, @tokenHash, @status, @account, @ownerId, @reviewRevision, @preparationId, @preparationError, @walletConnectionId, @walletGrantRevision, @currentAttemptId,
13
13
  @plansJson, @reviewStateJson,
14
14
  @createdAt, @expiresAt, @lastActivityAt, 0, @writeContractVersion)`).run(serialized);
15
15
  }
@@ -25,7 +25,7 @@ export function updateLiveReviewSessionRow(db, expectedRevision, session) {
25
25
  preparation_id = @preparationId,
26
26
  preparation_error = @preparationError,
27
27
  wallet_connection_id = @walletConnectionId,
28
- wallet_connection_revision = @walletConnectionRevision,
28
+ wallet_grant_revision = @walletGrantRevision,
29
29
  current_attempt_id = @currentAttemptId,
30
30
  plans_json = @plansJson,
31
31
  review_state_json = @reviewStateJson,
@@ -133,7 +133,7 @@ export function sessionFromLiveReviewSessionRow(row) {
133
133
  ownerId: row.owner_id,
134
134
  reviewRevision: row.review_revision,
135
135
  ...(row.wallet_connection_id === null ? {} : { walletConnectionId: row.wallet_connection_id }),
136
- ...(row.wallet_connection_revision === null ? {} : { walletConnectionRevision: row.wallet_connection_revision }),
136
+ ...(row.wallet_grant_revision === null ? {} : { walletGrantRevision: row.wallet_grant_revision }),
137
137
  ...(row.preparation_error === null ? {} : { preparationError: row.preparation_error }),
138
138
  ...(row.preparation_id === null ? {} : { preparationId: row.preparation_id }),
139
139
  ...(row.current_attempt_id === null ? {} : { currentAttemptId: row.current_attempt_id })
@@ -150,7 +150,7 @@ export function serializeSessionForLiveReviewSessionRow(session) {
150
150
  preparationId: session.preparationId ?? null,
151
151
  preparationError: session.preparationError ?? null,
152
152
  walletConnectionId: session.walletConnectionId ?? null,
153
- walletConnectionRevision: session.walletConnectionRevision ?? null,
153
+ walletGrantRevision: session.walletGrantRevision ?? null,
154
154
  currentAttemptId: session.currentAttemptId ?? null,
155
155
  plansJson: JSON.stringify(session.plans),
156
156
  reviewStateJson: session.reviewState ? JSON.stringify(session.reviewState) : null,