dexterm-cli 0.1.7 → 0.2.0

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 (119) hide show
  1. package/dist/packages/agents/src/spectator.js +3 -1
  2. package/dist/packages/agents/src/tiers.js +9 -0
  3. package/dist/packages/agents/src/tools.js +10 -5
  4. package/dist/packages/autotrade/assets/limits.schema.json +569 -0
  5. package/dist/packages/autotrade/examples/executor/README.md +70 -0
  6. package/dist/packages/autotrade/examples/executor/jsonl-tail-executor.mjs +57 -0
  7. package/dist/packages/autotrade/examples/executor/kit.mjs +144 -0
  8. package/dist/packages/autotrade/examples/executor/signal-cmd-executor.mjs +25 -0
  9. package/dist/packages/autotrade/examples/executor/telegram-bot-recipe.md +47 -0
  10. package/dist/packages/autotrade/examples/executor/webhook-executor.mjs +47 -0
  11. package/dist/packages/autotrade/src/backtest.js +2 -1
  12. package/dist/packages/autotrade/src/chat/config.js +4 -2
  13. package/dist/packages/autotrade/src/chat/control.js +1 -0
  14. package/dist/packages/autotrade/src/chat/notify.js +16 -6
  15. package/dist/packages/autotrade/src/chat/secrets.js +25 -18
  16. package/dist/packages/autotrade/src/chat/service.js +3 -0
  17. package/dist/packages/autotrade/src/engine.js +16 -2
  18. package/dist/packages/autotrade/src/executor-health.js +217 -0
  19. package/dist/packages/autotrade/src/fleet.js +3 -0
  20. package/dist/packages/autotrade/src/forward.js +355 -0
  21. package/dist/packages/autotrade/src/index.js +19 -0
  22. package/dist/packages/autotrade/src/journey.js +477 -0
  23. package/dist/packages/autotrade/src/limits.js +682 -0
  24. package/dist/packages/autotrade/src/outbox.js +394 -0
  25. package/dist/packages/autotrade/src/paper-limits.js +87 -0
  26. package/dist/packages/autotrade/src/real-mode.js +688 -0
  27. package/dist/packages/autotrade/src/receipts.js +330 -0
  28. package/dist/packages/autotrade/src/report.js +168 -0
  29. package/dist/packages/autotrade/src/runner.js +62 -11
  30. package/dist/packages/autotrade/src/service-guard.js +125 -0
  31. package/dist/packages/autotrade/src/service-install.js +213 -0
  32. package/dist/packages/autotrade/src/service-recover.js +232 -0
  33. package/dist/packages/autotrade/src/service.js +289 -0
  34. package/dist/packages/autotrade/src/signal-channels.js +312 -0
  35. package/dist/packages/autotrade/src/signal.js +196 -0
  36. package/dist/packages/autotrade/src/strategy.js +3 -1
  37. package/dist/packages/autotrade/src/trade-ledger.js +2 -2
  38. package/dist/packages/cli/src/analyst/backend.js +3 -2
  39. package/dist/packages/cli/src/analyst/codex.js +3 -2
  40. package/dist/packages/cli/src/app-help-gen.js +14 -2
  41. package/dist/packages/cli/src/autotrade-cmd/audit.js +19 -2
  42. package/dist/packages/cli/src/autotrade-cmd/backtest.js +21 -4
  43. package/dist/packages/cli/src/autotrade-cmd/executor.js +91 -0
  44. package/dist/packages/cli/src/autotrade-cmd/fleet.js +8 -4
  45. package/dist/packages/cli/src/autotrade-cmd/forward.js +283 -0
  46. package/dist/packages/cli/src/autotrade-cmd/journey.js +208 -0
  47. package/dist/packages/cli/src/autotrade-cmd/limits.js +393 -0
  48. package/dist/packages/cli/src/autotrade-cmd/receipt.js +63 -0
  49. package/dist/packages/cli/src/autotrade-cmd/report.js +79 -0
  50. package/dist/packages/cli/src/autotrade-cmd/run.js +257 -13
  51. package/dist/packages/cli/src/autotrade-cmd/service.js +873 -0
  52. package/dist/packages/cli/src/autotrade-cmd/signal.js +824 -0
  53. package/dist/packages/cli/src/autotrade-cmd.js +50 -4
  54. package/dist/packages/cli/src/ledger-cmd.js +4 -2
  55. package/dist/packages/cli/src/pnl-card.js +4 -0
  56. package/dist/packages/cli/src/screens/autotrade-journey.js +134 -0
  57. package/dist/packages/cli/src/screens/autotrade-keys.js +13 -0
  58. package/dist/packages/cli/src/screens/autotrade-screen.js +57 -6
  59. package/dist/packages/cli/src/screens/autotrade-ui.js +17 -7
  60. package/dist/packages/cli/src/screens/autotrade-worker.js +4 -2
  61. package/dist/packages/cli/src/telegram-cmd.js +4 -1
  62. package/dist/packages/cli/src/version.js +1 -1
  63. package/dist/packages/data/src/trade/ledger/schema.js +1 -1
  64. package/dist/packages/mcp/assets/kb/app-help/autotrade-executor-kit.md +44 -0
  65. package/dist/packages/mcp/assets/kb/app-help/autotrade-journey.md +46 -0
  66. package/dist/packages/mcp/assets/kb/app-help/autotrade-limits.md +48 -0
  67. package/dist/packages/mcp/assets/kb/app-help/autotrade-real-signal.md +45 -0
  68. package/dist/packages/mcp/assets/kb/app-help/live-trading.md +1 -1
  69. package/dist/packages/mcp/assets/kb/app-help/screen-autotrade.md +12 -0
  70. package/dist/packages/mcp/assets/kb/app-help/trade-ledger.md +1 -1
  71. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-change-a-strategy-by-chat.md +3 -1
  72. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-on-the-q-screen.md +1 -0
  73. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-running-it-all-day.md +2 -2
  74. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-stopping-it.md +2 -0
  75. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-always-on-service.md +57 -0
  76. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-is-paper-self-directed.md +9 -2
  77. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-will-not-do.md +1 -1
  78. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-1-ask-for-a-strategy.md +27 -0
  79. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-2-backtest-it-held-out-split.md +31 -0
  80. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-3-forward-test-it-on-live-data.md +44 -0
  81. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-4-promote-it-to-always-on-paper.md +36 -0
  82. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-5-real-only-if-you-choose.md +36 -0
  83. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-ladder.md +39 -0
  84. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-report.md +31 -0
  85. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-what-the-analyst-knows.md +30 -0
  86. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat.md +24 -0
  87. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
  88. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +1 -1
  89. package/dist/packages/mcp/assets/kb/docs/docs-going-real-executor-health.md +38 -0
  90. package/dist/packages/mcp/assets/kb/docs/docs-going-real-paper-vs-real.md +33 -0
  91. package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-digest.md +36 -0
  92. package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-executor-kit.md +48 -0
  93. package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-whole-path.md +37 -0
  94. package/dist/packages/mcp/assets/kb/docs/docs-going-real-what-dexterm-does-and-never-does.md +44 -0
  95. package/dist/packages/mcp/assets/kb/docs/docs-going-real-your-limits.md +77 -0
  96. package/dist/packages/mcp/assets/kb/docs/docs-going-real.md +24 -0
  97. package/dist/packages/mcp/assets/kb/docs/docs-live-trading-changed-in-0-1-7.md +1 -1
  98. package/dist/packages/mcp/assets/kb/docs/docs-live-trading-real-mode-your-strategy-s-trades-to-your.md +31 -0
  99. package/dist/packages/mcp/assets/kb/docs/docs-live-trading-the-short-version.md +1 -1
  100. package/dist/packages/mcp/assets/kb/docs/docs-live-trading-using-a-trading-bot-with-dexterm.md +0 -2
  101. package/dist/packages/mcp/assets/kb/docs/docs-live-trading-what-dexterm-does-not-do.md +1 -1
  102. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-receipts-what-your-executor-did.md +42 -0
  103. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-safety.md +34 -0
  104. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-set-it-up.md +47 -0
  105. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-the-channels.md +42 -0
  106. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-the-signal.md +48 -0
  107. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-is.md +35 -0
  108. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-will-not-do.md +34 -0
  109. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-where-you-see-it.md +34 -0
  110. package/dist/packages/mcp/assets/kb/docs/docs-real-mode.md +27 -0
  111. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +1 -1
  112. package/dist/packages/mcp/assets/kb/entries/autotrade.md +4 -2
  113. package/dist/packages/mcp/src/autotrade-tools.js +168 -5
  114. package/dist/packages/mcp/src/engine/restrictions.js +3 -3
  115. package/dist/packages/mcp/src/index.js +1 -1
  116. package/dist/packages/mcp/src/ledger-tools.js +155 -7
  117. package/dist/packages/mcp/src/server.js +21 -10
  118. package/dist/packages/tui/src/keymap.js +7 -0
  119. package/package.json +1 -1
