@senpi/runtime 3.0.59 → 3.0.63

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 (151) hide show
  1. package/README.md +73 -2
  2. package/dist/actions/decision-engine.d.ts +0 -2
  3. package/dist/actions/decision-engine.d.ts.map +1 -1
  4. package/dist/actions/decision-engine.js +1 -79
  5. package/dist/actions/decision-engine.js.map +1 -1
  6. package/dist/cli/deploy-render.d.ts +91 -0
  7. package/dist/cli/deploy-render.d.ts.map +1 -0
  8. package/dist/cli/deploy-render.js +216 -0
  9. package/dist/cli/deploy-render.js.map +1 -0
  10. package/dist/cli/guide-commands.d.ts.map +1 -1
  11. package/dist/cli/guide-commands.js +78 -6
  12. package/dist/cli/guide-commands.js.map +1 -1
  13. package/dist/cli/senpi-commands.d.ts +6 -1
  14. package/dist/cli/senpi-commands.d.ts.map +1 -1
  15. package/dist/cli/senpi-commands.js +244 -8
  16. package/dist/cli/senpi-commands.js.map +1 -1
  17. package/dist/cli/validate-command.d.ts.map +1 -1
  18. package/dist/cli/validate-command.js +32 -1
  19. package/dist/cli/validate-command.js.map +1 -1
  20. package/dist/deploy/deadlines.d.ts +47 -0
  21. package/dist/deploy/deadlines.d.ts.map +1 -0
  22. package/dist/deploy/deadlines.js +56 -0
  23. package/dist/deploy/deadlines.js.map +1 -0
  24. package/dist/deploy/error-text.d.ts +25 -0
  25. package/dist/deploy/error-text.d.ts.map +1 -0
  26. package/dist/deploy/error-text.js +42 -0
  27. package/dist/deploy/error-text.js.map +1 -0
  28. package/dist/deploy/funding.d.ts +87 -0
  29. package/dist/deploy/funding.d.ts.map +1 -0
  30. package/dist/deploy/funding.js +229 -0
  31. package/dist/deploy/funding.js.map +1 -0
  32. package/dist/deploy/install-gate-runner.d.ts +12 -0
  33. package/dist/deploy/install-gate-runner.d.ts.map +1 -0
  34. package/dist/deploy/install-gate-runner.js +17 -0
  35. package/dist/deploy/install-gate-runner.js.map +1 -0
  36. package/dist/deploy/job.d.ts +199 -0
  37. package/dist/deploy/job.d.ts.map +1 -0
  38. package/dist/deploy/job.js +463 -0
  39. package/dist/deploy/job.js.map +1 -0
  40. package/dist/deploy/min-budget.d.ts +68 -0
  41. package/dist/deploy/min-budget.d.ts.map +1 -0
  42. package/dist/deploy/min-budget.js +297 -0
  43. package/dist/deploy/min-budget.js.map +1 -0
  44. package/dist/deploy/orchestrator.d.ts +305 -0
  45. package/dist/deploy/orchestrator.d.ts.map +1 -0
  46. package/dist/deploy/orchestrator.js +2910 -0
  47. package/dist/deploy/orchestrator.js.map +1 -0
  48. package/dist/deploy/package.d.ts +130 -0
  49. package/dist/deploy/package.d.ts.map +1 -0
  50. package/dist/deploy/package.js +351 -0
  51. package/dist/deploy/package.js.map +1 -0
  52. package/dist/deploy/proof-gate.d.ts +82 -0
  53. package/dist/deploy/proof-gate.d.ts.map +1 -0
  54. package/dist/deploy/proof-gate.js +269 -0
  55. package/dist/deploy/proof-gate.js.map +1 -0
  56. package/dist/deploy/python-float.d.ts +34 -0
  57. package/dist/deploy/python-float.d.ts.map +1 -0
  58. package/dist/deploy/python-float.js +46 -0
  59. package/dist/deploy/python-float.js.map +1 -0
  60. package/dist/deploy/recheck-commands.d.ts +50 -0
  61. package/dist/deploy/recheck-commands.d.ts.map +1 -0
  62. package/dist/deploy/recheck-commands.js +59 -0
  63. package/dist/deploy/recheck-commands.js.map +1 -0
  64. package/dist/deploy/register.d.ts +99 -0
  65. package/dist/deploy/register.d.ts.map +1 -0
  66. package/dist/deploy/register.js +689 -0
  67. package/dist/deploy/register.js.map +1 -0
  68. package/dist/deploy/taxonomy.d.ts +24 -0
  69. package/dist/deploy/taxonomy.d.ts.map +1 -0
  70. package/dist/deploy/taxonomy.js +31 -0
  71. package/dist/deploy/taxonomy.js.map +1 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +244 -132
  74. package/dist/index.js.map +1 -1
  75. package/dist/runtime/strategy-registry.d.ts +8 -0
  76. package/dist/runtime/strategy-registry.d.ts.map +1 -1
  77. package/dist/runtime/strategy-registry.js +14 -1
  78. package/dist/runtime/strategy-registry.js.map +1 -1
  79. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  80. package/dist/runtime-api/intake/intake-mount.js +7 -4
  81. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  82. package/dist/scanners/implementations/market-regime.d.ts +1 -1
  83. package/dist/senpi/client.d.ts +58 -4
  84. package/dist/senpi/client.d.ts.map +1 -1
  85. package/dist/senpi/client.js +192 -15
  86. package/dist/senpi/client.js.map +1 -1
  87. package/dist/senpi/strategy-status.d.ts +32 -0
  88. package/dist/senpi/strategy-status.d.ts.map +1 -0
  89. package/dist/senpi/strategy-status.js +70 -0
  90. package/dist/senpi/strategy-status.js.map +1 -0
  91. package/dist/senpi/types.d.ts +47 -3
  92. package/dist/senpi/types.d.ts.map +1 -1
  93. package/dist/skills-manager/manifest.js +1 -1
  94. package/dist/skills-manager/skills-manifest.json +1 -1
  95. package/dist/telemetry/metrics.d.ts +23 -0
  96. package/dist/telemetry/metrics.d.ts.map +1 -1
  97. package/dist/telemetry/metrics.js +65 -0
  98. package/dist/telemetry/metrics.js.map +1 -1
  99. package/dist/types/strategy.d.ts +9 -1
  100. package/dist/types/strategy.d.ts.map +1 -1
  101. package/dist/universe/package-universe.d.ts +97 -0
  102. package/dist/universe/package-universe.d.ts.map +1 -0
  103. package/dist/universe/package-universe.js +275 -0
  104. package/dist/universe/package-universe.js.map +1 -0
  105. package/dist/validate/codes.d.ts +20 -0
  106. package/dist/validate/codes.d.ts.map +1 -1
  107. package/dist/validate/codes.js +36 -0
  108. package/dist/validate/codes.js.map +1 -1
  109. package/dist/validate/import-stage.js +5 -5
  110. package/dist/validate/import-stage.js.map +1 -1
  111. package/dist/validate/index.d.ts +1 -1
  112. package/dist/validate/index.d.ts.map +1 -1
  113. package/dist/validate/index.js.map +1 -1
  114. package/dist/validate/install-gate.d.ts.map +1 -1
  115. package/dist/validate/install-gate.js +10 -2
  116. package/dist/validate/install-gate.js.map +1 -1
  117. package/dist/validate/live-instruments.d.ts +44 -0
  118. package/dist/validate/live-instruments.d.ts.map +1 -0
  119. package/dist/validate/live-instruments.js +71 -0
  120. package/dist/validate/live-instruments.js.map +1 -0
  121. package/dist/validate/live-stage.js +5 -5
  122. package/dist/validate/live-stage.js.map +1 -1
  123. package/dist/validate/proof.d.ts +11 -1
  124. package/dist/validate/proof.d.ts.map +1 -1
  125. package/dist/validate/proof.js +7 -6
  126. package/dist/validate/proof.js.map +1 -1
  127. package/dist/validate/recipe-checks.d.ts.map +1 -1
  128. package/dist/validate/recipe-checks.js +6 -4
  129. package/dist/validate/recipe-checks.js.map +1 -1
  130. package/dist/validate/run.d.ts +5 -0
  131. package/dist/validate/run.d.ts.map +1 -1
  132. package/dist/validate/run.js +10 -1
  133. package/dist/validate/run.js.map +1 -1
  134. package/dist/validate/target.d.ts +41 -0
  135. package/dist/validate/target.d.ts.map +1 -1
  136. package/dist/validate/target.js +192 -16
  137. package/dist/validate/target.js.map +1 -1
  138. package/dist/validate/universe-stage.d.ts +71 -0
  139. package/dist/validate/universe-stage.d.ts.map +1 -0
  140. package/dist/validate/universe-stage.js +183 -0
  141. package/dist/validate/universe-stage.js.map +1 -0
  142. package/openclaw.plugin.json +1 -1
  143. package/package.json +1 -1
  144. package/dist/runtime/managed-wallet-guard.d.ts +0 -47
  145. package/dist/runtime/managed-wallet-guard.d.ts.map +0 -1
  146. package/dist/runtime/managed-wallet-guard.js +0 -106
  147. package/dist/runtime/managed-wallet-guard.js.map +0 -1
  148. package/dist/runtime/protection-nudge.d.ts +0 -67
  149. package/dist/runtime/protection-nudge.d.ts.map +0 -1
  150. package/dist/runtime/protection-nudge.js +0 -244
  151. package/dist/runtime/protection-nudge.js.map +0 -1
