dexterm-cli 0.1.7 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/packages/agents/src/spectator.js +31 -3
- package/dist/packages/agents/src/tiers.js +12 -0
- package/dist/packages/agents/src/tools.js +13 -5
- package/dist/packages/autotrade/assets/limits.schema.json +569 -0
- package/dist/packages/autotrade/examples/executor/README.md +70 -0
- package/dist/packages/autotrade/examples/executor/jsonl-tail-executor.mjs +57 -0
- package/dist/packages/autotrade/examples/executor/kit.mjs +144 -0
- package/dist/packages/autotrade/examples/executor/signal-cmd-executor.mjs +25 -0
- package/dist/packages/autotrade/examples/executor/telegram-bot-recipe.md +47 -0
- package/dist/packages/autotrade/examples/executor/webhook-executor.mjs +47 -0
- package/dist/packages/autotrade/src/backtest.js +7 -2
- package/dist/packages/autotrade/src/chat/config.js +4 -2
- package/dist/packages/autotrade/src/chat/control.js +1 -0
- package/dist/packages/autotrade/src/chat/notify.js +16 -6
- package/dist/packages/autotrade/src/chat/secrets.js +25 -18
- package/dist/packages/autotrade/src/chat/service.js +3 -0
- package/dist/packages/autotrade/src/engine.js +16 -2
- package/dist/packages/autotrade/src/executor-health.js +217 -0
- package/dist/packages/autotrade/src/fleet.js +5 -2
- package/dist/packages/autotrade/src/forward.js +355 -0
- package/dist/packages/autotrade/src/index.js +21 -0
- package/dist/packages/autotrade/src/journey.js +477 -0
- package/dist/packages/autotrade/src/limits.js +682 -0
- package/dist/packages/autotrade/src/outbox.js +394 -0
- package/dist/packages/autotrade/src/paper-limits.js +87 -0
- package/dist/packages/autotrade/src/real-mode.js +688 -0
- package/dist/packages/autotrade/src/receipts.js +330 -0
- package/dist/packages/autotrade/src/report.js +168 -0
- package/dist/packages/autotrade/src/rugproof-probe.js +415 -0
- package/dist/packages/autotrade/src/rugproof.js +231 -0
- package/dist/packages/autotrade/src/runner.js +194 -28
- package/dist/packages/autotrade/src/service-guard.js +125 -0
- package/dist/packages/autotrade/src/service-install.js +215 -0
- package/dist/packages/autotrade/src/service-recover.js +232 -0
- package/dist/packages/autotrade/src/service.js +289 -0
- package/dist/packages/autotrade/src/sessions.js +16 -7
- package/dist/packages/autotrade/src/signal-channels.js +312 -0
- package/dist/packages/autotrade/src/signal.js +196 -0
- package/dist/packages/autotrade/src/strategy-stats.js +344 -0
- package/dist/packages/autotrade/src/strategy.js +15 -2
- package/dist/packages/autotrade/src/trade-ledger.js +2 -2
- package/dist/packages/classifier/src/calibrate.js +138 -0
- package/dist/packages/classifier/src/classify.js +107 -0
- package/dist/packages/classifier/src/config.js +220 -0
- package/dist/packages/classifier/src/dossier.js +95 -0
- package/dist/packages/classifier/src/hardware.js +63 -0
- package/dist/packages/classifier/src/index.js +18 -0
- package/dist/packages/classifier/src/models.js +111 -0
- package/dist/packages/classifier/src/providers/heuristic.js +77 -0
- package/dist/packages/classifier/src/providers/http.js +63 -0
- package/dist/packages/classifier/src/providers/jev-open.js +86 -0
- package/dist/packages/classifier/src/providers/llm.js +89 -0
- package/dist/packages/classifier/src/providers/systemone-http.js +95 -0
- package/dist/packages/classifier/src/questions.js +97 -0
- package/dist/packages/classifier/src/registry.js +42 -0
- package/dist/packages/classifier/src/snapshot.js +31 -0
- package/dist/packages/classifier/src/systemone.js +88 -0
- package/dist/packages/classifier/src/types.js +18 -0
- package/dist/packages/cli/assets/dash/dash.css +139 -0
- package/dist/packages/cli/assets/dash/dash.js +200 -0
- package/dist/packages/cli/assets/dash/icon.svg +1 -0
- package/dist/packages/cli/assets/dash/index.html +36 -0
- package/dist/packages/cli/src/analyst/backend.js +3 -2
- package/dist/packages/cli/src/analyst/codex.js +3 -2
- package/dist/packages/cli/src/app-help-gen.js +26 -2
- package/dist/packages/cli/src/autotrade-cmd/audit.js +19 -2
- package/dist/packages/cli/src/autotrade-cmd/backtest.js +21 -4
- package/dist/packages/cli/src/autotrade-cmd/executor.js +91 -0
- package/dist/packages/cli/src/autotrade-cmd/fleet.js +16 -5
- package/dist/packages/cli/src/autotrade-cmd/forward.js +283 -0
- package/dist/packages/cli/src/autotrade-cmd/journey.js +208 -0
- package/dist/packages/cli/src/autotrade-cmd/limits.js +393 -0
- package/dist/packages/cli/src/autotrade-cmd/receipt.js +63 -0
- package/dist/packages/cli/src/autotrade-cmd/report.js +79 -0
- package/dist/packages/cli/src/autotrade-cmd/run.js +268 -14
- package/dist/packages/cli/src/autotrade-cmd/service.js +873 -0
- package/dist/packages/cli/src/autotrade-cmd/signal.js +824 -0
- package/dist/packages/cli/src/autotrade-cmd.js +50 -4
- package/dist/packages/cli/src/classifier-cmd.js +353 -0
- package/dist/packages/cli/src/classifier-live.js +81 -0
- package/dist/packages/cli/src/dash/data.js +217 -0
- package/dist/packages/cli/src/dash/server.js +92 -0
- package/dist/packages/cli/src/dash-cmd.js +113 -0
- package/dist/packages/cli/src/doctor.js +34 -0
- package/dist/packages/cli/src/ledger-cmd.js +4 -2
- package/dist/packages/cli/src/live/screens/token-live.js +4 -0
- package/dist/packages/cli/src/live/signals.js +8 -0
- package/dist/packages/cli/src/main.js +98 -6
- package/dist/packages/cli/src/mcp-follow.js +21 -1
- package/dist/packages/cli/src/narratives-cmd.js +147 -0
- package/dist/packages/cli/src/narratives.js +114 -0
- package/dist/packages/cli/src/overlay/model.js +147 -0
- package/dist/packages/cli/src/overlay/page.js +146 -0
- package/dist/packages/cli/src/overlay/pnl.js +75 -0
- package/dist/packages/cli/src/overlay/server.js +170 -0
- package/dist/packages/cli/src/overlay-cmd.js +367 -0
- package/dist/packages/cli/src/pnl-card.js +4 -0
- package/dist/packages/cli/src/record-cmd.js +82 -11
- package/dist/packages/cli/src/rules-cmd.js +2 -1
- package/dist/packages/cli/src/rules.js +24 -4
- package/dist/packages/cli/src/screens/autotrade-journey.js +134 -0
- package/dist/packages/cli/src/screens/autotrade-keys.js +13 -0
- package/dist/packages/cli/src/screens/autotrade-screen.js +57 -6
- package/dist/packages/cli/src/screens/autotrade-ui.js +17 -7
- package/dist/packages/cli/src/screens/autotrade-worker.js +4 -2
- package/dist/packages/cli/src/screens/narrative.js +154 -2
- package/dist/packages/cli/src/state.js +1 -0
- package/dist/packages/cli/src/telegram-cmd.js +4 -1
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/custom/src/rules.js +5 -1
- package/dist/packages/data/src/trade/ledger/schema.js +1 -1
- package/dist/packages/mcp/assets/kb/app-help/autotrade-executor-kit.md +44 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-journey.md +46 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-limits.md +48 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-real-signal.md +45 -0
- package/dist/packages/mcp/assets/kb/app-help/classification-models.md +41 -0
- package/dist/packages/mcp/assets/kb/app-help/live-trading.md +1 -1
- package/dist/packages/mcp/assets/kb/app-help/narratives-tracker.md +45 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-autotrade.md +12 -0
- package/dist/packages/mcp/assets/kb/app-help/strategy-dashboard.md +38 -0
- package/dist/packages/mcp/assets/kb/app-help/streamer-overlay.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/trade-ledger.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-change-a-strategy-by-chat.md +3 -1
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-on-the-q-screen.md +1 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-rug-proof-entries-experimental.md +52 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-running-it-all-day.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-stopping-it.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-always-on-service.md +59 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-strategy-dashboard-dexterm-dash.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-is-paper-self-directed.md +9 -2
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-will-not-do.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade.md +3 -1
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-before-any-experimental-trading.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-copy-and-paste-without-surprises.md +0 -1
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-if-dexterm-says-command-not-found.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-reopen-dexterm-later.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-terminal-shell-prompt-command-output-and-path.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-1-ask-for-a-strategy.md +27 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-2-backtest-it-held-out-split.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-3-forward-test-it-on-live-data.md +44 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-4-promote-it-to-always-on-paper.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-5-real-only-if-you-choose.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-ladder.md +41 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-report.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-what-the-analyst-knows.md +30 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat.md +26 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-hosted-jev-with-your-own-key.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-how-they-measured-on-dexterm-s-own-history.md +52 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-set-up-kev-on-your-own-machine.md +39 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-settings.md +35 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-the-choices.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-where-it-plugs-in.md +29 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-which-model-for-which-machine.md +42 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier.md +32 -0
- package/dist/packages/mcp/assets/kb/docs/docs-cli-invocations.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-configuration-headless-recorder-dexterm-record.md +3 -0
- package/dist/packages/mcp/assets/kb/docs/docs-copy-trading.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-data-sources-recorded-history-and-retention.md +1 -0
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -11
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-executor-health.md +38 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-paper-vs-real.md +33 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-digest.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-executor-kit.md +48 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-whole-path.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-what-dexterm-does-and-never-does.md +44 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-your-limits.md +77 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real.md +24 -0
- package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-larp-paste-a-coin-on-the-web.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-changed-in-0-1-7.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-real-mode-your-strategy-s-trades-to-your.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-the-short-version.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-using-a-trading-bot-with-dexterm.md +0 -2
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-what-dexterm-does-not-do.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-narratives-how-coins-are-grouped.md +39 -0
- package/dist/packages/mcp/assets/kb/docs/docs-narratives-limits.md +29 -0
- package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-is-measured.md +44 -0
- package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-testing-showed.md +43 -0
- package/dist/packages/mcp/assets/kb/docs/docs-narratives-where-to-find-it.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-narratives.md +29 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-before-you-start.md +33 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-limits.md +30 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-options.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-security-model.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-set-it-up-in-obs-step-by-step.md +49 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-pnl-scene-paper-and-live.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-three-scenes.md +35 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-what-is-redacted.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay.md +25 -0
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-receipts-what-your-executor-did.md +42 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-safety.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-set-it-up.md +47 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-the-channels.md +42 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-the-signal.md +48 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-is.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-will-not-do.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-where-you-see-it.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode.md +27 -0
- package/dist/packages/mcp/assets/kb/docs/docs-screens-bubble-map.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-streaming-it.md +1 -0
- package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-voice-linux-and-windows.md +2 -2
- package/dist/packages/mcp/assets/kb/entries/autotrade.md +4 -2
- package/dist/packages/mcp/assets/kb/entries/narratives-tracker.md +45 -0
- package/dist/packages/mcp/src/autotrade-tools.js +168 -5
- package/dist/packages/mcp/src/engine/restrictions.js +3 -3
- package/dist/packages/mcp/src/index.js +1 -1
- package/dist/packages/mcp/src/ledger-tools.js +155 -7
- package/dist/packages/mcp/src/server.js +125 -10
- package/dist/packages/mcp/src/spectator-sink.js +27 -0
- package/dist/packages/narratives/src/cluster.js +193 -0
- package/dist/packages/narratives/src/embed.js +184 -0
- package/dist/packages/narratives/src/export.js +60 -0
- package/dist/packages/narratives/src/index.js +10 -0
- package/dist/packages/narratives/src/label.js +48 -0
- package/dist/packages/narratives/src/normalize.js +179 -0
- package/dist/packages/narratives/src/recorder.js +93 -0
- package/dist/packages/narratives/src/tracker.js +328 -0
- package/dist/packages/recorder/src/exit-probes.js +215 -0
- package/dist/packages/recorder/src/index.js +1 -0
- package/dist/packages/recorder/src/recorder.js +11 -1
- package/dist/packages/recorder/src/retention.js +4 -0
- package/dist/packages/recorder/src/schema.js +20 -1
- package/dist/packages/tui/src/keymap.js +12 -0
- package/package.json +3 -1
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-powershell-experimental.md +0 -39
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// dexterm autotrade — the always-on service: lock, heartbeat, stop marker, crash-loop guard and the `ensure`
|
|
2
|
+
// decision (0.2.0 card B, AUTOTRADE-SPEC §7.4 / AT-10). Mode-agnostic: the service hosts `run --all` and never
|
|
3
|
+
// decides paper or real itself.
|
|
4
|
+
//
|
|
5
|
+
// Shape (one per machine and DEXTERM_HOME):
|
|
6
|
+
// OS scheduler (LaunchAgent / systemd user timer / scheduled task), every 60 s and at login
|
|
7
|
+
// └─ `dexterm autotrade service ensure` exits at once when the heartbeat is fresh; otherwise starts the service
|
|
8
|
+
// └─ `dexterm autotrade service run` detached: service lock + heartbeat every 10 s + guard + the fleet
|
|
9
|
+
//
|
|
10
|
+
// Files under $DEXTERM_HOME/autotrade/service/:
|
|
11
|
+
// service.lock {pid, bootAt, startedAt}: a stale lock is reclaimed only when that pid is dead (or from an older boot)
|
|
12
|
+
// heartbeat.json dexterm-autotrade-service/1: state, fleet id, guard states; rewritten every 10 s; `stopped` on a
|
|
13
|
+
// clean exit (anything else with a dead pid is a crash)
|
|
14
|
+
// STOP the stop marker: `service stop`, a Telegram / Q-screen / CLI stop of the fleet, or a plain `touch`.
|
|
15
|
+
// A clean stop stays stopped: `ensure` does nothing until `service start` removes it.
|
|
16
|
+
// starts.json the crash-loop guard: every start after a crash, and the give-up record (`attention`)
|
|
17
|
+
// state.json the fleet the service is running (recovery after a crash reads its ledgers, service-recover.ts)
|
|
18
|
+
// Logs: $DEXTERM_HOME/autotrade/logs/service.log (+ .1), size-capped (BoundedLog).
|
|
19
|
+
//
|
|
20
|
+
// Crash-loop guard: a start that follows a crash waits a backoff (0, 0, 1, 2, 4 min for the 1st..5th crash in an
|
|
21
|
+
// hour); the 6th crash within an hour writes `attention`, sends ONE notification and leaves the service down until
|
|
22
|
+
// `dexterm autotrade service status --clear`. A clean stop and a manual `service start` never count.
|
|
23
|
+
//
|
|
24
|
+
// Nothing here holds a key, signs, touches a wallet or the network, and no model reaches any of it.
|
|
25
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync } from 'node:fs';
|
|
26
|
+
import { uptime } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { writeJsonAtomic } from "./ledger.js";
|
|
29
|
+
export const SERVICE_SCHEMA = 'dexterm-autotrade-service/1';
|
|
30
|
+
export const SERVICE_HEARTBEAT_MS = 10_000;
|
|
31
|
+
/** `ensure` treats a heartbeat older than this as "not running" (three missed beats). */
|
|
32
|
+
export const SERVICE_STALE_MS = 35_000;
|
|
33
|
+
/** A live pid whose heartbeat is this old is hung: `ensure` ends it so the next tick restarts it. */
|
|
34
|
+
export const SERVICE_HUNG_MS = 180_000;
|
|
35
|
+
export const CRASH_WINDOW_MS = 60 * 60_000;
|
|
36
|
+
/** Crash restarts allowed in CRASH_WINDOW_MS; the next one gives up. */
|
|
37
|
+
export const MAX_CRASH_RESTARTS = 5;
|
|
38
|
+
export const serviceDir = (home) => join(home, 'autotrade', 'service');
|
|
39
|
+
export const serviceLogDir = (home) => join(home, 'autotrade', 'logs');
|
|
40
|
+
export const serviceLockPath = (home) => join(serviceDir(home), 'service.lock');
|
|
41
|
+
export const serviceHeartbeatPath = (home) => join(serviceDir(home), 'heartbeat.json');
|
|
42
|
+
export const serviceStopPath = (home) => join(serviceDir(home), 'STOP');
|
|
43
|
+
export const serviceStartsPath = (home) => join(serviceDir(home), 'starts.json');
|
|
44
|
+
export const serviceStatePath = (home) => join(serviceDir(home), 'state.json');
|
|
45
|
+
export const serviceLogPath = (home) => join(serviceLogDir(home), 'service.log');
|
|
46
|
+
const mkdirs = (home) => { mkdirSync(serviceDir(home), { recursive: true, mode: 0o700 }); };
|
|
47
|
+
const readJson = (path) => { try {
|
|
48
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
} };
|
|
53
|
+
export const pidAlive = (pid) => {
|
|
54
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
55
|
+
return false;
|
|
56
|
+
try {
|
|
57
|
+
process.kill(pid, 0);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
return e.code === 'EPERM';
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
/** This boot's start, rounded to the minute (a pid from an older boot is never this process). */
|
|
65
|
+
export const bootAt = () => Math.round((Date.now() - uptime() * 1000) / 60_000) * 60_000;
|
|
66
|
+
/** Take the service lock (O_EXCL). A holder is stale only when its pid is dead or it is from an older boot. */
|
|
67
|
+
export function acquireServiceLock(home, d = {}) {
|
|
68
|
+
mkdirs(home);
|
|
69
|
+
const path = serviceLockPath(home);
|
|
70
|
+
const alive = d.alive ?? pidAlive;
|
|
71
|
+
const lock = { pid: d.pid ?? process.pid, bootAt: d.boot ?? bootAt(), startedAt: d.now ?? Date.now() };
|
|
72
|
+
let reclaimed = null;
|
|
73
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
74
|
+
try {
|
|
75
|
+
const fd = openSync(path, 'wx', 0o600);
|
|
76
|
+
writeSync(fd, `${JSON.stringify(lock)}\n`);
|
|
77
|
+
closeSync(fd);
|
|
78
|
+
return { ok: true, lock, reclaimed };
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
if (e.code !== 'EEXIST')
|
|
82
|
+
throw e;
|
|
83
|
+
}
|
|
84
|
+
const held = readJson(path);
|
|
85
|
+
if (held && typeof held.pid === 'number' && alive(held.pid) && Math.abs((held.bootAt ?? 0) - lock.bootAt) <= 5 * 60_000)
|
|
86
|
+
return { ok: false, holder: held };
|
|
87
|
+
reclaimed = held;
|
|
88
|
+
rmSync(path, { force: true });
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`could not take ${path}`);
|
|
91
|
+
}
|
|
92
|
+
export function releaseServiceLock(home, pid = process.pid) {
|
|
93
|
+
const held = readJson(serviceLockPath(home));
|
|
94
|
+
if (held && held.pid === pid)
|
|
95
|
+
rmSync(serviceLockPath(home), { force: true });
|
|
96
|
+
}
|
|
97
|
+
/** The lock holder when its pid is alive (and from this boot), else null. */
|
|
98
|
+
export function liveServiceHolder(home, alive = pidAlive, boot = bootAt()) {
|
|
99
|
+
const held = readJson(serviceLockPath(home));
|
|
100
|
+
if (!held || typeof held.pid !== 'number' || !alive(held.pid))
|
|
101
|
+
return null;
|
|
102
|
+
return Math.abs((held.bootAt ?? 0) - boot) <= 5 * 60_000 ? held : null;
|
|
103
|
+
}
|
|
104
|
+
export function writeServiceHeartbeat(home, hb) {
|
|
105
|
+
mkdirs(home);
|
|
106
|
+
writeJsonAtomic(serviceHeartbeatPath(home), hb);
|
|
107
|
+
}
|
|
108
|
+
export function readServiceHeartbeat(home) {
|
|
109
|
+
const d = readJson(serviceHeartbeatPath(home));
|
|
110
|
+
return d && d.schema === SERVICE_SCHEMA && typeof d.pid === 'number' && typeof d.heartbeatAt === 'number' ? d : null;
|
|
111
|
+
}
|
|
112
|
+
export function writeStopMarker(home, by, reason, now = Date.now()) {
|
|
113
|
+
mkdirs(home);
|
|
114
|
+
const m = { at: now, by, reason: reason.slice(0, 200) };
|
|
115
|
+
writeJsonAtomic(serviceStopPath(home), m);
|
|
116
|
+
return m;
|
|
117
|
+
}
|
|
118
|
+
/** The stop marker, or null. A file that is not our JSON (a plain `touch`) is still a stop. */
|
|
119
|
+
export function readStopMarker(home) {
|
|
120
|
+
const p = serviceStopPath(home);
|
|
121
|
+
if (!existsSync(p))
|
|
122
|
+
return null;
|
|
123
|
+
const d = readJson(p);
|
|
124
|
+
if (d && typeof d.at === 'number')
|
|
125
|
+
return { at: d.at, by: (d.by ?? 'file'), reason: typeof d.reason === 'string' ? d.reason : 'stop' };
|
|
126
|
+
let at = 0;
|
|
127
|
+
try {
|
|
128
|
+
at = statSync(p).mtimeMs;
|
|
129
|
+
}
|
|
130
|
+
catch { /* raced */ }
|
|
131
|
+
return { at, by: 'file', reason: 'the STOP file is present' };
|
|
132
|
+
}
|
|
133
|
+
export const clearStopMarker = (home) => { rmSync(serviceStopPath(home), { force: true }); };
|
|
134
|
+
const EMPTY_STARTS = { crashStarts: [], lastStartAt: null, attention: null, downNotifiedAt: null };
|
|
135
|
+
export function readStarts(home) {
|
|
136
|
+
const d = readJson(serviceStartsPath(home));
|
|
137
|
+
if (!d)
|
|
138
|
+
return { ...EMPTY_STARTS, crashStarts: [] };
|
|
139
|
+
return {
|
|
140
|
+
crashStarts: Array.isArray(d.crashStarts) ? d.crashStarts.filter((t) => typeof t === 'number') : [],
|
|
141
|
+
lastStartAt: typeof d.lastStartAt === 'number' ? d.lastStartAt : null,
|
|
142
|
+
attention: d.attention && typeof d.attention.at === 'number' ? { at: d.attention.at, why: String(d.attention.why ?? ''), notified: !!d.attention.notified } : null,
|
|
143
|
+
downNotifiedAt: typeof d.downNotifiedAt === 'number' ? d.downNotifiedAt : null,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export function writeStarts(home, d) { mkdirs(home); writeJsonAtomic(serviceStartsPath(home), d); }
|
|
147
|
+
/** Wait before the n-th crash restart within the window (1-based): 0, 0, 1, 2, 4 minutes. */
|
|
148
|
+
export function crashBackoffMs(n) { return n <= 2 ? 0 : Math.min(15, 2 ** (n - 3)) * 60_000; }
|
|
149
|
+
/** `service status --clear`: forget the crash history and lift the give-up. */
|
|
150
|
+
export function clearAttention(home) {
|
|
151
|
+
const d = { ...readStarts(home), crashStarts: [], attention: null };
|
|
152
|
+
writeStarts(home, d);
|
|
153
|
+
return d;
|
|
154
|
+
}
|
|
155
|
+
/** True when the last run did not end cleanly (a heartbeat that is not `stopped`, with no live holder). */
|
|
156
|
+
export function lastRunCrashed(hb) { return !!hb && hb.state !== 'stopped'; }
|
|
157
|
+
/**
|
|
158
|
+
* What one scheduler tick does. Pure: the caller reads the files and carries the action out (startService,
|
|
159
|
+
* the kill, the notification, writeStarts).
|
|
160
|
+
*/
|
|
161
|
+
export function ensureDecision(i) {
|
|
162
|
+
if (i.holder) {
|
|
163
|
+
const age = i.heartbeat && i.heartbeat.pid === i.holder.pid ? i.now - i.heartbeat.heartbeatAt : i.now - i.holder.startedAt;
|
|
164
|
+
if (age <= SERVICE_STALE_MS)
|
|
165
|
+
return { action: 'noop', why: `running (pid ${i.holder.pid}, heartbeat ${Math.round(age / 1000)} s old)` };
|
|
166
|
+
if (age >= SERVICE_HUNG_MS)
|
|
167
|
+
return { action: 'kill-hung', pid: i.holder.pid, why: `pid ${i.holder.pid} is alive but its heartbeat is ${Math.round(age / 1000)} s old (hung)` };
|
|
168
|
+
return { action: 'noop', why: `pid ${i.holder.pid} is alive; heartbeat ${Math.round(age / 1000)} s old (starting or busy)` };
|
|
169
|
+
}
|
|
170
|
+
if (i.stop)
|
|
171
|
+
return { action: 'noop', why: `stopped (${i.stop.reason}); \`dexterm autotrade service start\` starts it again` };
|
|
172
|
+
if (i.starts.attention)
|
|
173
|
+
return { action: 'give-up', why: i.starts.attention.why, notify: !i.starts.attention.notified };
|
|
174
|
+
if (i.panic)
|
|
175
|
+
return { action: 'noop', why: `the panic switch is engaged (${i.panic}); nothing starts until \`dexterm wallet resume\`` };
|
|
176
|
+
const crashed = lastRunCrashed(i.heartbeat);
|
|
177
|
+
if (!crashed)
|
|
178
|
+
return { action: 'start', crashed: false, why: i.heartbeat ? 'the service is not running' : 'first start', notifyDown: false };
|
|
179
|
+
const recent = i.starts.crashStarts.filter((t) => i.now - t < CRASH_WINDOW_MS);
|
|
180
|
+
if (recent.length >= MAX_CRASH_RESTARTS) {
|
|
181
|
+
return { action: 'give-up', why: `crash loop: ${recent.length + 1} crashes within an hour; the service stays down until \`dexterm autotrade service status --clear\``, notify: true };
|
|
182
|
+
}
|
|
183
|
+
const wait = crashBackoffMs(recent.length + 1);
|
|
184
|
+
const last = recent.length ? Math.max(...recent) : 0;
|
|
185
|
+
if (last && i.now - last < wait)
|
|
186
|
+
return { action: 'wait', why: `crash ${recent.length + 1} within the hour: backing off ${Math.round(wait / 60_000)} min`, untilMs: last + wait };
|
|
187
|
+
const notifyDown = !i.starts.downNotifiedAt || i.now - i.starts.downNotifiedAt >= CRASH_WINDOW_MS;
|
|
188
|
+
return { action: 'start', crashed: true, why: `the last run ended without a clean stop (pid ${i.heartbeat.pid}); restarting`, notifyDown };
|
|
189
|
+
}
|
|
190
|
+
/** Record what a decision did in starts.json (the caller does the process work). */
|
|
191
|
+
export function applyEnsure(home, d, now) {
|
|
192
|
+
const s = readStarts(home);
|
|
193
|
+
s.crashStarts = s.crashStarts.filter((t) => now - t < CRASH_WINDOW_MS);
|
|
194
|
+
if (d.action === 'start') {
|
|
195
|
+
s.lastStartAt = now;
|
|
196
|
+
if (d.crashed)
|
|
197
|
+
s.crashStarts.push(now);
|
|
198
|
+
if (d.notifyDown)
|
|
199
|
+
s.downNotifiedAt = now;
|
|
200
|
+
}
|
|
201
|
+
else if (d.action === 'give-up') {
|
|
202
|
+
s.attention = { at: s.attention?.at ?? now, why: d.why, notified: true };
|
|
203
|
+
}
|
|
204
|
+
writeStarts(home, s);
|
|
205
|
+
return s;
|
|
206
|
+
}
|
|
207
|
+
export function writeServiceState(home, d) { mkdirs(home); writeJsonAtomic(serviceStatePath(home), d); }
|
|
208
|
+
export function readServiceState(home) {
|
|
209
|
+
const d = readJson(serviceStatePath(home));
|
|
210
|
+
return d && typeof d.startedAt === 'number' ? { fleetId: typeof d.fleetId === 'string' ? d.fleetId : null, startedAt: d.startedAt, pid: Number(d.pid) || 0 } : null;
|
|
211
|
+
}
|
|
212
|
+
// ───────────────────────────── the bounded log ─────────────────────────────
|
|
213
|
+
export const LOG_MAX_BYTES = 4 * 1024 * 1024;
|
|
214
|
+
/**
|
|
215
|
+
* An append-only log capped at maxBytes: past the cap the file moves to `.1` (replacing the older one) and a new
|
|
216
|
+
* file starts, so the log never holds more than about 2 × maxBytes. Each line is stamped; write errors are swallowed
|
|
217
|
+
* (a full disk must never stop the exits).
|
|
218
|
+
*/
|
|
219
|
+
export class BoundedLog {
|
|
220
|
+
path;
|
|
221
|
+
max;
|
|
222
|
+
size;
|
|
223
|
+
constructor(path, maxBytes = LOG_MAX_BYTES) {
|
|
224
|
+
this.path = path;
|
|
225
|
+
this.max = maxBytes;
|
|
226
|
+
try {
|
|
227
|
+
mkdirSync(join(path, '..'), { recursive: true, mode: 0o700 });
|
|
228
|
+
}
|
|
229
|
+
catch { /* reported on write */ }
|
|
230
|
+
try {
|
|
231
|
+
this.size = statSync(path).size;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
this.size = 0;
|
|
235
|
+
}
|
|
236
|
+
if (this.size >= this.max)
|
|
237
|
+
this.rotate();
|
|
238
|
+
}
|
|
239
|
+
rotate() {
|
|
240
|
+
try {
|
|
241
|
+
renameSync(this.path, `${this.path}.1`);
|
|
242
|
+
}
|
|
243
|
+
catch { /* nothing to move */ }
|
|
244
|
+
this.size = 0;
|
|
245
|
+
}
|
|
246
|
+
line(s, now = Date.now()) {
|
|
247
|
+
const text = `${new Date(now).toISOString()} ${s.replace(/\n+$/, '')}\n`;
|
|
248
|
+
const n = Buffer.byteLength(text);
|
|
249
|
+
if (this.size + n > this.max)
|
|
250
|
+
this.rotate();
|
|
251
|
+
try {
|
|
252
|
+
appendFileSync(this.path, text, { mode: 0o600 });
|
|
253
|
+
this.size += n;
|
|
254
|
+
}
|
|
255
|
+
catch { /* never the service's problem */ }
|
|
256
|
+
}
|
|
257
|
+
/** A stream-like face for code that writes chunks (the fleet's stdout / stderr). */
|
|
258
|
+
writer(prefix = '') {
|
|
259
|
+
let buf = '';
|
|
260
|
+
return {
|
|
261
|
+
write: (s) => {
|
|
262
|
+
buf += s;
|
|
263
|
+
let i;
|
|
264
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
265
|
+
this.line(prefix + buf.slice(0, i));
|
|
266
|
+
buf = buf.slice(i + 1);
|
|
267
|
+
}
|
|
268
|
+
if (buf.length > 8192) {
|
|
269
|
+
this.line(prefix + buf);
|
|
270
|
+
buf = '';
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** The last n lines of the service log (and the rotated one before it when short). */
|
|
278
|
+
export function tailServiceLog(home, n = 50) {
|
|
279
|
+
const read = (p) => { try {
|
|
280
|
+
return readFileSync(p, 'utf8').split('\n').filter(Boolean);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return [];
|
|
284
|
+
} };
|
|
285
|
+
const cur = read(serviceLogPath(home));
|
|
286
|
+
if (cur.length >= n)
|
|
287
|
+
return cur.slice(-n);
|
|
288
|
+
return [...read(`${serviceLogPath(home)}.1`), ...cur].slice(-n);
|
|
289
|
+
}
|
|
@@ -131,12 +131,10 @@ class ClaudeCliSession {
|
|
|
131
131
|
tools: iso.tools, mcpServers: iso.mcpServers, plugins: iso.plugins, agents: iso.agents, skills: iso.skills, slashCommands: iso.slashCommands,
|
|
132
132
|
};
|
|
133
133
|
this.o.onInit?.(init);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (init.tools !== 0 || init.mcpServers !== 0) {
|
|
139
|
-
this.kill('not-tool-less');
|
|
134
|
+
const failed = !this.o.account.expectApiKeySource.includes(init.apiKeySource) ? 'account-mismatch' : init.tools !== 0 || init.mcpServers !== 0 ? 'not-tool-less' : null;
|
|
135
|
+
if (failed) {
|
|
136
|
+
this.o.onProofFailed?.(failed);
|
|
137
|
+
this.kill(failed);
|
|
140
138
|
return;
|
|
141
139
|
}
|
|
142
140
|
continue;
|
|
@@ -229,6 +227,8 @@ export class SessionGate {
|
|
|
229
227
|
pausedUntil = 0;
|
|
230
228
|
/** Set when a session proved the account or the tool list wrong: no new session until the runner restarts. */
|
|
231
229
|
blocked = null;
|
|
230
|
+
/** The session whose own failure set `blocked` (null when the block came from elsewhere, e.g. a sibling strategy). */
|
|
231
|
+
blockedBy = null;
|
|
232
232
|
constructor(caps, guard, opts = {}) {
|
|
233
233
|
this.caps = caps;
|
|
234
234
|
this.guard = guard;
|
|
@@ -312,7 +312,12 @@ export class SessionGate {
|
|
|
312
312
|
/** Are new sessions paused by the allowance guard right now (wall clock)? */
|
|
313
313
|
paused() { return this.clock() < this.pausedUntil; }
|
|
314
314
|
/** A misconfigured account (or a CLI that reports tools) fails the same way every time: stop spawning for this run. */
|
|
315
|
-
block(reason) {
|
|
315
|
+
block(reason, sessionId) {
|
|
316
|
+
if (this.blocked !== null)
|
|
317
|
+
return;
|
|
318
|
+
this.blocked = reason;
|
|
319
|
+
this.blockedBy = sessionId ?? null;
|
|
320
|
+
}
|
|
316
321
|
}
|
|
317
322
|
const isRec = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
318
323
|
/** The MCP-shaped sections a replay can honestly fill from the tape row; everything else is named as unavailable. */
|
|
@@ -455,6 +460,7 @@ export function claudeSessionFactory(f) {
|
|
|
455
460
|
catch (e) {
|
|
456
461
|
failed = 'account-unavailable';
|
|
457
462
|
f.log('session_account_error', { sessionId: id, error: e.message });
|
|
463
|
+
f.gate.block(failed, id);
|
|
458
464
|
return null;
|
|
459
465
|
}
|
|
460
466
|
if (closed)
|
|
@@ -467,6 +473,9 @@ export function claudeSessionFactory(f) {
|
|
|
467
473
|
onRateLimit: (r) => { f.gate.rateLimit(r); f.log('allowance', { sessionId: id, status: r.status, fiveHour: r.fiveHour, sevenDay: r.sevenDay, resetsAt: r.resetsAt }); },
|
|
468
474
|
onLate: (text) => f.log('late_reply_ignored', { sessionId: id, why: 'arrived after decideWithinSec', text: text.slice(0, 400) }),
|
|
469
475
|
onRaw: f.onRaw ? (line) => f.onRaw(id, line) : undefined,
|
|
476
|
+
// The gate closes at the failed proof itself, not when the runner later reads this session's (null) reply: a
|
|
477
|
+
// candidate that closes first (its TTL) must not leave the gate open for more sessions on the wrong account.
|
|
478
|
+
onProofFailed: (why) => f.gate.block(why, id),
|
|
470
479
|
});
|
|
471
480
|
return inner;
|
|
472
481
|
})());
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// dexterm autotrade — the trade-fire delivery channels and their config (task-1645, 0.2.0 card A).
|
|
2
|
+
//
|
|
3
|
+
// The user enables any of these, in ONE place: the `signals` block of $DEXTERM_HOME/connectors.json, next to the
|
|
4
|
+
// Telegram setup (`dexterm autotrade signal channels …`, shown by `dexterm telegram status` too). No secret is in
|
|
5
|
+
// the file: the webhook's HMAC secret lives in the OS keychain store, like the bot token.
|
|
6
|
+
//
|
|
7
|
+
// webhook POST the signal line to a loopback URL (http://127.0.0.1|localhost|[::1]) or a user https URL.
|
|
8
|
+
// Headers: x-dexterm-signature (HMAC, signal.ts), x-dexterm-nonce, idempotency-key. Redirects are an
|
|
9
|
+
// error. 2xx = ack, 409 = duplicate (ack), 408/425/429 and 5xx/network = retry, other 4xx = rejected.
|
|
10
|
+
// cmd `--signal-cmd`: an argv (absolute program path, no shell, no PATH lookup); the signal line on stdin;
|
|
11
|
+
// a minimal environment (no dexterm secret, DEXTERM_SIGNAL_SIGNATURE when an HMAC secret is set).
|
|
12
|
+
// Exit 0 = ack, 2 = rejected, 3 = duplicate (ack), anything else or a 10 s timeout = retry.
|
|
13
|
+
// jsonl append the line to a stream file (default autotrade/real/signals.jsonl, 0600); an executor tails it.
|
|
14
|
+
// task-1654: `signed` (opt-in, needs the HMAC secret) writes each line as an envelope instead,
|
|
15
|
+
// {"schema":"dexterm-trade-fire-envelope/1","signature":"t=…,v1=…","signal":"<the exact signal line>"},
|
|
16
|
+
// so a tailing executor can verify the HMAC like the webhook's.
|
|
17
|
+
// mcp append to autotrade/real/mcp-feed.jsonl, which the MCP tool `trade_fire_feed` reads (status only).
|
|
18
|
+
// telegram the human-visible copy to the paired chat (the bot cannot be the executor's input: a bot cannot read
|
|
19
|
+
// another bot's messages). Never sufficient on its own for real mode.
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, writeSync } from 'node:fs';
|
|
22
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
23
|
+
import { chooseSecretStore, secretStore, SIGNAL_HMAC_ITEM } from "./chat/secrets.js";
|
|
24
|
+
import { loadConnectors, saveConnectors, CONNECTORS_SCHEMA, defaultTelegram } from "./chat/config.js";
|
|
25
|
+
import { inert, shortMint, usdShort } from "./chat/format.js";
|
|
26
|
+
import { realDir } from "./outbox.js";
|
|
27
|
+
import { clampTtl, signBody, SIGNAL_TTL_DEFAULT_SEC } from "./signal.js";
|
|
28
|
+
export const defaultSignalsConfig = () => ({
|
|
29
|
+
webhook: { enabled: false, url: null, secretStore: null }, cmd: { enabled: false, argv: [] }, jsonl: { enabled: false, path: null, signed: false },
|
|
30
|
+
mcp: { enabled: false }, telegram: { enabled: true }, ttlSec: SIGNAL_TTL_DEFAULT_SEC, executor: { listenPort: null },
|
|
31
|
+
});
|
|
32
|
+
export const streamPathDefault = (home) => join(realDir(home), 'signals.jsonl');
|
|
33
|
+
export const mcpFeedPath = (home) => join(realDir(home), 'mcp-feed.jsonl');
|
|
34
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
|
|
35
|
+
/** null when the URL may receive signals: loopback http(s), or https anywhere. No credentials in the URL. */
|
|
36
|
+
export function webhookUrlProblem(u) {
|
|
37
|
+
let url;
|
|
38
|
+
try {
|
|
39
|
+
url = new URL(u);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return 'not a URL';
|
|
43
|
+
}
|
|
44
|
+
if (url.username || url.password)
|
|
45
|
+
return 'credentials in a URL are refused (the HMAC secret lives in the keychain)';
|
|
46
|
+
if (url.protocol === 'https:')
|
|
47
|
+
return null;
|
|
48
|
+
if (url.protocol === 'http:' && LOOPBACK.has(url.hostname))
|
|
49
|
+
return null;
|
|
50
|
+
return 'use https://, or http:// to 127.0.0.1 / localhost / [::1] only';
|
|
51
|
+
}
|
|
52
|
+
export function argvProblem(argv) {
|
|
53
|
+
if (!Array.isArray(argv) || argv.length < 1 || argv.length > 16)
|
|
54
|
+
return 'the command is an argv array of 1 to 16 strings';
|
|
55
|
+
if (!argv.every((a) => typeof a === 'string' && a.length > 0 && a.length <= 512 && !a.includes('\0')))
|
|
56
|
+
return 'every argv item is a non-empty string (512 chars max)';
|
|
57
|
+
if (!isAbsolute(argv[0]))
|
|
58
|
+
return 'the program is an absolute path (no shell, no PATH lookup)';
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/** Read the `signals` block; anything invalid is turned off (fail closed), with the reasons. */
|
|
62
|
+
export function parseSignalsConfig(raw) {
|
|
63
|
+
const c = defaultSignalsConfig();
|
|
64
|
+
const problems = [];
|
|
65
|
+
const r = (raw && typeof raw === 'object' ? raw : {});
|
|
66
|
+
const w = (r.webhook ?? {});
|
|
67
|
+
if (typeof w.url === 'string') {
|
|
68
|
+
const p = webhookUrlProblem(w.url);
|
|
69
|
+
if (p)
|
|
70
|
+
problems.push(`webhook: ${p}`);
|
|
71
|
+
else
|
|
72
|
+
c.webhook.url = w.url;
|
|
73
|
+
}
|
|
74
|
+
c.webhook.enabled = w.enabled === true && c.webhook.url !== null;
|
|
75
|
+
c.webhook.secretStore = w.secretStore === 'keychain' || w.secretStore === 'libsecret' || w.secretStore === 'file' ? w.secretStore : null;
|
|
76
|
+
const m = (r.cmd ?? {});
|
|
77
|
+
if (m.argv !== undefined) {
|
|
78
|
+
const p = argvProblem(m.argv);
|
|
79
|
+
if (p)
|
|
80
|
+
problems.push(`cmd: ${p}`);
|
|
81
|
+
else
|
|
82
|
+
c.cmd.argv = [...m.argv];
|
|
83
|
+
}
|
|
84
|
+
c.cmd.enabled = m.enabled === true && c.cmd.argv.length > 0;
|
|
85
|
+
const j = (r.jsonl ?? {});
|
|
86
|
+
if (typeof j.path === 'string') {
|
|
87
|
+
if (isAbsolute(j.path))
|
|
88
|
+
c.jsonl.path = j.path;
|
|
89
|
+
else
|
|
90
|
+
problems.push('jsonl: the stream path must be absolute');
|
|
91
|
+
}
|
|
92
|
+
c.jsonl.enabled = j.enabled === true;
|
|
93
|
+
c.jsonl.signed = j.signed === true;
|
|
94
|
+
const ex = (r.executor ?? {});
|
|
95
|
+
if (ex.listenPort !== undefined && ex.listenPort !== null) {
|
|
96
|
+
const n = Number(ex.listenPort);
|
|
97
|
+
if (Number.isInteger(n) && n >= 1024 && n <= 65535)
|
|
98
|
+
c.executor.listenPort = n;
|
|
99
|
+
else
|
|
100
|
+
problems.push('executor: listenPort must be a whole number from 1024 to 65535');
|
|
101
|
+
}
|
|
102
|
+
c.mcp.enabled = (r.mcp ?? {}).enabled === true;
|
|
103
|
+
c.telegram.enabled = (r.telegram ?? {}).enabled !== false;
|
|
104
|
+
c.ttlSec = clampTtl(typeof r.ttlSec === 'number' ? r.ttlSec : SIGNAL_TTL_DEFAULT_SEC);
|
|
105
|
+
return { config: c, problems };
|
|
106
|
+
}
|
|
107
|
+
/** The `signals` block of connectors.json (a missing file or block = the defaults: only the Telegram copy). */
|
|
108
|
+
export function loadSignalsConfig(home) {
|
|
109
|
+
const c = loadConnectors(home);
|
|
110
|
+
if (!c.ok)
|
|
111
|
+
return { config: { ...defaultSignalsConfig(), telegram: { enabled: false } }, problems: [c.error] };
|
|
112
|
+
return parseSignalsConfig(c.config.signals);
|
|
113
|
+
}
|
|
114
|
+
/** Write the `signals` block back, keeping the Telegram setup as it is. */
|
|
115
|
+
export function saveSignalsConfig(home, sc) {
|
|
116
|
+
const c = loadConnectors(home);
|
|
117
|
+
if (!c.ok)
|
|
118
|
+
throw new Error(c.error);
|
|
119
|
+
saveConnectors(home, { ...(c.ok ? c.config : { schema: CONNECTORS_SCHEMA, telegram: defaultTelegram() }), signals: sc });
|
|
120
|
+
}
|
|
121
|
+
export function enabledChannels(c) {
|
|
122
|
+
return [c.webhook.enabled ? 'webhook' : null, c.cmd.enabled ? 'cmd' : null, c.jsonl.enabled ? 'jsonl' : null, c.mcp.enabled ? 'mcp' : null, c.telegram.enabled ? 'telegram' : null].filter((x) => !!x);
|
|
123
|
+
}
|
|
124
|
+
export class WebhookChannel {
|
|
125
|
+
name = 'webhook';
|
|
126
|
+
o;
|
|
127
|
+
constructor(o) {
|
|
128
|
+
const p = webhookUrlProblem(o.url);
|
|
129
|
+
if (p)
|
|
130
|
+
throw new Error(`webhook: ${p}`);
|
|
131
|
+
this.o = o;
|
|
132
|
+
}
|
|
133
|
+
async deliver(s, line) {
|
|
134
|
+
const headers = { 'content-type': 'application/json', 'x-dexterm-nonce': String(s.nonce), 'idempotency-key': s.id, 'user-agent': 'dexterm-trade-fire/1' };
|
|
135
|
+
const secret = this.o.secret();
|
|
136
|
+
if (secret)
|
|
137
|
+
headers['x-dexterm-signature'] = signBody(secret, line, (this.o.now ?? Date.now)());
|
|
138
|
+
const ac = new AbortController();
|
|
139
|
+
const timer = setTimeout(() => ac.abort(), this.o.timeoutMs ?? 5000);
|
|
140
|
+
try {
|
|
141
|
+
const f = this.o.fetch ?? globalThis.fetch;
|
|
142
|
+
const res = await f(this.o.url, { method: 'POST', headers, body: line, redirect: 'error', signal: ac.signal });
|
|
143
|
+
if (res.status >= 200 && res.status < 300)
|
|
144
|
+
return { ok: true };
|
|
145
|
+
if (res.status === 409)
|
|
146
|
+
return { ok: true, duplicate: true };
|
|
147
|
+
if (res.status === 408 || res.status === 425 || res.status === 429 || res.status >= 500)
|
|
148
|
+
return { ok: false, retry: true, why: `http ${res.status}` };
|
|
149
|
+
return { ok: false, retry: false, why: `http ${res.status}` };
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
return { ok: false, retry: true, why: ac.signal.aborted ? 'timeout' : e.message.slice(0, 120) };
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// ───────────────────────────── command hook ─────────────────────────────
|
|
160
|
+
export class CommandChannel {
|
|
161
|
+
name = 'cmd';
|
|
162
|
+
o;
|
|
163
|
+
constructor(o) {
|
|
164
|
+
const p = argvProblem(o.argv);
|
|
165
|
+
if (p)
|
|
166
|
+
throw new Error(`signal-cmd: ${p}`);
|
|
167
|
+
this.o = o;
|
|
168
|
+
}
|
|
169
|
+
deliver(s, line) {
|
|
170
|
+
const secret = this.o.secret?.() ?? null;
|
|
171
|
+
// A minimal environment: no token, no RPC URL, no DEXTERM_* setting leaks into the user's executor.
|
|
172
|
+
const env = { PATH: process.env.PATH ?? '/usr/bin:/bin', LANG: process.env.LANG ?? 'C.UTF-8', DEXTERM_SIGNAL_NONCE: String(s.nonce), DEXTERM_SIGNAL_ID: s.id };
|
|
173
|
+
if (process.env.HOME)
|
|
174
|
+
env.HOME = process.env.HOME;
|
|
175
|
+
if (process.env.SystemRoot)
|
|
176
|
+
env.SystemRoot = process.env.SystemRoot;
|
|
177
|
+
if (secret)
|
|
178
|
+
env.DEXTERM_SIGNAL_SIGNATURE = signBody(secret, line, (this.o.now ?? Date.now)());
|
|
179
|
+
return new Promise((done) => {
|
|
180
|
+
let settled = false;
|
|
181
|
+
const finish = (r) => { if (!settled) {
|
|
182
|
+
settled = true;
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
done(r);
|
|
185
|
+
} };
|
|
186
|
+
let child;
|
|
187
|
+
try {
|
|
188
|
+
child = spawn(this.o.argv[0], this.o.argv.slice(1), { shell: false, env, stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true });
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
done({ ok: false, retry: true, why: `spawn: ${e.message.slice(0, 100)}` });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); finish({ ok: false, retry: true, why: 'timeout' }); }, this.o.timeoutMs ?? 10_000);
|
|
195
|
+
child.on('error', (e) => finish({ ok: false, retry: true, why: `spawn: ${e.message.slice(0, 100)}` }));
|
|
196
|
+
child.on('close', (code) => finish(code === 0 ? { ok: true } : code === 3 ? { ok: true, duplicate: true } : code === 2 ? { ok: false, retry: false, why: 'rejected (exit 2)' } : { ok: false, retry: true, why: `exit ${code}` }));
|
|
197
|
+
child.stdin.on('error', () => { });
|
|
198
|
+
child.stdin.end(`${line}\n`);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// ───────────────────────────── JSONL stream / MCP feed ─────────────────────────────
|
|
203
|
+
export const ENVELOPE_SCHEMA = 'dexterm-trade-fire-envelope/1';
|
|
204
|
+
export class JsonlChannel {
|
|
205
|
+
name;
|
|
206
|
+
path;
|
|
207
|
+
signer;
|
|
208
|
+
/** task-1654: `signed` = HMAC envelope lines (a secret must be set, else the line is retried until one is). */
|
|
209
|
+
constructor(path, name = 'jsonl', signed) { this.path = path; this.name = name; this.signer = signed ? { secret: signed.secret, now: signed.now ?? Date.now } : null; }
|
|
210
|
+
async deliver(_s, line) {
|
|
211
|
+
let out = line;
|
|
212
|
+
if (this.signer) {
|
|
213
|
+
const secret = this.signer.secret();
|
|
214
|
+
if (!secret)
|
|
215
|
+
return { ok: false, retry: true, why: 'signed stream: no HMAC secret set (dexterm autotrade signal secret generate --out FILE)' };
|
|
216
|
+
out = JSON.stringify({ schema: ENVELOPE_SCHEMA, signature: signBody(secret, line, this.signer.now()), signal: line });
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
220
|
+
const fd = openSync(this.path, 'a', 0o600);
|
|
221
|
+
try {
|
|
222
|
+
writeSync(fd, `${out}\n`);
|
|
223
|
+
fsyncSync(fd);
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
closeSync(fd);
|
|
227
|
+
}
|
|
228
|
+
return { ok: true };
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
return { ok: false, retry: true, why: e.code ?? e.message.slice(0, 80) };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// ───────────────────────────── Telegram copy ─────────────────────────────
|
|
236
|
+
/** The human-visible copy (plain text lines, the LIVE badge). The symbol is already sanitized; it still goes through inert(). */
|
|
237
|
+
export function telegramLines(s, now = Date.now()) {
|
|
238
|
+
const left = Math.max(0, Math.round((s.expiresAt - now) / 1000));
|
|
239
|
+
const strat = `${inert(s.strategy.id, 32)} v${inert(s.strategy.version, 12)}`;
|
|
240
|
+
if (s.type === 'halted') {
|
|
241
|
+
// task-1679 (0.2.0 decision): open positions at the end of a real run must be impossible to miss.
|
|
242
|
+
const open = s.open ?? [];
|
|
243
|
+
return [
|
|
244
|
+
`LIVE TRADE FIRE · HALTED · ${inert(s.reason, 40)}`,
|
|
245
|
+
`${s.outstanding.length} outstanding signal(s) · run ${inert(s.runId.slice(-12), 16)} · account ${inert(s.accountId, 32)} · nonce ${s.nonce}`,
|
|
246
|
+
...(open.length ? [`${open.length} position(s): ${open.slice(0, 10).map(shortMint).join(', ')}${open.length > 10 ? ` +${open.length - 10} more` : ''}`, 'these positions are still open: manage them in your executor', 'until the next real run of this strategy, dexterm sends NO signal for them, sells included'] : []),
|
|
247
|
+
'no more signals from this run',
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
if (s.type === 'test')
|
|
251
|
+
return ['LIVE TRADE FIRE · TEST', `nonce ${s.nonce} · account ${inert(s.accountId, 32)} · never trade on this`];
|
|
252
|
+
const size = s.side === 'buy' ? `${s.sizeSol} SOL (${usdShort(s.sizeUsd)}, ${s.pctOfBook}% of book)` : `${Math.round((s.sellFraction ?? 1) * 1000) / 10}% of the position (~${s.sizeSol} SOL)`;
|
|
253
|
+
return [
|
|
254
|
+
`LIVE TRADE FIRE · ${s.side.toUpperCase()} "${inert(s.symbol, 16)}" ${shortMint(s.mint)}`,
|
|
255
|
+
`${size}${s.exitReason ? ` · ${inert(s.exitReason, 40)}` : ''}`,
|
|
256
|
+
`${strat} · account ${inert(s.accountId, 32)} · nonce ${s.nonce} · ignore after ${left} s`,
|
|
257
|
+
'your executor acts on it, not this chat',
|
|
258
|
+
];
|
|
259
|
+
}
|
|
260
|
+
export class TelegramCopyChannel {
|
|
261
|
+
name = 'telegram';
|
|
262
|
+
send;
|
|
263
|
+
now;
|
|
264
|
+
constructor(send, now = Date.now) { this.send = send; this.now = now; }
|
|
265
|
+
async deliver(s) {
|
|
266
|
+
try {
|
|
267
|
+
await this.send(telegramLines(s, this.now()));
|
|
268
|
+
return { ok: true };
|
|
269
|
+
}
|
|
270
|
+
catch (e) {
|
|
271
|
+
return { ok: false, retry: true, why: e.message.slice(0, 120) };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// ───────────────────────────── from the config ─────────────────────────────
|
|
276
|
+
/** Where the webhook's HMAC secret is (the store setup recorded, else the best on this machine). */
|
|
277
|
+
export function hmacStore(home, sc, env = process.env) {
|
|
278
|
+
return sc.webhook.secretStore ? secretStore(sc.webhook.secretStore, home, SIGNAL_HMAC_ITEM) : chooseSecretStore(home, env, process.platform, SIGNAL_HMAC_ITEM);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The channel objects for a config. Without a Telegram sender (outside a run) the copy is deferred: it stays pending
|
|
282
|
+
* in the outbox for the next run instead of being dropped. The secret is read once, on first use.
|
|
283
|
+
*/
|
|
284
|
+
export function channelsFromConfig(home, sc, o = {}) {
|
|
285
|
+
let cached;
|
|
286
|
+
const store = o.secret ?? hmacStore(home, sc, o.env);
|
|
287
|
+
const secret = () => { if (cached === undefined) {
|
|
288
|
+
try {
|
|
289
|
+
cached = store.get();
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
cached = null;
|
|
293
|
+
}
|
|
294
|
+
} return cached; };
|
|
295
|
+
const out = [];
|
|
296
|
+
const defer = [];
|
|
297
|
+
if (sc.webhook.enabled && sc.webhook.url)
|
|
298
|
+
out.push(new WebhookChannel({ url: sc.webhook.url, secret, fetch: o.fetch, now: o.now }));
|
|
299
|
+
if (sc.cmd.enabled && sc.cmd.argv.length)
|
|
300
|
+
out.push(new CommandChannel({ argv: sc.cmd.argv, secret, now: o.now }));
|
|
301
|
+
if (sc.jsonl.enabled)
|
|
302
|
+
out.push(new JsonlChannel(sc.jsonl.path ?? streamPathDefault(home), 'jsonl', sc.jsonl.signed ? { secret, now: o.now } : undefined));
|
|
303
|
+
if (sc.mcp.enabled)
|
|
304
|
+
out.push(new JsonlChannel(mcpFeedPath(home), 'mcp'));
|
|
305
|
+
if (sc.telegram.enabled) {
|
|
306
|
+
if (o.telegram)
|
|
307
|
+
out.push(new TelegramCopyChannel(o.telegram, o.now));
|
|
308
|
+
else
|
|
309
|
+
defer.push('telegram');
|
|
310
|
+
}
|
|
311
|
+
return { channels: out, defer };
|
|
312
|
+
}
|