@vultisig/cli 2.7.0 → 2.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 (3) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/index.js +194 -63
  3. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @vultisig/cli
2
2
 
3
+ ## 2.8.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#867](https://github.com/vultisig/vultisig-sdk/pull/867) [`ddd08af`](https://github.com/vultisig/vultisig-sdk/commit/ddd08af883a1b2ee2f72dac4d406782de9090672) Thanks [@neavra](https://github.com/neavra)! - Agent: poll for final on-chain confirmation after broadcasting a signed tx
8
+ (audit F1). A `pending` `tx_status` only means "broadcast accepted" — the tx can
9
+ still revert, expire, or be dropped. After broadcast the session now polls
10
+ `vault.getTxStatus` until the tx reaches a final state and emits `confirmed` /
11
+ `failed`, or `timeout` when the bounded poll budget (~120s) is exhausted. The
12
+ `ask` result records the latest per-tx `status` (deduped by hash), and the pipe
13
+ `tx_status` event gains a `timeout` status. Best-effort and non-fatal: when the
14
+ chain can't be resolved or the vault can't poll status, the existing `pending`
15
+ status stands. The blocking confirmation wait is scoped to the top of the
16
+ message loop (depth 0 — the single-tx ask/pipe case); inside a multi-turn tool
17
+ loop a leg keeps its honest `pending` instead of stacking the poll budget per
18
+ tx. The shared `pending | confirmed | failed | timeout` union is now threaded
19
+ through the ask result, pipe event, and UI callback without unchecked casts.
20
+
21
+ ## 2.8.0
22
+
23
+ ### Patch Changes
24
+
25
+ - [#868](https://github.com/vultisig/vultisig-sdk/pull/868) [`6f84f19`](https://github.com/vultisig/vultisig-sdk/commit/6f84f19444752976a6677d3ee4054701b0904eae) Thanks [@neavra](https://github.com/neavra)! - `agent ask --json` now emits one stable v1 envelope on stdout for both success and error. Previously the success envelope was written through a redirected `console.log` and landed on stderr (stdout empty), and the error path wrote a different flat `{error,code}` shape. The envelope now carries `conversation_id` (success + error) and per-tool-call `id`s, and a mid-stream backend/SSE `error` frame makes the command exit non-zero instead of reporting false success.
26
+
27
+ - Updated dependencies [[`c9a235b`](https://github.com/vultisig/vultisig-sdk/commit/c9a235b959c7c82cd189482fab86ce3d27ddb4ff), [`9585b6f`](https://github.com/vultisig/vultisig-sdk/commit/9585b6f246de3ce537eae201f0d660fc89ff1012), [`f82caf5`](https://github.com/vultisig/vultisig-sdk/commit/f82caf58532f58af9d62b0143c7466cabcd88b06), [`361ba58`](https://github.com/vultisig/vultisig-sdk/commit/361ba58f79f241c4c00e33785a66ec6987628d26), [`7625e0b`](https://github.com/vultisig/vultisig-sdk/commit/7625e0bf325c8957bc3e28270454fd54c5589e2f), [`2024a92`](https://github.com/vultisig/vultisig-sdk/commit/2024a92b44760e1ff2043b0e45b083edc131b16c)]:
28
+ - @vultisig/sdk@2.8.0
29
+ - @vultisig/rujira@41.0.0
30
+
3
31
  ## 2.7.0
4
32
 
5
33
  ### Patch Changes
package/dist/index.js CHANGED
@@ -5053,11 +5053,11 @@ function bigIntReplacer(_key, value) {
5053
5053
  }
5054
5054
  function outputJson(data) {
5055
5055
  const transformed = applyOutputTransforms(data);
5056
- console.log(JSON.stringify({ success: true, v: 1, data: transformed }, bigIntReplacer, 2));
5056
+ process.stdout.write(JSON.stringify({ success: true, v: 1, data: transformed }, bigIntReplacer, 2) + "\n");
5057
5057
  }
5058
5058
  function outputErrorJson(errJson) {
5059
5059
  const transformed = applyOutputTransforms(errJson);
5060
- console.log(JSON.stringify(transformed, bigIntReplacer, 2));
5060
+ process.stdout.write(JSON.stringify(transformed, bigIntReplacer, 2) + "\n");
5061
5061
  }
5062
5062
  function info(message) {
5063
5063
  if (!silentMode) {
@@ -8184,6 +8184,7 @@ var AskInterface = class {
8184
8184
  toolCalls = [];
8185
8185
  transactions = [];
8186
8186
  cards = [];
8187
+ error;
8187
8188
  constructor(session, verbose = false, autoApprove = false) {
8188
8189
  this.session = session;
8189
8190
  this.verbose = verbose;
@@ -8204,8 +8205,8 @@ var AskInterface = class {
8204
8205
  `);
8205
8206
  }
8206
8207
  },
8207
- onToolResult: (_id, action, success2, data, error2, code) => {
8208
- this.toolCalls.push({ action, success: success2, data, error: error2, code });
8208
+ onToolResult: (id, action, success2, data, error2, code) => {
8209
+ this.toolCalls.push({ id, action, success: success2, data, error: error2, code });
8209
8210
  if (this.verbose) {
8210
8211
  const status = success2 ? "ok" : `error: ${error2}${code ? ` [${code}]` : ""}`;
8211
8212
  process.stderr.write(`[tool] ${action}: ${status}
@@ -8222,14 +8223,23 @@ var AskInterface = class {
8222
8223
  },
8223
8224
  onSuggestions: (_suggestions) => {
8224
8225
  },
8225
- onTxStatus: (txHash, chain, _status, explorerUrl) => {
8226
- this.transactions.push({ hash: txHash, chain, explorerUrl });
8226
+ onTxStatus: (txHash, chain, status, explorerUrl) => {
8227
+ const existing = this.transactions.find((t) => t.hash === txHash);
8228
+ if (existing) {
8229
+ existing.status = status;
8230
+ if (explorerUrl) existing.explorerUrl = explorerUrl;
8231
+ } else {
8232
+ this.transactions.push({ hash: txHash, chain, explorerUrl, status });
8233
+ }
8227
8234
  if (this.verbose) {
8228
- process.stderr.write(`[tx] ${chain}: ${txHash}
8235
+ process.stderr.write(`[tx] ${chain}: ${txHash} (${status})
8229
8236
  `);
8230
8237
  }
8231
8238
  },
8232
8239
  onError: (message, code) => {
8240
+ if (!this.error) {
8241
+ this.error = { message, code };
8242
+ }
8233
8243
  process.stderr.write(`[error] ${message} [${code}]
8234
8244
  `);
8235
8245
  },
@@ -8259,14 +8269,27 @@ var AskInterface = class {
8259
8269
  this.toolCalls = [];
8260
8270
  this.transactions = [];
8261
8271
  this.cards = [];
8272
+ this.error = void 0;
8262
8273
  const callbacks = this.getCallbacks();
8263
8274
  await this.session.sendMessage(message, callbacks);
8275
+ return this.partialResult();
8276
+ }
8277
+ /**
8278
+ * Snapshot of everything collected so far this turn. Identical to a normal
8279
+ * `ask()` return, but callable from a catch block when `ask()` THREW mid-turn
8280
+ * — e.g. the follow-up request that reports recent_actions back to the backend
8281
+ * fails (timeout/5xx/auth) AFTER a tx has already broadcast and `onTxStatus`
8282
+ * fired. Lets the caller still surface the already-broadcast tx hash in the
8283
+ * error envelope instead of stranding funds the turn just moved.
8284
+ */
8285
+ partialResult() {
8264
8286
  return {
8265
8287
  sessionId: this.session.getConversationId() || "",
8266
8288
  response: this.responseParts[this.responseParts.length - 1] || "",
8267
8289
  toolCalls: this.toolCalls,
8268
8290
  transactions: this.transactions,
8269
- cards: this.cards
8291
+ cards: this.cards,
8292
+ error: this.error
8270
8293
  };
8271
8294
  }
8272
8295
  };
@@ -12063,6 +12086,8 @@ var CLIENT_SIDE_TOOL_DISPATCH = {
12063
12086
  var MAX_MESSAGE_LOOP_DEPTH = 16;
12064
12087
  var RECOVERY_POLL_INTERVAL_MS = 2e3;
12065
12088
  var RECOVERY_MAX_POLLS = 90;
12089
+ var TX_CONFIRM_POLL_INTERVAL_MS = 3e3;
12090
+ var TX_CONFIRM_MAX_POLLS = 40;
12066
12091
  var AgentSession = class {
12067
12092
  client;
12068
12093
  vault;
@@ -12080,6 +12105,10 @@ var AgentSession = class {
12080
12105
  // poll loop without real 2s waits.
12081
12106
  recoveryPollIntervalMs = RECOVERY_POLL_INTERVAL_MS;
12082
12107
  recoveryMaxPolls = RECOVERY_MAX_POLLS;
12108
+ // Post-broadcast confirmation poll cadence — instance fields so tests can
12109
+ // drive the loop without real waits.
12110
+ txConfirmPollIntervalMs = TX_CONFIRM_POLL_INTERVAL_MS;
12111
+ txConfirmMaxPolls = TX_CONFIRM_MAX_POLLS;
12083
12112
  constructor(vault, config) {
12084
12113
  this.vault = vault;
12085
12114
  this.config = config;
@@ -12358,7 +12387,9 @@ var AgentSession = class {
12358
12387
  const txHash = recent.data.tx_hash;
12359
12388
  const chain = recent.data.chain;
12360
12389
  const explorerUrl = recent.data.explorer_url;
12361
- if (txHash) ui.onTxStatus(txHash, chain || "", "pending", explorerUrl);
12390
+ if (txHash) {
12391
+ await this.emitAndConfirmTx(txHash, chain, explorerUrl, depth, ui);
12392
+ }
12362
12393
  }
12363
12394
  await this.processMessageLoop(null, ui, depth + 1);
12364
12395
  return;
@@ -12417,6 +12448,75 @@ var AgentSession = class {
12417
12448
  recoverySleep() {
12418
12449
  return new Promise((resolve) => setTimeout(resolve, this.recoveryPollIntervalMs));
12419
12450
  }
12451
+ /**
12452
+ * Post-broadcast confirmation polling (audit F1). A bare `pending` status only
12453
+ * means "broadcast accepted"; the tx can still revert, expire, or be dropped,
12454
+ * so a headless caller that stops at `pending` may mark a later-reverted
12455
+ * operation complete. Poll vault.getTxStatus until the tx reaches a final
12456
+ * state and emit the matching lifecycle status (`confirmed`/`failed`), or
12457
+ * `timeout` when the bounded poll budget is exhausted (the tx may still
12458
+ * confirm later — callers can re-check with `vultisig tx-status`).
12459
+ *
12460
+ * Transient RPC/network errors are treated as "not final yet" and retried
12461
+ * until the budget is spent. Best-effort and non-fatal: if the chain can't be
12462
+ * resolved or the vault doesn't expose getTxStatus, the caller's already-
12463
+ * emitted `pending` status stands and this returns quietly.
12464
+ *
12465
+ * Scoped to headless callers (ask/pipe) that need machine-readable finality.
12466
+ * The interactive TUI already shows `pending` + an explorer link immediately
12467
+ * and has the dedicated `vultisig tx-status` command, so blocking its prompt
12468
+ * for the full poll budget would be a UX regression the audit didn't scope.
12469
+ * The poll also bails on cancel (Ctrl-C aborts the controller) so a long wait
12470
+ * is interruptible.
12471
+ *
12472
+ * The caller only invokes this at message-loop depth 0 (see the call site):
12473
+ * inside a multi-turn tool loop the broadcast result already drives the next
12474
+ * turn, so blocking here would stack the poll budget per leg without feeding
12475
+ * the server any extra signal. Those deeper legs keep their honest `pending`.
12476
+ */
12477
+ async emitAndConfirmTx(txHash, chain, explorerUrl, depth, ui) {
12478
+ ui.onTxStatus(txHash, chain || "", "pending", explorerUrl);
12479
+ if (depth === 0) {
12480
+ await this.confirmBroadcastedTx(txHash, chain, explorerUrl, ui);
12481
+ }
12482
+ }
12483
+ async confirmBroadcastedTx(txHash, chainName, explorerUrl, ui) {
12484
+ if (!this.config.askMode && !this.config.viaAgent) return;
12485
+ const chain = resolveChain(chainName ?? "");
12486
+ if (!chain || typeof this.vault?.getTxStatus !== "function") return;
12487
+ for (let attempt = 0; attempt < this.txConfirmMaxPolls; attempt++) {
12488
+ if (this.abortController?.signal?.aborted) return;
12489
+ try {
12490
+ const result = await this.vault.getTxStatus({ chain, txHash });
12491
+ if (result.status === "success") {
12492
+ ui.onTxStatus(txHash, chainName ?? "", "confirmed", explorerUrl);
12493
+ return;
12494
+ }
12495
+ if (result.status === "error") {
12496
+ ui.onTxStatus(txHash, chainName ?? "", "failed", explorerUrl);
12497
+ return;
12498
+ }
12499
+ } catch (err) {
12500
+ if (this.config.verbose) {
12501
+ process.stderr.write(`[session] tx confirm poll ${attempt + 1} failed: ${err?.message ?? err}
12502
+ `);
12503
+ }
12504
+ }
12505
+ if (attempt < this.txConfirmMaxPolls - 1) await this.txConfirmSleep();
12506
+ }
12507
+ if (this.abortController?.signal?.aborted) return;
12508
+ if (this.config.verbose) {
12509
+ process.stderr.write(
12510
+ `[session] tx ${txHash} not confirmed within ${this.txConfirmMaxPolls} polls; emitting timeout
12511
+ `
12512
+ );
12513
+ }
12514
+ ui.onTxStatus(txHash, chainName ?? "", "timeout", explorerUrl);
12515
+ }
12516
+ /** Sleep between confirmation polls. Separate method so tests can stub it out. */
12517
+ txConfirmSleep() {
12518
+ return new Promise((resolve) => setTimeout(resolve, this.txConfirmPollIntervalMs));
12519
+ }
12420
12520
  /**
12421
12521
  * Fold a recovered assistant message back into the live stream result: the
12422
12522
  * authoritative message wins over any partial deltas, and any persisted
@@ -13068,12 +13168,85 @@ async function executeAgent(ctx2, options) {
13068
13168
  }
13069
13169
  }
13070
13170
  }
13171
+ function outputAskError(wantsJson, message, code, conversationId, result) {
13172
+ if (wantsJson) {
13173
+ const data = {};
13174
+ if (result?.transactions.length) data.transactions = result.transactions;
13175
+ if (result?.toolCalls.length) data.tool_calls = result.toolCalls;
13176
+ if (result?.response) data.response = result.response;
13177
+ outputErrorJson({
13178
+ success: false,
13179
+ v: 1,
13180
+ error: { message, code, conversation_id: conversationId },
13181
+ ...Object.keys(data).length > 0 ? { data } : {}
13182
+ });
13183
+ } else {
13184
+ process.stderr.write(`Error: ${message} [${code}]
13185
+ `);
13186
+ }
13187
+ }
13188
+ function outputAskHuman(result, confirmationRequired, proposed) {
13189
+ process.stdout.write(`session:${result.sessionId}
13190
+ `);
13191
+ if (confirmationRequired) {
13192
+ process.stdout.write(`confirmation-required:pass --yes to authorize signing
13193
+ `);
13194
+ if (proposed) {
13195
+ process.stdout.write(`proposed:${proposed}
13196
+ `);
13197
+ }
13198
+ }
13199
+ for (const card of result.cards) {
13200
+ process.stdout.write(`
13201
+ ${renderBalanceSummaryCard(card)}
13202
+ `);
13203
+ }
13204
+ if (result.response) {
13205
+ process.stdout.write(`
13206
+ ${result.response}
13207
+ `);
13208
+ }
13209
+ for (const tx of result.transactions) {
13210
+ process.stdout.write(`
13211
+ tx:${tx.chain}:${tx.hash}
13212
+ `);
13213
+ if (tx.explorerUrl) {
13214
+ process.stdout.write(`explorer:${tx.explorerUrl}
13215
+ `);
13216
+ }
13217
+ }
13218
+ }
13219
+ function outputAskSuccess(wantsJson, result, conversationId) {
13220
+ const confirmationRequired = result.toolCalls.some((tc) => tc.code === "CONFIRMATION_REQUIRED" /* CONFIRMATION_REQUIRED */);
13221
+ const proposedCall = result.toolCalls.find(
13222
+ (tc) => tc.code === "CONFIRMATION_REQUIRED" /* CONFIRMATION_REQUIRED */ && typeof tc.data?.proposed === "string"
13223
+ );
13224
+ const proposed = proposedCall?.data?.proposed;
13225
+ if (wantsJson) {
13226
+ outputJson({
13227
+ conversation_id: conversationId,
13228
+ session_id: result.sessionId,
13229
+ response: result.response,
13230
+ tool_calls: result.toolCalls,
13231
+ transactions: result.transactions,
13232
+ ...result.cards.length > 0 ? { cards: result.cards } : {},
13233
+ ...confirmationRequired ? { confirmation_required: true } : {},
13234
+ ...proposed ? { proposed } : {}
13235
+ });
13236
+ return;
13237
+ }
13238
+ outputAskHuman(result, confirmationRequired, proposed);
13239
+ }
13071
13240
  async function executeAgentAsk(ctx2, message, options) {
13072
13241
  setSilentMode(true);
13073
13242
  const originalConsoleLog = console.log;
13074
13243
  console.log = (...args) => {
13075
13244
  process.stderr.write(args.map(String).join(" ") + "\n");
13076
13245
  };
13246
+ const wantsJson = !!options.json || isJsonOutput();
13247
+ let conversationId = "";
13248
+ let exitCode = 0;
13249
+ let ask;
13077
13250
  try {
13078
13251
  const vault = await ctx2.ensureActiveVault();
13079
13252
  const config = {
@@ -13087,70 +13260,28 @@ async function executeAgentAsk(ctx2, message, options) {
13087
13260
  profile: options.profile ?? process.env.VULTISIG_AGENT_PROFILE ?? ""
13088
13261
  };
13089
13262
  const session = new AgentSession(vault, config);
13090
- const ask = new AskInterface(session, !!config.verbose, !!options.autoApprove);
13263
+ ask = new AskInterface(session, !!config.verbose, !!options.autoApprove);
13091
13264
  const callbacks = ask.getCallbacks();
13092
13265
  await session.initialize(callbacks);
13093
13266
  const result = await ask.ask(message);
13094
- const confirmationRequired = result.toolCalls.some((tc) => tc.code === "CONFIRMATION_REQUIRED" /* CONFIRMATION_REQUIRED */);
13095
- const proposedCall = result.toolCalls.find(
13096
- (tc) => tc.code === "CONFIRMATION_REQUIRED" /* CONFIRMATION_REQUIRED */ && typeof tc.data?.proposed === "string"
13097
- );
13098
- const proposed = proposedCall?.data?.proposed;
13099
- if (options.json || isJsonOutput()) {
13100
- outputJson({
13101
- session_id: result.sessionId,
13102
- response: result.response,
13103
- tool_calls: result.toolCalls,
13104
- transactions: result.transactions,
13105
- ...result.cards.length > 0 ? { cards: result.cards } : {},
13106
- ...confirmationRequired ? { confirmation_required: true } : {},
13107
- ...proposed ? { proposed } : {}
13108
- });
13267
+ conversationId = result.sessionId;
13268
+ if (result.error) {
13269
+ exitCode = 1;
13270
+ outputAskError(wantsJson, result.error.message, result.error.code, conversationId, result);
13109
13271
  } else {
13110
- process.stdout.write(`session:${result.sessionId}
13111
- `);
13112
- if (confirmationRequired) {
13113
- process.stdout.write(`confirmation-required:pass --yes to authorize signing
13114
- `);
13115
- if (proposed) {
13116
- process.stdout.write(`proposed:${proposed}
13117
- `);
13118
- }
13119
- }
13120
- for (const card of result.cards) {
13121
- process.stdout.write(`
13122
- ${renderBalanceSummaryCard(card)}
13123
- `);
13124
- }
13125
- if (result.response) {
13126
- process.stdout.write(`
13127
- ${result.response}
13128
- `);
13129
- }
13130
- for (const tx of result.transactions) {
13131
- process.stdout.write(`
13132
- tx:${tx.chain}:${tx.hash}
13133
- `);
13134
- if (tx.explorerUrl) {
13135
- process.stdout.write(`explorer:${tx.explorerUrl}
13136
- `);
13137
- }
13138
- }
13272
+ outputAskSuccess(wantsJson, result, conversationId);
13139
13273
  }
13140
13274
  } catch (err) {
13141
13275
  const { code, message: message2 } = normalizeAgentError(err);
13142
- if (options.json) {
13143
- process.stdout.write(JSON.stringify({ error: message2, code }) + "\n");
13144
- } else {
13145
- process.stderr.write(`Error: ${message2} [${code}]
13146
- `);
13147
- }
13148
- process.exit(1);
13276
+ exitCode = 1;
13277
+ const partial = ask?.partialResult();
13278
+ if (partial && !conversationId) conversationId = partial.sessionId;
13279
+ outputAskError(wantsJson, message2, code, conversationId, partial);
13149
13280
  } finally {
13150
13281
  console.log = originalConsoleLog;
13151
13282
  setSilentMode(false);
13152
13283
  }
13153
- process.exit(0);
13284
+ process.exit(exitCode);
13154
13285
  }
13155
13286
  async function executeAgentSessionsList(ctx2, options) {
13156
13287
  const vault = await ctx2.ensureActiveVault();
@@ -13235,7 +13366,7 @@ var cachedVersion = null;
13235
13366
  function getVersion() {
13236
13367
  if (cachedVersion) return cachedVersion;
13237
13368
  if (true) {
13238
- cachedVersion = "2.7.0";
13369
+ cachedVersion = "2.8.1";
13239
13370
  return cachedVersion;
13240
13371
  }
13241
13372
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vultisig/cli",
3
- "version": "2.7.0",
3
+ "version": "2.8.1",
4
4
  "description": "The self-custody MPC wallet CLI for AI coding agents (Claude Code, Cursor, OpenCode). Natural-language agent mode, 36+ chains, DKLS23 threshold signatures. Seedless.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -75,8 +75,8 @@
75
75
  "@noble/hashes": "^2.2.0",
76
76
  "@vultisig/client-shared": "^0.2.16",
77
77
  "@vultisig/core-chain": "^2.17.8",
78
- "@vultisig/rujira": "^40.0.0",
79
- "@vultisig/sdk": "^2.7.0",
78
+ "@vultisig/rujira": "^41.0.0",
79
+ "@vultisig/sdk": "^2.8.0",
80
80
  "chalk": "^5.6.2",
81
81
  "cli-table3": "^0.6.5",
82
82
  "commander": "^15.0.0",