package/dist/index.js CHANGED
@@ -14,6 +14,9 @@
14
14
  import { readFileSync } from "node:fs";
15
15
  import { join, dirname } from "node:path";
16
16
  import { SenpiClient } from "./senpi/client.js";
17
+ import { buildWalletStatusHint } from "./senpi/strategy-status.js";
18
+ import { DeployJobController } from "./deploy/job.js";
19
+ import { registerDeployGatewayMethods } from "./deploy/register.js";
17
20
  import { AuditQueryError } from "./runtime/audit-query.types.js";
18
21
  import { buildRuntimeStateDir, archiveStateDir } from "./runtime/state-layout.js";
19
22
  import { registerSenpiCli } from "./cli/senpi-commands.js";
@@ -35,8 +38,6 @@ import { resolvePackageName } from "./runtime/auto-update/resolve-package-name.j
35
38
  import { runOpenClawPluginsUpdate } from "./runtime/auto-update/run-update-command.js";
36
39
  import { registerSpanHooks } from "./telemetry/spans/index.js";
37
40
  import { registerSessionContextSeedHook } from "./prompt/session-context-seed-hook.js";
38
- import { createManagedWalletGuard } from "./runtime/managed-wallet-guard.js";
39
- import { createProtectionGate, createProtectionNudge, createProtectionSummaryCapture, } from "./runtime/protection-nudge.js";
40
41
  import { initDefaultPiiRedactor } from "./telemetry/spans/redact-pii.js";
