@thirdfy/agent-cli 0.2.34 → 0.2.35

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,18 @@ All notable changes to `@thirdfy/agent-cli` are documented here. The format is b
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.35] - 2026-07-25
8
+
9
+ ### Fixed
10
+
11
+ - 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.
12
+ - `--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.
13
+ - `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.
14
+
15
+ ### Changed
16
+
17
+ - 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.
18
+
7
19
  ## [0.2.34] - 2026-07-25
8
20
 
9
21
  ### 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.35
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
+ - Read-only actions return the provider payload directly in every run mode. Market data reads such as `get_hyperliquid_perps_meta` no longer come back wrapped as an execution intent, and `--run-mode self` no longer refuses them.
46
+ - Read detection now matches the Thirdfy action catalog, so `fetch_*`, `show_*`, `dogeos_get_*`, and `*_info` actions are recognized as reads.
47
+ - Reads are not 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.
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.35",
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,15 +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;
84
+ return isReadOnlyResolvedAction(resolved);
92
85
  }
93
86
 
94
87
  // Reads carry no transaction, so wallet balance never blocks them. Every other preflight failure
@@ -139,6 +132,34 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
139
132
  };
140
133
  }
141
134
  if (runMode === 'self') {
135
+ // Reads have no unsigned tx to prepare. Keep preflight on validation-only execute-intent so
136
+ // operators dry-running self mode do not trigger a real catalog read (run still uses the read rail).
137
+ if (isReadOnlyAction(resolved)) {
138
+ const payload = buildIntentPayload(flags, {
139
+ validationOnly: true,
140
+ forceIdempotency: false,
141
+ resolvedAction,
142
+ runMode: 'self',
143
+ mirrorOnly: false,
144
+ });
145
+ const response = await apiPost(ctx, '/api/v1/agent/execute-intent', payload);
146
+ const normalized = normalizeIntentResponse(response);
147
+ const blockedCount = normalized.blocked || 0;
148
+ if (!normalized.success || blockedCount > 0) {
149
+ normalized.success = false;
150
+ normalized.error = normalized.error || 'Preflight blocked self execution';
151
+ normalized.preflightBlocked = true;
152
+ }
153
+ return {
154
+ message: normalized.success
155
+ ? 'Self preflight completed'
156
+ : normalized.preflightBlocked
157
+ ? 'Self preflight blocked by governance'
158
+ : 'Self preflight failed',
159
+ route: 'execute_intent_validation',
160
+ normalized,
161
+ };
162
+ }
142
163
  const route = shouldUseBuildTxPreflight(runMode, resolved) ? 'build_tx' : 'execute_intent_validation';
143
164
  const normalized = await executeSelfRun(
144
165
  ctx,
@@ -157,43 +178,74 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
157
178
  normalized,
158
179
  };
159
180
  }
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',
181
+ if (runMode === 'hybrid') {
182
+ // Reads have nothing to mirror and need no wallet. Keep preflight on validation-only
183
+ // execute-intent so dry-runs 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: 'hybrid',
190
+ mirrorOnly: false,
191
+ hybridWalletMode: options.hybridWalletMode,
192
+ });
193
+ const response = await apiPost(ctx, '/api/v1/agent/execute-intent', payload);
194
+ const normalized = normalizeIntentResponse(response);
195
+ const blockedCount = normalized.blocked || 0;
196
+ if (!normalized.success || blockedCount > 0) {
197
+ normalized.success = false;
198
+ normalized.error = normalized.error || 'Preflight blocked hybrid execution';
199
+ normalized.preflightBlocked = true;
200
+ }
201
+ return {
202
+ message: normalized.success
203
+ ? 'Hybrid preflight completed'
204
+ : normalized.preflightBlocked
205
+ ? 'Hybrid preflight blocked by governance'
206
+ : 'Hybrid preflight failed',
207
+ route: 'execute_intent_validation',
208
+ normalized,
209
+ };
210
+ }
211
+ if (options.hybridWalletMode === 'agent_wallet') {
212
+ const managedPayload = buildIntentPayload(flags, {
213
+ validationOnly: true,
214
+ forceIdempotency: false,
215
+ resolvedAction,
216
+ runMode: 'agent_wallet',
217
+ mirrorOnly: false,
191
218
  hybridWalletMode: 'agent_wallet',
192
- managed: managedNormalized,
193
- mirror: mirrorNormalized,
194
- error: managedNormalized.error || mirrorNormalized.error || null,
195
- }),
196
- };
219
+ });
220
+ const managedResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', managedPayload);
221
+ const managedNormalized = normalizeIntentResponse(managedResponse);
222
+ const mirrorPayload = buildIntentPayload(flags, {
223
+ validationOnly: true,
224
+ forceIdempotency: false,
225
+ resolvedAction,
226
+ runMode: 'hybrid',
227
+ mirrorOnly: true,
228
+ hybridWalletMode: 'agent_wallet',
229
+ });
230
+ const mirrorResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', mirrorPayload);
231
+ const mirrorNormalized = normalizeIntentResponse(mirrorResponse);
232
+ return {
233
+ message:
234
+ managedNormalized.success && mirrorNormalized.success
235
+ ? 'Hybrid preflight completed (agent_wallet + thirdfy mirror)'
236
+ : 'Hybrid preflight failed (agent_wallet + thirdfy mirror)',
237
+ route: 'execute_intent_validation_dual',
238
+ normalized: normalizeIntentResponse({
239
+ success: Boolean(managedNormalized.success && mirrorNormalized.success),
240
+ status: mirrorNormalized.status || managedNormalized.status || 'failed',
241
+ mode: 'hybrid',
242
+ hybridWalletMode: 'agent_wallet',
243
+ managed: managedNormalized,
244
+ mirror: mirrorNormalized,
245
+ error: managedNormalized.error || mirrorNormalized.error || null,
246
+ }),
247
+ };
248
+ }
197
249
  }
