@thirdfy/agent-cli 0.2.75 → 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,45 @@
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
+
35
+ ## [0.2.76] - 2026-09-23
36
+
37
+ ### Fixed
38
+
39
+ - Managed writes (perps orders, TP/SL updates, closes) use a 90s HTTP timeout instead of the 30s default. They sign, broadcast, and wait for a receipt in one API call. Reads keep the base timeout; onboarding, earn, and bridge actions keep 180s. Override: `THIRDFY_CLI_WRITE_TIMEOUT_MS` (or `THIRDFY_API_WRITE_TIMEOUT_MS`).
40
+ - `requestJson` resends a timed-out request only when it is a GET, a validation-only preview (`validationOnly` or `executionLane: validation_only`), or marked `writeAction: false` (read rail, build-tx, telemetry, governance preflight, execute-intent preflight). Every other POST (venue fund, delegation, onboarding) returns `WRITE_OUTCOME_UNKNOWN` instead of a second send. Read/write classification uses catalog metadata when the resolved action is available.
41
+ - Avantis market open/close (`place_avantis_perps_order`, `close_avantis_position`) use the 180s long timeout; the API streams, then polls, for up to about 180s.
42
+ - A write whose request times out is not resent. The retry could double a trade that already executed. The CLI throws `WRITE_OUTCOME_UNKNOWN` (`nextAction: reread_state_before_retry`). Preflight (`validationOnly`) and reads still retry once. Pin: `test/actionTimeouts.test.cjs`. Pairs with `thirdfy-mcp` **0.0.125**.
43
+
5
44
  ## [0.2.75] - 2026-09-21
6
45
 
7
46
  ### 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.75
43
+ ## What's new in v0.2.77
44
44
 
45
- - World ID deposit proof commands load without a duplicate `--network` flag.
46
- - `delegation world-id-deposit-begin` accepts amount, runtime, and destination flags.
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.75",
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);
@@ -108,7 +108,8 @@ export function createCondorCommands({ commandRun, printEnvelope }) {
108
108
  ...params,
109
109
  },
110
110
  };
111
- const data = await apiPost(ctx, '/api/v1/agent/governance/preflight', body, {
111
+ // Preflight only evaluates policy, so a timed-out request is safe to resend.
112
+ const data = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/governance/preflight', body, {
112
113
  'x-agent-api-key': agentApiKey,
113
114
  });