@@ -0,0 +1,70 @@
1
+ # dexterm executor reference kit
2
+
3
+ dexterm holds no key and signs nothing. In real mode a strategy sends a **LIVE TRADE FIRE** signal
4
+ (`dexterm-trade-fire/1`) for every buy and sell. **Your own executor** acts on it: a Telegram trading bot you chose,
5
+ or any script.
6
+
7
+ This folder holds three small executors you can wire up in minutes, so you can see the whole path work before
8
+ anything can spend money:
9
+
10
+ | file | channel | how dexterm reaches it |
11
+ |---|---|---|
12
+ | `webhook-executor.mjs` | webhook | `dexterm autotrade signal channels --webhook http://127.0.0.1:8787/signal` |
13
+ | `signal-cmd-executor.mjs` | command hook | `dexterm autotrade signal channels --signal-cmd '["/abs/node","/abs/signal-cmd-executor.mjs","--account","main"]'` |
14
+ | `jsonl-tail-executor.mjs` | JSONL stream | `dexterm autotrade signal channels --jsonl on` (add `--jsonl-signed on` for HMAC lines) |
15
+
16
+ Each one handles every signal the same way:
17
+
18
+ 1. It verifies the HMAC, when you give it the secret file. Without `--secret-file` it acts on unsigned bodies (it says `HMAC NOT checked` when it starts); before you put a real trade into `act()`, run it with `--secret-file` and `--require-hmac`.
19
+ 2. It checks the account id.
20
+ 3. It dedupes by nonce. Delivery is at-least-once, so a repeat is normal, and the seen set survives a restart.
21
+ 4. It ignores anything past `expiresAt` (the TTL).
22
+ 5. It writes a receipt to dexterm's `autotrade/real/receipts.jsonl` and a heartbeat to `autotrade/real/executor.jsonl`.
23
+
24
+ **All of them are DRY RUN.** `act()` in `kit.mjs` only logs what it WOULD do, and the receipt says so:
25
+ status `rejected`, reason `dry-run: would BUY …`. There is no wallet code, no key and no chain call anywhere in this
26
+ kit. Trading for real means replacing `act()` with your own code, or handing the order to your own bot. That is your
27
+ code and your wallet, never dexterm's.
28
+
29
+ ## Five minutes, end to end (paper money only)
30
+
31
+ ```sh
32
+ # 1. a secret for the HMAC (written to a 0600 file, stored in your keychain, never printed)
33
+ dexterm autotrade signal secret generate --out ~/.dexterm-exec.secret
34
+
35
+ # 2. start the kit's webhook executor (dry run) in another terminal
36
+ node webhook-executor.mjs --account main --secret-file ~/.dexterm-exec.secret
37
+
38
+ # 3. point dexterm at it, then prove the round trip
39
+ dexterm autotrade signal channels --webhook http://127.0.0.1:8787/signal
40
+ dexterm autotrade signal test # per channel: delivered, latency, the kit's receipt
41
+ dexterm autotrade executor status # alive · last heartbeat · last receipt · lag
42
+ ```
43
+
44
+ `signal test` sends a signal marked `test: true` that names no mint, so there is nothing in it to trade. The kit
45
+ receipts it as `rejected` (`test signal: received, never traded`).
46
+
47
+ ## Heartbeats and the silence pause
48
+
49
+ dexterm counts a heartbeat, a receipt, or an ack on the webhook or command hook as a sign of life. When it hears
50
+ nothing for longer than your `executorSilenceMin` limit (placeholder: 15 min), a real run **pauses new buy
51
+ signals** and tells you in the run log and on Telegram. Exits keep going out. The kit sends a heartbeat every 60 s
52
+ (`--heartbeat-sec`). Your own executor can append the same line, POST it to the run's loopback listener
53
+ (`--executor-port`), or run `dexterm autotrade executor heartbeat`:
54
+
55
+ ```json
56
+ {"schema":"dexterm-executor-heartbeat/1","at":1761300000000,"executor":"my-bot","accountId":"main","dryRun":false,"lastNonce":42}
57
+ ```
58
+
59
+ ## Receipts
60
+
61
+ A receipt is data only. It moves a real position from `unconfirmed` to executed / rejected / failed. With a fill
62
+ (`priceUsd`, `sizeSol`, `tokens`, `txSig`), dexterm also measures how far the real fill was from its own paper fill:
63
+ slippage, latency, partial fills and rejections. That shows in `dexterm autotrade audit`, `signal status` and
64
+ `dexterm autotrade report`.
65
+
66
+ ```json
67
+ {"schema":"dexterm-trade-receipt/1","signalId":"<the signal's id>","nonce":42,"accountId":"main","status":"executed","at":1761300001200,"fill":{"priceUsd":0.00123,"sizeSol":0.25,"tokens":40650,"txSig":null},"reason":null,"executor":"my-bot"}
68
+ ```
69
+
70
+ See `telegram-bot-recipe.md` for the Telegram trading-bot setup. The bot executes, and dexterm never signs.
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // dexterm executor reference kit: the JSONL stream tail (task-1654). DRY RUN: logs what it would do; no wallet code.
3
+ //
4
+ // node jsonl-tail-executor.mjs --account main [--stream FILE] [--secret-file FILE --require-hmac] [--once] [--poll-ms 1000]
5
+ //
6
+ // Turn the stream on in dexterm (`dexterm autotrade signal channels --jsonl on`, default file
7
+ // autotrade/real/signals.jsonl). With `--jsonl-signed on` each line is an HMAC envelope this tail verifies; a bare
8
+ // line has no signature, so --require-hmac refuses it. The read offset is saved, so a restart resumes where it was.
9
+ // --once reads what is there and exits (handy for a test).
10
+
11
+ import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { SeenStore, args, dextermHome, handle, heartbeat, readSecret, realDir, unwrapLine } from './kit.mjs';
14
+
15
+ const a = args();
16
+ if (!a.account) { console.error('usage: node jsonl-tail-executor.mjs --account ID [--stream FILE] [--secret-file FILE] [--once]'); process.exit(2); }
17
+ const home = a.home || dextermHome();
18
+ const stream = a.stream || join(realDir(home), 'signals.jsonl');
19
+ const offFile = a['offset-file'] || join(realDir(home), 'kit-tail-offset.json');
20
+ const seen = new SeenStore(a.state || join(realDir(home), 'kit-tail-seen.json'));
21
+ const secret = readSecret(a['secret-file']);
22
+ const name = String(a.name || 'kit-jsonl-tail').slice(0, 32);
23
+ const log = (s) => console.log(`[${new Date().toISOString()}] ${s}`);
24
+ let offset = 0;
25
+ try { offset = Number(JSON.parse(readFileSync(offFile, 'utf8')).offset) || 0; } catch { /* from the start */ }
26
+ let carry = '';
27
+
28
+ function poll() {
29
+ if (!existsSync(stream)) return;
30
+ const size = statSync(stream).size;
31
+ if (size < offset) { offset = 0; carry = ''; }
32
+ if (size === offset) return;
33
+ const fd = openSync(stream, 'r');
34
+ const buf = Buffer.alloc(size - offset);
35
+ try { readSync(fd, buf, 0, buf.length, offset); } finally { closeSync(fd); }
36
+ offset = size;
37
+ const lines = (carry + buf.toString('utf8')).split('\n');
38
+ carry = lines.pop() ?? '';
39
+ for (const l of lines) {
40
+ if (!l.trim()) continue;
41
+ const { body, signature } = unwrapLine(l);
42
+ handle(body, { accountId: a.account, secret, requireHmac: !!a['require-hmac'], signature, seen, home, executor: name, log, now: Date.now() });
43
+ }
44
+ writeFileSync(offFile, JSON.stringify({ offset: offset - Buffer.byteLength(carry) }));
45
+ }
46
+
47
+ const beat = () => { try { heartbeat(home, { executor: name, accountId: a.account, lastNonce: seen.maxNonce || null }); } catch (e) { log(`heartbeat failed: ${e.message}`); } };
48
+ poll();
49
+ beat();
50
+ if (!a.once) {
51
+ log(`dexterm kit JSONL tail executor (DRY RUN) on ${stream} · account ${a.account}`);
52
+ const t1 = setInterval(poll, Math.max(100, Number(a['poll-ms'] ?? 1000)));
53
+ const t2 = setInterval(beat, Math.max(5, Number(a['heartbeat-sec'] ?? 60)) * 1000);
54
+ const stop = () => { clearInterval(t1); clearInterval(t2); process.exit(0); };
55
+ process.on('SIGINT', stop);
56
+ process.on('SIGTERM', stop);
57
+ }
@@ -0,0 +1,144 @@
1
+ // dexterm executor reference kit: the shared part (task-1654). Plain Node (22+), no dependencies, no wallet code.
2
+ //
3
+ // Every kit executor does the same five things with a LIVE TRADE FIRE signal (`dexterm-trade-fire/1`):
4
+ // 1. verify the HMAC (`t=<unix s>,v1=<hex sha256 of "<t>.<body>">`) when you gave it the secret file
5
+ // 2. check the shape, and that the signal is for YOUR account id
6
+ // 3. dedupe by nonce and id (delivery is at-least-once: a repeat is normal); the seen set survives a restart
7
+ // 4. honor the TTL: anything past `expiresAt` is ignored, never acted on late
8
+ // 5. write a receipt (`dexterm-trade-receipt/1`) to dexterm's receipts.jsonl, and a heartbeat now and then
9
+ //
10
+ // It is DRY RUN, always: `act()` below only logs what it WOULD do and the receipt says so (status "rejected",
11
+ // reason "dry-run: …"). There is no key, no wallet and no chain call in this kit. To trade for real you replace
12
+ // `act()` with your own executor's code (or hand the order to the Telegram trading bot you chose); dexterm never
13
+ // signs anything.
14
+
15
+ import { createHmac, timingSafeEqual } from 'node:crypto';
16
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { homedir } from 'node:os';
19
+
20
+ export const SIGNAL_SCHEMA = 'dexterm-trade-fire/1';
21
+ export const RECEIPT_SCHEMA = 'dexterm-trade-receipt/1';
22
+ export const HEARTBEAT_SCHEMA = 'dexterm-executor-heartbeat/1';
23
+ export const ENVELOPE_SCHEMA = 'dexterm-trade-fire-envelope/1';
24
+ const TOLERANCE_SEC = 300;
25
+ const MINT = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
26
+
27
+ /** `--name value` / `--flag` argv into an object. */
28
+ export function args(argv = process.argv.slice(2)) {
29
+ const o = {};
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (!a.startsWith('--')) continue;
33
+ const k = a.slice(2);
34
+ const v = argv[i + 1];
35
+ if (v !== undefined && !v.startsWith('--')) { o[k] = v; i++; } else o[k] = true;
36
+ }
37
+ return o;
38
+ }
39
+
40
+ /** Where dexterm keeps its files (DEXTERM_HOME, else ~/.dexterm). */
41
+ export const dextermHome = (env = process.env) => env.DEXTERM_HOME || join(homedir(), '.dexterm');
42
+ export const realDir = (home) => join(home, 'autotrade', 'real');
43
+
44
+ /** The HMAC secret from a file (`dexterm autotrade signal secret generate --out FILE`), or null. */
45
+ export function readSecret(file) {
46
+ if (!file) return null;
47
+ const v = readFileSync(file, 'utf8').trim();
48
+ return v || null;
49
+ }
50
+
51
+ export function verify(secret, header, body, nowMs = Date.now()) {
52
+ const m = /^t=(\d{1,12}),v1=([0-9a-f]{64})$/.exec(String(header ?? ''));
53
+ if (!m) return { ok: false, why: 'signature-missing' };
54
+ const t = Number(m[1]);
55
+ if (Math.abs(nowMs / 1000 - t) > TOLERANCE_SEC) return { ok: false, why: 'signature-stale' };
56
+ const want = createHmac('sha256', secret).update(`${t}.${body}`).digest();
57
+ const got = Buffer.from(m[2], 'hex');
58
+ return got.length === want.length && timingSafeEqual(got, want) ? { ok: true } : { ok: false, why: 'signature-bad' };
59
+ }
60
+
61
+ /** The seen nonces and ids, in a small JSON file so a restart does not act on a repeat. */
62
+ export class SeenStore {
63
+ constructor(file) { this.file = file; this.seen = new Set(); this.maxNonce = 0; try { const d = JSON.parse(readFileSync(file, 'utf8')); for (const x of d.seen ?? []) this.seen.add(x); this.maxNonce = Number(d.maxNonce) || 0; } catch { /* first run */ } }
64
+ has(s) { return this.seen.has(s.id) || this.seen.has(`n${s.nonce}`); }
65
+ add(s) {
66
+ this.seen.add(s.id); this.seen.add(`n${s.nonce}`); this.maxNonce = Math.max(this.maxNonce, s.nonce);
67
+ const keep = [...this.seen].slice(-4000);
68
+ mkdirSync(dirname(this.file), { recursive: true });
69
+ const tmp = `${this.file}.${process.pid}.tmp`;
70
+ writeFileSync(tmp, JSON.stringify({ seen: keep, maxNonce: this.maxNonce }));
71
+ renameSync(tmp, this.file);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Check one signal body. Returns { verdict, signal?, why }: verdict is act | duplicate | expired | rejected.
77
+ * `secret` null = no HMAC check (only when you chose not to set one).
78
+ */
79
+ export function check(body, o) {
80
+ const now = o.now ?? Date.now();
81
+ if (o.secret) { const v = verify(o.secret, o.signature, body, now); if (!v.ok) return { verdict: 'rejected', why: v.why }; }
82
+ else if (o.requireHmac) return { verdict: 'rejected', why: 'signature-required' };
83
+ let s;
84
+ try { s = JSON.parse(body); } catch { return { verdict: 'rejected', why: 'not-json' }; }
85
+ if (!s || s.schema !== SIGNAL_SCHEMA || !['trade', 'halted', 'test'].includes(s.type) || !Number.isSafeInteger(s.nonce) || typeof s.id !== 'string') return { verdict: 'rejected', why: 'shape' };
86
+ if (s.paper !== false) return { verdict: 'rejected', why: 'paper-flag' };
87
+ if (s.accountId !== o.accountId) return { verdict: 'rejected', why: 'wrong-account', signal: s };
88
+ if (o.seen.has(s)) return { verdict: 'duplicate', why: 'duplicate', signal: s };
89
+ if (!(now <= s.expiresAt)) return { verdict: 'expired', why: 'expired', signal: s };
90
+ if (s.type === 'trade' && (!MINT.test(s.mint) || (s.side !== 'buy' && s.side !== 'sell'))) return { verdict: 'rejected', why: 'shape', signal: s };
91
+ if (s.type === 'trade' && s.nonce <= o.seen.maxNonce) return { verdict: 'rejected', why: 'nonce-replay', signal: s };
92
+ return { verdict: 'act', why: null, signal: s };
93
+ }
94
+
95
+ /**
96
+ * What the executor does with a fresh signal. DRY RUN: it only says what it WOULD do. Replace this with your own
97
+ * code to trade (your wallet, your bot); keep the rest of the pipeline (verify, dedupe, TTL, receipt) as it is.
98
+ */
99
+ export function act(s) {
100
+ if (s.type === 'test') return { status: 'rejected', reason: 'test signal: received, never traded' };
101
+ if (s.type === 'halted') return { status: null, reason: `halted (${s.reason}): would cancel ${s.outstanding.length} outstanding signal(s)` };
102
+ const what = s.side === 'buy' ? `would BUY ${s.sizeSol} SOL of ${s.symbol} (${s.mint}) with ${s.slippageBps} bps slippage` : `would SELL ${Math.round(s.sellFraction * 1000) / 10}% of ${s.symbol} (${s.mint}): ${s.exitReason}`;
103
+ return { status: 'rejected', reason: `dry-run: ${what}`.slice(0, 120) };
104
+ }
105
+
106
+ /** Append a receipt line to dexterm's receipts.jsonl (the same file `dexterm autotrade receipt` writes). */
107
+ export function writeReceipt(home, s, r, executor, now = Date.now()) {
108
+ const line = { schema: RECEIPT_SCHEMA, signalId: s.id, nonce: s.nonce, accountId: s.accountId, status: r.status, at: now, fill: r.fill ?? null, reason: r.reason ?? null, executor };
109
+ mkdirSync(realDir(home), { recursive: true, mode: 0o700 });
110
+ appendFileSync(join(realDir(home), 'receipts.jsonl'), `${JSON.stringify(line)}\n`, { mode: 0o600 });
111
+ return line;
112
+ }
113
+
114
+ /** Append a heartbeat to dexterm's executor.jsonl: dexterm pauses new buys when it hears nothing for too long. */
115
+ export function heartbeat(home, o) {
116
+ const hb = { schema: HEARTBEAT_SCHEMA, at: o.now ?? Date.now(), executor: o.executor, accountId: o.accountId ?? null, dryRun: true, lastNonce: o.lastNonce ?? null, note: o.note ?? null };
117
+ mkdirSync(realDir(home), { recursive: true, mode: 0o700 });
118
+ appendFileSync(join(realDir(home), 'executor.jsonl'), `${JSON.stringify(hb)}\n`, { mode: 0o600 });
119
+ return hb;
120
+ }
121
+
122
+ /** The whole pipeline for one body: check, act (dry run), receipt, log line. Returns the verdict. */
123
+ export function handle(body, o) {
124
+ const c = check(body, o);
125
+ const s = c.signal;
126
+ if (c.verdict === 'act') {
127
+ o.seen.add(s);
128
+ const r = act(s);
129
+ if (r.status && s.type !== 'halted') writeReceipt(o.home, s, r, o.executor, o.now ?? Date.now());
130
+ o.log(`#${s.nonce} ${s.type}${s.type === 'trade' ? ` ${s.side}` : ''}: ${r.reason}`);
131
+ } else o.log(`${s ? `#${s.nonce} ` : ''}${c.verdict}: ${c.why}`);
132
+ return c;
133
+ }
134
+
135
+ /** A stream line: a bare signal, or a signed envelope (`dexterm autotrade signal channels --jsonl-signed on`). */
136
+ export function unwrapLine(line) {
137
+ try {
138
+ const x = JSON.parse(line);
139
+ if (x && x.schema === ENVELOPE_SCHEMA && typeof x.signal === 'string') return { body: x.signal, signature: x.signature ?? null };
140
+ } catch { /* handled by check() */ }
141
+ return { body: line, signature: null };
142
+ }
143
+
144
+ export const exists = existsSync;
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ // dexterm executor reference kit: the --signal-cmd hook (task-1654). DRY RUN: logs what it would do; no wallet code.
3
+ //
4
+ // dexterm runs it once per signal, with no shell, the signal line on stdin and the HMAC in DEXTERM_SIGNAL_SIGNATURE
5
+ // (when a secret is set). Wire it with an absolute path:
6
+ // dexterm autotrade signal channels --signal-cmd '["/usr/local/bin/node","/path/to/signal-cmd-executor.mjs","--account","main","--secret-file","/path/to/secret"]'
7
+ //
8
+ // Exit codes dexterm reads: 0 acted (dry run) · 3 duplicate (counted delivered) · 2 rejected or expired (never
9
+ // retried) · anything else is retried. A line per signal goes to --log (default autotrade/real/kit-cmd.log).
10
+
11
+ import { appendFileSync, readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { SeenStore, args, dextermHome, handle, heartbeat, readSecret, realDir } from './kit.mjs';
14
+
15
+ const a = args();
16
+ if (!a.account) { console.error('usage: signal-cmd-executor.mjs --account ID [--secret-file FILE] [--home DIR]'); process.exit(2); }
17
+ const home = a.home || dextermHome();
18
+ const logFile = a.log || join(realDir(home), 'kit-cmd.log');
19
+ const log = (s) => { try { appendFileSync(logFile, `[${new Date().toISOString()}] ${s}\n`); } catch { /* the exit code still answers */ } };
20
+ const name = String(a.name || 'kit-signal-cmd').slice(0, 32);
21
+ const body = readFileSync(0, 'utf8').trim();
22
+ const seen = new SeenStore(a.state || join(realDir(home), 'kit-cmd-seen.json'));
23
+ const c = handle(body, { accountId: a.account, secret: readSecret(a['secret-file']), requireHmac: !!a['require-hmac'], signature: process.env.DEXTERM_SIGNAL_SIGNATURE ?? null, seen, home, executor: name, log, now: Date.now() });
24
+ try { heartbeat(home, { executor: name, accountId: a.account, lastNonce: seen.maxNonce || null }); } catch { /* optional */ }
25
+ process.exit(c.verdict === 'act' ? 0 : c.verdict === 'duplicate' ? 3 : 2);
@@ -0,0 +1,47 @@
1
+ # Recipe: a Telegram trading bot as your executor
2
+
3
+ Many people already trade memecoins through a Telegram trading bot. That bot holds the wallet and signs the swaps.
4
+ dexterm can drive the decision side, but it can never be the bot and never holds the bot's key.
5
+
6
+ ## Why the Telegram copy alone is not enough
7
+
8
+ dexterm's Telegram copy of each signal goes to **your** chat, for you to read. A bot cannot read another bot's
9
+ messages, so your trading bot never sees that copy. Something on your side has to hand each signal to the bot.
10
+
11
+ ## The shape that works
12
+
13
+ ```
14
+ dexterm (real mode) --signal--> your small relay (one of the kit executors) --order--> your trading bot
15
+ <--receipt-- <--fill--
16
+ ```
17
+
18
+ 1. Start from one of the kit executors, for example `webhook-executor.mjs`. It already verifies the HMAC, dedupes
19
+ by nonce, honours the TTL, writes receipts and sends heartbeats.
20
+ 2. Replace `act()` in `kit.mjs` with the call your bot offers to accounts that automate it. Depending on the bot
21
+ that is an HTTP API, a CLI, or a message your own user account sends to it through a Telegram client library.
22
+ Use only what the bot documents and allows. That code, its credentials and the wallet are yours; keep them out
23
+ of dexterm's folders.
24
+ 3. When the bot answers, write the receipt with the real fill: `status: "executed"` plus `priceUsd`, `sizeSol`,
25
+ `tokens` and `txSig`, or `rejected` / `failed` with a reason. dexterm then shows how far the real fills were
26
+ from paper.
27
+ 4. Keep the relay's heartbeat running. If the relay or the bot goes quiet longer than your `executorSilenceMin`,
28
+ dexterm pauses new buy signals and tells you.
29
+
30
+ ## Rules the relay must keep (the kit does)
31
+
32
+ - It acts only on signals for its own `accountId`.
33
+ - It never acts after `expiresAt`, and it never retries a stale order on its own.
34
+ - It dedupes by `nonce` and `id`, because the same signal can arrive twice.
35
+ - It treats `type: "test"` as a test: there is never an order.
36
+ - On `type: "halted"`, it may cancel the `outstanding` nonces that are still open on its side. `open` lists what
37
+ dexterm still counts as held.
38
+ - It sizes from its own wallet. `sizeSol` is what dexterm's limits allowed, and `sellFraction` applies to what the
39
+ bot really holds.
40
+
41
+ ## Before any real money
42
+
43
+ - Run the relay in dry run first. `dexterm autotrade signal test` must show every channel delivered and the relay's
44
+ receipt.
45
+ - Set your own limits (`dexterm autotrade limits set …`). The defaults are placeholders: 0.25 SOL per trade, 1 SOL
46
+ a day, 20 buys, 3 open, stop at -0.5 SOL a day.
47
+ - Keep `dexterm autotrade signal kill` at hand. It stops every signal except one final `halted`.
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // dexterm executor reference kit: the WEBHOOK receiver (task-1654). DRY RUN: logs what it would do; no wallet code.
3
+ //
4
+ // node webhook-executor.mjs --account main [--port 8787] [--secret-file FILE] [--home DIR] [--heartbeat-sec 60]
5
+ //
6
+ // Then point dexterm at it: dexterm autotrade signal channels --webhook http://127.0.0.1:8787/signal
7
+ // and prove the wiring: dexterm autotrade signal test
8
+ //
9
+ // Answers: 200 acted (dry run) · 409 duplicate (dexterm counts it delivered) · 410 expired · 401 bad signature ·
10
+ // 400 anything else it will not act on. It binds 127.0.0.1 only. Ctrl-C stops it.
11
+
12
+ import { createServer } from 'node:http';
13
+ import { join } from 'node:path';
14
+ import { SeenStore, args, dextermHome, handle, heartbeat, readSecret, realDir } from './kit.mjs';
15
+
16
+ const a = args();
17
+ if (!a.account) { console.error('usage: node webhook-executor.mjs --account ID [--port 8787] [--secret-file FILE] [--home DIR]'); process.exit(2); }
18
+ const home = a.home || dextermHome();
19
+ const secret = readSecret(a['secret-file']);
20
+ const seen = new SeenStore(a.state || join(realDir(home), 'kit-webhook-seen.json'));
21
+ const name = String(a.name || 'kit-webhook').slice(0, 32);
22
+ const log = (s) => console.log(`[${new Date().toISOString()}] ${s}`);
23
+ const o = { accountId: a.account, secret, requireHmac: !!a['require-hmac'], seen, home, executor: name, log };
24
+
25
+ const server = createServer((req, res) => {
26
+ if (req.method !== 'POST') { res.writeHead(405); res.end('POST only'); return; }
27
+ let body = '';
28
+ req.setEncoding('utf8');
29
+ req.on('data', (c) => { body += c; if (body.length > 64 * 1024) req.destroy(); });
30
+ req.on('end', () => {
31
+ const c = handle(body, { ...o, signature: req.headers['x-dexterm-signature'] ?? null, now: Date.now() });
32
+ const code = c.verdict === 'act' ? 200 : c.verdict === 'duplicate' ? 409 : c.verdict === 'expired' ? 410 : /^signature/.test(c.why ?? '') ? 401 : 400;
33
+ res.writeHead(code, { 'content-type': 'text/plain' });
34
+ res.end(c.verdict);
35
+ });
36
+ });
37
+
38
+ const beat = () => { try { heartbeat(home, { executor: name, accountId: a.account, lastNonce: seen.maxNonce || null }); } catch (e) { log(`heartbeat failed: ${e.message}`); } };
39
+ server.listen(Number(a.port ?? 8787), '127.0.0.1', () => {
40
+ const port = server.address().port;
41
+ log(`dexterm kit webhook executor (DRY RUN) listening on http://127.0.0.1:${port}/signal · account ${a.account} · HMAC ${secret ? 'checked' : 'NOT checked (no --secret-file)'}`);
42
+ beat();
43
+ });
44
+ const timer = setInterval(beat, Math.max(5, Number(a['heartbeat-sec'] ?? 60)) * 1000);
45
+ const stop = () => { clearInterval(timer); server.close(() => process.exit(0)); };
46
+ process.on('SIGINT', stop);
47
+ process.on('SIGTERM', stop);
@@ -32,7 +32,8 @@ export const DATA_LIMITS = [
32
32
  /** A closed trade at or below this is counted as a rug (the challenge's rugAtPct). */
33
33
  export const RUG_PCT = -80;
34
34
  export async function backtest(strategy, tape, sessions = null) {
35
- const run = await runAutotrade({ strategy, tape, sessions, pacing: { mode: 'lockstep' } });
35
+ // task-1645: a strategy in mode real backtests as its paper twin (a backtest never emits a trade-fire signal).
36
+ const run = await runAutotrade({ strategy: strategy.mode === 'real' ? { ...strategy, mode: 'paper' } : strategy, tape, sessions, pacing: { mode: 'lockstep' } });
36
37
  return { report: report(strategy, tape, run), run };
37
38
  }
38
39
  export function report(s, tape, run) {
@@ -3,7 +3,9 @@
3
3
  // $DEXTERM_HOME/connectors.json (0600):
4
4
  // { schema, telegram: { enabled, bot: {id, username}, secretStore, tiers, allow: [{senderId, chatId, tiers,
5
5
  // pairedAt}], notify: {…per event…}, dailySummaryAt, quietHours, approvals, approvalExpirySec, repriceTolPct,
6
- // showAbsoluteBook } }
6
+ // showAbsoluteBook }, signals: {…} }
7
+ // task-1645: `signals` is the real-mode trade-fire channel setup (signal-channels.ts parses it); it lives here so the
8
+ // Telegram copy and the executor channels are configured in one place. This module only carries it through.
7
9
  // Only local commands write it (`dexterm telegram setup|pair|revoke|approvals|notify`). From chat, exactly one
8
10
  // field can change: `approvals` from "off" to "on" (safer); everything else, and "off", is local only.
9
11
  import { existsSync, mkdirSync, readFileSync } from 'node:fs';
@@ -70,7 +72,7 @@ export function loadConnectors(home) {
70
72
  repriceTolPct: Number.isFinite(tol) && tol > 0 ? Math.min(10, tol) : 3,
71
73
  showAbsoluteBook: t.showAbsoluteBook === true,
72
74
  };
73
- return { ok: true, config: { schema: CONNECTORS_SCHEMA, telegram }, exists: true };
75
+ return { ok: true, config: { schema: CONNECTORS_SCHEMA, telegram, ...(d.signals !== undefined ? { signals: d.signals } : {}) }, exists: true };
74
76
  }
75
77
  export function saveConnectors(home, c) {
76
78
  mkdirSync(home, { recursive: true, mode: 0o700 });
@@ -45,6 +45,7 @@ export class ControlRouter {
45
45
  `open ${s.open} · candidates ${s.candidates} · day P/L ${pct(s.dayPnlPct)}`,
46
46
  `model spend today $${s.spend.todayUsd.toFixed(2)}${Number.isFinite(s.spend.dayCapUsd) ? ` of $${s.spend.dayCapUsd}` : ''}${s.allowance?.fiveHour !== null && s.allowance?.fiveHour !== undefined ? ` · 5 h window ${Math.round(s.allowance.fiveHour * 100)}%` : ''}`,
47
47
  `approvals: ${s.approvals}`,
48
+ ...(s.real ? [`executor: ${inert(s.real.executor, 160)}`, `limits: ${inert(s.real.limits, 160)}`, ...(s.real.pause ? [`NEW BUYS PAUSED: ${inert(s.real.pause, 120)}`] : [])] : []),
48
49
  ],
49
50
  };
50
51
  }
@@ -49,6 +49,9 @@ export class NotifyBus {
49
49
  warned = new Set();
50
50
  summarySentFor = '';
51
51
  dayStart;
52
+ /** task-1645: a run in mode real is LIVE in every message (its trades go to the user's executor as signals). */
53
+ realRun = false;
54
+ badge() { return this.realRun ? 'LIVE' : this.o.badge; }
52
55
  constructor(o) {
53
56
  this.o = o;
54
57
  this.now = o.now ?? Date.now;
@@ -77,7 +80,7 @@ export class NotifyBus {
77
80
  }
78
81
  }
79
82
  emit(template, lines, urgent = false) {
80
- this.post(template, { kind: 'text', badge: this.o.badge, lines }, urgent);
83
+ this.post(template, { kind: 'text', badge: this.badge(), lines }, urgent);
81
84
  }
82
85
  post(template, msg, urgent) {
83
86
  const now = this.now();
@@ -95,7 +98,7 @@ export class NotifyBus {
95
98
  ofBook(usd) {
96
99
  const eq = this.state?.book.equityUsd ?? 0;
97
100
  const share = eq > 0 ? `${((usd / eq) * 100).toFixed(1)}% of book` : '% of book unknown';
98
- return this.o.config().showAbsoluteBook && this.o.badge === 'PAPER' && eq > 0 ? `${share}, book ${usdShort(eq)}` : share;
101
+ return this.o.config().showAbsoluteBook && this.badge() === 'PAPER' && eq > 0 ? `${share}, book ${usdShort(eq)}` : share;
99
102
  }
100
103
  sym(mint, fallback) { return `"${inert(this.symbols.get(mint) ?? fallback ?? '?', 20)}" ${shortMint(mint)}`; }
101
104
  handle(e) {
@@ -105,8 +108,9 @@ export class NotifyBus {
105
108
  switch (e.kind) {
106
109
  case 'run_start': {
107
110
  this.highRisk = e.highRisk === true;
111
+ this.realRun = e.mode === 'real';
108
112
  this.strategyLine = `${this.highRisk ? '⚠ HIGH RISK · ' : ''}${inert(e.strategy, 32)} v${inert(e.version, 12)} (${String(e.strategyHash ?? '').slice(0, 8)})`;
109
- this.emit('run_start', [`RUNNER STARTED · ${this.strategyLine}`, ...(this.highRisk ? ['full-port strategy: one entry can be the whole stake, and one rug can zero it. Experimental; not financial advice.'] : []), `entries: ${e.decide === 'agent+approve' || e.approvals === 'required' ? 'each one waits for your tap' : String(e.decide) === 'auto' ? 'mechanical' : 'your agent decides'} · /status /pause /kill /help`], true);
113
+ this.emit('run_start', [`RUNNER STARTED · ${this.strategyLine}`, ...(this.highRisk ? ['full-port strategy: one entry can be the whole stake, and one rug can zero it. Experimental; not financial advice.'] : []), ...(this.realRun ? ['REAL MODE: every trade below also goes to YOUR executor as a LIVE TRADE FIRE signal (dexterm holds no key). Experimental; not financial advice.'] : []), `entries: ${e.decide === 'agent+approve' || e.approvals === 'required' ? 'each one waits for your tap' : String(e.decide) === 'auto' ? 'mechanical' : 'your agent decides'} · /status /pause /kill /help`], true);
110
114
  return;
111
115
  }
112
116
  case 'candidate': {
@@ -171,13 +175,19 @@ export class NotifyBus {
171
175
  return;
172
176
  const caption = `CLOSED · "${inert(t.symbol, 20)}" ${shortMint(t.mint)} · ${pct(t.pnlPct)} (${t.pnlUsd >= 0 ? '+' : ''}${usdShort(t.pnlUsd)}) by ${inert(t.exitReason, 40)} · held ${Math.max(1, Math.round((t.closedAt - t.openedAt) / 60_000))} min`;
173
177
  const card = this.o.card;
178
+ // task-1645: a real run's close is the monitor's modelled view, not your executor's fills: text only, no
179
+ // shareable card (the receipts say what really filled).
180
+ if (this.realRun) {
181
+ this.emit('closed', [`${caption} · modelled; your executor's fills may differ (dexterm autotrade receipt list)`]);
182
+ return;
183
+ }
174
184
  if (!card) {
175
185
  this.emit('closed', [caption]);
176
186
  return;
177
187
  }
178
188
  void card(t).then((png) => {
179
189
  if (png)
180
- this.post('closed_card', { kind: 'card', badge: this.o.badge, png, caption }, false);
190
+ this.post('closed_card', { kind: 'card', badge: this.badge(), png, caption }, false);
181
191
  else
182
192
  this.emit('closed', [caption]);
183
193
  }, () => this.emit('closed', [caption]));
@@ -253,7 +263,7 @@ export class NotifyBus {
253
263
  this.sentAt.shift();
254
264
  if (this.digest.length && !quiet && this.sentAt.length < DIGEST_ABOVE_PER_MIN / 2) {
255
265
  const lines = this.digest.splice(0);
256
- this.post('digest', { kind: 'text', badge: this.o.badge, lines: [`DIGEST · ${lines.length} update${lines.length === 1 ? '' : 's'}`, ...lines.slice(-25)] }, true);
266
+ this.post('digest', { kind: 'text', badge: this.badge(), lines: [`DIGEST · ${lines.length} update${lines.length === 1 ? '' : 's'}`, ...lines.slice(-25)] }, true);
257
267
  }
258
268
  this.maybeSummary(now);
259
269
  await this.queue;
@@ -270,7 +280,7 @@ export class NotifyBus {
270
280
  if (this.summarySentFor === day)
271
281
  return;
272
282
  this.summarySentFor = day;
273
- this.post('daily', { kind: 'text', badge: this.o.badge, lines: this.summaryLines() }, true);
283
+ this.post('daily', { kind: 'text', badge: this.badge(), lines: this.summaryLines() }, true);
274
284
  }
275
285
  summaryLines() {
276
286
  const st = this.state;
@@ -14,12 +14,18 @@ import { join, resolve } from 'node:path';
14
14
  export const SECRET_SERVICE = 'xyz.dexterm.telegram';
15
15
  export const accountFor = (home) => `bot-token-${createHash('sha256').update(resolve(home)).digest('hex').slice(0, 12)}`;
16
16
  const has = (bin) => spawnSync('/usr/bin/env', ['which', bin], { stdio: 'ignore' }).status === 0;
17
+ export const TELEGRAM_TOKEN_ITEM = { service: SECRET_SERVICE, label: 'dexterm telegram bot', file: 'telegram-bot.token', account: accountFor };
18
+ export const SIGNAL_HMAC_ITEM = {
19
+ service: 'xyz.dexterm.signal', label: 'dexterm trade-fire webhook secret', file: 'signal-webhook.secret',
20
+ account: (home) => `signal-hmac-${createHash('sha256').update(resolve(home)).digest('hex').slice(0, 12)}`,
21
+ };
17
22
  class KeychainStore {
18
23
  kind = 'keychain';
19
24
  account;
20
- constructor(account) { this.account = account; }
25
+ item;
26
+ constructor(account, item = TELEGRAM_TOKEN_ITEM) { this.account = account; this.item = item; }
21
27
  get() {
22
- const r = spawnSync('security', ['find-generic-password', '-a', this.account, '-s', SECRET_SERVICE, '-w'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
28
+ const r = spawnSync('security', ['find-generic-password', '-a', this.account, '-s', this.item.service, '-w'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
23
29
  const v = r.status === 0 ? r.stdout.trim() : '';
24
30
  return v || null;
25
31
  }
@@ -27,32 +33,33 @@ class KeychainStore {
27
33
  if (!/^[A-Za-z0-9:_-]+$/.test(value))
28
34
  throw new Error('refusing to store a value with characters a bot token never has');
29
35
  // The add command (with the value) goes through stdin; argv only says "-i".
30
- const r = spawnSync('security', ['-i'], { input: `add-generic-password -U -a ${this.account} -s ${SECRET_SERVICE} -l "dexterm telegram bot" -w "${value}"\n`, encoding: 'utf8', stdio: ['pipe', 'ignore', 'pipe'] });
36
+ const r = spawnSync('security', ['-i'], { input: `add-generic-password -U -a ${this.account} -s ${this.item.service} -l "${this.item.label}" -w "${value}"\n`, encoding: 'utf8', stdio: ['pipe', 'ignore', 'pipe'] });
31
37
  if (r.status !== 0 || this.get() !== value)
32
38
  throw new Error('the macOS keychain did not store the token');
33
39
  }
34
- remove() { spawnSync('security', ['delete-generic-password', '-a', this.account, '-s', SECRET_SERVICE], { stdio: 'ignore' }); }
40
+ remove() { spawnSync('security', ['delete-generic-password', '-a', this.account, '-s', this.item.service], { stdio: 'ignore' }); }
35
41
  }
36
42
  class LibsecretStore {
37
43
  kind = 'libsecret';
38
44
  account;
39
- constructor(account) { this.account = account; }
45
+ item;
46
+ constructor(account, item = TELEGRAM_TOKEN_ITEM) { this.account = account; this.item = item; }
40
47
  get() {
41
- const r = spawnSync('secret-tool', ['lookup', 'service', SECRET_SERVICE, 'account', this.account], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
48
+ const r = spawnSync('secret-tool', ['lookup', 'service', this.item.service, 'account', this.account], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
42
49
  const v = r.status === 0 ? r.stdout.trim() : '';
43
50
  return v || null;
44
51
  }
45
52
  set(value) {
46
- const r = spawnSync('secret-tool', ['store', '--label=dexterm telegram bot', 'service', SECRET_SERVICE, 'account', this.account], { input: value, encoding: 'utf8', stdio: ['pipe', 'ignore', 'pipe'] });
53
+ const r = spawnSync('secret-tool', ['store', `--label=${this.item.label}`, 'service', this.item.service, 'account', this.account], { input: value, encoding: 'utf8', stdio: ['pipe', 'ignore', 'pipe'] });
47
54
  if (r.status !== 0 || this.get() !== value)
48
55
  throw new Error('libsecret did not store the token');
49
56
  }
50
- remove() { spawnSync('secret-tool', ['clear', 'service', SECRET_SERVICE, 'account', this.account], { stdio: 'ignore' }); }
57
+ remove() { spawnSync('secret-tool', ['clear', 'service', this.item.service, 'account', this.account], { stdio: 'ignore' }); }
51
58
  }
52
59
  class FileStore {
53
60
  kind = 'file';
54
61
  path;
55
- constructor(home) { this.path = join(home, 'secrets', 'telegram-bot.token'); }
62
+ constructor(home, item = TELEGRAM_TOKEN_ITEM) { this.path = join(home, 'secrets', item.file); }
56
63
  get() {
57
64
  if (!existsSync(this.path))
58
65
  return null;
@@ -77,23 +84,23 @@ export class MemoryStore {
77
84
  remove() { this.v = null; }
78
85
  }
79
86
  /** The store a kind names (the one setup recorded), for this home. */
80
- export function secretStore(kind, home) {
87
+ export function secretStore(kind, home, item = TELEGRAM_TOKEN_ITEM) {
81
88
  if (kind === 'keychain')
82
- return new KeychainStore(accountFor(home));
89
+ return new KeychainStore(item.account(home), item);
83
90
  if (kind === 'libsecret')
84
- return new LibsecretStore(accountFor(home));
91
+ return new LibsecretStore(item.account(home), item);
85
92
  if (kind === 'memory')
86
93
  return new MemoryStore();
87
- return new FileStore(home);
94
+ return new FileStore(home, item);
88
95
  }
89
96
  /** The best store on this machine: DEXTERM_SECRET_STORE wins, then the OS store, then the 0600 file. */
90
- export function chooseSecretStore(home, env = process.env, platform = process.platform) {
97
+ export function chooseSecretStore(home, env = process.env, platform = process.platform, item = TELEGRAM_TOKEN_ITEM) {
91
98
  const want = env.DEXTERM_SECRET_STORE;
92
99
  if (want === 'file' || want === 'keychain' || want === 'libsecret')
93
- return secretStore(want, home);
100
+ return secretStore(want, home, item);
94
101
  if (platform === 'darwin' && has('security'))
95
- return secretStore('keychain', home);
102
+ return secretStore('keychain', home, item);
96
103
  if (platform === 'linux' && has('secret-tool'))
97
- return secretStore('libsecret', home);
98
- return secretStore('file', home);
104
+ return secretStore('libsecret', home, item);
105
+ return secretStore('file', home, item);
99
106
  }
@@ -12,6 +12,8 @@
12
12
  import { riskDelta } from '../../../mcp/src/index.js';
13
13
  import { StrategyStore } from "../store.js";
14
14
  import { requestControl } from "../safety.js";
15
+ import { healthLine } from "../executor-health.js";
16
+ import { limitsSummary } from "../limits.js";
15
17
  import { badgeOf } from "../execution-port.js";
16
18
  import { ApprovalBroker, CallbackStore, callbackDbPath } from "./approvals.js";
17
19
  import { ChatAudit } from "./audit.js";
@@ -64,6 +66,7 @@ export class HostControlPort {
64
66
  runId, state: st.state, haltReason: st.haltReason, strategy: st.strategy, open: st.positions.length, candidates: st.candidates.length,
65
67
  dayPnlPct: start > 0 ? (st.book.realizedTodayUsd / start) * 100 : null, spend: { todayUsd: st.spend.todayUsd, dayCapUsd: st.spend.dayCapUsd }, allowance: st.spend.allowance,
66
68
  approvals: s?.entry.decide === 'agent+approve' ? 'required by the strategy' : cfg.approvals,
69
+ ...(st.real?.executor ? { real: { executor: healthLine(st.real.executor), limits: limitsSummary(st.real.limits ?? {}), pause: st.real.risk?.buyPause ?? null } } : {}),
67
70
  };
68
71
  }
69
72
  positions() {