@thirdfy/agent-cli 0.2.34 → 0.2.36

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
@@ -4,6 +4,29 @@ All notable changes to `@thirdfy/agent-cli` are documented here. The format is b
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.36] - 2026-07-25
8
+
9
+ ### Fixed
10
+
11
+ - `--run-mode agent_wallet` market-data reads use `/api/v1/agent/execute` again and keep the provider payload on the CLI envelope (`raw` plus coerced `data.result`). Version 0.2.34 routed every managed read through `/execute-intent` to avoid an execution-shaped reply, but solo Hermes `agent_wallet` intents come back as an empty queued envelope with no Hyperliquid universe. Live `/execute` still returns the meta payload (often as a JSON string under `data.result`); the CLI now parses that string so EarnClaw pack parsers can find `universe`. Execute-intent remains the fallback only when the execute rail rejects the action (`does not support execute rail`).
12
+ - **MCP parity:** the same rail fix belongs in `thirdfy-mcp` (`walletExecute`) as **0.0.79**. See workspace rule `cli-mcp-parity` and API docs-dev `cli-mcp-parity.md`.
13
+
14
+ ### Changed
15
+
16
+ - Reads still skip execution-wallet funding checks. Routing and identity preflight still fail closed.
17
+
18
+ ## [0.2.35] - 2026-07-25
19
+
20
+ ### Fixed
21
+
22
+ - Read-only detection now matches the Thirdfy API action manifest instead of a narrower client-side guess. `fetch_*`, `show_*`, `dogeos_get_*`, `*_info`, `dogeos_barkswap_get_quote`, `dogeos_laika_read_contract`, and `dogeos_laika_token_state` were previously classified as writes, so under `--run-mode agent_wallet` they went to the execute rail and came back as execution envelopes rather than data. A test now pins the two lists together so they cannot drift apart again.
23
+ - `--run-mode self` and `--run-mode hybrid` serve read-only actions from the read rail. Previously a read under `self` either failed with `SELF_MODE_REQUIRES_BUILD_TX` or, when catalog metadata was missing, asked `/api/v1/agent/build-tx` to build a transaction for a market-data query. Under `hybrid` the payload was nested beneath `managed`, where callers reading `results` could not find it, and a mirror failure could mark a successful read as failed.
24
+ - `normalizeIntentResponse` no longer drops top-level `data` and `raw`. It is a whitelist, and read responses carry their payload in `data`, so a caller could receive a well-formed envelope with nothing in it.
25
+
26
+ ### Changed
27
+
28
+ - Bitfinex reads under `--run-mode hybrid` are served from the delegated rail instead of returning `HYBRID_MODE_REQUIRES_BUILD_TX` with a hint to rerun using `--run-mode thirdfy`. The guard still applies to Bitfinex writes.
29
+
7
30
  ## [0.2.34] - 2026-07-25
8
31
 
9
32
  ### Fixed
package/README.md CHANGED
@@ -40,10 +40,11 @@ Run without global install:
40
40
  npx @thirdfy/agent-cli --help