41
42
  import { initOtelSdk, shutdownOtelSdk } from "./telemetry/otel-sdk.js";
42
43
  import { getLogLevel, getLogLevelSource, parseLogLevel, setLogLevel, } from "./utils/logger.js";
@@ -245,49 +246,6 @@ const senpiRuntimePlugin = {
245
246
  });
246
247
  }
247
248
  const runtimeHandles = new Map();
248
- // Position-tool guards on ONE before_tool_call registration, composed in
249
- // order. The managed-wallet hard block runs first: it vetoes agent-issued
250
- // create_position calls into wallets an installed runtime manages (checking
251
- // live handles — wallet parsed from YAML, covering registry entries with
252
- // missing/stale wallet fields — then the registry read from the resolved
253
- // `baseStateDir`, not the raw openclaw stateDir). A wallet-guard block
254
- // short-circuits; only if it allows does the protection gate run, which on
255
- // a manual (non-runtime) wallet vetoes an unprotected create_position and a
256
- // stop-loss-cancel edit unless the agent opted out with protection:"none".
257
- // The gate stashes each allowed create_position's protection summary into a
258
- // shared capture that the tool_result_persist nudge reads back — the nudge
259
- // event carries no params. `on`'s typing is void-only, so the
260
- // veto/message-returning handlers are cast at this boundary.
261
- try {
262
- const managedWalletGuard = createManagedWalletGuard({
263
- resolveStateDir: () => baseStateDir,
264
- getLiveWallets: () => Array.from(runtimeHandles, ([id, handle]) => ({ id, wallet: handle.wallet })),
265
- logger: createLogger("[senpi-core][managed-wallet-guard]"),
266
- });
267
- const protectionCapture = createProtectionSummaryCapture();
268
- const protectionGate = createProtectionGate({
269
- logger: createLogger("[senpi-core][protection-gate]"),
270
- capture: protectionCapture,
271
- });
272
- const beforeToolCall = async (event) => {
273
- const walletVerdict = await managedWalletGuard(event);
274
- if (walletVerdict)
275
- return walletVerdict;
276
- return protectionGate(event);
277
- };
278
- api.on("before_tool_call", beforeToolCall);
279
- const protectionNudge = createProtectionNudge({
280
- logger: createLogger("[senpi-core][protection-nudge]"),
281
- capture: protectionCapture,
282
- });
283
- api.on("tool_result_persist", protectionNudge);
284
- pluginLogger.info("[senpi-runtime] position-tool guards registered (before_tool_call: managed-wallet + protection gate; tool_result_persist: protection nudge)");
285
- }
286
- catch (err) {
287
- pluginLogger.warn("[senpi-runtime] failed to register position-tool guards", {
288
- err: err instanceof Error ? err.message : String(err),
289
- });
290
- }
291
249
  let apiHandle;
292
250
  // Supervised scanner state — module-scoped so stop() can reach them.
293
251
  // Created eagerly at register() time (not deferred to start()) so that
@@ -427,9 +385,23 @@ const senpiRuntimePlugin = {
427
385
  restartPrefixes: [`plugins.entries.${api.id}.config.autoUpdate.lastRestartTrigger`],
428
386
  });
429
387
  }
