@thirdfy/agent-cli 0.2.76 → 0.2.77

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  All notable changes to `@thirdfy/agent-cli` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [0.2.77] - 2026-09-24
6
+
7
+ ### Changed
8
+
9
+ - **Dependencies on current releases:**
10
+ - `commander` 14.0.3: 12 → 14 is two majors.
11
+ - `ethers` 6.17.0 and `@open-wallet-standard/core` 1.4.2.
12
+ - Commander 15 is held back because it needs Node ≥22.12. agent-cli still supports Node ≥20, and Hermes runtimes install `@latest` on the Node their image already has.
13
+ - **Extra positional arguments are still accepted.** Commander 13+ rejects them by default; the CLI opts back in (`allowExcessArguments`), so a command like `intent-status <id>` behaves as in 0.2.76. Pin: `test/commander-routing.test.cjs`.
14
+ - **`-ni` still works.** Commander 13+ rejects two-letter short flags. The option is `--not-interactive`, and a bare `-ni` is rewritten to it before parsing. Pin: `test/commander-routing.test.cjs`.
15
+ - **`venue readiness|setup|fund` declare `<venue>`.** The manifest passed `true` as the argument name, which commander 12 accepted as one required positional. The behavior is unchanged: `thirdfy-agent venue readiness avantis`.
16
+ - **CI and release:**
17
+ - CI tests Node 20, 22 and 24.
18
+ - Release and live e2e run on Node 24.
19
+ - `actions/checkout@v7`, `actions/setup-node@v7`.
20
+ - Dependabot with weekly updates.
21
+ - **agent_wallet reads make one API call.** A read in `agent_wallet` mode skips the `/execution-wallet` preflight, because the API resolves the execution wallet itself. The preflight still runs when the read needs its answer: `--wallet-address` set to a wallet other than the agent key, chain 4663 (Robinhood delegated lane), or a Hyperliquid/Lighter/Avantis address default. With `--wallet-address` equal to the agent key, the wallet the API reports is checked after the read (same `EXECUTION_WALLET_MISMATCH` rule). The result shows `executionWalletPreflight: { skipped: "read_fast_path" }`. Matches `thirdfy-mcp` `walletExecute`.
22
+ - **`WRITE_OUTCOME_UNKNOWN` advice matches `thirdfy-mcp`.** The error names the action and the idempotency key: re-read state first; if the write landed, do not resend; if unsure and retrying, reuse the exact same key; if the re-read shows it did not land, send it with a new key. The API holds an unknown-outcome claim for 24 h.
23
+ - **Unknown actions.** An action that is not in the catalog, or not allowed for the agent key, still fails with `error: PARAMS_SCHEMA_INVALID`, now with `blockedReason: UNKNOWN_ACTION`, `resolvedAction: null`, and `suggestions` (closest catalog actions). Same shape as `thirdfy-mcp` 0.1.6.
24
+ - **Write timeout floor (pin only).** The CLI picks write timeouts from its own read/write classifier, not the catalog `behavior.latencyClass`, so a write never gets the read timeout. `test/mcp-api-parity.test.cjs` pins it.
25
+
26
+ ### Security
27
+
28
+ - **The agent API key never goes in a URL.** The policy-aware actions listing (`thirdfy-agent actions --agent-api-key …`, and action resolution before `run` / `preflight` / `wallet execute`) called `GET /api/v1/agent/actions?agentApiKey=…`. It now sends the key in the `x-agent-api-key` header, which every agent route reads since Thirdfy API 3.15; a key in the query string gets `Deprecation` headers. Pin: `test/mcp-api-parity.test.cjs`.
29
+
30
+ ### Added
31
+
32
+ - **Replayed writes are labeled.** When the API answers a value-moving write from an earlier identical one (`Idempotent-Replayed: true` header or `idempotentReplay: true` in the body), the result carries `idempotentReplay: true` and a note: nothing new was executed; send a new `executionIdempotencyKey` (`idempotencyKey` on execute-intent) to place another. Both come from `--idempotency-key`. Matches `thirdfy-mcp` `idempotentReplayFields`.
33
+ - **`deposit_earn_position` amount aliases.** `amountHuman` and `amountInHuman` are accepted in place of `amount` (a decimal token-unit string). Two amounts, or raw base units (`amountInRaw`, `amountIn`), fail with `EARN_AMOUNT_*` before any request. **New refusal:** earn deposits that sent `amountIn` / `amountInRaw` used to reach the API, which reads `amount` as token units; they now fail locally. `execute_optimal_swap` takes the same `amountInRaw` / `amountInHuman` + `tokenInDecimals` contract as `swap`. Matches the `thirdfy-mcp` params gate (`earn_deposit_decimal_amount`, `evm_erc20_amount_in`).
34
+
5
35
  ## [0.2.76] - 2026-09-23
6
36
 
7
37
  ### Fixed
package/README.md CHANGED
@@ -40,10 +40,13 @@ Run without global install:
40
40
  npx @thirdfy/agent-cli --help
