@senpi/runtime 3.0.64 → 3.0.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/actions/close-position/close-position.action.d.ts.map +1 -1
  2. package/dist/actions/close-position/close-position.action.js +8 -3
  3. package/dist/actions/close-position/close-position.action.js.map +1 -1
  4. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  5. package/dist/actions/open-position/open-position.action.js +10 -4
  6. package/dist/actions/open-position/open-position.action.js.map +1 -1
  7. package/dist/cli/deploy-render.d.ts +3 -1
  8. package/dist/cli/deploy-render.d.ts.map +1 -1
  9. package/dist/cli/deploy-render.js +233 -11
  10. package/dist/cli/deploy-render.js.map +1 -1
  11. package/dist/cli/guide-commands.d.ts.map +1 -1
  12. package/dist/cli/guide-commands.js +9 -7
  13. package/dist/cli/guide-commands.js.map +1 -1
  14. package/dist/cli/senpi-commands.js +4 -4
  15. package/dist/cli/senpi-commands.js.map +1 -1
  16. package/dist/deploy/deadlines.d.ts +3 -3
  17. package/dist/deploy/deadlines.js +3 -3
  18. package/dist/deploy/job.d.ts +16 -0
  19. package/dist/deploy/job.d.ts.map +1 -1
  20. package/dist/deploy/job.js +7 -1
  21. package/dist/deploy/job.js.map +1 -1
  22. package/dist/deploy/orchestrator.d.ts +2 -1
  23. package/dist/deploy/orchestrator.d.ts.map +1 -1
  24. package/dist/deploy/orchestrator.js +402 -84
  25. package/dist/deploy/orchestrator.js.map +1 -1
  26. package/dist/deploy/proof-gate.d.ts.map +1 -1
  27. package/dist/deploy/proof-gate.js +8 -3
  28. package/dist/deploy/proof-gate.js.map +1 -1
  29. package/dist/deploy/register.js +1 -1
  30. package/dist/dsl/events/handlers.d.ts.map +1 -1
  31. package/dist/dsl/events/handlers.js +11 -0
  32. package/dist/dsl/events/handlers.js.map +1 -1
  33. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  34. package/dist/dsl/monitor/process-one-position.js +9 -0
  35. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +32 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/senpi/client.d.ts +60 -1
  40. package/dist/senpi/client.d.ts.map +1 -1
  41. package/dist/senpi/client.js +318 -14
  42. package/dist/senpi/client.js.map +1 -1
  43. package/dist/senpi/types.d.ts +18 -0
  44. package/dist/senpi/types.d.ts.map +1 -1
  45. package/dist/validate/proof.js +4 -1
  46. package/dist/validate/proof.js.map +1 -1
  47. package/dist/validate/run.d.ts.map +1 -1
  48. package/dist/validate/run.js +4 -1
  49. package/dist/validate/run.js.map +1 -1
  50. package/openclaw.plugin.json +1 -1
  51. package/package.json +1 -1