388
+ // ── senpi deploy (detached job) ───────────────────────────────────────────
389
+ // Constructed here, ahead of registerService, because the service `start` hook below runs its
390
+ // boot scan: declaring it after would leave that reference in the temporal dead zone for any
391
+ // future refactor that starts the service earlier. The rest of the deploy wiring lives in
392
+ // src/deploy/register.ts behind an explicit context; this closure contributes collaborators only.
393
+ const deployController = new DeployJobController({
394
+ deploysDir: join(baseStateDir, "deploys"),
395
+ now: () => Date.now(),
396
+ log: (line) => api.logger.info(`[senpi-runtime] ${line}`),
397
+ });
430
398
  api.registerService({
431
399
  id: "senpi-runtime",
432
400
  async start() {
401
+ // Deploy-journal janitor: stamp anything a restart left unterminated as `interrupted`.
402
+ // It never re-enters a deploy state machine — discovery is pull-based via
403
+ // `senpi deploy status`.
404
+ await deployController.scanOnBoot();
433
405
  await autoUpdateCoordinator.start();
434
406
  // SENPI_RUNTIME_API_DISABLED=true also disables the skills coordinator.
435
407
  // Skills bootstrap performs a real `git clone` synchronously during
@@ -741,13 +713,15 @@ const senpiRuntimePlugin = {
741
713
  pluginLogger.info("[senpi-runtime] CLI registrar callback invoked — attaching senpi subcommands to program");
742
714
  registerSenpiCli(ctx.program);
743
715
  }, { commands: ["senpi"] });
744
- /** Gateway respond accepts (ok, payload?, error?) with error as { code, message }; plugin SDK types may omit the third arg. */
745
- api.registerGatewayMethod("senpi.installRuntime", async (ctx) => {
746
- const { params } = ctx;
747
- const respond = ctx.respond;
716
+ /**
717
+ * The whole `senpi.installRuntime` behaviour, callable without a gateway ctx so
718
+ * `senpi deploy` installs through the SAME gates (wallet ownership, one-runtime-per-wallet,
719
+ * scanner-path resolution) instead of a parallel path that could drift.
720
+ */
721
+ async function installRuntimeCore(params) {
748
722
  const toError = (message) => ({ code: "INVALID_REQUEST", message });
749
723
  const toUnavailable = (message) => ({ code: "UNAVAILABLE", message });
750
- const { readRegistry, writeRegistry, validateEntry, hasId, deriveRuntimeId, sanitizeId } = await import("./runtime/strategy-registry.js");
724
+ const { readRegistry, writeRegistry, validateEntry, hasId, findByWallet, deriveRuntimeId, sanitizeId } = await import("./runtime/strategy-registry.js");
751
725
  const id = typeof params?.id === "string" ? params.id : undefined;
752
726
  const runtimeYamlPath = typeof params?.runtimeYamlPath === "string" ? params.runtimeYamlPath : undefined;
753
727
  const runtimeYamlContent = typeof params?.runtimeYamlContent === "string" ? params.runtimeYamlContent : undefined;
@@ -760,12 +734,10 @@ const senpiRuntimePlugin = {
760
734
  const hasPath = runtimeYamlPath !== undefined && runtimeYamlPath.length > 0;
761
735
  const hasContent = runtimeYamlContent !== undefined;
762
736
  if (!hasPath && !hasContent) {
763
- respond(false, undefined, toError("Please provide your runtime YAML: use the create command with --path <file> or --content \"<yaml text>\"."));
764
- return;
737
+ return { ok: false, error: toError("Please provide your runtime YAML: use the create command with --path <file> or --content \"<yaml text>\".") };
765
738
  }
766
739
  if (hasPath && hasContent) {
767
- respond(false, undefined, toError("Please provide either a file path (--path) or YAML content (--content), not both."));
768
- return;
740
+ return { ok: false, error: toError("Please provide either a file path (--path) or YAML content (--content), not both.") };
769
741
  }
770
742
  const pathOrContent = runtimeYamlPath ?? runtimeYamlContent;
771
743
  // Parse YAML first to extract name, version, and wallet for ID derivation.
@@ -784,10 +756,15 @@ const senpiRuntimePlugin = {
784
756
  }
785
757
  catch (err) {
786
758
  const msg = err instanceof Error ? err.message : String(err);
787
- respond(false, undefined, toUnavailable(`Could not start the runtime. This is usually due to invalid or incomplete YAML.\n\n${msg}`));
788
- return;
759
+ return { ok: false, error: toUnavailable(`Could not start the runtime. This is usually due to invalid or incomplete YAML.\n\n${msg}`) };
789
760
  }
790
761
  // ── install gate ────────────────────────────────────────────────────
762
+ // The cwd-resolution trap (M226926 inline -c, M279357 --path) is closed by this gate's
763
+ // E_VALIDATE_UNRESOLVABLE_SCANNER_PATH: a relative scanner path with no source dir would
764
+ // silently resolve against the process cwd (intake-mount.ts selectScanners baseDir=undefined)
765
+ // and mount a dead scanner. A second check for the same condition used to sit immediately
766
+ // above this one; two gates on one boundary meant the message a caller saw depended on which
767
+ // ran first, so it was folded into the finding's own `fix`.
791
768
  // The schema accepts recipes that cannot trade: an entry action with no
792
769
  // reachable margin source declines every signal it will ever see, one with
793
770
  // no guardrails trades with no daily-loss ceiling, and a declared scanner
@@ -797,9 +774,12 @@ const senpiRuntimePlugin = {
797
774
  // running runtime is untouched. Deliberately NOT done at boot: that loop
798
775
  // stops every started runtime on a throw, so refusing there would take the
799
776
  // box down over one bad recipe.
800
- // Decide inside the guard, act outside it. A `return` within the `try`
801
- // would let a throw from `respond` be swallowed by the catch below and the
802
- // install proceed after we had decided to refuse it.
777
+ // Decide inside the guard, act outside it. The catch below exists to keep a
778
+ // defect in the CHECKS from failing an install, and it cannot tell one of
779
+ // those from a fault raised while acting on a refusal it already decided.
780
+ // Returning after the guard keeps the two apart, so no future statement
781
+ // added on the refusal path can be swallowed into "the gate could not run"
782
+ // and let the install proceed.
803
783
  let gateRefusal;
804
784
  try {
805
785
  const { collectRecipeFindings } = await import("./validate/recipe.js");
@@ -829,6 +809,13 @@ const senpiRuntimePlugin = {
829
809
  // themselves — log it and let the install proceed rather than blocking a
830
810
  // good recipe on our own bug.
831
811
  //
812
+ // This side fails OPEN and `runDeploy`'s pre-money install gate
813
+ // (deploy/orchestrator.ts, the `deploy.install_gate` span) fails CLOSED on
814
+ // the SAME fault, deliberately: refusing a good recipe HERE blocks a
815
+ // working install, while refusing a deploy costs only a re-run, before any
816
+ // money has moved. Two boundaries, one predicate — neither branch may be
817
+ // flipped on its own.
818
+ //
832
819
  // But say so on the ledger, not only in a log line. Proceeding ungated is
833
820
  // silent by construction: the install succeeds, nothing looks wrong, and
834
821
  // protection has reverted to what it was before the gate existed — for
@@ -845,25 +832,67 @@ const senpiRuntimePlugin = {
845
832
  pluginLogger.warn(appendCause("[senpi-runtime] install gate could not run — proceeding without it", gateErr));
846
833
  }
847
834
  if (gateRefusal !== undefined) {
848
- respond(false, undefined, toError(gateRefusal));
849
- return;
835
+ return { ok: false, error: toError(gateRefusal) };
850
836
  }
851
837
  const registry = await readRegistry(baseStateDir);
852
838
  const resolvedId = id && id.length > 0
853
839
  ? (sanitizeId(id) || "runtime")
854
840
  : deriveRuntimeId(runtimeName, wallet);
855
841
  if (hasId(registry, resolvedId)) {
856
- respond(false, undefined, toError(`A runtime with id "${resolvedId}" already exists. Use a different id (e.g. --runtime-id another-name) or remove the existing one first: openclaw senpi runtime delete ${resolvedId}`));
857
- return;
858
- }
859
- // Enforce one running runtime per wallet address
842
+ return { ok: false, error: toError(`A runtime with id "${resolvedId}" already exists. Use a different id (e.g. --runtime-id another-name) or remove the existing one first: openclaw senpi runtime delete ${resolvedId}`) };
843
+ }
844
+ /**
845
+ * One runtime per wallet address — asked of PERSISTED state, not only of this process.
846
+ *
847
+ * Two engines on one wallet is not a tidiness problem: each keeps its own high-water state,
848
+ * so they disagree about what is protected and stop each other's positions out on different
849
+ * floors, against one margin account.
850
+ *
851
+ * The live-handle half alone could not carry that guarantee, because it is blind in exactly
852
+ * the window where it is needed. A runtime that is INSTALLED but not running right now — its
853
+ * boot failed, or the box came up and it never started — has a registry row and no handle,
854
+ * so the loop below sees nothing and lets a second row land on the same wallet. Nothing
855
+ * reconciles the two afterwards: the registry has no uniqueness on `wallet`, and the boot
856
+ * loop starts every row it can resolve. The restart that fixes the first runtime is the one
857
+ * that brings up both.
858
+ *
859
+ * Both halves are kept because they answer different questions and the answers differ:
860
+ * a live handle proves a runtime is trading NOW (and names the stop-it-first step), while a
861
+ * registry row proves one is installed and will be started at the next boot. Case-folded on
862
+ * both, because the registry stores `strategy.wallet` verbatim from the YAML and a
863
+ * checksummed address is the same margin account as its lowercase spelling.
864
+ */
860
865
  if (wallet) {
861
- for (const [existingId, existingHandle] of runtimeHandles) {
862
- if (existingHandle.wallet === wallet) {
863
- respond(false, undefined, toError(`A runtime for wallet "${wallet}" is already running (runtime "${existingId}"). ` +
864
- `Stop it first: openclaw senpi runtime delete --id ${existingId}`));
865
- return;
866
+ const walletKey = wallet.toLowerCase();
867
+ const liveId = [...runtimeHandles].find(([, handle]) => (handle.wallet ?? "").toLowerCase() === walletKey)?.[0];
868
+ // Which runtime holds this wallet, from either read. The live half matches on
869
+ // `handle.wallet`, the registry half on `entry.wallet`, so the id is what they have in
870
+ // common — and it is the id, not which read matched, that the two messages differ on.
871
+ const holderId = liveId ?? findByWallet(registry, wallet)?.id;
872
+ if (holderId !== undefined) {
873
+ // "running" vs "installed and not running" is asked of THAT id — the same predicate
874
+ // `senpi.listRuntimes` renders "stopped" from — rather than inferred from which of the
875
+ // two reads found it. They agree in practice; the refusal has to agree with the surface
876
+ // it sends the reader to, because that is where the reader checks it.
877
+ if (runtimeHandles.has(holderId)) {
878
+ return { ok: false, error: toError(`A runtime for wallet "${wallet}" is already running (runtime "${holderId}"). ` +
879
+ `Stop it first: openclaw senpi runtime delete --id ${holderId}`) };
866
880
  }
881
+ return { ok: false, error: toError(
882
+ // What is CLAIMED here is only what the two reads above prove: a registry row exists,
883
+ // and this gateway has no live handle under its id. Not "it crashed", not "it is
884
+ // broken" — neither was read, and the boot loop will not necessarily manage to start
885
+ // it either (a boot that already failed once fails again), so the claim is that the
886
+ // next restart TRIES. The next step leads with the read, because the operator has to
887
+ // find out which of the two runtimes they actually want before removing either, and
888
+ // deleting the wrong one leaves a funded wallet watched by nothing.
889
+ `A runtime for wallet "${wallet}" is already installed (runtime "${holderId}"). ` +
890
+ `It is not running in this gateway right now, but it is in the registry, so the next ` +
891
+ `restart will try to start it — installing a second runtime on the same wallet would ` +
892
+ `put two exit engines on one wallet's margin, each tracking its own high-water floor. ` +
893
+ `Nothing was installed. See what that runtime is before removing anything: ` +
894
+ `openclaw senpi runtime list. If it is genuinely unwanted, remove it first: ` +
895
+ `openclaw senpi runtime delete --id ${holderId}`) };
867
896
  }
868
897
  }
869
898
  // Best-effort wallet ownership validation: calls the MCP server to check
@@ -882,48 +911,21 @@ const senpiRuntimePlugin = {
882
911
  const match = strategies.find((s) => s.strategyWalletAddress.toLowerCase() === wallet.toLowerCase());
883
912
  if (!match) {
884
913
  pluginLogger.info("[senpi-runtime] Wallet validation failed: not owned", { wallet });
885
- respond(false, undefined, toError(`Strategy wallet "${wallet}" (YAML field: strategy.wallet) was not found in your Senpi account's strategies. ` +
886
- `Check the address for typos. ` +
887
- `To list all your strategies and their wallet addresses, call the MCP tool: strategy_list (no arguments needed). ` +
888
- `Each result includes "strategyWalletAddress" — use that value as strategy.wallet in your YAML.`));
889
914
  // `return` triggers the inner `finally` (disconnect) before exiting.
890
- return;
915
+ return { ok: false, error: toError(`Strategy wallet "${wallet}" (YAML field: strategy.wallet) was not found in your Senpi account's strategies. ` +
916
+ `Check the address for typos. ` +
917
+ `To list all your strategies and their wallet addresses, call the MCP tool: strategy_list (no arguments needed). ` +
918
+ `Each result includes "strategyWalletAddress" — use that value as strategy.wallet in your YAML.`) };
891
919
  }
892
920
  if (match.status !== "ACTIVE") {
893
921
  pluginLogger.info("[senpi-runtime] Wallet validation failed: strategy not active", { wallet, status: match.status, strategyId: match.id });
894
- respond(false, undefined, toError((() => {
895
- const initializingStatuses = new Set([
896
- "SET_REFERRER_CODE", "SET_BUILDER_FEE", "SUBSCRIBE_TRADER",
897
- "CREATE_WALLET", "FUND_WALLET", "INITIALIZE_POSITIONS",
898
- ]);
899
- const terminalStatuses = new Set([
900
- "CLOSED", "FAILED", "CLOSING_POSITIONS", "CLOSE_STRATEGY",
901
- "UNSUBSCRIBE_TRADER", "FUND_SOURCE_WALLET", "INACTIVE",
902
- ]);
903
- let hint;
904
- if (initializingStatuses.has(match.status)) {
905
- hint =
906
- `The strategy is still initializing (${match.status}). ` +
907
- `It will reach ACTIVE automatically — wait a few minutes, then retry. ` +
908
- `Initialization order: CREATE_WALLET → FUND_WALLET → INITIALIZE_POSITIONS → ACTIVE.`;
909
- }
910
- else if (match.status === "PAUSED") {
911
- hint =
912
- `The strategy is paused and cannot be used to create a runtime — please create a new strategy instead.`;
913
- }
914
- else if (terminalStatuses.has(match.status)) {
915
- hint =
916
- `The strategy is closing or already closed (${match.status}). ` +
917
- `A runtime cannot be created for a closed strategy — please create a new strategy instead.`;
918
- }
919
- else {
920
- hint = `Only ACTIVE strategies can run a runtime.`;
921
- }
922
- return (`Strategy wallet "${wallet}" (YAML field: strategy.wallet) has status "${match.status}" — a runtime requires ACTIVE status. ` +
923
- hint +
924
- ` To check the latest status, call the MCP tool: strategy_list.`);
925
- })()));
926
- return;
922
+ return { ok: false, error: toError(
923
+ // H7/D-10: the status vocabulary lives in one module, shared with deploy's
924
+ // reconcile. This gate's private copy was already missing TERMINATED and
925
+ // CLOSING_DONE, so a gone strategy fell to the generic "only ACTIVE" hint.
926
+ `Strategy wallet "${wallet}" (YAML field: strategy.wallet) has status "${match.status}" — a runtime requires ACTIVE status. ` +
927
+ buildWalletStatusHint(match.status) +
928
+ ` To check the latest status, call the MCP tool: strategy_list.`) };
927
929
  }
928
930
  pluginLogger.info("[senpi-runtime] Wallet validation passed", { wallet, strategyId: match.id, status: match.status });
929
931
  }
@@ -952,11 +954,17 @@ const senpiRuntimePlugin = {
952
954
  };
953
955
  const valid = validateEntry(entry);
954
956
  if (!valid.ok) {
955
- respond(false, undefined, toError(valid.error));
956
- return;
957
+ return { ok: false, error: toError(valid.error) };
957
958
  }
958
959
  registry.runtimes.push(entry);
959
960
  await writeRegistry(baseStateDir, registry);
961
+ /**
962
+ * The unwired verdict for THIS install, tracked locally rather than read back out of
963
+ * `unwiredRuntimes`, so the result reports what this call did and never a leftover verdict
964
+ * from an earlier runtime that happened to share the id. Set at each of the three sites that
965
+ * leave the runtime without live entry scanners; cleared by the wire-success path.
966
+ */
967
+ let unwiredPhase;
960
968
  try {
961
969
  const scannerProviders = await createScannerProvidersForRun({
962
970
  mcpTransport,
@@ -1013,6 +1021,7 @@ const senpiRuntimePlugin = {
1013
1021
  await hotLauncher.start();
1014
1022
  launchers.set(resolvedId, hotLauncher);
1015
1023
  unwiredRuntimes.delete(resolvedId);
1024
+ unwiredPhase = undefined;
1016
1025
  runtimeHandles.get(resolvedId)?.attachSupervisionSource(() => ({
1017
1026
  unwired: false,
1018
1027
  rows: hotLauncher.describeSupervision(),
@@ -1021,6 +1030,7 @@ const senpiRuntimePlugin = {
1021
1030
  catch (startErr) {
1022
1031
  await hotLauncher.stop().catch(() => { });
1023
1032
  unwiredRuntimes.set(resolvedId, "install_launch");
1033
+ unwiredPhase = "install_launch";
1024
1034
  runtimeHandles.get(resolvedId)?.attachSupervisionSource(() => ({
1025
1035
  unwired: true,
1026
1036
  unwiredPhase: "install_launch",
@@ -1031,8 +1041,10 @@ const senpiRuntimePlugin = {
1031
1041
  }
1032
1042
  }
1033
1043
  catch (wireErr) {
1034
- // Wire failure must NOT fail the install — the runtime is already up.
1044
+ // Wire failure must NOT fail the install — the runtime is already up. It IS reported
1045
+ // across the result boundary (payload.unwired) so a funding caller can refuse it.
1035
1046
  unwiredRuntimes.set(resolvedId, "install_wire");
1047
+ unwiredPhase = "install_wire";
1036
1048
  runtimeHandles.get(resolvedId)?.attachSupervisionSource(() => ({
1037
1049
  unwired: true,
1038
1050
  unwiredPhase: "install_wire",
@@ -1047,6 +1059,7 @@ const senpiRuntimePlugin = {
1047
1059
  // external scanners but has NO live entry scanners. Record that fail-closed
1048
1060
  // state (state only, no new event) so listRuntimes/health don't lie.
1049
1061
  unwiredRuntimes.set(resolvedId, "no_intake");
1062
+ unwiredPhase = "no_intake";
1050
1063
  runtimeHandles.get(resolvedId)?.attachSupervisionSource(() => ({
1051
1064
  unwired: true,
1052
1065
  unwiredPhase: "no_intake",
@@ -1054,14 +1067,31 @@ const senpiRuntimePlugin = {
1054
1067
  }));
1055
1068
  }
1056
1069
  // ──────────────────────────────────────────────────────────────────────
1057
- respond(true, { id: resolvedId, name: runtimeName, wallet });
1070
+ return {
1071
+ ok: true,
1072
+ payload: {
1073
+ id: resolvedId,
1074
+ name: runtimeName,
1075
+ wallet,
1076
+ ...(unwiredPhase ? { unwired: unwiredPhase } : {}),
1077
+ },
1078
+ };
1058
1079
  }
1059
1080
  catch (err) {
1060
1081
  const next = registry.runtimes.filter((e) => e.id !== resolvedId);
1061
1082
  await writeRegistry(baseStateDir, { ...registry, runtimes: next });
1062
1083
  const msg = err instanceof Error ? err.message : String(err);
1063
- respond(false, undefined, toUnavailable(`Could not start the runtime. This is usually due to invalid or incomplete YAML.\n\n${msg}`));
1084
+ return { ok: false, error: toUnavailable(`Could not start the runtime. This is usually due to invalid or incomplete YAML.\n\n${msg}`) };
1064
1085
  }
1086
+ }
1087
+ /** Gateway respond accepts (ok, payload?, error?) with error as { code, message }; plugin SDK types may omit the third arg. */
1088
+ api.registerGatewayMethod("senpi.installRuntime", async (ctx) => {
1089
+ const respond = ctx.respond;
1090
+ const result = await installRuntimeCore(ctx.params);
1091
+ if (result.ok)
1092
+ respond(true, result.payload);
1093
+ else
1094
+ respond(false, undefined, result.error);
1065
1095
  }, { scope: "operator.write" });
1066
1096
  api.registerGatewayMethod("senpi.listRuntimes", async (ctx) => {
1067
1097
  const respond = ctx.respond;
@@ -1088,9 +1118,12 @@ const senpiRuntimePlugin = {
1088
1118
  });
1089
1119
  respond(true, { runtimes });
1090
1120
  }, { scope: "operator.read" });
1091
- api.registerGatewayMethod("senpi.deleteRuntime", async (ctx) => {
1092
- const { params } = ctx;
1093
- const respond = ctx.respond;
1121
+ /**
1122
+ * The whole `senpi.deleteRuntime` behaviour, callable without a gateway ctx. `senpi deploy`
1123
+ * reuses it to retire a runtime bound to an old wallet, so archival, launcher teardown and the
1124
+ * unwired-verdict cleanup all happen exactly once, here.
1125
+ */
1126
+ async function deleteRuntimeCore(params) {
1094
1127
  const { readRegistry, writeRegistry } = await import("./runtime/strategy-registry.js");
1095
1128
  const registry = await readRegistry(baseStateDir);
1096
1129
  let targetId = typeof params?.id === "string" ? params.id : undefined;
@@ -1099,29 +1132,26 @@ const senpiRuntimePlugin = {
1099
1132
  if (!targetId && targetAddress) {
1100
1133
  const entry = registry.runtimes.find((e) => e.wallet === targetAddress);
1101
1134
  if (!entry) {
1102
- respond(false, undefined, {
1103
- code: "NOT_FOUND",
1104
- message: `No runtime found for wallet address "${targetAddress}".`,
1105
- });
1106
- return;
1135
+ return { ok: false, error: {
1136
+ code: "NOT_FOUND",
1137
+ message: `No runtime found for wallet address "${targetAddress}".`,
1138
+ } };
1107
1139
  }
1108
1140
  targetId = entry.id;
1109
1141
  }
1110
1142
  if (!targetId) {
1111
- respond(false, undefined, {
1112
- code: "INVALID_REQUEST",
1113
- message: "Please provide the runtime id or wallet address. Example: openclaw senpi runtime delete --id my-runtime",
1114
- });
1115
- return;
1143
+ return { ok: false, error: {
1144
+ code: "INVALID_REQUEST",
1145
+ message: "Please provide the runtime id or wallet address. Example: openclaw senpi runtime delete --id my-runtime",
1146
+ } };
1116
1147
  }
1117
1148
  const deletedEntry = registry.runtimes.find((e) => e.id === targetId);
1118
1149
  const handle = runtimeHandles.get(targetId);
1119
1150
  if (!deletedEntry && !handle) {
1120
- respond(false, undefined, {
1121
- code: "NOT_FOUND",
1122
- message: `No runtime found with id "${targetId}".`,
1123
- });
1124
- return;
1151
+ return { ok: false, error: {
1152
+ code: "NOT_FOUND",
1153
+ message: `No runtime found with id "${targetId}".`,
1154
+ } };
1125
1155
  }
1126
1156
  if (handle) {
1127
1157
  await handle.teardownDslForRuntimeDeletion();
@@ -1166,8 +1196,90 @@ const senpiRuntimePlugin = {
1166
1196
  address: deletedEntry?.wallet,
1167
1197
  hadLiveHandle: Boolean(handle),
1168
1198
  }));
1169
- respond(true, { id: targetId });
1199
+ return { ok: true, payload: { id: targetId } };
1200
+ }
1201
+ api.registerGatewayMethod("senpi.deleteRuntime", async (ctx) => {
1202
+ const respond = ctx.respond;
1203
+ const result = await deleteRuntimeCore(ctx.params);
1204
+ if (result.ok)
1205
+ respond(true, result.payload);
1206
+ else
1207
+ respond(false, undefined, result.error);
1170
1208
  }, { scope: "operator.write" });
1209
+ /** One connected SenpiClient per job; always disconnected, even on failure. */
1210
+ async function withSenpiClient(fn) {
1211
+ const client = mcpTransport ? new SenpiClient(mcpTransport) : new SenpiClient({ apiKey });
1212
+ if (!mcpTransport)
1213
+ await client.connect();
1214
+ try {
1215
+ return await fn(client);
1216
+ }
1217
+ finally {
1218
+ if (!mcpTransport)
1219
+ await client.disconnect().catch(() => { });
1220
+ }
1221
+ }
1222
+ async function findRegistryEntryByWallet(wallet) {
1223
+ const { readRegistry, findByWallet } = await import("./runtime/strategy-registry.js");
1224
+ const entry = findByWallet(await readRegistry(baseStateDir), wallet);
1225
+ return entry ? { id: entry.id, wallet: entry.wallet } : undefined;
1226
+ }
1227
+ async function findRegistryEntryById(id) {
1228
+ const { readRegistry } = await import("./runtime/strategy-registry.js");
1229
+ const entry = (await readRegistry(baseStateDir)).runtimes.find((e) => e.id === id);
1230
+ return entry ? { id: entry.id, wallet: entry.wallet } : undefined;
1231
+ }
1232
+ /** Narrow a live runtime's system state to the scanner columns the deploy report quotes. */
1233
+ async function readScannerRows(runtimeId) {
1234
+ const handle = runtimeHandles.get(runtimeId);
1235
+ if (!handle)
1236
+ return null;
1237
+ try {
1238
+ const state = await handle.getSystemState();
1239
+ const rows = state?.components?.scanners?.state?.scanners;
1240
+ if (!Array.isArray(rows))
1241
+ return null;
1242
+ return rows.map((row) => ({
1243
+ scannerId: row.scannerId,
1244
+ health: row.health,
1245
+ enabled: row.enabled,
1246
+ scheduleMode: row.scheduleMode,
1247
+ lastRunStatus: row.lastRunStatus,
1248
+ lastRunFinishedAt: row.lastRunFinishedAt,
1249
+ lastAliveAt: row.lastAliveAt,
1250
+ nextRunAt: row.nextRunAt,
1251
+ runCount: row.runCount,
1252
+ errorCount: row.errorCount,
1253
+ signalsProduced: row.signalsProduced,
1254
+ lastError: row.lastError,
1255
+ }));
1256
+ }
1257
+ catch (err) {
1258
+ api.logger.warn("[senpi-runtime] deploy: could not read scanner rows", { runtimeId, err });
1259
+ return null;
1260
+ }
1261
+ }
1262
+ registerDeployGatewayMethods(api, {
1263
+ controller: deployController,
1264
+ withSenpiClient,
1265
+ installRuntime: (params) => installRuntimeCore(params),
1266
+ deleteRuntime: async (id) => {
1267
+ const result = await deleteRuntimeCore({ id });
1268
+ if (!result.ok)
1269
+ throw new Error(`${result.error.code}: ${result.error.message}`);
1270
+ },
1271
+ registryFindByWallet: findRegistryEntryByWallet,
1272
+ registryFindById: findRegistryEntryById,
1273
+ getScannerRows: readScannerRows,
1274
+ // H4: `runtimeHandles` holds the live `run()` handles. A registry row without one is a
1275
+ // previous install whose runtime died before start — deploy must reinstall, not report it
1276
+ // adopted. `unwiredRuntimes` is the same judgement one step further in: a runtime that is up
1277
+ // but whose entry scanners never wired is running BLIND (the health surface already renders
1278
+ // it "running — NO ENTRY SCANNERS"), and reporting `install: skipped` over it would be the
1279
+ // same clean line over a runtime that cannot tick.
1280
+ runtimeIsLive: async (id) => runtimeHandles.has(id) && !unwiredRuntimes.has(id),
1281
+ logger: { info: (line) => api.logger.info(`[senpi-runtime] ${line}`) },
1282
+ });
1171
1283
  /**
1172
1284
  * Gateway shim for `senpi.ingestExternalScannerData`.
1173
1285
  *