114
115
  printEnvelope({
@@ -122,7 +122,7 @@ async function commandTrackAction(ctx, flags, capabilities) {
122
122
  if (flags.decision) payload.decision = parseJsonFlag(flags, 'decision');
123
123
  if (flags.proof) payload.proof = parseJsonFlag(flags, 'proof');
124
124
 
125
- const response = await apiPost(ctx, '/api/v1/agent/telemetry/actions', payload);
125
+ const response = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/telemetry/actions', payload);
126
126
  printEnvelope({
127
127
  success: Boolean(response?.success !== false),
128
128
  code: response?.success === false ? 'TRACK_ACTION_FAILED' : 'TRACK_ACTION_OK',
@@ -1,4 +1,12 @@
1
1
  import { DEFAULT_TIMEOUT_MS } from './constants.mjs';
2
+ import { isReadOnlyActionName, isReadOnlyResolvedAction } from './readOnlyActions.mjs';
3
+
4
+ /**
5
+ * Default HTTP timeout for managed writes. A perps order or TP/SL update signs, broadcasts, and waits
6
+ * for a receipt inside one API call; that routinely takes longer than a read. Mirrors thirdfy-mcp
7
+ * `THIRDFY_API_WRITE_TIMEOUT_MS`.
8
+ */
9
+ const DEFAULT_WRITE_TIMEOUT_MS = 90_000;
2
10
 
3
11
  const LONG_RUNNING_ACTIONS = new Set([
4
12
  'complete_avantis_onboarding',
@@ -21,6 +29,10 @@ const LONG_RUNNING_ACTIONS = new Set([
21
29
  'swap',
22
30
  'execute_optimal_swap',
23
31
  'deposit_hyperliquid_cctp',
32
+ // Avantis market open/close: the API streams the batched order for up to 90s, then polls its
33
+ // tracking status for up to 90s more before it answers.
34
+ 'place_avantis_perps_order',
35
+ 'close_avantis_position',
24
36
  ]);
25
37
 
26
38
  function normalizeActionId(action) {
@@ -30,17 +42,51 @@ function normalizeActionId(action) {
30
42
  .toLowerCase();
31
43
  }
32
44
 
33
- /** Longer HTTP timeout for Avantis/Lighter onboarding, Avantis setup-status compose, and Morpho/Aave earn writes. */
34
- export function resolveActionTimeoutMs(action, baseTimeoutMs = DEFAULT_TIMEOUT_MS) {
45
+ function positiveEnvMs(...names) {
46
+ for (const name of names) {
47
+ const value = Number(process.env[name]);
48
+ if (Number.isFinite(value) && value > 0) return value;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /** True when the action changes state (anything the read-only classifier does not claim). */
54
+ export function isWriteAction(action) {
55
+ const key = normalizeActionId(action);
56
+ return Boolean(key) && !isReadOnlyActionName(key);
57
+ }
58
+
59
+ /**
60
+ * HTTP timeout for one action. Long-running onboarding, earn, bridge, and compose actions get the
61
+ * long timeout. Other writes get the write timeout (90s). Reads keep the base timeout. Pass
62
+ * `options.write` when the caller already classified the action (catalog metadata).
63
+ */
64
+ export function resolveActionTimeoutMs(action, baseTimeoutMs = DEFAULT_TIMEOUT_MS, options = {}) {
35
65
  const base = Number.isFinite(baseTimeoutMs) && baseTimeoutMs > 0 ? baseTimeoutMs : DEFAULT_TIMEOUT_MS;
36
- if (!LONG_RUNNING_ACTIONS.has(normalizeActionId(action))) return base;
37
- const longMs = Number(
38
- process.env.THIRDFY_CLI_LONG_TIMEOUT_MS || process.env.THIRDFY_API_LONG_TIMEOUT_MS || 180_000,
39
- );
40
- const resolvedLong = Number.isFinite(longMs) && longMs > 0 ? longMs : 180_000;
41
- return Math.max(base, resolvedLong);
66
+ if (LONG_RUNNING_ACTIONS.has(normalizeActionId(action))) {
67
+ const longMs = positiveEnvMs('THIRDFY_CLI_LONG_TIMEOUT_MS', 'THIRDFY_API_LONG_TIMEOUT_MS') ?? 180_000;
68
+ return Math.max(base, longMs);
69
+ }
70
+ const write = typeof options.write === 'boolean' ? options.write : isWriteAction(action);
71
+ if (write) {
72
+ const writeMs =
73
+ positiveEnvMs('THIRDFY_CLI_WRITE_TIMEOUT_MS', 'THIRDFY_API_WRITE_TIMEOUT_MS') ?? DEFAULT_WRITE_TIMEOUT_MS;
74
+ return Math.max(base, writeMs);
75
+ }
76
+ return base;
42
77
  }
43
78
 
44
- export function withActionTimeout(ctx, action) {
45
- return { ...ctx, timeoutMs: resolveActionTimeoutMs(action, ctx.timeoutMs) };
79
+ /**
80
+ * Context for one action's HTTP calls. Accepts the action id or the resolved catalog action, whose
81
+ * metadata can mark a read the name does not. `writeAction` tells `requestJson` not to resend a
82
+ * write whose request timed out: the API may have broadcast it, so the outcome is unknown until
83
+ * state is re-read.
84
+ */
85
+ export function withActionTimeout(ctx, actionOrResolved) {
86
+ const resolved = actionOrResolved && typeof actionOrResolved === 'object' ? actionOrResolved : null;
87
+ const action = resolved ? resolved.resolvedAction : actionOrResolved;
88
+ const write = resolved
89
+ ? Boolean(normalizeActionId(action)) && !isReadOnlyResolvedAction(resolved)
90
+ : isWriteAction(action);
91
+ return { ...ctx, timeoutMs: resolveActionTimeoutMs(action, ctx.timeoutMs, { write }), writeAction: write };
46
92
  }
@@ -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
@@ -12,6 +12,94 @@ function isRetryableApiStatus(statusCode) {
12
12
  return statusCode === 502 || statusCode === 503 || statusCode === 504;
13
13
  }
14
14
 
15
+ function isTimeoutError(error) {
16
+ const message = String(error?.message || '').toLowerCase();
17
+ return error?.name === 'AbortError' || message.includes('abort') || message.includes('timeout');
18
+ }
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
+
34
+ /**
35
+ * A write whose request timed out may already be on chain. Resending it can double a trade, so the
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).
39
+ */
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}`);
47
+ error.code = 'WRITE_OUTCOME_UNKNOWN';
48
+ error.statusCode = 504;
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
+ };
58
+ error.cause = cause;
59
+ return error;
60
+ }
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
+
85
+ /**
86
+ * Whether a request that timed out may be sent again. GETs, validation-only previews, and calls the
87
+ * caller marks `writeAction: false` are safe. Preflight sends `executionLane: validation_only`
88
+ * (not a `validationOnly` field). Any other POST changes state (an order, a venue funding, a
89
+ * delegation grant), so the default is not to resend it.
90
+ */
91
+ function resendAfterTimeoutIsSafe(ctx, init) {
92
+ if (ctx?.writeAction === true) return false;
93
+ if (ctx?.writeAction === false) return true;
94
+ if (String(init?.method || 'GET').toUpperCase() !== 'POST') return true;
95
+ try {
96
+ const body = JSON.parse(String(init?.body || '{}'));
97
+ return body?.validationOnly === true || body?.executionLane === 'validation_only';
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
15
103
  function isRetryableApiError(error) {
16
104
  const statusCode = Number(error?.statusCode || 0);
17
105
  const message = String(error?.message || '').toLowerCase();
@@ -58,6 +146,7 @@ async function requestJsonOnce(ctx, route, init) {
58
146
  });
59
147
  const text = await response.text();
60
148
  const data = text ? safeJsonParse(text) : {};
149
+ rememberResponseHeaders(data, response);
61
150
  if (!response.ok) {
62
151
  const error = new Error(data?.error || data?.message || `HTTP ${response.status}`);
63
152
  error.statusCode = response.status;
@@ -77,6 +166,9 @@ export async function requestJson(ctx, route, init) {
77
166
  return await requestJsonOnce(ctx, route, init);
78
167
  } catch (error) {
79
168
  lastError = error;
169
+ if (isTimeoutError(error) && !resendAfterTimeoutIsSafe(ctx, init)) {
170
+ throw writeOutcomeUnknownError(route, ctx, error, init);
171
+ }
80
172
  if (attempt === 0 && isRetryableApiError(error)) {
81
173
  await sleep(250 + Math.floor(Math.random() * 250));
82
174
  continue;
@@ -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') {
@@ -192,7 +237,7 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
192
237
  runMode: 'self',
193
238
  mirrorOnly: false,
194
239
  });
195
- const response = await apiPost(ctx, '/api/v1/agent/execute-intent', payload);
240
+ const response = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/execute-intent', payload);
196
241
  const normalized = normalizeIntentResponse(response);
197
242
  const blockedCount = normalized.blocked || 0;
198
243
  if (!normalized.success || blockedCount > 0) {
@@ -240,7 +285,7 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
240
285
  mirrorOnly: false,
241
286
  hybridWalletMode: options.hybridWalletMode,
242
287
  });
243
- const response = await apiPost(ctx, '/api/v1/agent/execute-intent', payload);
288
+ const response = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/execute-intent', payload);
244
289
  const normalized = normalizeIntentResponse(response);
245
290
  const blockedCount = normalized.blocked || 0;
246
291
  if (!normalized.success || blockedCount > 0) {
@@ -267,7 +312,7 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
267
312
  mirrorOnly: false,
268
313
  hybridWalletMode: 'agent_wallet',
269
314
  });
270
- const managedResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', managedPayload);
315
+ const managedResponse = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/execute-intent', managedPayload);
271
316
  const managedNormalized = normalizeIntentResponse(managedResponse);
272
317
  const mirrorPayload = buildIntentPayload(flags, {
273
318
  validationOnly: true,
@@ -277,7 +322,7 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
277
322
  mirrorOnly: true,
278
323
  hybridWalletMode: 'agent_wallet',
279
324
  });
280
- const mirrorResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', mirrorPayload);
325
+ const mirrorResponse = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/execute-intent', mirrorPayload);
281
326
  const mirrorNormalized = normalizeIntentResponse(mirrorResponse);
282
327
  return {
283
328
  message:
@@ -304,7 +349,7 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
304
349
  runMode,
305
350
  mirrorOnly: runMode === 'hybrid',
306
351
  });
307
- const response = await apiPost(ctx, '/api/v1/agent/execute-intent', payload);
352
+ const response = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/execute-intent', payload);
308
353
  const normalized = normalizeIntentResponse(response);
309
354
  return {
310
355
  message: normalized.success
@@ -403,7 +448,7 @@ async function runExecutionByMode(runMode, ctx, flags, resolved, options) {
403
448
 
404
449
  async function executeThirdfyRun(ctx, flags, resolved, options, runMode = 'thirdfy') {
405
450
  const skipPreflight = Boolean(options?.skipPreflight);
406
- const actionCtx = withActionTimeout(ctx, resolved.resolvedAction);
451
+ const actionCtx = withActionTimeout(ctx, resolved);
407
452
  if (!skipPreflight) {
408
453
  const preflightPayload = buildIntentPayload(flags, {
409
454
  validationOnly: true,
@@ -413,7 +458,12 @@ async function executeThirdfyRun(ctx, flags, resolved, options, runMode = 'third
413
458
  mirrorOnly: false,
414
459
  hybridWalletMode: options?.hybridWalletMode,
415
460
  });
416
- const preflight = await apiPost(actionCtx, '/api/v1/agent/execute-intent', preflightPayload);
461
+ // validationOnly never writes, so a timed-out preflight is safe to resend.
462
+ const preflight = await apiPost(
463
+ { ...actionCtx, writeAction: false },
464
+ '/api/v1/agent/execute-intent',
465
+ preflightPayload,
466
+ );
417
467
  const preflightNormalized = normalizeIntentResponse(preflight);
418
468
  const blockedCount = preflightNormalized.blocked || 0;
419
469
  if (!preflightNormalized.success || blockedCount > 0) {
@@ -434,6 +484,9 @@ async function executeThirdfyRun(ctx, flags, resolved, options, runMode = 'third
434
484
  const response = await apiPost(actionCtx, '/api/v1/agent/execute-intent', payload);
435
485
  const normalized = normalizeIntentResponse(response);
436
486
  normalized.idempotencyKey = payload.idempotencyKey;
487
+ if (!isReadOnlyAction(resolved)) {
488
+ Object.assign(normalized, idempotentReplayFields(response, responseHeadersFor(response), 'idempotencyKey'));
489
+ }
437
490
  return normalized;
438
491
  }
439
492
 
@@ -460,7 +513,7 @@ async function runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, pre
460
513
  intentResult.routeFallback = 'execute_intent_read_only';
461
514
  if (preflight) {
462
515
  intentResult.executionWalletPreflight = preflight;
463
- intentResult.executionWalletAddress = preflight.executionWalletAddress || null;
516
+ intentResult.executionWalletAddress = preflight.executionWalletAddress || intentResult.executionWalletAddress || null;
464
517
  }
465
518
  intentResult.signerMethod = 'managed_wallet_server';
466
519
  return intentResult;
@@ -474,8 +527,11 @@ async function executeManagedWalletRun(ctx, flags, resolved, options) {
474
527
  // (often as a JSON string under data.result). Reads skip funding gates; routing still fails closed.
475
528
  const isRead = isReadOnlyAction(resolved);
476
529
  const skipPreflight = Boolean(options?.skipPreflight);
477
- let preflight = null;
478
- 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) {
479
535
  preflight = await resolveManagedExecutionPreflight(ctx, flags, resolved, {
480
536
  runMode: 'agent_wallet',
481
537
  requireFundingCheck: !isRead,
@@ -503,10 +559,21 @@ async function executeManagedWalletRun(ctx, flags, resolved, options) {
503
559
  runMode: 'agent_wallet',
504
560
  forceIdempotency: true,
505
561
  });
506
- const actionCtx = withActionTimeout(ctx, resolved.resolvedAction);
562
+ const actionCtx = withActionTimeout(ctx, resolved);
507
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
+ }
508
568
  if (shouldFallbackAgentWalletToExecuteIntent(resolved, response)) {
509
- 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);
510
577
  if (isEmptyQueuedExecuteIntent(intentResult.raw || intentResult)) {
511
578
  intentResult.success = false;
512
579
  intentResult.status = 'failed';
@@ -522,6 +589,33 @@ async function executeManagedWalletRun(ctx, flags, resolved, options) {
522
589
  return normalizeManagedExecuteResponse(response, { preflight, payload, isRead });
523
590
  }
524
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
+
525
619
  async function executeSelfRun(ctx, flags, resolved, options) {
526
620
  const skipPreflight = Boolean(options?.skipPreflight);
527
621
  const blockedReasonCode = String(options?.blockedReasonCode || 'SELF_MODE_REQUIRES_BUILD_TX');
@@ -539,7 +633,7 @@ async function executeSelfRun(ctx, flags, resolved, options) {
539
633
  runMode: 'self',
540
634
  mirrorOnly: false,
541
635
  });
542
- const preflight = await apiPost(ctx, '/api/v1/agent/execute-intent', preflightPayload);
636
+ const preflight = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/execute-intent', preflightPayload);
543
637
  const preflightNormalized = normalizeIntentResponse(preflight);
544
638
  const blockedCount = preflightNormalized.blocked || 0;
545
639
  if (!preflightNormalized.success || blockedCount > 0) {
@@ -561,7 +655,8 @@ async function executeSelfRun(ctx, flags, resolved, options) {
561
655
  return normalized;
562
656
  }
563
657
  const buildTxPayload = buildBuildTxPayload(flags, resolved.resolvedAction);
564
- const buildTx = await apiPost(ctx, '/api/v1/agent/build-tx', buildTxPayload);
658
+ // build-tx returns an unsigned transaction; nothing is broadcast, so a timeout may be resent.
659
+ const buildTx = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/build-tx', buildTxPayload);
565
660
  return normalizeIntentResponse({
566
661
  ...buildTx,
567
662
  status: buildTx?.status || 'ready',
@@ -598,7 +693,7 @@ async function executeHybridRun(ctx, flags, resolved, options) {
598
693
  mirrorOnly: true,
599
694
  hybridWalletMode,
600
695
  });
601
- const mirrorResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', mirrorPayload);
696
+ const mirrorResponse = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/execute-intent', mirrorPayload);
602
697
  const mirrorNormalized = normalizeIntentResponse(mirrorResponse);
603
698
  return normalizeIntentResponse({
604
699
  success: Boolean(managedResult.success && mirrorNormalized.success),
@@ -624,7 +719,7 @@ async function executeHybridRun(ctx, flags, resolved, options) {
624
719
  runMode: 'hybrid',
625
720
  mirrorOnly: true,
626
721
  });
627
- const mirrorResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', mirrorPayload);
722
+ const mirrorResponse = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/execute-intent', mirrorPayload);
628
723
  const mirrorNormalized = normalizeIntentResponse(mirrorResponse);
629
724
  return normalizeIntentResponse({
630
725
  success: Boolean(selfResult.success && mirrorNormalized.success),
@@ -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
  }