41
41
  ```
42
42
 
43
- ## What's new in v0.2.76
43
+ ## What's new in v0.2.77
44
44
 
45
- - Writes (orders, TP/SL updates, closes) get a 90s HTTP timeout instead of 30s. Override with `THIRDFY_CLI_WRITE_TIMEOUT_MS`.
46
- - A write whose request times out is no longer resent. The CLI reports `WRITE_OUTCOME_UNKNOWN`: re-read positions before retrying.
45
+ - The agent API key is sent in the `x-agent-api-key` header, never in a URL.
46
+ - A write the API answers from an earlier identical one is marked `idempotentReplay: true`. Send a new `--idempotency-key` to place another.
47
+ - `agent_wallet` reads make one API call (no separate wallet preflight when the read does not need it).
48
+ - `deposit_earn_position` accepts `amountHuman` / `amountInHuman` as `amount`. Unknown actions report `blockedReason: UNKNOWN_ACTION` with `suggestions`.
49
+ - Built on commander 14 and ethers 6.17; Node 20+ is still supported.
47
50
  - See [CHANGELOG.md](./CHANGELOG.md) and GitHub Releases for older versions.
48
51
 
49
52
  ## Quick start
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thirdfy/agent-cli",
3
- "version": "0.2.76",
3
+ "version": "0.2.77",
4
4
  "description": "Thirdfy Agent CLI for onboarding, governance preflight, execute-intent, and status polling.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -71,8 +71,8 @@
71
71
  },
72
72
  "homepage": "https://github.com/thirdfy/agent-cli#readme",
73
73
  "dependencies": {
74
- "@open-wallet-standard/core": "^1.3.0",
75
- "commander": "^12.1.0",
76
- "ethers": "^6.16.0"
74
+ "@open-wallet-standard/core": "^1.4.2",
75
+ "commander": "^14.0.3",
76
+ "ethers": "^6.17.0"
77
77
  }
78
78
  }
@@ -104,7 +104,7 @@ export const CLI_MANIFEST = [
104
104
  handler: 'commandVenueReadiness',
105
105
  options: ['auth', 'venue'],
106
106
  description: 'Normalized venue readiness (polymarket, hyperliquid, lighter, avantis)',
107
- args: { query: true },
107
+ args: { query: '<venue>' },
108
108
  tier: 'online',
109
109
  },
110
110
  {
@@ -112,7 +112,7 @@ export const CLI_MANIFEST = [
112
112
  handler: 'commandVenueSetup',
113
113
  options: ['auth', 'venue'],
114
114
  description: 'Prepare or complete venue onboarding via shared API contract',
115
- args: { query: true },
115
+ args: { query: '<venue>' },
116
116
  tier: 'online',
117
117
  },
118
118
  {
@@ -120,7 +120,7 @@ export const CLI_MANIFEST = [
120
120
  handler: 'commandVenueFund',
121
121
  options: ['auth', 'venue'],
122
122
  description: 'Return venue funding plan (does not move funds implicitly)',
123
- args: { query: true },
123
+ args: { query: '<venue>' },
124
124
  tier: 'online',
125
125
  },
126
126
  {
@@ -1,3 +1,9 @@
1
+ /** Commander 13+ rejects multi-character short flags; keep the historical `-ni` spelling working. */
2
+ export function normalizeLegacyShortFlags(argv) {
3
+ const end = argv.indexOf('--');
4
+ return argv.map((arg, index) => (arg === '-ni' && (end === -1 || index < end) ? '--not-interactive' : arg));
5
+ }
6
+
1
7
  export function attachBootstrapOptions(cmd) {
2
8
  return cmd
3
9
  .option('--proof-type <type>', 'session_token|wallet_signature')
@@ -10,7 +16,7 @@ export function attachBootstrapOptions(cmd) {
10
16
  .option('--otp <otp>', 'email OTP alias')
11
17
  .option('--accept-terms', 'accept terms for email login')
12
18
  .option('--key-name <name>', 'Privy key name')
13
- .option('-ni, --not-interactive', 'fail fast when required login input is missing')
19
+ .option('--not-interactive', 'fail fast when required login input is missing (alias: -ni)')
14
20
  .option('--no-bootstrap-agent', 'skip agent bootstrap on email login')
15
21
  .option('--rotate-agent-key', 'rotate agent key')
16
22
  .option('--login-attempt-id <id>', 'email login attempt id')
@@ -3,3 +3,4 @@ import { buildCliProgram } from './register.mjs';
3
3
  export { buildCliProgram };
4
4
  export { collectRootFlags, collectLegacyFlags } from './flags.mjs';
5
5
  export { configureCommanderErrors, handleRootOnlyFlows, collectVersionFlagsFromArgv } from './errors.mjs';
6
+ export { normalizeLegacyShortFlags } from './options/bootstrap.mjs';
@@ -107,6 +107,9 @@ function registerManifestEntry(program, entry, nodeByPath, deps) {
107
107
  export function buildCliProgram(deps) {
108
108
  const program = new Command('thirdfy-agent');
109
109
  program.name('thirdfy-agent');
110
+ // Commander 13+ rejects extra positional arguments by default. 0.2.76 (commander 12) ignored them,
111
+ // and agents that pass an id positionally (`intent-status <id>`) must keep working.
112
+ program.allowExcessArguments(true);
110
113
  attachGlobalOptions(program);
111
114
  program.addHelpCommand(false);
112
115
  program.showHelpAfterError(false);
@@ -131,11 +131,11 @@ export async function getCachedActionsCatalog(ctx, flags) {
131
131
  if (hit && hit.expiresAt > now) {
132
132
  return hit.actions;
133
133
  }
134
- let endpoint = '/api/v1/agent/actions/catalog';
135
- if (policyAware) {
136
- endpoint = `/api/v1/agent/actions?agentApiKey=${encodeURIComponent(String(flags.agentApiKey))}`;
137
- }
138
- const response = await apiGet(ctx, endpoint);
134
+ // The agent key goes in the `x-agent-api-key` header, never the URL: URLs land in access logs and
135
+ // proxies, and API 3.15 answers a URL key with Deprecation headers.
136
+ const response = policyAware
137
+ ? await apiGet(ctx, '/api/v1/agent/actions', { 'x-agent-api-key': String(flags.agentApiKey) })
138
+ : await apiGet(ctx, '/api/v1/agent/actions/catalog');
139
139
  const actions = extractActions(response);
140
140
  catalogCache.set(cacheKey, {
141
141
  actions,
@@ -42,3 +42,31 @@ export function wrapWriteIdempotencyKeyForHostedRuntime(existingKey, runtimeId,
42
42
  }
43
43
  return existing;
44
44
  }
45
+
46
+ /** Told to the caller when the API answered a write from an earlier identical one. */
47
+ export const idempotentReplayNote = (keyArg = 'executionIdempotencyKey') =>
48
+ `This result is from an earlier identical write; nothing new was executed. Send a new ${keyArg} to place another.`;
49
+
50
+ export const IDEMPOTENT_REPLAY_NOTE = idempotentReplayNote();
51
+
52
+ function hasReplayFlag(value, depth) {
53
+ if (!value || typeof value !== 'object' || depth > 4) return false;
54
+ if (Array.isArray(value)) return value.some((item) => hasReplayFlag(item, depth + 1));
55
+ if (value.idempotentReplay === true) return true;
56
+ return Object.values(value).some((item) => hasReplayFlag(item, depth + 1));
57
+ }
58
+
59
+ /**
60
+ * `{ idempotentReplay: true, note }` when the API replayed an earlier write with the same key:
61
+ * `idempotentReplay: true` anywhere in the body, or an `Idempotent-Replayed: true` header (API
62
+ * 3.15). The note names the key field to change (`executionIdempotencyKey` on /execute,
63
+ * `idempotencyKey` on /execute-intent; both come from `--idempotency-key`). Mirrors thirdfy-mcp
64
+ * `idempotentReplayFields`.
65
+ */
66
+ export function idempotentReplayFields(data, headers, keyArg) {
67
+ const header = headers
68
+ ? Object.entries(headers).find(([name]) => name.toLowerCase() === 'idempotent-replayed')?.[1]
69
+ : undefined;
70
+ const replayed = String(header ?? '').trim().toLowerCase() === 'true' || hasReplayFlag(data, 0);
71
+ return replayed ? { idempotentReplay: true, note: idempotentReplayNote(keyArg) } : {};
72
+ }
package/src/core/http.mjs CHANGED
@@ -17,21 +17,71 @@ function isTimeoutError(error) {
17
17
  return error?.name === 'AbortError' || message.includes('abort') || message.includes('timeout');
18
18
  }
19
19
 
20
+ /** The idempotency key a write body carries, and the field that carries it. */
21
+ function writeIdempotencyKey(init) {
22
+ try {
23
+ const body = JSON.parse(String(init?.body || '{}'));
24
+ const executionKey = String(body?.executionIdempotencyKey || '').trim();
25
+ if (executionKey) return { key: executionKey, keyArg: 'executionIdempotencyKey', action: body?.action };
26
+ const intentKey = String(body?.idempotencyKey || '').trim();
27
+ if (intentKey) return { key: intentKey, keyArg: 'idempotencyKey', action: body?.action };
28
+ return { key: '', keyArg: 'executionIdempotencyKey', action: body?.action };
29
+ } catch {
30
+ return { key: '', keyArg: 'executionIdempotencyKey', action: undefined };
31
+ }
32
+ }
33
+
20
34
  /**
21
35
  * A write whose request timed out may already be on chain. Resending it can double a trade, so the
22
- * caller gets `WRITE_OUTCOME_UNKNOWN` and re-reads state before any retry.
36
+ * caller gets `WRITE_OUTCOME_UNKNOWN` and re-reads state before any retry. The advice matches
37
+ * thirdfy-mcp `thirdfyWriteOutcomeUnknownPayload`: reusing the same key lets the API answer with
38
+ * the first write's result if it landed (the API holds an unknown-outcome claim for 24 h).
23
39
  */
24
- function writeOutcomeUnknownError(route, ctx, cause) {
25
- const error = new Error(
26
- `WRITE_OUTCOME_UNKNOWN: ${route} timed out after ${ctx.timeoutMs}ms; the write may have executed. Re-read state before retrying.`,
27
- );
40
+ function writeOutcomeUnknownError(route, ctx, cause, init) {
41
+ const { key, keyArg, action } = writeIdempotencyKey(init);
42
+ const retryAdvice = key
43
+ ? ` If it landed, do not send it again. If you cannot tell and retry, reuse this exact ${keyArg} ("${key}") so the API cannot run it twice. If the re-read shows it did not land, send it again with a new ${keyArg}.`
44
+ : '';
45
+ const details = `Thirdfy did not answer within ${Math.round(Number(ctx.timeoutMs || 0) / 1000)}s. The write may have executed. Re-read positions, orders, or balances first, and do not resend the same write blindly.${retryAdvice}`;
46
+ const error = new Error(`WRITE_OUTCOME_UNKNOWN: ${route} timed out after ${ctx.timeoutMs}ms. ${details}`);
28
47
  error.code = 'WRITE_OUTCOME_UNKNOWN';
29
48
  error.statusCode = 504;
30
- error.payload = { error: 'WRITE_OUTCOME_UNKNOWN', nextAction: 'reread_state_before_retry', timeoutMs: ctx.timeoutMs };
49
+ error.payload = {
50
+ error: 'WRITE_OUTCOME_UNKNOWN',
51
+ code: 504,
52
+ ...(action ? { action: String(action) } : {}),
53
+ ...(key ? { [keyArg]: key } : {}),
54
+ details,
55
+ nextAction: 'reread_state_before_retry',
56
+ timeoutMs: ctx.timeoutMs,
57
+ };
31
58
  error.cause = cause;
32
59
  return error;
33
60
  }
34
61
 
62
+ /**
63
+ * Response headers a caller may need after `requestJson` returns only the parsed body. Kept off the
64
+ * body itself so nothing extra is printed. Today: `Idempotent-Replayed` (API 3.15).
65
+ */
66
+ const responseHeadersByBody = new WeakMap();
67
+
68
+ const TRACKED_RESPONSE_HEADERS = ['idempotent-replayed'];
69
+
70
+ function rememberResponseHeaders(data, response) {
71
+ if (!data || typeof data !== 'object' || typeof response?.headers?.get !== 'function') return;
72
+ const tracked = {};
73
+ for (const name of TRACKED_RESPONSE_HEADERS) {
74
+ const value = response.headers.get(name);
75
+ if (value !== null && value !== undefined) tracked[name] = String(value);
76
+ }
77
+ if (Object.keys(tracked).length) responseHeadersByBody.set(data, tracked);
78
+ }
79
+
80
+ /** Tracked response headers (lowercase names) for a body returned by `apiGet` / `apiPost`. */
81
+ export function responseHeadersFor(data) {
82
+ return (data && typeof data === 'object' && responseHeadersByBody.get(data)) || {};
83
+ }
84
+
35
85
  /**
36
86
  * Whether a request that timed out may be sent again. GETs, validation-only previews, and calls the
37
87
  * caller marks `writeAction: false` are safe. Preflight sends `executionLane: validation_only`
@@ -96,6 +146,7 @@ async function requestJsonOnce(ctx, route, init) {
96
146
  });
97
147
  const text = await response.text();
98
148
  const data = text ? safeJsonParse(text) : {};
149
+ rememberResponseHeaders(data, response);
99
150
  if (!response.ok) {
100
151
  const error = new Error(data?.error || data?.message || `HTTP ${response.status}`);
101
152
  error.statusCode = response.status;
@@ -116,7 +167,7 @@ export async function requestJson(ctx, route, init) {
116
167
  } catch (error) {
117
168
  lastError = error;
118
169
  if (isTimeoutError(error) && !resendAfterTimeoutIsSafe(ctx, init)) {
119
- throw writeOutcomeUnknownError(route, ctx, error);
170
+ throw writeOutcomeUnknownError(route, ctx, error, init);
120
171
  }
121
172
  if (attempt === 0 && isRetryableApiError(error)) {
122
173
  await sleep(250 + Math.floor(Math.random() * 250));
@@ -1,5 +1,6 @@
1
1
  import { requireFlag } from '../../core/args.mjs';
2
2
  import { getCachedActionsCatalog } from '../../core/context.mjs';
3
+ import { suggestCatalogActions } from '../validation/actionParamsGate.mjs';
3
4
  export function createActionSelection({ getTradingProviderHint, formatProviderAmbiguousActionHint, formatProviderUnknownActionHint }) {
4
5
  function getActionKey(action) {
5
6
  return String(action?.action || action?.id || action?.key || action?.name || '')
@@ -361,7 +362,7 @@ function resolveActionFromRows(requestedAction, actions, flags = {}) {
361
362
  .filter((entry) => entry.key);
362
363
 
363
364
  if (!keyed.length) {
364
- return { requestedAction, resolvedAction: requestedAction, resolvedActionMeta: null };
365
+ return { requestedAction, resolvedAction: requestedAction, resolvedActionMeta: null, suggestions: [] };
365
366
  }
366
367
 
367
368
  const canonicalAliasOverrides = {
@@ -462,7 +463,15 @@ function resolveActionFromRows(requestedAction, actions, flags = {}) {
462
463
  )}?${formatProviderAmbiguousActionHint(providerHint)}`