@@ -28,12 +28,212 @@ const logger = createLogger("[senpi][client]");
28
28
  export class SenpiClientError extends Error {
29
29
  code;
30
30
  details;
31
- constructor(code, message, details) {
32
- super(message);
31
+ /**
32
+ * The venue may have acted despite the failure — set when the envelope reports
33
+ * an ambiguous upstream code (the server's own execution-timeout abort) on a
34
+ * mutating call. Callers must reconcile rather than re-send.
35
+ */
36
+ mayHaveExecuted;
37
+ /** Envelope `error.code`, kept so telemetry and callers can key on it. */
38
+ upstreamCode;
39
+ constructor(code, message, details, opts) {
40
+ super(message, opts?.cause !== undefined ? { cause: opts.cause } : undefined);
33
41
  this.name = "SenpiClientError";
34
42
  this.code = code;
35
43
  this.details = details;
44
+ this.mayHaveExecuted = opts?.mayHaveExecuted ?? false;
45
+ this.upstreamCode = opts?.upstreamCode;
46
+ }
47
+ }
48
+ /** JSON-RPC code the MCP SDK uses for a client-side request timeout. */
49
+ export const MCP_REQUEST_TIMEOUT_CODE = -32001;
50
+ /**
51
+ * Server-side ALO wait window when the recipe doesn't override it
52
+ * (`FeeOptimizedLimitOptions.executionTimeoutSeconds`, see types.ts).
53
+ */
54
+ const SERVER_DEFAULT_ALO_SECONDS = 45;
55
+ /**
56
+ * Slack added on top of the ALO budget: the venue round-trip and the taker
57
+ * crossing that `ensure_execution_as_taker` performs *after* the maker window
58
+ * closes both land outside the budget the recipe asked for.
59
+ */
60
+ const ALO_TIMEOUT_HEADROOM_MS = 30_000;
61
+ /**
62
+ * Floor for a **mutating** MCP request. Sized just above the trading-engine
63
+ * dependency's own ~70s ceiling (453k calls/7d: p50 90ms, p95 19.8s, max 70.0s) —
64
+ * a client that gives up earlier abandons work that then succeeds, and for a
65
+ * mutation that ambiguity is the expensive kind.
66
+ *
67
+ * Deliberately not larger: signal processing is strictly serial (one consumer, see
68
+ * CLAUDE.md), so a hung call blocks every queued signal behind it for the whole
69
+ * timeout. 90s clears the measured ceiling with ~20s of margin while keeping that
70
+ * worst-case stall as short as the evidence allows.
71
+ */
72
+ const MCP_MUTATION_TIMEOUT_FLOOR_MS = 90_000;
73
+ /**
74
+ * Timeout for a **read**. Much shorter than a mutation's, because the two fail
75
+ * asymmetrically: a timed-out read is idempotent and simply retried, while a
76
+ * timed-out mutation may have executed. Waiting longer on a read buys almost
77
+ * nothing and costs real time in the two places that block on one.
78
+ *
79
+ * The DSL monitor is strictly serial (`dsl/plugin/index.ts` schedules the next
80
+ * tick only after the previous finishes) and wraps its reads in `withRetry(2)`,
81
+ * so the per-tick worst case is 3× this value with no trailing-floor update on
82
+ * live positions. Positions keep their exchange-side stop throughout — what a
83
+ * blind tick loses is the ratchet, not the protection.
84
+ *
85
+ * 45s is >6× the measured p99.9 (`market_get_prices` 7.08s over 4.14M calls,
86
+ * `execution_get_order_status` 2.60s over 2.84M), giving a 135s worst-case blind
87
+ * window against 180s before this change and 270s under a single shared floor.
88
+ *
89
+ * Only reads landing between 45s and the old 60s cutoff fail where they would
90
+ * previously have returned; they are retried, since reads are retriable. The
91
+ * whole 30–60s band is 127 calls/week out of ~7M, so the affected slice is a
92
+ * fraction of 0.002%. Chosen over a tighter 30s (90s worst case) deliberately:
93
+ * the extra 45s of blind window is cheap next to failing reads that were about
94
+ * to succeed, and the telemetry has no 30–45s sub-band to distinguish them.
95
+ */
96
+ const MCP_READ_TIMEOUT_MS = 45_000;
97
+ /** MCP request timeout for a call carrying an ALO execution budget. */
98
+ export function mcpTimeoutForAlo(opts) {
99
+ const budgetSeconds = opts?.executionTimeoutSeconds ?? SERVER_DEFAULT_ALO_SECONDS;
100
+ return Math.max(budgetSeconds * 1000 + ALO_TIMEOUT_HEADROOM_MS, MCP_MUTATION_TIMEOUT_FLOOR_MS);
101
+ }
102
+ /**
103
+ * A `tools/call` the client stopped waiting for. **Not a failed order.**
104
+ *
105
+ * The server returns 200 on this work — an ALO order is *designed* to occupy the
106
+ * connection for its full `execution_timeout_seconds`, so a timeout means we
107
+ * stopped listening, not that nothing happened. Retrying a timed-out mutation
108
+ * can double it (a second close against a closing position opens the reverse).
109
+ *
110
+ * `mayHaveExecuted` marks that ambiguity so callers reconcile instead of
111
+ * retrying, and so the agent narrates a timeout rather than inventing an auth
112
+ * fault from a bare `exception`.
113
+ */
114
+ export class SenpiTimeoutError extends Error {
115
+ /**
116
+ * Numeric so `jsonRpcErrorCode` still resolves it: the span attribute
117
+ * `rpc.response.status_code` and the `mcp.call_failed` event stay keyed on
118
+ * `-32001`, which is what existing saved queries and alerts match on — and what
119
+ * this class exists to make visible.
120
+ */
121
+ code = MCP_REQUEST_TIMEOUT_CODE;
122
+ toolName;
123
+ timeoutMs;
124
+ /** Mutations may have landed server-side; reads cannot have side effects. */
125
+ mayHaveExecuted;
126
+ /** Safe to re-issue verbatim. False for mutations — reconcile instead. */
127
+ retriable;
128
+ constructor(toolName, timeoutMs, mayHaveExecuted, cause) {
129
+ super(mayHaveExecuted
130
+ ? `${toolName} timed out after ${Math.round(timeoutMs / 1000)}s waiting for the Senpi MCP server. ` +
131
+ `This is a client-side timeout, NOT a rejected order and NOT an authentication problem — the server ` +
132
+ `may have executed it. Do not retry: read the current position and order state, and act on what is ` +
133
+ `actually there.`
134
+ : `${toolName} timed out after ${Math.round(timeoutMs / 1000)}s waiting for the Senpi MCP server. ` +
135
+ `This is a client-side timeout, NOT an authentication problem. The call has no side effects and is ` +
136
+ `safe to retry.`,
137
+ // Keep the SDK error's stack — the classification replaces the message, not
138
+ // the evidence of where it came from.
139
+ cause !== undefined ? { cause } : undefined);
140
+ this.name = "SenpiTimeoutError";
141
+ this.toolName = toolName;
142
+ this.timeoutMs = timeoutMs;
143
+ this.mayHaveExecuted = mayHaveExecuted;
144
+ this.retriable = !mayHaveExecuted;
145
+ }
146
+ }
147
+ /**
148
+ * Tools with no side effects. Everything else is treated as mutating.
149
+ *
150
+ * Deliberately an allowlist of reads rather than a denylist of writes: an
151
+ * unrecognised tool is then assumed to move money, which costs at most a retry
152
+ * we could have taken. The inverse fails open — a mutation added later would
153
+ * default to retriable, and a re-sent withdraw or close is not recoverable.
154
+ */
155
+ const READ_ONLY_TOOLS = new Set([
156
+ "audit_query",
157
+ "discovery_get_trader_history",
158
+ "discovery_get_trader_state",
159
+ "execution_get_order_status",
160
+ "market_get_prices",
161
+ "market_list_instruments",
162
+ "ratchet_stop_events",
163
+ "ratchet_stop_list",
164
+ "strategy_get_asset_trading_limits",
165
+ "strategy_get_clearinghouse_state",
166
+ "strategy_get_open_orders",
167
+ "strategy_get_pnl_and_account_value_history",
168
+ "strategy_list",
169
+ // Reached via asToolClient(), which passes arbitrary tool names through — the
170
+ // scanner data providers call these two. Without them a market-data read would
171
+ // be narrated as "the server may have executed it".
172
+ "market_get_asset_data",
173
+ "leaderboard_get_markets",
174
+ ]);
175
+ /** True for a `tools/call` that can change venue or account state if it lands. */
176
+ function isMutatingTool(toolName) {
177
+ return !READ_ONLY_TOOLS.has(toolName);
178
+ }
179
+ /** A timeout thrown by the MCP SDK, whatever its surface shape. */
180
+ function isMcpTimeout(err) {
181
+ if (err instanceof SenpiTimeoutError)
182
+ return true;
183
+ if (err && typeof err === "object" && "code" in err) {
184
+ const code = err.code;
185
+ if (code === MCP_REQUEST_TIMEOUT_CODE)
186
+ return true;
36
187
  }
188
+ return err instanceof Error && /request timed out/i.test(err.message);
189
+ }
190
+ /**
191
+ * Envelope `error.code` values that leave the outcome unknown.
192
+ *
193
+ * The MCP server aborts its own trading-engine call at
194
+ * `SENPI_TRADING_EXECUTION_TIMEOUT_MS` (70s by default) and reports it as
195
+ * `UNAVAILABLE` via `isNetworkError`. That abort says nothing about whether the
196
+ * venue accepted the order — so for a mutation it carries the same ambiguity a
197
+ * client-side timeout does. `INTERNAL` is the unclassified bucket and is treated
198
+ * the same way.
199
+ *
200
+ * Everything else the server emits — INVALID_ARGUMENT, UNAUTHENTICATED,
201
+ * PERMISSION_DENIED, NOT_FOUND, RESOURCE_EXHAUSTED — is a rejection *before*
202
+ * execution, so those stay retriable.
203
+ */
204
+ const AMBIGUOUS_UPSTREAM_CODES = new Set(["UNAVAILABLE", "INTERNAL"]);
205
+ /** Envelope `error.code`, wherever the failure surfaced. */
206
+ function envelopeErrorCode(error) {
207
+ if (!isRecord(error))
208
+ return undefined;
209
+ return asString(error.code) ?? undefined;
210
+ }
211
+ /**
212
+ * True when a `success:false` envelope leaves it unknown whether the venue acted.
213
+ * Only meaningful for a mutating tool: a read that fails changed nothing either way.
214
+ */
215
+ function envelopeOutcomeUnknown(error) {
216
+ const code = envelopeErrorCode(error);
217
+ return code !== undefined && AMBIGUOUS_UPSTREAM_CODES.has(code);
218
+ }
219
+ /**
220
+ * Whether a thrown MCP failure is safe to re-issue verbatim.
221
+ *
222
+ * Two distinct paths reach here for the same underlying event — a slow order:
223
+ * the client giving up (`SenpiTimeoutError`) and the *server* giving up at its
224
+ * own 70s cap and returning `UNAVAILABLE`. Raising the client timeout above the
225
+ * server's makes the second the common one, so guarding only the first would
226
+ * move the duplicate-submission hazard rather than remove it.
227
+ *
228
+ * Exported so DSL and action call sites pass it to `withRetry`'s `shouldRetry`
229
+ * rather than each re-deriving the rule.
230
+ */
231
+ export function isRetriableMcpError(err) {
232
+ if (err instanceof SenpiTimeoutError)
233
+ return err.retriable;
234
+ if (err instanceof SenpiClientError)
235
+ return !err.mayHaveExecuted;
236
+ return true;
37
237
  }
38
238
  /**
39
239
  * Best-effort engine error message extraction. Mirrors the resolver used
@@ -196,12 +396,21 @@ function assertClosePositionSucceeded(raw, ctx) {
196
396
  }
197
397
  const hasData = "data" in raw;
198
398
  if (raw.success === false && (!hasData || raw.data == null)) {
199
- throw new Error(`close_position failed (${ctx.wallet} ${ctx.coin}): ${formatSenpiCloseFailureError(raw.error) || "unknown error"}`);
399
+ // An UNAVAILABLE here is the server's own 70s execution-timeout abort, which
400
+ // says nothing about whether the venue filled. Carry that forward so the DSL
401
+ // reconciles instead of submitting a second close.
402
+ throw new SenpiClientError("UPSTREAM_ERROR", `close_position failed (${ctx.wallet} ${ctx.coin}): ${formatSenpiCloseFailureError(raw.error) || "unknown error"}`, raw.error, {
403
+ mayHaveExecuted: envelopeOutcomeUnknown(raw.error),
404
+ upstreamCode: envelopeErrorCode(raw.error),
405
+ });
200
406
  }
201
407
  const payload = unwrapEnvelope(raw) ?? raw;
202
408
  if (isRecord(payload) && payload.success === false) {
203
409
  const detail = formatSenpiCloseFailureError(payload.error);
204
- throw new Error(`close_position rejected (${ctx.wallet} ${ctx.coin}): ${detail || "success=false"}`);
410
+ throw new SenpiClientError("ENGINE_FAILURE", `close_position rejected (${ctx.wallet} ${ctx.coin}): ${detail || "success=false"}`, payload.error, {
411
+ mayHaveExecuted: envelopeOutcomeUnknown(payload.error),
412
+ upstreamCode: envelopeErrorCode(payload.error),
413
+ });
205
414
  }
206
415
  }
207
416
  /**
@@ -436,9 +645,32 @@ export class SenpiClient {
436
645
  if (!this.mcpClient) {
437
646
  throw new Error('SenpiClient not connected — connect() did not establish a client');
438
647
  }
439
- const result = opts?.signal
440
- ? await this.mcpClient.callTool({ name: toolName, arguments: args }, undefined, { signal: opts.signal })
441
- : await this.mcpClient.callTool({ name: toolName, arguments: args });
648
+ // A timeout is always passed. Leaving RequestOptions off falls back to the SDK's
649
+ // DEFAULT_REQUEST_TIMEOUT_MSEC (60s), which is below both the ALO budgets recipes
650
+ // request and the trading engine's own ~70s ceiling — so the client abandons work
651
+ // the server then completes.
652
+ //
653
+ // Reads and mutations get different defaults on purpose: they fail asymmetrically,
654
+ // so patience is worth buying for one and not the other. See the two constants.
655
+ const timeoutMs = opts?.timeoutMs ??
656
+ (isMutatingTool(toolName) ? MCP_MUTATION_TIMEOUT_FLOOR_MS : MCP_READ_TIMEOUT_MS);
657
+ const requestOptions = {
658
+ timeout: timeoutMs,
659
+ ...(opts?.signal ? { signal: opts.signal } : {}),
660
+ };
661
+ let result;
662
+ try {
663
+ result = await this.mcpClient.callTool({ name: toolName, arguments: args }, undefined, requestOptions);
664
+ }
665
+ catch (err) {
666
+ // A deliberate cancellation (shutdown drain) stays an AbortError — only a real
667
+ // timeout is reclassified, so callers can tell "we stopped waiting" apart from
668
+ // "it was rejected" and reconcile rather than retry.
669
+ if (opts?.signal?.aborted !== true && isMcpTimeout(err)) {
670
+ throw new SenpiTimeoutError(toolName, timeoutMs, isMutatingTool(toolName), err);
671
+ }
672
+ throw err;
673
+ }
442
674
  if (result.content && Array.isArray(result.content) && result.content.length > 0) {
443
675
  const part = result.content[0];
444
676
  if (part && 'text' in part && typeof part.text === 'string') {
@@ -561,11 +793,16 @@ export class SenpiClient {
561
793
  }
562
794
  async createPosition(request) {
563
795
  logger.debug('createPosition request', { request });
796
+ // Orders are worked one at a time server-side, so the longest single ALO budget
797
+ // bounds the call — not their sum.
798
+ const aloTimeoutMs = Math.max(...request.orders.map((o) => o.orderType === 'FEE_OPTIMIZED_LIMIT'
799
+ ? mcpTimeoutForAlo(o.feeOptimizedLimitOptions)
800
+ : MCP_MUTATION_TIMEOUT_FLOOR_MS), MCP_MUTATION_TIMEOUT_FLOOR_MS);
564
801
  const raw = await this.callTool('create_position', {
565
802
  strategyWalletAddress: request.strategyWalletAddress,
566
803
  orders: request.orders,
567
804
  ...(request.reason != null ? { reason: request.reason } : {}),
568
- });
805
+ }, { timeoutMs: aloTimeoutMs });
569
806
  logger.debug('createPosition raw response', { raw });
570
807
  return parseCreatePositionResult(raw);
571
808
  }
@@ -821,7 +1058,7 @@ export class SenpiClient {
821
1058
  ...(params.feeOptimizedLimitOptions ? { feeOptimizedLimitOptions: params.feeOptimizedLimitOptions } : {}),
822
1059
  ...(params.closeLimitMinutes !== undefined ? { closeLimitMinutes: params.closeLimitMinutes } : {}),
823
1060
  ...(params.closePeakRoe !== undefined ? { closePeakRoe: params.closePeakRoe } : {}),
824
- });
1061
+ }, { timeoutMs: mcpTimeoutForAlo(params.feeOptimizedLimitOptions) });
825
1062
  assertClosePositionSucceeded(raw, { wallet, coin: effectiveCoin });
826
1063
  return unwrapEnvelope(raw) ?? raw;
827
1064
  }
@@ -975,7 +1212,7 @@ export class SenpiClient {
975
1212
  const data = unwrapEnvelope(raw);
976
1213
  if (!isRecord(data))
977
1214
  return { success: false };
978
- return { success: data.success === true, message: asString(data.message) ?? undefined };
1215
+ return { success: data.success === true, ...readFailureFields(data) };
979
1216
  }
980
1217
  /**
981
1218
  * Raw portfolio from account_get_portfolio. Interpretation (the funding
@@ -1002,22 +1239,39 @@ export class SenpiClient {
1002
1239
  const data = unwrapEnvelope(raw);
1003
1240
  if (!isRecord(data))
1004
1241
  return { success: false };
1005
- const strategyId = asString(data.strategyId) ?? asString(data.id) ?? asString(data.strategy_id) ?? undefined;
1006
- return { success: data.success === true, strategyId, message: asString(data.message) ?? undefined };
1242
+ // The MCP nests the created record: `data.strategy.{id,strategyWalletAddress,status,…}` beside
1243
+ // an async-start `data.message` — there is no top-level id (senpi-hyperliquid-mcp
1244
+ // `execution.tools.ts`, `createResponse(tool, 'create', { strategy, message })`). Reading only
1245
+ // the flat keys returned `strategyId: undefined` for a create that had already made AND FUNDED
1246
+ // a wallet, which the deploy then reported as a failure.
1247
+ //
1248
+ // The nested record WINS over a flat id when both appear. It is the object the backend
1249
+ // actually created — the same one `strategy_list` returns, carrying the wallet and status the
1250
+ // rest of the deploy binds to — so its id is guaranteed to belong to the wallet that just took
1251
+ // the money. A flat id sitting beside it could only be a second, unattached identifier, and on
1252
+ // a money call the expensive mistake is attaching a funded wallet to the wrong id. Flat shapes
1253
+ // stay accepted for the reason the Python lifecycle reader keeps them (senpi-skills
1254
+ // `_cli.py` `strategy_obj`: list entries "may be flat or wrapped").
1255
+ // Read UNCONDITIONALLY, not only on `success: true`: a create that made and funded a wallet
1256
+ // and then reported it oddly must still hand back the id — losing it is the defect 6f2cd917
1257
+ // fixed, and it costs a funded wallet. A refusal envelope carries no id at any of these keys
1258
+ // (`{ success, error, meta }`), so there is nothing there to mint one out of.
1259
+ const strategyId = readStrategyId(isRecord(data.strategy) ? data.strategy : undefined) ?? readStrategyId(data);
1260
+ return { success: data.success === true, strategyId, ...readFailureFields(data) };
1007
1261
  }
1008
1262
  async withdrawFunds(wallet, amount) {
1009
1263
  const raw = await this.callTool("strategy_withdraw_funds", { strategy_wallet: wallet, amount });
1010
1264
  const data = unwrapEnvelope(raw);
1011
1265
  if (!isRecord(data))
1012
1266
  return { success: false };
1013
- return { success: data.success === true, message: asString(data.message) ?? undefined };
1267
+ return { success: data.success === true, ...readFailureFields(data) };
1014
1268
  }
1015
1269
  async closeStrategy(wallet, opts) {
1016
1270
  const raw = await this.callTool("strategy_close", { strategy_wallet: wallet }, opts);
1017
1271
  const data = unwrapEnvelope(raw);
1018
1272
  if (!isRecord(data))
1019
1273
  return { success: false };
1020
- return { success: data.success === true, message: asString(data.message) ?? undefined };
1274
+ return { success: data.success === true, ...readFailureFields(data) };
1021
1275
  }
1022
1276
  /**
1023
1277
  * Submit a batch close_positions MCP call.
@@ -1228,6 +1482,56 @@ function extractSlOrderId(unwrapped, raw) {
1228
1482
  }
1229
1483
  return null;
1230
1484
  }
1485
+ /**
1486
+ * The `errorCode`/`message` pair for the strategy lifecycle calls that report failure by RETURNING
1487
+ * rather than throwing (`topUp`, `createCustomStrategy`, `withdrawFunds`, `closeStrategy`).
1488
+ *
1489
+ * A backend refusal reaches these methods as `{ success: false, error: { code, message, … }, meta }`
1490
+ * — `createErrorResponse` in senpi-hyperliquid-mcp `src/services/response.service.ts`, returned by
1491
+ * `server.ts` as an ordinary tool result with no `isError`, so nothing throws. That shape has **no
1492
+ * `data` key**, and `unwrapEnvelope` returns any payload without one untouched: `data` therefore IS
1493
+ * the envelope, and the old flat `asString(data.message)` read was looking one level above the
1494
+ * text. Every backend refusal arrived as `message: undefined`, and deploy reported "backend
1495
+ * returned no strategyId" over a refusal that had said exactly why it refused.
1496
+ *
1497
+ * `data.message` stays as the fallback because that is where a SUCCESS envelope puts its text (the
1498
+ * create's async-start message arrives that way), and a string-shaped `error` is read only after it
1499
+ * — `unwrapEnvelope` hoists such an `error` onto the inner record, and other parsers in this client
1500
+ * accept that legacy shape (`parseCreatePositionResult`, `parseClosePositionResult`), though these
1501
+ * four methods never did: at `cfecb432` they read `data.message` and nothing else.
1502
+ *
1503
+ * That last point is why the order is `error.message` → `data.message` → string `error`, and not
1504
+ * the other way round — deliberately UNLIKE `extractEngineMessage` and `parseCreatePositionResult`,
1505
+ * which rank a string `error` above `data.message`. Those read engine mutation results, where
1506
+ * `error` is the failure channel and `message` is often the generic line beside it. These four read
1507
+ * a lifecycle envelope whose `message` is the real text on the success path, and they read nothing
1508
+ * else at `cfecb432`. A payload carrying BOTH a top-level string `error` and a `data.message` is a
1509
+ * shape no MCP response produces (`createResponse` never emits a top-level `error`), so no evidence
1510
+ * decides the tie: what decides it is that ranking the legacy string first would silently change
1511
+ * which text these four answer with — on a SUCCESS envelope — as a side effect of a change about
1512
+ * refusals. The legacy arm keeps its value where it is the ONLY text (`{ success: false,
1513
+ * error: "…" }`, no `data` key and no error object) and takes nothing away where it is not.
1514
+ *
1515
+ * The code is returned as its own field and never concatenated into the message: `deploy`'s
1516
+ * unnamed-create retry branches on it, and a guard that re-derives a code by scanning prose is the
1517
+ * guard this repo already had.
1518
+ */
1519
+ function readFailureFields(data) {
1520
+ const err = isRecord(data.error) ? data.error : undefined;
1521
+ return {
1522
+ errorCode: (err && asString(err.code)) ?? undefined,
1523
+ message: (err && asString(err.message)) ?? asString(data.message) ?? asString(data.error) ?? undefined,
1524
+ };
1525
+ }
1526
+ /**
1527
+ * The strategy id out of a strategy-shaped record, trying the key spellings the strategy surface
1528
+ * uses in the order the Python lifecycle reader tries them (`_cli.py` `strategy_id_of`).
1529
+ */
1530
+ function readStrategyId(obj) {
1531
+ if (obj == null)
1532
+ return undefined;
1533
+ return asString(obj.strategyId) ?? asString(obj.id) ?? asString(obj.strategy_id) ?? undefined;
1534
+ }
1231
1535
  function parseCreatePositionResult(raw) {
1232
1536
  const data = unwrapEnvelope(raw);
1233
1537
  if (!isRecord(data)) {