@vultisig/cli 2.17.0 → 2.18.6

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 +53 -0
  2. package/dist/index.js +248 -284
  3. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # @vultisig/cli
2
2
 
3
+ ## 2.18.6
4
+
5
+ ### Patch Changes
6
+
7
+ - [#1003](https://github.com/vultisig/vultisig-sdk/pull/1003) [`b27786d`](https://github.com/vultisig/vultisig-sdk/commit/b27786d8ac596ea3d2d4a13da958b266f589b73c) Thanks [@neavra](https://github.com/neavra)! - fix(agent): sign purely from the `tool-output-available` channel and remove the `tx_ready` signing path ([#927](https://github.com/vultisig/vultisig-sdk/issues/927) Phase 2). The client-enriched tool-output candidate (flat builders and `execute_*` prep) is now the sole signing source, matching what the production backend emits — it writes the signable payload on tool-output and emits `data-tx_ready` only as a hollow `{typed_confirm}` marker the CLI never consumed. Removes the Phase-1 dual-read + parity cross-check machinery, the tx_ready capture/selection, and the recovered-tx_ready replay. Fail-closed postures are preserved (a structurally-unsignable candidate is never buffered), and a disconnect that ran a signable tool now warns to re-run rather than signing. Patch: no CLI API change and the same real transactions still sign — this aligns the internal signing source with production.
8
+
9
+ - [#1024](https://github.com/vultisig/vultisig-sdk/pull/1024) [`3bc7904`](https://github.com/vultisig/vultisig-sdk/commit/3bc790403483dd7e90dac2efc33d7bc64c18b921) Thanks [@neavra](https://github.com/neavra)! - Stop `tx-status` from reporting malformed or never-seen transaction hashes as `pending` forever.
10
+
11
+ - The EVM status resolver now distinguishes a genuinely-pending tx (the node knows the hash, receipt still lagging) from one the node has never seen, returning a new terminal `not_found` status for the latter instead of an indefinite `pending`.
12
+ - New `isValidTxHash(chain, hash)` helper validates a hash's shape per chain-kind; the CLI `tx-status` command validates `--tx-hash` before any RPC and fails fast with `INVALID_INPUT` (exit 4) on a malformed hash.
13
+ - CLI `tx-status` polling is now bounded by a total wait budget (`--timeout <seconds>`, default 120) and exits non-zero on give-up — `TX_NOT_FOUND` (exit 5) when the node has no record of the hash, `TX_STATUS_TIMEOUT` (exit 3, retryable) when it is still pending.
14
+ - The poll loop now caps each sleep at the remaining wait budget instead of always sleeping the full poll interval, so a small `--timeout` gives up promptly instead of overshooting by up to one poll interval.
15
+
16
+ - Updated dependencies [[`ce38186`](https://github.com/vultisig/vultisig-sdk/commit/ce381864b977b19668702eae6e1ecad63ecbdf2b), [`3bc7904`](https://github.com/vultisig/vultisig-sdk/commit/3bc790403483dd7e90dac2efc33d7bc64c18b921)]:
17
+ - @vultisig/sdk@2.18.6
18
+ - @vultisig/core-chain@2.23.3
19
+
20
+ ## 2.18.0
21
+
22
+ ### Minor Changes
23
+
24
+ - [#1004](https://github.com/vultisig/vultisig-sdk/pull/1004) [`42fe389`](https://github.com/vultisig/vultisig-sdk/commit/42fe3893196e7d8945f852695387828eb49bfb41) Thanks [@neavra](https://github.com/neavra)! - feat(cli): surface a typed turn-outcome for `agent ask` (success / blocked / refusal / error)
25
+
26
+ The CLI now advertises the `turn_outcome` surface and parses the backend's
27
+ `data-turn_outcome` SSE part, so a headless `agent ask` caller can tell four turn
28
+ endings apart WITHOUT parsing prose:
29
+
30
+ - **success** — the turn completed normally
31
+ - **blocked** — a fund-safety guardrail deliberately blocked the requested action
32
+ - **refusal** — the model refused or asked a clarifying question (no action taken)
33
+ - **error** — an infrastructure error ended the turn
34
+
35
+ Two additive surfaces:
36
+
37
+ - `agent ask --output json` gains a top-level `outcome` field
38
+ (`{ kind, code?, detail? }`) on both the success and error envelopes. Human
39
+ output prints a greppable `outcome:<kind>[:<code>]` line for non-success turns.
40
+ - New dedicated exit codes (additive; the 0–9 taxonomy is unchanged):
41
+ `10` = a fund-safety block, `11` = a refusal/clarifying question. A frame-less
42
+ infrastructure error now exits `1` instead of a false `0`. Success stays `0`.
43
+
44
+ Behavior change to note: a turn the backend BLOCKS or the model REFUSES previously
45
+ exited `0` (indistinguishable from success); it now exits `10`/`11`. Scripts that
46
+ treated every `agent ask` as success-on-0 should branch on `outcome.kind` (or the
47
+ exit code). Against an older backend that does not emit `data-turn_outcome`, the
48
+ `outcome` field is absent and exit codes are unchanged.
49
+
50
+ ### Patch Changes
51
+
52
+ - Updated dependencies [[`31814c4`](https://github.com/vultisig/vultisig-sdk/commit/31814c4b452ef5b2628dc12bc46e427fcf0b7237), [`b37dc3c`](https://github.com/vultisig/vultisig-sdk/commit/b37dc3c31d6f93af982cd1e082e0b4d1382dc19d), [`01c5630`](https://github.com/vultisig/vultisig-sdk/commit/01c56303d0b872a76de07da9685331106f586c80)]:
53
+ - @vultisig/sdk@2.18.0
54
+ - @vultisig/rujira@51.0.0
55
+
3
56
  ## 2.17.0
4
57
 
5
58
  ### Patch Changes
package/dist/index.js CHANGED
@@ -5316,7 +5316,9 @@ var EXIT_CODE_DESCRIPTIONS = {
5316
5316
  [6 /* EXTERNAL_SERVICE */]: "External service error (retryable)",
5317
5317
  [7 /* UNKNOWN */]: "Unknown/unexpected error",
5318
5318
  [8 /* ACK_FAILED */]: "Broadcast succeeded but post-broadcast report failed \u2014 hash is valid, do NOT retry",
5319
- [9 /* DUPLICATE_BROADCAST */]: "Duplicate broadcast refused (nothing sent) \u2014 retry with --force to override"
5319
+ [9 /* DUPLICATE_BROADCAST */]: "Duplicate broadcast refused (nothing sent) \u2014 retry with --force to override",
5320
+ [10 /* AGENT_TURN_BLOCKED */]: "agent ask: a fund-safety guardrail blocked the requested action",
5321
+ [11 /* AGENT_TURN_REFUSAL */]: "agent ask: the model refused or asked a clarifying question (no action taken)"
5320
5322
  };
5321
5323
  var VsigError = class extends Error {
5322
5324
  hint;
@@ -5398,6 +5400,21 @@ var TokenNotFoundError = class extends VsigError {
5398
5400
  super(message, hint, suggestions, context);
5399
5401
  }
5400
5402
  };
5403
+ var TxNotFoundError = class extends VsigError {
5404
+ exitCode = 5 /* RESOURCE_NOT_FOUND */;
5405
+ code = "TX_NOT_FOUND";
5406
+ constructor(message, hint, suggestions, context) {
5407
+ super(message, hint, suggestions, context);
5408
+ }
5409
+ };
5410
+ var TxStatusTimeoutError = class extends VsigError {
5411
+ exitCode = 3 /* NETWORK */;
5412
+ code = "TX_STATUS_TIMEOUT";
5413
+ retryable = true;
5414
+ constructor(message, hint, suggestions, context) {
5415
+ super(message, hint, suggestions, context);
5416
+ }
5417
+ };
5401
5418
  var ExternalServiceError = class extends VsigError {
5402
5419
  exitCode = 6 /* EXTERNAL_SERVICE */;
5403
5420
  code = "EXTERNAL_SERVICE";
@@ -6536,33 +6553,78 @@ async function executeBroadcast(ctx2, params) {
6536
6553
  }
6537
6554
 
6538
6555
  // src/commands/tx-status.ts
6539
- import { Chain as Chain5, Vultisig as Vultisig5 } from "@vultisig/sdk";
6556
+ import { Chain as Chain5, isValidTxHash, Vultisig as Vultisig5 } from "@vultisig/sdk";
6540
6557
  var POLL_INTERVAL_MS = 5e3;
6541
- async function executeTxStatus(ctx2, params) {
6558
+ var DEFAULT_TIMEOUT_SEC = 120;
6559
+ var isTerminal = (status) => status === "success" || status === "error";
6560
+ function resolveTimeoutMs(timeoutSec) {
6561
+ if (typeof timeoutSec !== "number" || !Number.isFinite(timeoutSec)) {
6562
+ return DEFAULT_TIMEOUT_SEC * 1e3;
6563
+ }
6564
+ return Math.max(0, timeoutSec) * 1e3;
6565
+ }
6566
+ async function executeTxStatus(ctx2, params, opts = {}) {
6542
6567
  const vault = await ctx2.ensureActiveVault();
6543
6568
  if (!Object.values(Chain5).includes(params.chain)) {
6544
- throw new Error(`Invalid chain: ${params.chain}`);
6569
+ throw new InvalidInputError(`Invalid chain: ${params.chain}`);
6570
+ }
6571
+ if (!isValidTxHash(params.chain, params.txHash)) {
6572
+ throw new InvalidInputError(
6573
+ `Invalid transaction hash for ${params.chain}: "${params.txHash}"`,
6574
+ "Check the hash \u2014 it must match the expected format for the chain.",
6575
+ void 0,
6576
+ { chain: params.chain, txHash: params.txHash }
6577
+ );
6545
6578
  }
6579
+ const pollIntervalMs = opts.pollIntervalMs ?? POLL_INTERVAL_MS;
6546
6580
  const spinner = createSpinner("Checking transaction status...");
6547
6581
  try {
6548
6582
  let result = await vault.getTxStatus({ chain: params.chain, txHash: params.txHash });
6549
- if (!params.noWait) {
6550
- let polls = 1;
6551
- while (result.status === "pending") {
6552
- spinner.text = `Transaction pending... (${polls * 5}s)`;
6553
- await sleep(POLL_INTERVAL_MS);
6583
+ if (!params.noWait && !isTerminal(result.status)) {
6584
+ const deadline = Date.now() + resolveTimeoutMs(params.timeoutSec);
6585
+ let waited = 0;
6586
+ while (!isTerminal(result.status)) {
6587
+ const remainingMs = deadline - Date.now();
6588
+ if (remainingMs <= 0) {
6589
+ spinner.fail(`Gave up waiting after ${Math.round(waited / 1e3)}s (status: ${result.status})`);
6590
+ throw giveUpError(params, result, waited);
6591
+ }
6592
+ const sleepMs = Math.min(pollIntervalMs, remainingMs);
6593
+ waited += sleepMs;
6594
+ spinner.text = `Transaction ${result.status}... (${Math.round(waited / 1e3)}s)`;
6595
+ await sleep(sleepMs);
6554
6596
  result = await vault.getTxStatus({ chain: params.chain, txHash: params.txHash });
6555
- polls++;
6556
6597
  }
6557
6598
  }
6558
6599
  spinner.succeed(`Transaction status: ${result.status}`);
6559
6600
  displayResult(params.chain, params.txHash, result);
6560
6601
  return result;
6561
6602
  } catch (error2) {
6603
+ if (error2 instanceof TxNotFoundError || error2 instanceof TxStatusTimeoutError) {
6604
+ throw error2;
6605
+ }
6562
6606
  spinner.fail("Failed to check transaction status");
6563
6607
  throw error2;
6564
6608
  }
6565
6609
  }
6610
+ function giveUpError(params, result, waitedMs) {
6611
+ const seconds = Math.round(waitedMs / 1e3);
6612
+ const context = { chain: params.chain, txHash: params.txHash, status: result.status };
6613
+ if (result.status === "not_found") {
6614
+ return new TxNotFoundError(
6615
+ `Transaction not found on ${params.chain} after ${seconds}s: ${params.txHash}`,
6616
+ "The node has no record of this hash \u2014 it may have been dropped, replaced, or never broadcast.",
6617
+ ["Verify the transaction hash", "Re-broadcast if it was never sent"],
6618
+ context
6619
+ );
6620
+ }
6621
+ return new TxStatusTimeoutError(
6622
+ `Transaction still ${result.status} on ${params.chain} after ${seconds}s: ${params.txHash}`,
6623
+ "The transaction may still confirm later.",
6624
+ ["Re-run to keep checking", "Increase the wait budget with --timeout <seconds>"],
6625
+ context
6626
+ );
6627
+ }
6566
6628
  function displayResult(chain, txHash, result) {
6567
6629
  if (isJsonOutput()) {
6568
6630
  outputJson({
@@ -8249,6 +8311,7 @@ var AskInterface = class {
8249
8311
  toolCalls = [];
8250
8312
  transactions = [];
8251
8313
  cards = [];
8314
+ outcome;
8252
8315
  error;
8253
8316
  // Initialize-time error, kept SEPARATE from the turn error so it stays the
8254
8317
  // LOWEST-priority signal. initialize() drives getCallbacks() BEFORE the first
@@ -8311,6 +8374,9 @@ var AskInterface = class {
8311
8374
  onBalanceSummary: (card) => {
8312
8375
  this.cards.push(card);
8313
8376
  },
8377
+ onTurnOutcome: (outcome) => {
8378
+ this.outcome = outcome;
8379
+ },
8314
8380
  onSuggestions: (_suggestions) => {
8315
8381
  },
8316
8382
  onTxStatus: (txHash, chain, status, explorerUrl) => {
@@ -8327,12 +8393,12 @@ var AskInterface = class {
8327
8393
  }
8328
8394
  },
8329
8395
  onError: (message, code) => {
8330
- const isTerminal = isTerminalAgentErrorCode(code);
8396
+ const isTerminal2 = isTerminalAgentErrorCode(code);
8331
8397
  if (!this.hasAsked) {
8332
8398
  this.initError = { message, code };
8333
- } else if (!this.error || isTerminal && !this.errorIsTerminal) {
8399
+ } else if (!this.error || isTerminal2 && !this.errorIsTerminal) {
8334
8400
  this.error = { message, code };
8335
- this.errorIsTerminal = isTerminal;
8401
+ this.errorIsTerminal = isTerminal2;
8336
8402
  }
8337
8403
  process.stderr.write(`[error] ${message} [${code}]
8338
8404
  `);
@@ -8363,6 +8429,7 @@ var AskInterface = class {
8363
8429
  this.toolCalls = [];
8364
8430
  this.transactions = [];
8365
8431
  this.cards = [];
8432
+ this.outcome = void 0;
8366
8433
  this.error = void 0;
8367
8434
  if (this.hasAsked) {
8368
8435
  this.initError = void 0;
@@ -8390,7 +8457,8 @@ var AskInterface = class {
8390
8457
  cards: this.cards,
8391
8458
  // A real turn error wins; fall back to the init-time signal (e.g. stale
8392
8459
  // --session SESSION_NOT_FOUND) only when the turn produced no error.
8393
- error: this.error ?? this.initError
8460
+ error: this.error ?? this.initError,
8461
+ ...this.outcome ? { outcome: this.outcome } : {}
8394
8462
  };
8395
8463
  }
8396
8464
  };
@@ -8797,7 +8865,19 @@ function padTo32Bytes(buf) {
8797
8865
 
8798
8866
  // src/agent/cards.ts
8799
8867
  import chalk8 from "chalk";
8800
- var CLI_SUPPORTED_SURFACES = ["balance_summary"];
8868
+ var CLI_SUPPORTED_SURFACES = ["balance_summary", "turn_outcome"];
8869
+ function parseTurnOutcome(raw) {
8870
+ if (!raw || typeof raw !== "object") return null;
8871
+ const kind = raw.kind;
8872
+ if (kind !== "success" && kind !== "blocked" && kind !== "refusal" && kind !== "error") return null;
8873
+ const code = raw.code;
8874
+ const detail = raw.detail;
8875
+ return {
8876
+ kind,
8877
+ ...typeof code === "string" ? { code } : {},
8878
+ ...typeof detail === "string" ? { detail } : {}
8879
+ };
8880
+ }
8801
8881
  function stripControlChars(s) {
8802
8882
  let out = "";
8803
8883
  for (const ch of s) {
@@ -11749,7 +11829,7 @@ var CLI_SIGNABLE_FLAT_TOOLS = /* @__PURE__ */ new Set([
11749
11829
  "build_max_subscription_renewal",
11750
11830
  "build_pro_subscription_renewal"
11751
11831
  ]);
11752
- var CLI_PARITY_PREP_TOOLS = /* @__PURE__ */ new Set([
11832
+ var CLI_SIGNABLE_PREP_TOOLS = /* @__PURE__ */ new Set([
11753
11833
  "execute_swap",
11754
11834
  "execute_send",
11755
11835
  "execute_contract_call"
@@ -11809,6 +11889,22 @@ function asChainIdString(value) {
11809
11889
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
11810
11890
  return void 0;
11811
11891
  }
11892
+ function resolvePrepChain(txArgs) {
11893
+ const byName = asChainString(txArgs.chain) ? resolveChain(asChainString(txArgs.chain)) : null;
11894
+ const byId = asChainIdString(txArgs.chain_id) ? resolveChainId(asChainIdString(txArgs.chain_id)) : null;
11895
+ if (!byName && !byId) return null;
11896
+ if (byName && byId && byName !== byId) return null;
11897
+ return byName ?? byId;
11898
+ }
11899
+ function resolvePrepParentChain(env) {
11900
+ const byChain = asChainString(env.chain) ? resolveChain(asChainString(env.chain)) : null;
11901
+ const byFromChain = asChainString(env.from_chain) ? resolveChain(asChainString(env.from_chain)) : null;
11902
+ const byId = asChainIdString(env.chain_id) ? resolveChainId(asChainIdString(env.chain_id)) : null;
11903
+ const candidates = [byChain, byFromChain, byId].filter((c) => c !== null);
11904
+ if (candidates.length === 0) return null;
11905
+ if (candidates.some((c) => c !== candidates[0])) return null;
11906
+ return candidates[0];
11907
+ }
11812
11908
  function buildTxReadyFromToolOutput(toolName, output) {
11813
11909
  if (!CLI_SIGNABLE_FLAT_TOOLS.has(toolName)) return null;
11814
11910
  const env = asRecord(output);
@@ -11843,12 +11939,24 @@ function deriveToolOutputCandidate(toolName, output) {
11843
11939
  const payload = buildTxReadyFromToolOutput(toolName, output);
11844
11940
  return payload ? { payload, source: "flat", toolName } : null;
11845
11941
  }
11846
- if (CLI_PARITY_PREP_TOOLS.has(toolName)) {
11942
+ if (CLI_SIGNABLE_PREP_TOOLS.has(toolName)) {
11847
11943
  const env = asRecord(output);
11848
11944
  if (!env || env.status === "error" || "error" in env) return null;
11849
11945
  const txArgs = asRecord(env.txArgs);
11850
11946
  if (!txArgs) return null;
11851
11947
  if (typeof txArgs.tx_encoding !== "string" || txArgs.tx_encoding === "") return null;
11948
+ const prepChain = resolvePrepChain(txArgs);
11949
+ if (!prepChain) return null;
11950
+ const hasParentChainMetadata = asChainString(env.chain) !== void 0 || asChainString(env.from_chain) !== void 0 || asChainIdString(env.chain_id) !== void 0;
11951
+ if (hasParentChainMetadata) {
11952
+ const parentChain = resolvePrepParentChain(env);
11953
+ if (!parentChain || parentChain !== prepChain) return null;
11954
+ }
11955
+ const approvalTxArgs = asRecord(env.approvalTxArgs);
11956
+ if (approvalTxArgs) {
11957
+ const approvalChain = resolvePrepChain(approvalTxArgs);
11958
+ if (!approvalChain || approvalChain !== prepChain) return null;
11959
+ }
11852
11960
  return { payload: env, source: "prep", toolName };
11853
11961
  }
11854
11962
  return null;
@@ -11858,105 +11966,6 @@ function str(value) {
11858
11966
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
11859
11967
  return void 0;
11860
11968
  }
11861
- function nestedSource(legObj) {
11862
- const nested = (asRecord(legObj.tx) || asRecord(legObj.swap_tx) || asRecord(legObj.send_tx) || asRecord(legObj.txArgs?.tx)) ?? asRecord(legObj.txArgs) ?? legObj;
11863
- return nested;
11864
- }
11865
- function canonGasLimit(value) {
11866
- if (typeof value === "number" && Number.isInteger(value) && value > 0) return String(value);
11867
- if (typeof value === "string") {
11868
- const t = value.trim();
11869
- if (/^\d+$/.test(t) || /^0x[0-9a-fA-F]+$/.test(t)) {
11870
- try {
11871
- const n = BigInt(t);
11872
- return n > 0n ? n.toString() : void 0;
11873
- } catch {
11874
- return void 0;
11875
- }
11876
- }
11877
- }
11878
- return void 0;
11879
- }
11880
- function canonLeg(legObj) {
11881
- const src = nestedSource(legObj);
11882
- const txArgs = asRecord(legObj.txArgs);
11883
- const toRaw = str(src.to) ?? str(src.to_address);
11884
- const dataRaw = str(src.data) ?? str(src.calldata);
11885
- const leg = {
11886
- to: toRaw ? toRaw.toLowerCase() : void 0,
11887
- value: normalizeValue(src.value),
11888
- data: dataRaw ? dataRaw.toLowerCase() : void 0,
11889
- gasLimit: canonGasLimit(src.gas_limit),
11890
- chain: str(legObj.chain) ?? str(txArgs?.chain) ?? str(src.chain),
11891
- chainId: str(legObj.chain_id) ?? str(txArgs?.chain_id) ?? str(src.chain_id),
11892
- txEncoding: str(src.tx_encoding) ?? str(txArgs?.tx_encoding),
11893
- amount: str(src.amount) ?? str(txArgs?.amount),
11894
- memo: str(src.memo) ?? str(txArgs?.memo)
11895
- };
11896
- return leg;
11897
- }
11898
- var TX_READY_EXCLUSIVE_KEYS = ["typed_confirm", "sequence_id", "sequence_index", "sequence_total"];
11899
- function collectExclusive(payload) {
11900
- const found = [];
11901
- const scan = (obj) => {
11902
- if (!obj) return;
11903
- for (const k of TX_READY_EXCLUSIVE_KEYS) if (k in obj && !found.includes(k)) found.push(k);
11904
- };
11905
- scan(payload);
11906
- scan(asRecord(payload.tx));
11907
- scan(asRecord(payload.txArgs));
11908
- return found;
11909
- }
11910
- function canonicalizeForParity(payload) {
11911
- const env = asRecord(payload);
11912
- if (!env) return null;
11913
- const approval = asRecord(env.approvalTxArgs);
11914
- const main = asRecord(env.txArgs);
11915
- const legs = approval && main ? [canonLeg(approval), canonLeg(main)] : [canonLeg(env)];
11916
- return { legs, exclusive: collectExclusive(env) };
11917
- }
11918
- var HARD_FIELDS = [
11919
- "to",
11920
- "value",
11921
- "data",
11922
- "chain",
11923
- "chainId",
11924
- "txEncoding",
11925
- "amount",
11926
- "memo",
11927
- // gas_limit is signing-relevant, NOT advisory: signEvmServerTx copies a
11928
- // server-supplied gas_limit into the signed ethereumSpecific.gasLimit when it
11929
- // exceeds the SDK estimate, so a gas_limit divergence means different signed
11930
- // bytes/fee. Parity must surface it.
11931
- "gasLimit"
11932
- ];
11933
- function diffToolOutputParity(enriched, txReady) {
11934
- const ce = canonicalizeForParity(enriched);
11935
- const ct = canonicalizeForParity(txReady);
11936
- const divergences = [];
11937
- if (!ce || !ct) {
11938
- return {
11939
- match: false,
11940
- divergences: [`uncomparable payload (enriched=${ce ? "ok" : "null"}, tx_ready=${ct ? "ok" : "null"})`],
11941
- txReadyExclusive: ct?.exclusive ?? []
11942
- };
11943
- }
11944
- if (ce.legs.length !== ct.legs.length) {
11945
- divergences.push(`leg count: tool-output ${ce.legs.length} vs tx_ready ${ct.legs.length}`);
11946
- }
11947
- const n = Math.min(ce.legs.length, ct.legs.length);
11948
- for (let i = 0; i < n; i++) {
11949
- for (const f of HARD_FIELDS) {
11950
- const a = ce.legs[i][f];
11951
- const b = ct.legs[i][f];
11952
- if (a !== b && !(a === void 0 && b === void 0)) {
11953
- divergences.push(`leg[${i}].${f}: tool-output ${a ?? "\u2205"} vs tx_ready ${b ?? "\u2205"}`);
11954
- }
11955
- }
11956
- }
11957
- const txReadyExclusive = ct.exclusive.filter((k) => !ce.exclusive.includes(k));
11958
- return { match: divergences.length === 0, divergences, txReadyExclusive };
11959
- }
11960
11969
  function payloadLooksSignable(payload) {
11961
11970
  const env = asRecord(payload);
11962
11971
  if (!env) return false;
@@ -12246,7 +12255,6 @@ var AgentClient = class {
12246
12255
  const result = {
12247
12256
  fullText: "",
12248
12257
  suggestions: [],
12249
- transactions: [],
12250
12258
  message: null,
12251
12259
  finished: false,
12252
12260
  disconnected: false,
@@ -12257,7 +12265,6 @@ var AgentClient = class {
12257
12265
  serverNow: res.headers.get("X-Server-Now")
12258
12266
  };
12259
12267
  const toolNameByCallId = /* @__PURE__ */ new Map();
12260
- const parity = { lastSignableToolCallId: null };
12261
12268
  const reader = res.body.getReader();
12262
12269
  const decoder = new TextDecoder();
12263
12270
  let buffer = "";
@@ -12272,7 +12279,7 @@ var AgentClient = class {
12272
12279
  currentData += (currentData ? "\n" : "") + stripLeadingSpace(line.slice(5));
12273
12280
  } else if (line === "") {
12274
12281
  if (currentData) {
12275
- this.handleSSEEvent(currentEvent || "message", currentData, result, callbacks, toolNameByCallId, parity);
12282
+ this.handleSSEEvent(currentEvent || "message", currentData, result, callbacks, toolNameByCallId);
12276
12283
  }
12277
12284
  currentEvent = "";
12278
12285
  currentData = "";
@@ -12292,7 +12299,7 @@ var AgentClient = class {
12292
12299
  if (done) {
12293
12300
  if (trailing) processLine(trailing);
12294
12301
  if (currentData) {
12295
- this.handleSSEEvent(currentEvent || "message", currentData, result, callbacks, toolNameByCallId, parity);
12302
+ this.handleSSEEvent(currentEvent || "message", currentData, result, callbacks, toolNameByCallId);
12296
12303
  }
12297
12304
  break;
12298
12305
  }
@@ -12309,7 +12316,7 @@ var AgentClient = class {
12309
12316
  }
12310
12317
  return result;
12311
12318
  }
12312
- handleSSEEvent(event, data, result, callbacks, toolNameByCallId, parity) {
12319
+ handleSSEEvent(event, data, result, callbacks, toolNameByCallId) {
12313
12320
  try {
12314
12321
  const parsed = JSON.parse(data);
12315
12322
  const v1Type = getV1Type(parsed);
@@ -12320,7 +12327,7 @@ var AgentClient = class {
12320
12327
  this.handleTextDelta(parsed, result, callbacks);
12321
12328
  break;
12322
12329
  case "tool_progress":
12323
- this.handleToolProgress(parsed, data, callbacks, toolNameByCallId, v1Type, parity);
12330
+ this.handleToolProgress(parsed, data, callbacks, toolNameByCallId, v1Type);
12324
12331
  break;
12325
12332
  case "title": {
12326
12333
  const title = v1Data?.title ?? parsed.title;
@@ -12333,20 +12340,16 @@ var AgentClient = class {
12333
12340
  callbacks.onSuggestions?.(suggestions);
12334
12341
  break;
12335
12342
  }
12336
- case "tx_ready":
12337
- if (this.verbose) process.stderr.write(`[SSE:tx_ready] raw: ${data.slice(0, 2e3)}
12338
- `);
12339
- {
12340
- const txReady = v1Data ?? parsed;
12341
- result.transactions.push(txReady);
12342
- callbacks.onTxReady?.(txReady, parity.lastSignableToolCallId ?? void 0);
12343
- }
12344
- break;
12345
12343
  case "balance_summary": {
12346
12344
  const card = v1Data ?? parsed.data ?? parsed;
12347
12345
  callbacks.onBalanceSummary?.(card);
12348
12346
  break;
12349
12347
  }
12348
+ case "turn_outcome": {
12349
+ const outcome = parseTurnOutcome(v1Data ?? parsed.data ?? parsed);
12350
+ if (outcome) callbacks.onTurnOutcome?.(outcome);
12351
+ break;
12352
+ }
12350
12353
  case "message": {
12351
12354
  const msg = v1Data?.message ?? parsed.message ?? parsed;
12352
12355
  result.message = msg;
@@ -12375,7 +12378,7 @@ var AgentClient = class {
12375
12378
  result.fullText += parsed.delta;
12376
12379
  callbacks.onTextDelta?.(parsed.delta);
12377
12380
  }
12378
- handleToolProgress(parsed, data, callbacks, toolNameByCallId, v1Type, parity) {
12381
+ handleToolProgress(parsed, data, callbacks, toolNameByCallId, v1Type) {
12379
12382
  if (this.verbose) process.stderr.write(`[SSE:tool_progress] raw: ${data.slice(0, 1e3)}
12380
12383
  `);
12381
12384
  const status = parsed.status ?? v1StatusFromType(v1Type);
@@ -12388,32 +12391,27 @@ var AgentClient = class {
12388
12391
  this.maybeEmitClientSideToolCall(parsed, callbacks, v1Type, callId, toolName);
12389
12392
  const ok = deriveToolDoneOk(status, parsed.output);
12390
12393
  if (status && toolName) callbacks.onToolProgress?.(toolName, status, label, ok);
12391
- this.maybeSignToolOutput(status, toolName, parsed.output, callbacks, callId, parity);
12394
+ this.maybeSignToolOutput(status, toolName, parsed.output, callbacks);
12392
12395
  if (status === "done" && callId) toolNameByCallId.delete(callId);
12393
12396
  }
12394
12397
  /**
12395
- * Phase-1 dual-read: derive a client-side signable candidate from a signable
12396
- * tool's raw `tool-output-available` output — the same envelope mobile reads —
12397
- * and hand it to the session via `onToolOutputTx` (SEPARATE from the backend
12398
- * `tx_ready` channel). The session decides how to use it:
12399
- * - flat off-chain tools with NO `tx_ready` (polymarket) the sign source
12400
- * (unchanged from #922);
12401
- * - `produces_calldata` tools that also emit `tx_ready` (`execute_*`,
12402
- * `erc20_approve`, `build_custom_*`) → the PARITY reference; `tx_ready`
12403
- * stays authoritative when signable.
12404
- * The bridge guards against non-tx results (`no_op` / `insufficient_*` /
12405
- * errors) so those never reach the signer. Zero backend change.
12398
+ * #927 Phase 2: derive a client-side signable candidate from a signable tool's
12399
+ * raw `tool-output-available` output — the same envelope mobile reads — and
12400
+ * hand it to the session via `onToolOutputTx` as the SOLE signing source
12401
+ * (production emits the payload here; `data-tx_ready` is a hollow marker). The
12402
+ * bridge guards against non-tx results (`no_op` / `insufficient_*` / errors)
12403
+ * and phantom-card prep envelopes so those never reach the signer. Zero backend
12404
+ * change.
12406
12405
  */
12407
- maybeSignToolOutput(status, toolName, output, callbacks, callId, parity) {
12406
+ maybeSignToolOutput(status, toolName, output, callbacks) {
12408
12407
  if (status !== "done" || !toolName || !callbacks.onToolOutputTx) return;
12409
- if (!CLI_SIGNABLE_FLAT_TOOLS.has(toolName) && !CLI_PARITY_PREP_TOOLS.has(toolName)) return;
12408
+ if (!CLI_SIGNABLE_FLAT_TOOLS.has(toolName) && !CLI_SIGNABLE_PREP_TOOLS.has(toolName)) return;
12410
12409
  const candidate = deriveToolOutputCandidate(toolName, output);
12411
12410
  if (!candidate) return;
12412
- if (callId) parity.lastSignableToolCallId = callId;
12413
12411
  if (this.verbose)
12414
12412
  process.stderr.write(`[SSE:tool_output] ${toolName} \u2192 onToolOutputTx (${candidate.source} candidate)
12415
12413
  `);
12416
- callbacks.onToolOutputTx(candidate.payload, toolName, candidate.source, callId ?? void 0);
12414
+ callbacks.onToolOutputTx(candidate.payload, toolName, candidate.source);
12417
12415
  }
12418
12416
  maybeEmitClientSideToolCall(parsed, callbacks, v1Type, callId, toolName) {
12419
12417
  if (v1Type !== "tool-input-available" || !callId || !toolName || !this.clientSideToolNames.has(toolName) || !callbacks.onClientSideToolCall) {
@@ -12430,7 +12428,9 @@ var AgentClient = class {
12430
12428
  // Maps a V1 `type` field to the legacy event bucket used by handleSSEEvent's
12431
12429
  // switch. Frame-level types (start, text-start, text-end, finish-step) and
12432
12430
  // non-critical telemetry (data-tokens, data-usage, data-confirmation) route
12433
- // to 'ignore' which is a no-op.
12431
+ // to 'ignore' which is a no-op. `data-tx_ready` also routes to 'ignore':
12432
+ // #927 Phase 2 signs purely from `tool-output-available`, and production emits
12433
+ // `data-tx_ready` only as a hollow `{typed_confirm}` marker the CLI doesn't use.
12434
12434
  mapV1EventType(type) {
12435
12435
  switch (type) {
12436
12436
  case "text-delta":
@@ -12444,10 +12444,10 @@ var AgentClient = class {
12444
12444
  return "title";
12445
12445
  case "data-suggestions":
12446
12446
  return "suggestions";
12447
- case "data-tx_ready":
12448
- return "tx_ready";
12449
12447
  case "data-balance_summary":
12450
12448
  return "balance_summary";
12449
+ case "data-turn_outcome":
12450
+ return "turn_outcome";
12451
12451
  case "data-message":
12452
12452
  return "message";
12453
12453
  case "error":
@@ -13239,8 +13239,6 @@ var AgentSession = class {
13239
13239
  `);
13240
13240
  }
13241
13241
  }
13242
- let txReadyCandidate = null;
13243
- let txReadyTwinCallId = null;
13244
13242
  let toolOutputCandidate = null;
13245
13243
  let balanceCardRendered = false;
13246
13244
  const pendingDispatches = [];
@@ -13266,20 +13264,12 @@ var AgentSession = class {
13266
13264
  onSuggestions: (suggestions) => {
13267
13265
  ui.onSuggestions(suggestions);
13268
13266
  },
13269
- onTxReady: (tx, toolCallId) => {
13270
- if (txReadyCandidate !== null) {
13271
- this.reportDeferredSignable(ui);
13272
- return;
13273
- }
13274
- txReadyCandidate = tx;
13275
- txReadyTwinCallId = toolCallId ?? null;
13276
- },
13277
- onToolOutputTx: (payload, toolName, source, toolCallId) => {
13267
+ onToolOutputTx: (payload, toolName, source) => {
13278
13268
  if (toolOutputCandidate !== null) {
13279
13269
  this.reportDeferredSignable(ui);
13280
13270
  return;
13281
13271
  }
13282
- toolOutputCandidate = { payload, toolName, source, callId: toolCallId ?? null };
13272
+ toolOutputCandidate = { payload, toolName, source };
13283
13273
  },
13284
13274
  onBalanceSummary: (raw) => {
13285
13275
  const card = parseBalanceSummaryEnvelope(raw);
@@ -13288,6 +13278,9 @@ var AgentSession = class {
13288
13278
  ui.onBalanceSummary?.(card);
13289
13279
  }
13290
13280
  },
13281
+ onTurnOutcome: (outcome) => {
13282
+ ui.onTurnOutcome?.(outcome);
13283
+ },
13291
13284
  onMessage: (_msg) => {
13292
13285
  },
13293
13286
  onError: (error2, code) => {
@@ -13310,7 +13303,7 @@ var AgentSession = class {
13310
13303
  }
13311
13304
  if (streamResult.disconnected && !streamResult.message) {
13312
13305
  ui.onReconnecting?.();
13313
- await this.recoverDisconnectedTurn(streamResult, callbacks.onTxReady, callbacks.onBalanceSummary);
13306
+ await this.recoverDisconnectedTurn(streamResult, callbacks.onBalanceSummary);
13314
13307
  }
13315
13308
  const responseText = streamResult.message?.content || streamResult.fullText || "";
13316
13309
  let displayText = stripLeakedToolCallTags(responseText);
@@ -13320,7 +13313,7 @@ var AgentSession = class {
13320
13313
  if (displayText) {
13321
13314
  ui.onAssistantMessage(displayText);
13322
13315
  }
13323
- const bufferedSignable = this.selectAndBufferSignable(txReadyCandidate, txReadyTwinCallId, toolOutputCandidate, ui);
13316
+ const bufferedSignable = this.selectAndBufferSignable(toolOutputCandidate);
13324
13317
  if (bufferedSignable) {
13325
13318
  if (this.config.verbose) process.stderr.write(`[session] buffered a signable server tx, signing client-side
13326
13319
  `);
@@ -13370,102 +13363,42 @@ var AgentSession = class {
13370
13363
  );
13371
13364
  }
13372
13365
  /**
13373
- * Phase-1 dual-read reconciliation. Given the two per-turn candidates, run the
13374
- * parity cross-check (when both are the SAME tool call) and buffer the SAFE
13375
- * sign source into the executor. Returns true when something was buffered.
13376
- *
13377
- * Selection order (fund-safety):
13378
- * 1. a SIGNABLE `tx_ready` — AUTHORITATIVE, unchanged from today's behavior;
13379
- * 2. the client tool-output candidate — covers flat off-chain tools with no
13380
- * `tx_ready` (polymarket) and flat tools whose `tx_ready` is structurally
13381
- * unsignable (`build_custom_*`, divergent `to_address`/`calldata`);
13382
- * 3. an unsignable `tx_ready` as last resort — preserves today's behavior (it
13383
- * surfaces its own error at sign time) rather than silently dropping it.
13384
- * `payloadLooksSignable` mirrors the executor's real requirements so a
13385
- * structurally-present-but-unsignable `tx_ready` (which `storeServerTransaction`
13386
- * would still buffer, only to throw at sign time) doesn't pre-empt a good
13387
- * tool-output candidate.
13388
- *
13389
- * FAIL-CLOSED on divergence (review — gomesalexandre): a flat tool-output is a
13390
- * sign source ONLY when (a) there is NO `tx_ready` at all, or (b) the same-turn
13391
- * `tx_ready` MATCHED parity. Whenever a `tx_ready` is present and the flat did
13392
- * not prove equal to it, we do NOT sign the client-enriched candidate — we fall
13393
- * closed to the `tx_ready` path (which, when unsignable, errors at sign time).
13394
- *
13395
- * This sign gate is DELIBERATELY INDEPENDENT of the pairing heuristic below:
13396
- * whether the two same-turn candidates are "the same tool call" affects only
13397
- * divergence TELEMETRY, never the sign decision. So even if the pairing were
13398
- * wrong (e.g. the backend batched two tool-output frames before a `tx_ready`
13399
- * against the documented per-pending order — agent-backend agent.go:6397), a
13400
- * diverging flat still cannot be signed: it is gated by `parityMatched`, not by
13401
- * pairing. The fund-safety guarantee therefore rests on parity equality alone,
13402
- * not on a cross-repo wire-ordering invariant (security + Codex convergence).
13366
+ * #927 Phase 2 sign gate. Buffer the client-enriched tool-output candidate
13367
+ * the SOLE signing source into the executor. Returns true when something was
13368
+ * buffered (signed downstream), false otherwise.
13403
13369
  *
13404
- * Pairing (review gomesalexandre) — TELEMETRY ONLY: the two channels are the
13405
- * SAME tool call iff their tool-call ids match (`txReadyTwinCallId` is the id of
13406
- * the tool-output frame this `tx_ready` was emitted next to client.ts). A
13407
- * DEFINITELY-unpaired pair (both ids present AND different) skips the parity
13408
- * diff so an unrelated same-turn pair does not emit a false `[DIVERGENCE]`; a
13409
- * missing id falls back to running the diff so a real divergence is never
13410
- * suppressed. This gate never enqueues a sign source — it only silences noise.
13370
+ * FAIL-CLOSED, two structural gates:
13371
+ * 1. `deriveToolOutputCandidate` (client.ts) already rejected non-tx envelopes
13372
+ * (`no_op` / `insufficient_*` / errors) and phantom-card prep envelopes
13373
+ * (no `tx_encoding`) so a candidate that reaches here is well-formed.
13374
+ * 2. `payloadLooksSignable` mirrors the executor's real signer requirements
13375
+ * (`extractNestedTx().to` for EVM; `txArgs.{to,amount}` for non-EVM; both
13376
+ * legs for multi-leg). A structurally-present-but-unsignable candidate is
13377
+ * NOT routed to the signer — the mutation-check target for this gate.
13378
+ * `storeServerTransaction` is the final backstop (returns false if it can't
13379
+ * resolve a tx body / cross-chain multi-leg), so a false is never a wrong sign.
13411
13380
  */
13412
- selectAndBufferSignable(txReadyCandidate, txReadyTwinCallId, toolOutputCandidate, ui) {
13413
- const toolOutputCallId = toolOutputCandidate?.callId ?? null;
13414
- const definitelyUnpaired = txReadyCandidate !== null && toolOutputCandidate !== null && toolOutputCallId !== null && txReadyTwinCallId !== null && toolOutputCallId !== txReadyTwinCallId;
13415
- let parityMatched = false;
13416
- if (txReadyCandidate && toolOutputCandidate && !definitelyUnpaired) {
13417
- parityMatched = this.logToolOutputParity(toolOutputCandidate, txReadyCandidate);
13418
- }
13419
- const ordered = [];
13420
- if (txReadyCandidate && payloadLooksSignable(txReadyCandidate)) {
13421
- ordered.push({ surface: "tx_ready", payload: txReadyCandidate });
13422
- }
13423
- const flatIsSignSource = toolOutputCandidate?.source === "flat" && (txReadyCandidate === null || parityMatched);
13424
- if (toolOutputCandidate && flatIsSignSource) {
13425
- ordered.push({ surface: "tool_output", payload: toolOutputCandidate.payload });
13426
- }
13427
- if (txReadyCandidate && !payloadLooksSignable(txReadyCandidate)) {
13428
- ordered.push({ surface: "tx_ready", payload: txReadyCandidate });
13429
- }
13430
- for (const { surface, payload } of ordered) {
13431
- if (this.executor.storeServerTransaction(payload)) {
13432
- if (this.config.password) this.executor.setPassword(this.config.password);
13433
- if (this.config.verbose) {
13434
- const both = txReadyCandidate && toolOutputCandidate ? "both channels" : "single channel";
13435
- process.stderr.write(`[session] buffered signable from ${surface} (${both})
13436
- `);
13437
- }
13438
- if (surface === "tx_ready" && toolOutputCandidate?.source === "flat" && !parityMatched) {
13439
- this.reportDeferredSignable(ui);
13440
- }
13441
- return true;
13381
+ selectAndBufferSignable(toolOutputCandidate) {
13382
+ if (!toolOutputCandidate) return false;
13383
+ if (!payloadLooksSignable(toolOutputCandidate.payload)) {
13384
+ if (this.config.verbose) {
13385
+ process.stderr.write(
13386
+ `[session] tool-output candidate (${toolOutputCandidate.toolName}, ${toolOutputCandidate.source}) not structurally signable; not buffering
13387
+ `
13388
+ );
13442
13389
  }
13390
+ return false;
13443
13391
  }
13444
- return false;
13445
- }
13446
- /**
13447
- * Compare the client-enriched tool-output candidate to the backend `tx_ready`
13448
- * for the same turn and log the result. On a safety-relevant divergence this
13449
- * logs LOUDLY (always to stderr, not just under --verbose) — that divergence
13450
- * signal is exactly what Phase 1 exists to surface before Phase 2 makes
13451
- * tool-output the sole signing source.
13452
- */
13453
- logToolOutputParity(toolOutput, txReady) {
13454
- const result = diffToolOutputParity(toolOutput.payload, txReady);
13455
- const exclusive = result.txReadyExclusive.length ? ` | tx_ready-exclusive: ${result.txReadyExclusive.join(",")}` : "";
13456
- if (result.match) {
13392
+ if (this.executor.storeServerTransaction(toolOutputCandidate.payload)) {
13393
+ if (this.config.password) this.executor.setPassword(this.config.password);
13457
13394
  if (this.config.verbose) {
13458
13395
  process.stderr.write(
13459
- `[parity] ${toolOutput.toolName} (${toolOutput.source}): tool-output == tx_ready \u2713${exclusive}
13396
+ `[session] buffered signable from tool-output (${toolOutputCandidate.toolName}, ${toolOutputCandidate.source})
13460
13397
  `
13461
13398
  );
13462
13399
  }
13463
13400
  return true;
13464
13401
  }
13465
- process.stderr.write(
13466
- `[parity][DIVERGENCE] ${toolOutput.toolName} (${toolOutput.source}): tool-output != tx_ready \u2014 ${result.divergences.join("; ")}${exclusive}
13467
- `
13468
- );
13469
13402
  return false;
13470
13403
  }
13471
13404
  /**
@@ -13473,15 +13406,14 @@ var AgentSession = class {
13473
13406
  * assistant message. Polls /messages/since (server-clock anchored via
13474
13407
  * X-Server-Now) until the persisted assistant message lands or the bounded
13475
13408
  * budget is exhausted. On success it patches `streamResult.message` so the
13476
- * normal downstream flow surfaces the answer, and replays any persisted
13477
- * `data-tx_ready` part through `onTxReady` so a recovered signable card hits
13478
- * the same confirm/sign gate as a live one.
13409
+ * normal downstream flow surfaces the answer, and replays any persisted balance
13410
+ * card. #927 Phase 2: a signable tx is NEVER recovered — the signable payload
13411
+ * rides tool-output-available, which the persisted parts don't reconstruct so
13412
+ * a recovered turn that ran a signable tool warns to re-run instead of signing.
13479
13413
  */
13480
- async recoverDisconnectedTurn(streamResult, onTxReady, onBalanceSummary) {
13414
+ async recoverDisconnectedTurn(streamResult, onBalanceSummary) {
13481
13415
  if (!this.conversationId) return;
13482
- const serverAnchor = serverNowToIso(streamResult.serverNow);
13483
- const since = serverAnchor ?? new Date(Date.now() - 2e3).toISOString();
13484
- const replaySignableCards = serverAnchor !== null;
13416
+ const since = serverNowToIso(streamResult.serverNow) ?? new Date(Date.now() - 2e3).toISOString();
13485
13417
  let cursor;
13486
13418
  let authRecovered = false;
13487
13419
  for (let attempt = 0; attempt < this.recoveryMaxPolls; attempt++) {
@@ -13502,13 +13434,13 @@ var AgentSession = class {
13502
13434
  continue;
13503
13435
  }
13504
13436
  if (resp.cursor) cursor = resp.cursor;
13505
- const assistant = [...resp.messages].reverse().find((m) => m.role === "assistant" && (!!m.content || hasTxReadyPart(m.parts)));
13437
+ const assistant = [...resp.messages].reverse().find((m) => m.role === "assistant" && (!!m.content || hasSignableToolPart(m.parts)));
13506
13438
  if (assistant) {
13507
13439
  if (this.config.verbose) {
13508
13440
  process.stderr.write(`[session] recovered assistant message after ${attempt + 1} poll(s)
13509
13441
  `);
13510
13442
  }
13511
- this.applyRecoveredMessage(assistant, streamResult, onTxReady, replaySignableCards, onBalanceSummary);
13443
+ this.applyRecoveredMessage(assistant, streamResult, onBalanceSummary);
13512
13444
  return;
13513
13445
  }
13514
13446
  await this.recoverySleep();
@@ -13596,38 +13528,30 @@ var AgentSession = class {
13596
13528
  }
13597
13529
  /**
13598
13530
  * Fold a recovered assistant message back into the live stream result: the
13599
- * authoritative message wins over any partial deltas, and any persisted
13600
- * `data-tx_ready` part is replayed through the live tx_ready callback so the
13601
- * card flows through the same confirm/sign gate.
13602
- *
13603
- * `replaySignableCards` gates the tx_ready replay: it is false when the
13604
- * recovery anchor was the local-clock fallback (no X-Server-Now), where a
13605
- * recovered card cannot be proven to belong to the current turn. See
13606
- * recoverDisconnectedTurn — a stale tx_ready must never reach the signer.
13531
+ * authoritative message wins over any partial deltas, and any persisted balance
13532
+ * card is replayed. #927 Phase 2: a signable transaction is NEVER recovered —
13533
+ * the signable payload rides tool-output-available, which the persisted parts
13534
+ * do not reconstruct — so a recovered turn that ran a signable tool warns the
13535
+ * user to re-run rather than signing (fail-CLOSED, never a wrong sign).
13607
13536
  */
13608
- applyRecoveredMessage(msg, streamResult, onTxReady, replaySignableCards, onBalanceSummary) {
13537
+ applyRecoveredMessage(msg, streamResult, onBalanceSummary) {
13609
13538
  streamResult.message = msg;
13610
- let replayedTxReady = false;
13611
- let signableFlatToolPart;
13539
+ let signableToolPart;
13612
13540
  for (const part of msg.parts ?? []) {
13613
13541
  if (part.type === "data-balance_summary" && part.data) {
13614
13542
  onBalanceSummary?.(part.data);
13615
13543
  continue;
13616
13544
  }
13617
- if (replaySignableCards && part.type === "data-tx_ready" && part.data && typeof part.data === "object") {
13618
- const tx = part.data;
13619
- streamResult.transactions.push(tx);
13620
- onTxReady?.(tx);
13621
- replayedTxReady = true;
13622
- }
13623
13545
  if (typeof part.type === "string" && part.type.startsWith("tool-")) {
13624
13546
  const toolName = part.type.slice("tool-".length);
13625
- if (CLI_SIGNABLE_FLAT_TOOLS.has(toolName)) signableFlatToolPart = toolName;
13547
+ if (CLI_SIGNABLE_FLAT_TOOLS.has(toolName) || CLI_SIGNABLE_PREP_TOOLS.has(toolName)) {
13548
+ signableToolPart = toolName;
13549
+ }
13626
13550
  }
13627
13551
  }
13628
- if (signableFlatToolPart && !replayedTxReady) {
13552
+ if (signableToolPart) {
13629
13553
  process.stderr.write(
13630
- `[session][recovery] recovered turn ran signable tool '${signableFlatToolPart}', whose signable output rides tool-output-available (no data-tx_ready). The recovery path does not reconstruct that candidate, so NO transaction was signed this turn \u2014 re-run the request to sign. (Known Phase-1 limitation.)
13554
+ `[session][recovery] recovered turn ran signable tool '${signableToolPart}', whose signable output rides tool-output-available. The recovery path does not reconstruct that candidate, so NO transaction was signed this turn \u2014 re-run the request to sign.
13631
13555
  `
13632
13556
  );
13633
13557
  }
@@ -13795,8 +13719,12 @@ function serverNowToIso(serverNow) {
13795
13719
  if (!Number.isFinite(ms) || ms <= 0) return null;
13796
13720
  return new Date(ms).toISOString();
13797
13721
  }
13798
- function hasTxReadyPart(parts) {
13799
- return !!parts?.some((p) => p.type === "data-tx_ready" && !!p.data);
13722
+ function hasSignableToolPart(parts) {
13723
+ return !!parts?.some((p) => {
13724
+ if (typeof p.type !== "string" || !p.type.startsWith("tool-")) return false;
13725
+ const toolName = p.type.slice("tool-".length);
13726
+ return CLI_SIGNABLE_FLAT_TOOLS.has(toolName) || CLI_SIGNABLE_PREP_TOOLS.has(toolName);
13727
+ });
13800
13728
  }
13801
13729
  function isAuthError(err) {
13802
13730
  const msg = err instanceof Error ? err.message : String(err ?? "");
@@ -14280,6 +14208,7 @@ function outputAskError(wantsJson, message, code, conversationId, result) {
14280
14208
  if (result?.transactions.length) data.transactions = result.transactions;
14281
14209
  if (result?.toolCalls.length) data.tool_calls = result.toolCalls;
14282
14210
  if (result?.response) data.response = result.response;
14211
+ if (result?.outcome) data.outcome = result.outcome;
14283
14212
  outputErrorJson({
14284
14213
  success: false,
14285
14214
  v: 1,
@@ -14294,6 +14223,11 @@ function outputAskError(wantsJson, message, code, conversationId, result) {
14294
14223
  function outputAskHuman(result, confirmationRequired, proposed) {
14295
14224
  process.stdout.write(`session:${result.sessionId}
14296
14225
  `);
14226
+ if (result.outcome && result.outcome.kind !== "success") {
14227
+ const { kind, code } = result.outcome;
14228
+ process.stdout.write(`outcome:${kind}${code ? `:${code}` : ""}
14229
+ `);
14230
+ }
14297
14231
  if (confirmationRequired) {
14298
14232
  process.stdout.write(`confirmation-required:pass --yes to authorize signing
14299
14233
  `);
@@ -14336,6 +14270,12 @@ function outputAskSuccess(wantsJson, result, conversationId) {
14336
14270
  tool_calls: result.toolCalls,
14337
14271
  transactions: result.transactions,
14338
14272
  ...result.cards.length > 0 ? { cards: result.cards } : {},
14273
+ // a2a-02: the typed turn ending (success | blocked | refusal | error) at
14274
+ // `data.outcome` — the same relative slot as on the error envelope. Present
14275
+ // only against a backend that honored the advertised turn_outcome surface;
14276
+ // headless callers should branch on this (and the exit code), not `success`
14277
+ // (which stays true for a completed-but-blocked/refused turn).
14278
+ ...result.outcome ? { outcome: result.outcome } : {},
14339
14279
  ...confirmationRequired ? { confirmation_required: true } : {},
14340
14280
  ...proposed ? { proposed } : {}
14341
14281
  });
@@ -14343,6 +14283,21 @@ function outputAskSuccess(wantsJson, result, conversationId) {
14343
14283
  }
14344
14284
  outputAskHuman(result, confirmationRequired, proposed);
14345
14285
  }
14286
+ function outcomeToExitCode(outcome) {
14287
+ switch (outcome?.kind) {
14288
+ case "blocked":
14289
+ return 10 /* AGENT_TURN_BLOCKED */;
14290
+ case "refusal":
14291
+ return 11 /* AGENT_TURN_REFUSAL */;
14292
+ case "error":
14293
+ return 1 /* USAGE */;
14294
+ // 1 — generic failure; a stream error-frame path sets a more specific code first
14295
+ case "success":
14296
+ return 0 /* SUCCESS */;
14297
+ default:
14298
+ return void 0;
14299
+ }
14300
+ }
14346
14301
  async function executeAgentAsk(ctx2, message, options) {
14347
14302
  setSilentMode(true);
14348
14303
  const originalConsoleLog = console.log;
@@ -14376,6 +14331,7 @@ async function executeAgentAsk(ctx2, message, options) {
14376
14331
  exitCode = agentErrorCodeToExitCode(result.error.code);
14377
14332
  outputAskError(wantsJson, result.error.message, result.error.code, conversationId, result);
14378
14333
  } else {
14334
+ exitCode = outcomeToExitCode(result.outcome) ?? 0;
14379
14335
  outputAskSuccess(wantsJson, result, conversationId);
14380
14336
  }
14381
14337
  } catch (err) {
@@ -14494,7 +14450,7 @@ var cachedVersion = null;
14494
14450
  function getVersion() {
14495
14451
  if (cachedVersion) return cachedVersion;
14496
14452
  if (true) {
14497
- cachedVersion = "2.17.0";
14453
+ cachedVersion = "2.18.6";
14498
14454
  return cachedVersion;
14499
14455
  }
14500
14456
  try {
@@ -16886,19 +16842,27 @@ program.command("broadcast").description("Broadcast a pre-signed raw transaction
16886
16842
  });
16887
16843
  })
16888
16844
  );
16889
- program.command("tx-status").description("Check the status of a transaction (polls until confirmed)").requiredOption("--chain <chain>", "Target blockchain").requiredOption("--tx-hash <hash>", "Transaction hash to check").option("--no-wait", "Return immediately without waiting for confirmation").addHelpText(
16845
+ program.command("tx-status").description("Check the status of a transaction (polls until confirmed)").requiredOption("--chain <chain>", "Target blockchain").requiredOption("--tx-hash <hash>", "Transaction hash to check").option("--no-wait", "Return immediately without waiting for confirmation").option("--timeout <seconds>", "Max seconds to poll before giving up (default 120)").addHelpText(
16890
16846
  "after",
16891
16847
  `
16892
16848
  Examples:
16893
16849
  vultisig tx-status --chain Ethereum --tx-hash 0xabc...
16850
+ vultisig tx-status --chain Ethereum --tx-hash 0xabc... --timeout 300
16894
16851
  vultisig tx-status --chain Bitcoin --tx-hash abc... --no-wait --output json`
16895
16852
  ).action(
16896
16853
  withExit(async (options) => {
16897
16854
  const context = await init(program.opts().vault);
16855
+ const timeoutSec = options.timeout !== void 0 ? Number(options.timeout) : void 0;
16856
+ if (timeoutSec !== void 0 && (!Number.isFinite(timeoutSec) || timeoutSec < 0)) {
16857
+ throw new InvalidInputError(
16858
+ `Invalid --timeout: "${options.timeout}" (expected a non-negative number of seconds)`
16859
+ );
16860
+ }
16898
16861
  await executeTxStatus(context, {
16899
16862
  chain: findChainByName(options.chain) || options.chain,
16900
16863
  txHash: options.txHash,
16901
- noWait: !options.wait
16864
+ noWait: !options.wait,
16865
+ timeoutSec
16902
16866
  });
16903
16867
  })
16904
16868
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vultisig/cli",
3
- "version": "2.17.0",
3
+ "version": "2.18.6",
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": {
@@ -74,9 +74,9 @@
74
74
  "@napi-rs/keyring": "^1.3.0",
75
75
  "@noble/hashes": "^2.2.0",
76
76
  "@vultisig/client-shared": "^0.2.17",
77
- "@vultisig/core-chain": "^2.23.1",
78
- "@vultisig/rujira": "^50.0.0",
79
- "@vultisig/sdk": "^2.17.0",
77
+ "@vultisig/core-chain": "^2.23.3",
78
+ "@vultisig/rujira": "^51.0.0",
79
+ "@vultisig/sdk": "^2.18.6",
80
80
  "chalk": "^5.6.2",
81
81
  "cli-table3": "^0.6.5",
82
82
  "commander": "^15.0.0",