463
464
  );
464
465
  }
465
- return { requestedAction, resolvedAction: requestedAction, resolvedActionMeta: null };
466
+ return {
467
+ requestedAction,
468
+ resolvedAction: requestedAction,
469
+ resolvedActionMeta: null,
470
+ suggestions: suggestCatalogActions(
471
+ requestedAction,
472
+ keyed.map((entry) => ({ ...entry.action, action: entry.key })),
473
+ ),
474
+ };
466
475
  }
467
476
 
468
477
  return {
@@ -3,7 +3,62 @@ import { createCliError } from '../../core/envelope.mjs';
3
3
  export function createExecutionAmounts() {
4
4
  function shouldApplySwapAmountContract(actionName) {
5
5
  const action = String(actionName || '').trim().toLowerCase();
6
- return action === 'swap' || /(^|:|-)swap$/.test(action);
6
+ return action === 'swap' || action === 'execute_optimal_swap' || /(^|:|-)swap$/.test(action);
7
+ }
8
+
9
+ function normalizeContractActionKey(actionName) {
10
+ return String(actionName || '').trim().toLowerCase().replace(/_/g, '-');
11
+ }
12
+
13
+ /** `deposit_earn_position` takes `amount` as a decimal token-unit string (contract `earn_deposit_decimal_amount`). */
14
+ function shouldApplyEarnDepositAmountContract(actionName) {
15
+ return /(^|:)deposit-earn-position$/.test(normalizeContractActionKey(actionName));
16
+ }
17
+
18
+ function hasParamValue(value) {
19
+ return value !== undefined && value !== null && String(value).trim() !== '';
20
+ }
21
+
22
+ /**
23
+ * Accepts `amountHuman` or `amountInHuman` in place of `amount`, as thirdfy-mcp's params gate does
24
+ * (`earn_deposit_decimal_amount`). Exactly one decimal amount; raw base units are refused, because
25
+ * the API reads `amount` as token units.
26
+ */
27
+ function normalizeEarnDepositAmountContract(params) {
28
+ const hasAmount = hasParamValue(params.amount);
29
+ const hasAmountHuman = hasParamValue(params.amountHuman);
30
+ const hasAmountInHuman = hasParamValue(params.amountInHuman);
31
+ const humanInputs = [hasAmount, hasAmountHuman, hasAmountInHuman].filter(Boolean).length;
32
+ if (humanInputs > 1) {
33
+ throw createCliError(
34
+ 'EARN_AMOUNT_AMBIGUOUS',
35
+ 'Provide exactly one earn deposit amount: amount, amountHuman, or amountInHuman.'
36
+ );
37
+ }
38
+ if (humanInputs === 1) {
39
+ if (hasParamValue(params.amountInRaw) || hasParamValue(params.amountIn)) {
40
+ throw createCliError(
41
+ 'EARN_AMOUNT_UNIT_AMBIGUOUS',
42
+ 'Provide exactly one earn deposit amount; do not combine amount/amountHuman/amountInHuman with amountInRaw or amountIn.'
43
+ );
44
+ }
45
+ const amount = String(hasAmount ? params.amount : hasAmountHuman ? params.amountHuman : params.amountInHuman).trim();
46
+ if (!/^\d+(\.\d+)?$/.test(amount)) {
47
+ throw createCliError(
48
+ 'EARN_AMOUNT_INVALID',
49
+ 'Earn deposits require amount as a decimal token-unit string (example: "100.5").'
50
+ );
51
+ }
52
+ const { amountHuman: _amountHuman, amountInHuman: _amountInHuman, ...rest } = params;
53
+ return { ...rest, amount };
54
+ }
55
+ if (hasParamValue(params.amountInRaw) || hasParamValue(params.amountIn)) {
56
+ throw createCliError(
57
+ 'EARN_AMOUNT_UNIT_INVALID',
58
+ 'deposit_earn_position expects amount as a decimal token-unit string. Do not pass raw base-unit amountInRaw/amountIn for earn deposits.'
59
+ );
60
+ }
61
+ return params;
7
62
  }
8
63
 
9
64
  function normalizeIntegerRawAmount(input, code = 'AMOUNT_UNIT_INVALID') {
@@ -164,6 +219,11 @@ function normalizeSwapAmountContract(params) {
164
219
 
165
220
  function prepareActionParamsForFlags(flags, resolvedAction) {
166
221
  const parsedParams = parseJsonFlag(flags, 'params');
222
+ if (shouldApplyEarnDepositAmountContract(resolvedAction)) {
223
+ flags.__preparedParams = normalizeEarnDepositAmountContract(parsedParams || {});
224
+ flags.__swapInput = null;
225
+ return;
226
+ }
167
227
  if (!shouldApplySwapAmountContract(resolvedAction)) {
168
228
  flags.__preparedParams = parsedParams;
169
229
  flags.__swapInput = null;
@@ -187,6 +247,8 @@ function getPreparedParams(flags) {
187
247
  decimalToRawUnits,
188
248
  rawUnitsToDecimalString,
189
249
  normalizeSwapAmountContract,
250
+ shouldApplyEarnDepositAmountContract,
251
+ normalizeEarnDepositAmountContract,
190
252
  prepareActionParamsForFlags,
191
253
  getPreparedParams,
192
254
  };
@@ -9,7 +9,8 @@ import {
9
9
  getEffectiveRunMode,
10
10
  parseBooleanFlag,
11
11
  } from '../../core/runMode.mjs';
12
- import { apiGet, apiPost } from '../../core/http.mjs';
12
+ import { apiGet, apiPost, responseHeadersFor } from '../../core/http.mjs';
13
+ import { idempotentReplayFields } from '../../core/hostedRuntimeIdempotency.mjs';
13
14
  import { withActionTimeout } from '../../core/actionTimeouts.mjs';
14
15
  import { isReadOnlyResolvedAction } from '../../core/readOnlyActions.mjs';
15
16
  import { resolveWalletExecuteChainId } from '../../core/walletExecuteChainId.mjs';
@@ -32,6 +33,7 @@ export function createExecutionRunners(deps) {
32
33
  buildBitfinexOnboardingCommand,
33
34
  inferBitfinexPermissionProfileFromAction,
34
35
  resolveAgentApiKey,
36
+ resolveAgentKey,
35
37
  loadEthers,
36
38
  signAndBroadcastWithOws,
37
39
  readEffectBalances,
@@ -146,9 +148,52 @@ function normalizeManagedExecuteResponse(response, { preflight, payload, isRead
146
148
  shaped?.amountNormalization || shaped?.data?.amountNormalization || null;
147
149
  normalized.raw = shaped;
148
150
  normalized.idempotencyKey = payload.executionIdempotencyKey || null;
151
+ // A replay means nothing new ran; say so, or the caller reads it as a fresh fill.
152
+ if (!isRead) {
153
+ Object.assign(
154
+ normalized,
155
+ idempotentReplayFields(response, responseHeadersFor(response), 'executionIdempotencyKey'),
156
+ );
157
+ }
149
158
  return normalized;
150
159
  }
151
160
 
161
+ const ROBINHOOD_MAINNET_CHAIN_ID = 4663;
162
+
163
+ /** Marker a read on the fast path carries instead of an execution-wallet preflight. */
164
+ const READ_FAST_PATH_PREFLIGHT = Object.freeze({ success: true, skipped: 'read_fast_path' });
165
+
166
+ /**
167
+ * agent_wallet reads skip the /execution-wallet preflight: the API resolves the agent's execution
168
+ * wallet itself, so the extra round trip only matters when a read uses its answer: a strict wallet
169
+ * match, a funding check, or the Robinhood delegated lane. An expected wallet equal to the agent key
170
+ * is not a separate wallet to match first; the wallet the API reports is checked after the read.
171
+ * Mirrors thirdfy-mcp `walletExecute` readFastPath.
172
+ */
173
+ function shouldUseManagedReadFastPath(flags, resolved, options = {}) {
174
+ if (!isReadOnlyAction(resolved)) return false;
175
+ if (options.requireFundingCheck === true) return false;
176
+ const runMode = String(flags.runMode || 'agent_wallet').trim().toLowerCase();
177
+ if (runMode !== 'agent_wallet') return false;
178
+ const expectedWallet = String(flags.walletAddress || flags.executionWalletAddress || '').trim().toLowerCase();
179
+ const agentKey = String(typeof resolveAgentKey === 'function' ? resolveAgentKey(flags) : flags.agentKey || '')
180
+ .trim()
181
+ .toLowerCase();
182
+ if (expectedWallet && expectedWallet !== agentKey) return false;
183
+ let chainId;
184
+ try {
185
+ chainId = resolveWalletExecuteChainId({
186
+ action: String(resolved?.resolvedAction || flags.action || '').trim(),
187
+ chainId: flags.chainId,
188
+ params: getPreparedParams(flags),
189
+ });
190
+ } catch {
191
+ // Unresolvable chain (e.g. a CCTP quote without sourceChainId): the preflight path reports it.
192
+ return false;
193
+ }
194
+ return Number(chainId) !== ROBINHOOD_MAINNET_CHAIN_ID;
195
+ }
196
+
152
197
  async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
153
198
  const resolvedAction = resolved.resolvedAction;
154
199
  if (runMode === 'agent_wallet') {
@@ -439,6 +484,9 @@ async function executeThirdfyRun(ctx, flags, resolved, options, runMode = 'third
439
484
  const response = await apiPost(actionCtx, '/api/v1/agent/execute-intent', payload);
440
485
  const normalized = normalizeIntentResponse(response);
441
486
  normalized.idempotencyKey = payload.idempotencyKey;
487
+ if (!isReadOnlyAction(resolved)) {
488
+ Object.assign(normalized, idempotentReplayFields(response, responseHeadersFor(response), 'idempotencyKey'));
489
+ }
442
490
  return normalized;
443
491
  }
444
492
 
@@ -465,7 +513,7 @@ async function runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, pre
465
513
  intentResult.routeFallback = 'execute_intent_read_only';
466
514
  if (preflight) {
467
515
  intentResult.executionWalletPreflight = preflight;
468
- intentResult.executionWalletAddress = preflight.executionWalletAddress || null;
516
+ intentResult.executionWalletAddress = preflight.executionWalletAddress || intentResult.executionWalletAddress || null;
469
517
  }
470
518
  intentResult.signerMethod = 'managed_wallet_server';
471
519
  return intentResult;
@@ -479,8 +527,11 @@ async function executeManagedWalletRun(ctx, flags, resolved, options) {
479
527
  // (often as a JSON string under data.result). Reads skip funding gates; routing still fails closed.
480
528
  const isRead = isReadOnlyAction(resolved);
481
529
  const skipPreflight = Boolean(options?.skipPreflight);
482
- let preflight = null;
483
- if (!skipPreflight) {
530
+ // A seeded preflight (Hyperliquid/Lighter/Avantis address defaults) already ran; reuse it.
531
+ const readFastPath =
532
+ !skipPreflight && !options?.managedPreflight && shouldUseManagedReadFastPath(flags, resolved, options);
533
+ let preflight = readFastPath ? READ_FAST_PATH_PREFLIGHT : null;
534
+ if (!skipPreflight && !readFastPath) {
484
535
  preflight = await resolveManagedExecutionPreflight(ctx, flags, resolved, {
485
536
  runMode: 'agent_wallet',
486
537
  requireFundingCheck: !isRead,
@@ -510,8 +561,19 @@ async function executeManagedWalletRun(ctx, flags, resolved, options) {
510
561
  });
511
562
  const actionCtx = withActionTimeout(ctx, resolved);
512
563
  const response = await apiPost(actionCtx, '/api/v1/agent/execute', payload);
564
+ if (readFastPath) {
565
+ const mismatch = fastPathWalletMismatch(flags, response);
566
+ if (mismatch) return mismatch;
567
+ }
513
568
  if (shouldFallbackAgentWalletToExecuteIntent(resolved, response)) {
514
- const intentResult = await runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, preflight);
569
+ // On the fast path the wallet comes from the /execute answer, not a preflight.
570
+ const fallbackPreflight = readFastPath
571
+ ? {
572
+ ...READ_FAST_PATH_PREFLIGHT,
573
+ executionWalletAddress: response?.executionWalletAddress || response?.data?.executionWalletAddress || null,
574
+ }
575
+ : preflight;
576
+ const intentResult = await runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, fallbackPreflight);
515
577
  if (isEmptyQueuedExecuteIntent(intentResult.raw || intentResult)) {
516
578
  intentResult.success = false;
517
579
  intentResult.status = 'failed';
@@ -527,6 +589,33 @@ async function executeManagedWalletRun(ctx, flags, resolved, options) {
527
589
  return normalizeManagedExecuteResponse(response, { preflight, payload, isRead });
528
590
  }
529
591
 
592
+ /**
593
+ * The fast path only runs when the expected wallet is empty or equals the agent key. The preflight
594
+ * path refuses a wallet that differs from the execution wallet, so apply the same rule to the wallet
595
+ * the API reports for the read.
596
+ */
597
+ function fastPathWalletMismatch(flags, response) {
598
+ const expectedWallet = String(flags.walletAddress || flags.executionWalletAddress || '').trim().toLowerCase();
599
+ if (!expectedWallet || parseBooleanFlag(flags.allowWalletMismatch, false)) return null;
600
+ const apiWallet = String(response?.executionWalletAddress || response?.data?.executionWalletAddress || '')
601
+ .trim()
602
+ .toLowerCase();
603
+ if (!apiWallet || apiWallet === expectedWallet) return null;
604
+ const normalized = normalizeIntentResponse({
605
+ success: false,
606
+ status: 'failed',
607
+ mode: 'agent_wallet',
608
+ blockedReason: 'EXECUTION_WALLET_MISMATCH',
609
+ blockedStage: 'routing',
610
+ error: `Funded wallet (${expectedWallet}) does not match execution wallet (${apiWallet}). Use --allow-wallet-mismatch to override.`,
611
+ preflightBlocked: true,
612
+ executionWalletAddress: apiWallet,
613
+ });
614
+ normalized.executionWalletAddress = apiWallet;
615
+ normalized.executionWalletPreflight = READ_FAST_PATH_PREFLIGHT;
616
+ return normalized;
617
+ }
618
+
530
619
  async function executeSelfRun(ctx, flags, resolved, options) {
531
620
  const skipPreflight = Boolean(options?.skipPreflight);
532
621
  const blockedReasonCode = String(options?.blockedReasonCode || 'SELF_MODE_REQUIRES_BUILD_TX');
@@ -6,6 +6,7 @@ import {
6
6
  collectRootFlags,
7
7
  handleRootOnlyFlows,
8
8
  collectVersionFlagsFromArgv,
9
+ normalizeLegacyShortFlags,
9
10
  } from '../cli/program.mjs';
10
11
  import { createInitialRuntimeContext, applyFlagsToRuntimeContext } from './context.mjs';
11
12
  import { createProviderHints } from './providerHints.mjs';
@@ -87,6 +88,7 @@ export function createThirdfyAgentRuntime() {
87
88
  buildBitfinexOnboardingCommand: onboarding.buildBitfinexOnboardingCommand,
88
89
  inferBitfinexPermissionProfileFromAction: onboarding.inferBitfinexPermissionProfileFromAction,
89
90
  resolveAgentApiKey: payloads.resolveAgentApiKey,
91
+ resolveAgentKey: payloads.resolveAgentKey,
90
92
  loadEthers: ows.loadEthers,
91
93
  signAndBroadcastWithOws: ows.signAndBroadcastWithOws,
92
94
  readEffectBalances: ows.readEffectBalances,
@@ -154,7 +156,7 @@ export function createThirdfyAgentRuntime() {
154
156
  }
155
157
 
156
158
  try {
157
- await cliProgram.parseAsync(process.argv, { from: 'node' });
159
+ await cliProgram.parseAsync(normalizeLegacyShortFlags(process.argv), { from: 'node' });
158
160
  const versionHandled = await handleRootOnlyFlows(cliProgram, rootFlowDeps);
159
161
  if (versionHandled) return;
160
162
  } catch (error) {
@@ -6,6 +6,47 @@ import {
6
6
  formatParamsSchemaValidationResult,
7
7
  } from './paramsSchema.mjs';
8
8
 
9
+ const tokensOf = (action) =>
10
+ String(action || '')
11
+ .toLowerCase()
12
+ .split(/[-_\s]+/)
13
+ .filter(Boolean);
14
+
15
+ function editDistance(a, b) {
16
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
17
+ for (let i = 1; i <= a.length; i += 1) {
18
+ let prev = row[0];
19
+ row[0] = i;
20
+ for (let j = 1; j <= b.length; j += 1) {
21
+ const tmp = row[j];
22
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
23
+ prev = tmp;
24
+ }
25
+ }
26
+ return row[b.length];
27
+ }
28
+
29
+ /**
30
+ * Catalog actions closest to an unknown name: shared words first, then edit distance, so
31
+ * `open_avantis_position` suggests `place_avantis_perps_order` and typos find their action.
32
+ * Mirrors thirdfy-mcp `suggestCatalogActions`.
33
+ */
34
+ export function suggestCatalogActions(requested, actions, limit = 5) {
35
+ const wanted = tokensOf(requested);
36
+ const target = wanted.join('_');
37
+ return (Array.isArray(actions) ? actions : [])
38
+ .filter((action) => action?.action && action.enabled !== false)
39
+ .map((action) => {
40
+ const name = tokensOf(action.action).join('_');
41
+ const shared = tokensOf(action.action).filter((token) => wanted.includes(token)).length;
42
+ return { name: action.action, shared, distance: editDistance(target, name) };
43
+ })
44
+ .filter((candidate) => candidate.shared > 0 || candidate.distance <= 3)
45
+ .sort((a, b) => b.shared - a.shared || a.distance - b.distance)
46
+ .slice(0, limit)
47
+ .map((candidate) => candidate.name);
48
+ }
49
+
9
50
  export function resolveActionParamsSchema(resolved) {
10
51
  const meta = resolved?.resolvedActionMeta;
11
52
  if (!meta || typeof meta !== 'object') return null;
@@ -19,12 +60,22 @@ export function isReadOnlyCatalogAction(resolved) {
19
60
  export function validateResolvedActionParams(resolved, params) {
20
61
  const meta = resolved?.resolvedActionMeta;
21
62
  if (!meta || typeof meta !== 'object') {
63
+ // Same shape as thirdfy-mcp 0.1.6+: `error` stays PARAMS_SCHEMA_INVALID (callers match on it);
64
+ // `blockedReason: UNKNOWN_ACTION` plus `suggestions` point at real actions. With an agent key
65
+ // the list is the agent's own policy list, so the action may exist but not be allowed.
66
+ const suggestions = Array.isArray(resolved?.suggestions) ? resolved.suggestions : [];
22
67
  return {
23
68
  valid: false,
24
69
  skipped: false,
25
- error: `Action "${resolved?.requestedAction || ''}" was not found in the Thirdfy catalog`,
70
+ error: `Action "${resolved?.requestedAction || ''}" is not in the Thirdfy catalog or is not allowed for this agent${
71
+ suggestions.length
72
+ ? `. Did you mean: ${suggestions.join(', ')}?`
73
+ : '. Use `thirdfy-agent actions` to list actions.'
74
+ }`,
26
75
  field: null,
27
76
  code: 'PARAMS_SCHEMA_INVALID',
77
+ blockedReason: 'UNKNOWN_ACTION',
78
+ suggestions,
28
79
  };
29
80
  }
30
81
 
@@ -57,11 +108,13 @@ export function enforceCatalogActionParams(resolved, flags, getPreparedParams) {
57
108
  return;
58
109
  }
59
110
  throw createCliError('PARAMS_SCHEMA_INVALID', result.error || 'Action params failed catalog schema validation', {
60
- blockedReason: 'PARAMS_SCHEMA_INVALID',
111
+ blockedReason: result.blockedReason || 'PARAMS_SCHEMA_INVALID',
112
+ ...(Array.isArray(result.suggestions) ? { suggestions: result.suggestions } : {}),
61
113
  blockedStage: 'client_validation',
62
114
  validationError: result.error || null,
63
115
  field: result.field || null,
64
- resolvedAction: resolved?.resolvedAction || null,
116
+ // An unknown action resolved to nothing; MCP reports `resolvedAction: null` for it too.
117
+ resolvedAction: result.blockedReason === 'UNKNOWN_ACTION' ? null : resolved?.resolvedAction || null,
65
118
  requestedAction: resolved?.requestedAction || null,
66
119
  });
67
120
  }