198
250
  const payload = buildIntentPayload(flags, {
199
251
  validationOnly: true,
@@ -249,14 +301,20 @@ async function runExecutionByMode(runMode, ctx, flags, resolved, options) {
249
301
  if (runMode === 'self') {
250
302
  const normalized = await executeSelfRun(ctx, flags, resolved, options);
251
303
  const preflightBlocked = !normalized.success && Boolean(normalized.preflightBlocked);
304
+ const readOnlySuccess =
305
+ normalized.success && normalized.routeFallback === 'execute_intent_read_only';
252
306
  return {
253
307
  code: normalized.success
254
- ? 'SELF_UNSIGNED_TX_READY'
308
+ ? readOnlySuccess
309
+ ? 'SELF_READ_COMPLETED'
310
+ : 'SELF_UNSIGNED_TX_READY'
255
311
  : preflightBlocked
256
312
  ? 'PREFLIGHT_BLOCKED'
257
313
  : 'SELF_EXECUTION_FAILED',
258
314
  message: normalized.success
259
- ? 'Unsigned transaction prepared for self-custody execution'
315
+ ? readOnlySuccess
316
+ ? 'Read-only action completed via delegated execute-intent'
317
+ : 'Unsigned transaction prepared for self-custody execution'
260
318
  : preflightBlocked
261
319
  ? 'Preflight blocked self execution'
262
320
  : 'Self-custody execution failed',
@@ -269,12 +327,22 @@ async function runExecutionByMode(runMode, ctx, flags, resolved, options) {
269
327
  const hybridWalletMode = normalizeHybridWalletMode(
270
328
  normalized.hybridWalletMode || options?.hybridWalletMode || flags.hybridWalletMode || 'self',
271
329
  );
330
+ const readOnlySuccess =
331
+ normalized.success && normalized.routeFallback === 'execute_intent_read_only';
272
332
  return {
273
- code: normalized.success ? 'HYBRID_READY' : preflightBlocked ? 'PREFLIGHT_BLOCKED' : 'HYBRID_FAILED',
333
+ code: normalized.success
334
+ ? readOnlySuccess
335
+ ? 'HYBRID_READ_COMPLETED'
336
+ : 'HYBRID_READY'
337
+ : preflightBlocked
338
+ ? 'PREFLIGHT_BLOCKED'
339
+ : 'HYBRID_FAILED',
274
340
  message: normalized.success
275
- ? hybridWalletMode === 'agent_wallet'
276
- ? 'Hybrid execution prepared (agent wallet + mirror preflight)'
277
- : 'Hybrid execution prepared (self tx + mirror preflight)'
341
+ ? readOnlySuccess
342
+ ? 'Read-only action completed via delegated execute-intent'
343
+ : hybridWalletMode === 'agent_wallet'
344
+ ? 'Hybrid execution prepared (agent wallet + mirror preflight)'
345
+ : 'Hybrid execution prepared (self tx + mirror preflight)'
278
346
  : preflightBlocked
279
347
  ? 'Preflight blocked hybrid execution'
280
348
  : 'Hybrid execution failed',
@@ -319,7 +387,7 @@ async function executeThirdfyRun(ctx, flags, resolved, options, runMode = 'third
319
387
  return normalized;
320
388
  }
321
389
 
322
- async function runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, preflight) {
390
+ async function runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, preflight, reportedMode = 'agent_wallet') {
323
391
  const userDid = resolveEffectiveUserDid(flags, {
324
392
  runMode: 'agent_wallet',
325
393
  hybridWalletMode: options?.hybridWalletMode,
@@ -338,7 +406,7 @@ async function runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, pre
338
406
  { ...options, skipPreflight: true },
339
407
  'thirdfy'
340
408
  );
341
- intentResult.mode = 'agent_wallet';
409
+ intentResult.mode = reportedMode;
342
410
  intentResult.routeFallback = 'execute_intent_read_only';
343
411
  if (preflight) {
344
412
  intentResult.executionWalletPreflight = preflight;
@@ -449,6 +517,12 @@ async function executeManagedWalletRun(ctx, flags, resolved, options) {
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) {
@@ -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,