41
41
  ```
42
42
 
43
- ## What's new in v0.2.34
43
+ ## What's new in v0.2.36
44
44
 
45
- - Read-only actions (`get_*`, `list_*`, `search_*`) requested with `--run-mode agent_wallet` now return the provider payload directly instead of an execution envelope. Market data reads such as `get_hyperliquid_perps_meta` no longer come back wrapped as an intent.
46
- - Reads are no longer gated on execution-wallet funding. The execution address is still reported, but an unfunded wallet does not block a read. Routing and identity checks still apply.
45
+ - `--run-mode agent_wallet` market-data reads (`get_hyperliquid_perps_meta` and siblings) keep the live provider payload from `/api/v1/agent/execute`, including stringified `data.result` coercion, so pack callers can parse the Hyperliquid universe again.
46
+ - Execute-intent is only used for managed reads when the execute rail rejects the action. Solo agent_wallet intents no longer replace a successful market-data reply with an empty queue.
47
+ - Reads still skip funding checks; routing and identity preflight still fail closed.
47
48
 
48
49
  Older versions: see [CHANGELOG.md](./CHANGELOG.md) and [GitHub Releases](https://github.com/thirdfy/agent-cli/releases).
49
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thirdfy/agent-cli",
3
- "version": "0.2.34",
3
+ "version": "0.2.36",
4
4
  "description": "Thirdfy Agent CLI for onboarding, governance preflight, execute-intent, and status polling.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Read-only action classification.
3
+ *
4
+ * This mirrors `isReadOnlyAction` in the Thirdfy API
5
+ * (`src/services/agents/execution/catalog/actionContractManifest.ts`). Keep the two in sync: when the
6
+ * client and the server disagree about what counts as a read, reads get sent to the managed-wallet
7
+ * execute rail and come back shaped as an execution envelope instead of provider data.
8
+ */
9
+
10
+ const READ_ONLY_PREFIXES = ['get_', 'list_', 'fetch_', 'search_', 'show_', 'dogeos_get_'];
11
+
12
+ const READ_ONLY_EXACT = new Set([
13
+ 'dogeos_barkswap_get_quote',
14
+ 'dogeos_laika_read_contract',
15
+ 'dogeos_laika_token_state',
16
+ ]);
17
+
18
+ export function normalizeActionKey(actionKey) {
19
+ return String(actionKey || '')
20
+ .trim()
21
+ .toLowerCase()
22
+ .replace(/-/g, '_');
23
+ }
24
+
25
+ export function isReadOnlyActionName(actionKey) {
26
+ const key = normalizeActionKey(actionKey);
27
+ if (!key) return false;
28
+ if (READ_ONLY_EXACT.has(key)) return true;
29
+ if (key.endsWith('_info')) return true;
30
+ return READ_ONLY_PREFIXES.some((prefix) => key.startsWith(prefix));
31
+ }
32
+
33
+ /**
34
+ * Catalog metadata can mark an action read-only even when the name does not match. Production
35
+ * defaults set `supportsExecute: true` on nearly everything, so this rarely fires on its own.
36
+ */
37
+ export function isReadOnlyActionMeta(meta) {
38
+ if (!meta || typeof meta !== 'object') return false;
39
+ return meta.supportsExecute === false && meta.supportsExecuteIntent !== false;
40
+ }
41
+
42
+ export function isReadOnlyResolvedAction(resolved, options = {}) {
43
+ const meta = resolved?.resolvedActionMeta;
44
+ if (options.requireMeta && (!meta || typeof meta !== 'object')) return false;
45
+ return isReadOnlyActionName(resolved?.resolvedAction) || isReadOnlyActionMeta(meta);
46
+ }
@@ -11,6 +11,7 @@ import {
11
11
  } from '../../core/runMode.mjs';
12
12
  import { apiGet, apiPost } from '../../core/http.mjs';
13
13
  import { withActionTimeout } from '../../core/actionTimeouts.mjs';
14
+ import { isReadOnlyResolvedAction } from '../../core/readOnlyActions.mjs';
14
15
  import { createRequire } from 'module';
15
16
 
16
17
  const require = createRequire(import.meta.url);
@@ -80,24 +81,7 @@ function applyExecutionFallbackHints(normalized, { flags, runMode, resolvedActio
80
81
  }
81
82
 
82
83
  function isReadOnlyAction(resolved) {
83
- const meta = resolved?.resolvedActionMeta || {};
84
- const action = String(resolved?.resolvedAction || '')
85
- .trim()
86
- .toLowerCase()
87
- .replace(/-/g, '_');
88
- const readOnlyByName =
89
- action.startsWith('get_') || action.startsWith('search_') || action.startsWith('list_');
90
- const readOnlyByMeta = meta.supportsExecute === false && meta.supportsExecuteIntent !== false;
91
- return readOnlyByName || readOnlyByMeta;
92
- }
93
-
94
- // Reads carry no transaction, so wallet balance never blocks them. Every other preflight failure
95
- // (identity, routing, wallet mismatch) still fails closed, same as a write.
96
- const FUNDING_ONLY_PREFLIGHT_BLOCKS = new Set(['INSUFFICIENT_FUNDS', 'INVALID_FUNDING_BALANCE']);
97
-
98
- function isFundingOnlyPreflightBlock(preflight) {
99
- const reason = String(preflight?.blockedReason || '').trim().toUpperCase();
100
- return FUNDING_ONLY_PREFLIGHT_BLOCKS.has(reason);
84
+ return isReadOnlyResolvedAction(resolved);
101
85
  }
102
86
 
103
87
  function shouldFallbackAgentWalletToExecuteIntent(resolved, response) {
@@ -106,6 +90,62 @@ function shouldFallbackAgentWalletToExecuteIntent(resolved, response) {
106
90
  return err.includes('does not support execute rail');
107
91
  }
108
92
 
93
+ // Managed /execute often returns provider payloads as JSON strings under data.result. Pack parsers
94
+ // (and find_value_by_key) need a real object tree, so coerce strings that look like JSON.
95
+ function coerceJsonTreeValue(value) {
96
+ if (typeof value !== 'string') return value;
97
+ const trimmed = value.trim();
98
+ if (!trimmed || (trimmed[0] !== '{' && trimmed[0] !== '[')) return value;
99
+ try {
100
+ return JSON.parse(trimmed);
101
+ } catch {
102
+ return value;
103
+ }
104
+ }
105
+
106
+ function shapeManagedExecuteReadResponse(response) {
107
+ if (!response || typeof response !== 'object') return response;
108
+ const data =
109
+ response.data && typeof response.data === 'object' ? { ...response.data } : response.data;
110
+ if (data && typeof data === 'object' && Object.prototype.hasOwnProperty.call(data, 'result')) {
111
+ data.result = coerceJsonTreeValue(data.result);
112
+ }
113
+ return { ...response, data };
114
+ }
115
+
116
+ function normalizeManagedExecuteResponse(response, { preflight, payload, isRead }) {
117
+ const shaped = isRead ? shapeManagedExecuteReadResponse(response) : response;
118
+ const normalized = normalizeIntentResponse({
119
+ success: Boolean(shaped?.success),
120
+ status: shaped?.success ? 'completed' : 'failed',
121
+ mode: 'agent_wallet',
122
+ txHash: shaped?.txHash || null,
123
+ blockedReason: shaped?.blockedReason || shaped?.data?.blockedReason || null,
124
+ blockedStage: shaped?.blockedStage || shaped?.data?.blockedStage || null,
125
+ error: shaped?.error || null,
126
+ executionWalletAddress:
127
+ shaped?.executionWalletAddress || shaped?.data?.executionWalletAddress || null,
128
+ signerMethod: 'managed_wallet_server',
129
+ idempotencyKey: payload.executionIdempotencyKey || null,
130
+ executionWalletPreflight: preflight,
131
+ amountNormalization:
132
+ shaped?.amountNormalization || shaped?.data?.amountNormalization || null,
133
+ // Reads must keep provider payload on the envelope. EarnClaw packs parse
134
+ // data.raw.data.result (see parse_thirdfy_action_result).
135
+ ...(isRead && shaped?.data !== undefined ? { data: shaped.data } : {}),
136
+ raw: shaped,
137
+ });
138
+ normalized.executionWalletAddress =
139
+ shaped?.executionWalletAddress || shaped?.data?.executionWalletAddress || null;
140
+ normalized.signerMethod = 'managed_wallet_server';
141
+ normalized.executionWalletPreflight = preflight;
142
+ normalized.amountNormalization =
143
+ shaped?.amountNormalization || shaped?.data?.amountNormalization || null;
144
+ normalized.raw = shaped;
145
+ normalized.idempotencyKey = payload.executionIdempotencyKey || null;
146
+ return normalized;
147
+ }
148
+
109
149
  async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
110
150
  const resolvedAction = resolved.resolvedAction;
111
151
  if (runMode === 'agent_wallet') {
@@ -139,6 +179,34 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
139
179
  };
140
180
  }
141
181
  if (runMode === 'self') {
182
+ // Reads have no unsigned tx to prepare. Keep preflight on validation-only execute-intent so
183
+ // operators dry-running self mode do not trigger a real catalog read (run still uses the read rail).
184
+ if (isReadOnlyAction(resolved)) {
185
+ const payload = buildIntentPayload(flags, {
186
+ validationOnly: true,
187
+ forceIdempotency: false,
188
+ resolvedAction,
189
+ runMode: 'self',
190
+ mirrorOnly: false,
191
+ });
192
+ const response = await apiPost(ctx, '/api/v1/agent/execute-intent', payload);
193
+ const normalized = normalizeIntentResponse(response);
194
+ const blockedCount = normalized.blocked || 0;
195
+ if (!normalized.success || blockedCount > 0) {
196
+ normalized.success = false;
197
+ normalized.error = normalized.error || 'Preflight blocked self execution';
198
+ normalized.preflightBlocked = true;
199
+ }
200
+ return {
201
+ message: normalized.success
202
+ ? 'Self preflight completed'
203
+ : normalized.preflightBlocked
204
+ ? 'Self preflight blocked by governance'
205
+ : 'Self preflight failed',
206
+ route: 'execute_intent_validation',
207
+ normalized,
208
+ };
209
+ }
142
210
  const route = shouldUseBuildTxPreflight(runMode, resolved) ? 'build_tx' : 'execute_intent_validation';
143
211
  const normalized = await executeSelfRun(
144
212
  ctx,
@@ -157,43 +225,74 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
157
225
  normalized,
158
226
  };
159
227
  }
160
- if (runMode === 'hybrid' && options.hybridWalletMode === 'agent_wallet') {
161
- const managedPayload = buildIntentPayload(flags, {
162
- validationOnly: true,
163
- forceIdempotency: false,
164
- resolvedAction,
165
- runMode: 'agent_wallet',
166
- mirrorOnly: false,
167
- hybridWalletMode: 'agent_wallet',
168
- });
169
- const managedResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', managedPayload);
170
- const managedNormalized = normalizeIntentResponse(managedResponse);
171
- const mirrorPayload = buildIntentPayload(flags, {
172
- validationOnly: true,
173
- forceIdempotency: false,
174
- resolvedAction,
175
- runMode: 'hybrid',
176
- mirrorOnly: true,
177
- hybridWalletMode: 'agent_wallet',
178
- });
179
- const mirrorResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', mirrorPayload);
180
- const mirrorNormalized = normalizeIntentResponse(mirrorResponse);
181
- return {
182
- message:
183
- managedNormalized.success && mirrorNormalized.success
184
- ? 'Hybrid preflight completed (agent_wallet + thirdfy mirror)'
185
- : 'Hybrid preflight failed (agent_wallet + thirdfy mirror)',
186
- route: 'execute_intent_validation_dual',
187
- normalized: normalizeIntentResponse({
188
- success: Boolean(managedNormalized.success && mirrorNormalized.success),
189
- status: mirrorNormalized.status || managedNormalized.status || 'failed',
190
- mode: 'hybrid',
228
+ if (runMode === 'hybrid') {
229
+ // Reads have nothing to mirror and need no wallet. Keep preflight on validation-only
230
+ // execute-intent so dry-runs do not trigger a real catalog read (run still uses the read rail).
231
+ if (isReadOnlyAction(resolved)) {
232
+ const payload = buildIntentPayload(flags, {
233
+ validationOnly: true,
234
+ forceIdempotency: false,
235
+ resolvedAction,
236
+ runMode: 'hybrid',
237
+ mirrorOnly: false,
238
+ hybridWalletMode: options.hybridWalletMode,
239
+ });
240
+ const response = await apiPost(ctx, '/api/v1/agent/execute-intent', payload);
241
+ const normalized = normalizeIntentResponse(response);
242
+ const blockedCount = normalized.blocked || 0;
243
+ if (!normalized.success || blockedCount > 0) {
244
+ normalized.success = false;
245
+ normalized.error = normalized.error || 'Preflight blocked hybrid execution';
246
+ normalized.preflightBlocked = true;
247
+ }
248
+ return {
249
+ message: normalized.success
250
+ ? 'Hybrid preflight completed'
251
+ : normalized.preflightBlocked
252
+ ? 'Hybrid preflight blocked by governance'
253
+ : 'Hybrid preflight failed',
254
+ route: 'execute_intent_validation',
255
+ normalized,
256
+ };
257
+ }
258
+ if (options.hybridWalletMode === 'agent_wallet') {
259
+ const managedPayload = buildIntentPayload(flags, {
260
+ validationOnly: true,
261
+ forceIdempotency: false,
262
+ resolvedAction,
263
+ runMode: 'agent_wallet',
264
+ mirrorOnly: false,
191
265
  hybridWalletMode: 'agent_wallet',
192
- managed: managedNormalized,
193
- mirror: mirrorNormalized,
194
- error: managedNormalized.error || mirrorNormalized.error || null,
195
- }),
196
- };
266
+ });
267
+ const managedResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', managedPayload);
268
+ const managedNormalized = normalizeIntentResponse(managedResponse);
269
+ const mirrorPayload = buildIntentPayload(flags, {
270
+ validationOnly: true,
271
+ forceIdempotency: false,
272
+ resolvedAction,
273
+ runMode: 'hybrid',
274
+ mirrorOnly: true,
275
+ hybridWalletMode: 'agent_wallet',
276
+ });
277
+ const mirrorResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', mirrorPayload);
278
+ const mirrorNormalized = normalizeIntentResponse(mirrorResponse);
279
+ return {
280
+ message:
281
+ managedNormalized.success && mirrorNormalized.success
282
+ ? 'Hybrid preflight completed (agent_wallet + thirdfy mirror)'
283
+ : 'Hybrid preflight failed (agent_wallet + thirdfy mirror)',
284
+ route: 'execute_intent_validation_dual',
285
+ normalized: normalizeIntentResponse({
286
+ success: Boolean(managedNormalized.success && mirrorNormalized.success),
287
+ status: mirrorNormalized.status || managedNormalized.status || 'failed',
288
+ mode: 'hybrid',
289
+ hybridWalletMode: 'agent_wallet',
290
+ managed: managedNormalized,
291
+ mirror: mirrorNormalized,
292
+ error: managedNormalized.error || mirrorNormalized.error || null,
293
+ }),
294
+ };
295
+ }
197
296
  }
198
297
  const payload = buildIntentPayload(flags, {
199
298
  validationOnly: true,
@@ -249,14 +348,20 @@ async function runExecutionByMode(runMode, ctx, flags, resolved, options) {
249
348
  if (runMode === 'self') {
250
349
  const normalized = await executeSelfRun(ctx, flags, resolved, options);
251
350
  const preflightBlocked = !normalized.success && Boolean(normalized.preflightBlocked);
351
+ const readOnlySuccess =
352
+ normalized.success && normalized.routeFallback === 'execute_intent_read_only';
252
353
  return {
253
354
  code: normalized.success
254
- ? 'SELF_UNSIGNED_TX_READY'
355
+ ? readOnlySuccess
356
+ ? 'SELF_READ_COMPLETED'
357
+ : 'SELF_UNSIGNED_TX_READY'
255
358
  : preflightBlocked
256
359
  ? 'PREFLIGHT_BLOCKED'
257
360
  : 'SELF_EXECUTION_FAILED',
258
361
  message: normalized.success
259
- ? 'Unsigned transaction prepared for self-custody execution'
362
+ ? readOnlySuccess
363
+ ? 'Read-only action completed via delegated execute-intent'
364
+ : 'Unsigned transaction prepared for self-custody execution'
260
365
  : preflightBlocked
261
366
  ? 'Preflight blocked self execution'
262
367
  : 'Self-custody execution failed',
@@ -269,12 +374,22 @@ async function runExecutionByMode(runMode, ctx, flags, resolved, options) {
269
374
  const hybridWalletMode = normalizeHybridWalletMode(
270
375
  normalized.hybridWalletMode || options?.hybridWalletMode || flags.hybridWalletMode || 'self',
271
376
  );
377
+ const readOnlySuccess =
378
+ normalized.success && normalized.routeFallback === 'execute_intent_read_only';
272
379
  return {
273
- code: normalized.success ? 'HYBRID_READY' : preflightBlocked ? 'PREFLIGHT_BLOCKED' : 'HYBRID_FAILED',
380
+ code: normalized.success
381
+ ? readOnlySuccess
382
+ ? 'HYBRID_READ_COMPLETED'
383
+ : 'HYBRID_READY'
384
+ : preflightBlocked
385
+ ? 'PREFLIGHT_BLOCKED'
386
+ : 'HYBRID_FAILED',
274
387
  message: normalized.success
275
- ? hybridWalletMode === 'agent_wallet'
276
- ? 'Hybrid execution prepared (agent wallet + mirror preflight)'
277
- : 'Hybrid execution prepared (self tx + mirror preflight)'
388
+ ? readOnlySuccess
389
+ ? 'Read-only action completed via delegated execute-intent'
390
+ : hybridWalletMode === 'agent_wallet'
391
+ ? 'Hybrid execution prepared (agent wallet + mirror preflight)'
392
+ : 'Hybrid execution prepared (self tx + mirror preflight)'
278
393
  : preflightBlocked
279
394
  ? 'Preflight blocked hybrid execution'
280
395
  : 'Hybrid execution failed',
@@ -319,7 +434,7 @@ async function executeThirdfyRun(ctx, flags, resolved, options, runMode = 'third
319
434
  return normalized;
320
435
  }
321
436
 
322
- async function runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, preflight) {
437
+ async function runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, preflight, reportedMode = 'agent_wallet') {
323
438
  const userDid = resolveEffectiveUserDid(flags, {
324
439
  runMode: 'agent_wallet',
325
440
  hybridWalletMode: options?.hybridWalletMode,
@@ -338,7 +453,7 @@ async function runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, pre
338
453
  { ...options, skipPreflight: true },
339
454
  'thirdfy'
340
455
  );
341
- intentResult.mode = 'agent_wallet';
456
+ intentResult.mode = reportedMode;
342
457
  intentResult.routeFallback = 'execute_intent_read_only';
343
458
  if (preflight) {
344
459
  intentResult.executionWalletPreflight = preflight;
@@ -349,44 +464,18 @@ async function runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, pre
349
464
  }
350
465
 
351
466
  async function executeManagedWalletRun(ctx, flags, resolved, options) {
352
- // Read-only actions carry no transaction. Routing them through the managed-wallet execute rail
353
- // returns an execution envelope instead of the queried data, which silently starves callers that
354
- // asked for market state (see earnclaw-api HL universe probe regression, 2026-07-25).
355
- // The wallet address is still resolved for response parity, but a read is never gated on funding.
356
- if (isReadOnlyAction(resolved)) {
357
- // Do not swallow lookup/routing errors: writes let them propagate, and reads must fail closed
358
- // the same way (wallet mismatch, missing identity, failed /execution-wallet).
359
- const readPreflight = await resolveManagedExecutionPreflight(ctx, flags, resolved, {
360
- runMode: 'agent_wallet',
361
- requireFundingCheck: false,
362
- preflightSeed: options?.managedPreflight,
363
- });
364
- // Funding is intentionally skipped for reads (`requireFundingCheck: false`), but routing
365
- // failures (wallet mismatch, missing identity, failed address seed) must still fail closed.
366
- if (readPreflight && readPreflight.success === false && !isFundingOnlyPreflightBlock(readPreflight)) {
367
- const normalized = normalizeIntentResponse({
368
- success: false,
369
- status: 'failed',
370
- mode: 'agent_wallet',
371
- blockedReason: readPreflight.blockedReason || 'PRECHECK_FAILED',
372
- blockedStage: readPreflight.blockedStage || 'routing',
373
- error: readPreflight.error || 'Managed wallet preflight failed',
374
- preflightBlocked: true,
375
- executionWalletAddress: readPreflight.executionWalletAddress || null,
376
- executionWalletPreflight: readPreflight,
377
- });
378
- normalized.executionWalletAddress = readPreflight.executionWalletAddress || null;
379
- normalized.executionWalletPreflight = readPreflight;
380
- return normalized;
381
- }
382
- return runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, readPreflight);
383
- }
467
+ // Market-data reads under agent_wallet must use /execute and keep the provider payload on the
468
+ // envelope (raw + parsed data.result). Routing them only through /execute-intent was the 0.2.34
469
+ // attempt to avoid a burial bug, but solo Hermes agent_wallet intents come back as an empty
470
+ // queued envelope with no universe. Live /execute still returns the Hyperliquid meta payload
471
+ // (often as a JSON string under data.result). Reads skip funding gates; routing still fails closed.
472
+ const isRead = isReadOnlyAction(resolved);
384
473
  const skipPreflight = Boolean(options?.skipPreflight);
385
474
  let preflight = null;
386
475
  if (!skipPreflight) {
387
476
  preflight = await resolveManagedExecutionPreflight(ctx, flags, resolved, {
388
477
  runMode: 'agent_wallet',
389
- requireFundingCheck: true,
478
+ requireFundingCheck: !isRead,
390
479
  preflightSeed: options?.managedPreflight,
391
480
  });
392
481
  if (!preflight.success) {
@@ -422,33 +511,18 @@ async function executeManagedWalletRun(ctx, flags, resolved, options) {
422
511
  null;
423
512
  return intentResult;
424
513
  }
425
- const normalized = normalizeIntentResponse({
426
- success: Boolean(response?.success),
427
- status: response?.success ? 'completed' : 'failed',
428
- mode: 'agent_wallet',
429
- txHash: response?.txHash || null,
430
- blockedReason: response?.blockedReason || response?.data?.blockedReason || null,
431
- blockedStage: response?.blockedStage || response?.data?.blockedStage || null,
432
- error: response?.error || null,
433
- executionWalletAddress: response?.executionWalletAddress || response?.data?.executionWalletAddress || null,
434
- signerMethod: 'managed_wallet_server',
435
- idempotencyKey: payload.executionIdempotencyKey || null,
436
- executionWalletPreflight: preflight,
437
- amountNormalization: response?.amountNormalization || response?.data?.amountNormalization || null,
438
- raw: response,
439
- });
440
- normalized.executionWalletAddress = response?.executionWalletAddress || response?.data?.executionWalletAddress || null;
441
- normalized.signerMethod = 'managed_wallet_server';
442
- normalized.executionWalletPreflight = preflight;
443
- normalized.amountNormalization = response?.amountNormalization || response?.data?.amountNormalization || null;
444
- normalized.raw = response;
445
- normalized.idempotencyKey = payload.executionIdempotencyKey || null;
446
- return normalized;
514
+ return normalizeManagedExecuteResponse(response, { preflight, payload, isRead });
447
515
  }
448
516
 
449
517
  async function executeSelfRun(ctx, flags, resolved, options) {
450
518
  const skipPreflight = Boolean(options?.skipPreflight);
451
519
  const blockedReasonCode = String(options?.blockedReasonCode || 'SELF_MODE_REQUIRES_BUILD_TX');
520
+ // A read has no transaction to build or sign, so the self lane has nothing to do with it. Without
521
+ // this it either fails with SELF_MODE_REQUIRES_BUILD_TX or, when catalog metadata is missing, asks
522
+ // /build-tx to produce a transaction for a market-data query.
523
+ if (isReadOnlyAction(resolved)) {
524
+ return runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, null, options?.runMode || 'self');
525
+ }
452
526
  if (!skipPreflight) {
453
527
  const preflightPayload = buildIntentPayload(flags, {
454
528
  validationOnly: true,
@@ -491,6 +565,14 @@ async function executeSelfRun(ctx, flags, resolved, options) {
491
565
 
492
566
  async function executeHybridRun(ctx, flags, resolved, options) {
493
567
  const hybridWalletMode = normalizeHybridWalletMode(options?.hybridWalletMode || flags.hybridWalletMode || 'self');
568
+ // Reads have nothing to mirror and need no wallet, whichever hybrid wallet mode is selected.
569
+ // Answering from the read rail also keeps the payload at the top level, instead of nesting it under
570
+ // `managed` where callers that read `results` cannot find it.
571
+ if (isReadOnlyAction(resolved)) {
572
+ const readResult = await runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, null, 'hybrid');
573
+ readResult.hybridWalletMode = hybridWalletMode;
574
+ return readResult;
575
+ }
494
576
  if (hybridWalletMode === 'agent_wallet') {
495
577
  const managedResult = await executeManagedWalletRun(ctx, { ...flags, runMode: 'agent_wallet' }, resolved, options);
496
578
  if (!managedResult.success) {
@@ -598,15 +680,19 @@ async function resolveManagedExecutionPreflight(ctx, flags, resolved, options =
598
680
  try {
599
681
  parsedRawBalance = BigInt(rawBalance);
600
682
  } catch {
601
- return {
602
- success: false,
603
- blockedReason: 'INVALID_FUNDING_BALANCE',
604
- blockedStage: 'sizing',
605
- error: `Execution wallet ${executionWalletAddress || 'unknown'} returned invalid funding token balance.`,
606
- executionWalletAddress,
607
- signerMethod,
608
- fundingTokenBalance: response?.fundingTokenBalance || null,
609
- };
683
+ // Reads pass requireFundingCheck: false; a malformed balance must not block them.
684
+ // Writes still fail closed on unparseable fundingTokenBalance.raw.
685
+ if (options.requireFundingCheck) {
686
+ return {
687
+ success: false,
688
+ blockedReason: 'INVALID_FUNDING_BALANCE',
689
+ blockedStage: 'sizing',
690
+ error: `Execution wallet ${executionWalletAddress || 'unknown'} returned invalid funding token balance.`,
691
+ executionWalletAddress,
692
+ signerMethod,
693
+ fundingTokenBalance: response?.fundingTokenBalance || null,
694
+ };
695
+ }
610
696
  }
611
697
  }
612
698
  const expectedWallet = String(flags.walletAddress || flags.executionWalletAddress || '').trim().toLowerCase();
@@ -1,4 +1,5 @@
1
1
  import { createCliError } from '../../core/envelope.mjs';
2
+ import { isReadOnlyResolvedAction } from '../../core/readOnlyActions.mjs';
2
3
  import {
3
4
  sanitizeParamsForSchemaValidation,
4
5
  validateParamsSchema,
@@ -12,15 +13,7 @@ export function resolveActionParamsSchema(resolved) {
12
13
  }
13
14
 
14
15
  export function isReadOnlyCatalogAction(resolved) {
15
- const meta = resolved?.resolvedActionMeta;
16
- if (!meta || typeof meta !== 'object') return false;
17
- const action = String(resolved?.resolvedAction || '')
18
- .trim()
19
- .toLowerCase()
20
- .replace(/-/g, '_');
21
- const readOnlyByName = action.startsWith('get_') || action.startsWith('list_') || action.startsWith('search_');
22
- const readOnlyByMeta = meta.supportsExecute === false && meta.supportsExecuteIntent !== false;
23
- return readOnlyByName || readOnlyByMeta;
16
+ return isReadOnlyResolvedAction(resolved, { requireMeta: true });
24
17
  }
25
18
 
26
19
  export function validateResolvedActionParams(resolved, params) {
@@ -34,7 +34,14 @@ function normalizeIntentResponse(response) {
34
34
  const topLevelDelegationSignal = String(response?.delegationSignal || '').trim() || null;
35
35
  const policyEvaluated = response?.policyEvaluated === true;
36
36
  const preflightBlocked = response?.preflightBlocked === true;
37
+ // This function is a whitelist, so any provider payload outside the known keys is dropped. Read
38
+ // actions return their data in `data` (and sometimes `raw`) rather than in `results`, so those two
39
+ // must survive or the caller gets a well-formed envelope with nothing in it.
40
+ const passthrough = {};
41
+ if (response?.data !== undefined) passthrough.data = response.data;
42
+ if (response?.raw !== undefined) passthrough.raw = response.raw;
37
43
  return {
44
+ ...passthrough,
38
45
  success,
39
46
  status: response?.status || (success ? 'queued' : 'failed'),
40
47
  intentId: response?.intentId || null,