dexterm-cli 0.1.4 → 0.1.6

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 (131) hide show
  1. package/dist/packages/agents/src/index.js +2 -0
  2. package/dist/packages/agents/src/spectator.js +54 -0
  3. package/dist/packages/agents/src/story.js +523 -0
  4. package/dist/packages/agents/src/tiers.js +1 -0
  5. package/dist/packages/agents/src/tools.js +1 -0
  6. package/dist/packages/cli/assets/claude/plugin/skills/REGISTRY.json +1 -1
  7. package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +15 -8
  8. package/dist/packages/cli/src/agent-cmd.js +6 -4
  9. package/dist/packages/cli/src/agent-paper.js +60 -0
  10. package/dist/packages/cli/src/agent-spectator.js +17 -1
  11. package/dist/packages/cli/src/analyst/backend.js +206 -0
  12. package/dist/packages/cli/src/analyst/codex.js +301 -0
  13. package/dist/packages/cli/src/analyst/larp-gauge.js +60 -0
  14. package/dist/packages/cli/src/analyst/session.js +21 -3
  15. package/dist/packages/cli/src/app-help-gen.js +21 -12
  16. package/dist/packages/cli/src/backtest-cmd.js +13 -2
  17. package/dist/packages/cli/src/display.js +55 -9
  18. package/dist/packages/cli/src/doctor.js +58 -2
  19. package/dist/packages/cli/src/live/dev-history.js +115 -0
  20. package/dist/packages/cli/src/live/index.js +5 -1
  21. package/dist/packages/cli/src/live/newpairs.js +12 -0
  22. package/dist/packages/cli/src/live/screens/newpairs-live.js +1 -1
  23. package/dist/packages/cli/src/live/screens/token-live.js +51 -11
  24. package/dist/packages/cli/src/live/screens/watchlist-live.js +1 -1
  25. package/dist/packages/cli/src/live/token.js +1 -0
  26. package/dist/packages/cli/src/live/trade.js +22 -0
  27. package/dist/packages/cli/src/main.js +267 -19
  28. package/dist/packages/cli/src/mcp-follow.js +137 -10
  29. package/dist/packages/cli/src/plan-cmd.js +15 -5
  30. package/dist/packages/cli/src/popup.js +17 -1
  31. package/dist/packages/cli/src/record-cmd.js +2 -0
  32. package/dist/packages/cli/src/rules.js +25 -2
  33. package/dist/packages/cli/src/scan/ui.js +7 -1
  34. package/dist/packages/cli/src/screens/agent-hud.js +741 -0
  35. package/dist/packages/cli/src/screens/analyst-session.js +9 -3
  36. package/dist/packages/cli/src/screens/analyst.js +4 -3
  37. package/dist/packages/cli/src/screens/brain.js +608 -0
  38. package/dist/packages/cli/src/screens/newpairs.js +1 -1
  39. package/dist/packages/cli/src/screens/pnl-chip.js +143 -0
  40. package/dist/packages/cli/src/screens/screener.js +1 -0
  41. package/dist/packages/cli/src/screens/token.js +16 -7
  42. package/dist/packages/cli/src/screens/trending.js +1 -1
  43. package/dist/packages/cli/src/state.js +69 -5
  44. package/dist/packages/cli/src/version.js +1 -1
  45. package/dist/packages/custom/src/rules.js +2 -2
  46. package/dist/packages/custom/src/schema.js +13 -3
  47. package/dist/packages/data/src/fixtures.js +28 -6
  48. package/dist/packages/data/src/index.js +5 -0
  49. package/dist/packages/data/src/larp/score.js +220 -0
  50. package/dist/packages/data/src/larp/x-account.js +316 -0
  51. package/dist/packages/data/src/sources/jupiter.js +1 -0
  52. package/dist/packages/data/src/trade/agent/paper-desk.js +401 -0
  53. package/dist/packages/data/src/trade/agent/rendezvous.js +161 -0
  54. package/dist/packages/data/src/trade/plans/engine.js +7 -0
  55. package/dist/packages/data/src/trade/plans/mandate.js +28 -15
  56. package/dist/packages/data/src/trade/plans/service.js +1 -1
  57. package/dist/packages/mcp/assets/kb/app-help/agent-paper-trade.md +38 -0
  58. package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +1 -1
  59. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +1 -1
  60. package/dist/packages/mcp/assets/kb/app-help/dev-history.md +32 -0
  61. package/dist/packages/mcp/assets/kb/app-help/larp-check.md +5 -1
  62. package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +1 -1
  63. package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +1 -1
  64. package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +4 -1
  65. package/dist/packages/mcp/assets/kb/app-help/screen-token.md +4 -0
  66. package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +2 -1
  67. package/dist/packages/mcp/assets/kb/app-help/use-codex-analyst.md +31 -0
  68. package/dist/packages/mcp/assets/kb/docs/docs-agents-agent-paper-trading.md +51 -0
  69. package/dist/packages/mcp/assets/kb/docs/docs-agents-directory-metadata-prepared-not-submitted.md +36 -0
  70. package/dist/packages/mcp/assets/kb/docs/docs-agents-install-in-one-command.md +2 -0
  71. package/dist/packages/mcp/assets/kb/docs/docs-analyst-if-claude-is-missing.md +1 -1
  72. package/dist/packages/mcp/assets/kb/docs/docs-analyst-starting-a-run.md +2 -0
  73. package/dist/packages/mcp/assets/kb/docs/docs-analyst-use-codex-instead-of-claude.md +35 -0
  74. package/dist/packages/mcp/assets/kb/docs/docs-beginners-macos-or-linux-npm.md +1 -1
  75. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
  76. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-powershell-experimental.md +1 -1
  77. package/dist/packages/mcp/assets/kb/docs/docs-configuration-chart-y-axis.md +32 -0
  78. package/dist/packages/mcp/assets/kb/docs/docs-configuration-environment-variables.md +2 -0
  79. package/dist/packages/mcp/assets/kb/docs/docs-configuration-price-display-mode.md +3 -3
  80. package/dist/packages/mcp/assets/kb/docs/docs-configuration-rules-engine.md +1 -0
  81. package/dist/packages/mcp/assets/kb/docs/docs-configuration-the-analyst-s-launch.md +2 -0
  82. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +13 -3
  83. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +1 -1
  84. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-running-the-tests.md +1 -1
  85. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -2
  86. package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +1 -2
  87. package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +60 -0
  88. package/dist/packages/mcp/assets/kb/docs/docs-larp-the-verdict.md +0 -2
  89. package/dist/packages/mcp/assets/kb/docs/docs-larp-what-it-looks-at.md +4 -1
  90. package/dist/packages/mcp/assets/kb/docs/docs-larp.md +1 -1
  91. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-letting-an-agent-paper-trade-experimental.md +32 -0
  92. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-order-plans-and-standing-mandates.md +1 -9
  93. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-paper-only-mandates.md +41 -0
  94. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-what-the-live-path-keeps.md +1 -1
  95. package/dist/packages/mcp/assets/kb/docs/docs-popup-chart-market-cap-axis.md +1 -1
  96. package/dist/packages/mcp/assets/kb/docs/docs-popup-chart-what-it-shows.md +1 -0
  97. package/dist/packages/mcp/assets/kb/docs/docs-scan-the-read.md +2 -0
  98. package/dist/packages/mcp/assets/kb/docs/docs-screens-dev-history.md +47 -0
  99. package/dist/packages/mcp/assets/kb/docs/docs-screens-how-well-it-predicts-measured-held-out.md +46 -0
  100. package/dist/packages/mcp/assets/kb/docs/docs-spectator-follow-over-mcp-in-the-running-terminal.md +1 -1
  101. package/dist/packages/mcp/assets/kb/docs/docs-spectator-limits.md +2 -0
  102. package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-agent-hud-spectator-v2.md +58 -0
  103. package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-brain-view-and-the-pnl-chip.md +46 -0
  104. package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-three-surfaces.md +1 -1
  105. package/dist/packages/mcp/assets/kb/docs/docs-spectator.md +1 -1
  106. package/dist/packages/mcp/assets/kb/docs/docs-troubleshooting-doctor-lines.md +2 -0
  107. package/dist/packages/mcp/src/index.js +5 -2
  108. package/dist/packages/mcp/src/paper-agent.js +93 -0
  109. package/dist/packages/mcp/src/server.js +354 -19
  110. package/dist/packages/mcp/src/spectator-sink.js +158 -4
  111. package/dist/packages/mcp/src/user-content.js +1 -0
  112. package/dist/packages/packs/src/index.js +1 -0
  113. package/dist/packages/packs/src/larp-score-backtest.js +119 -0
  114. package/dist/packages/popup/src/pages/chart-lib.js +36 -15
  115. package/dist/packages/popup/src/pages/chart.css +9 -0
  116. package/dist/packages/popup/src/pages/chart.html +4 -1
  117. package/dist/packages/popup/src/pages/chart.js +34 -5
  118. package/dist/packages/popup/src/pages/spectator.css +45 -0
  119. package/dist/packages/popup/src/pages/spectator.html +1 -0
  120. package/dist/packages/popup/src/pages/spectator.js +69 -0
  121. package/dist/packages/popup/src/protocol.js +2 -0
  122. package/dist/packages/popup/src/server.js +5 -1
  123. package/dist/packages/recorder/src/dev-history.js +133 -0
  124. package/dist/packages/recorder/src/index.js +1 -0
  125. package/dist/packages/recorder/src/recorder.js +23 -0
  126. package/dist/packages/recorder/src/schema.js +11 -0
  127. package/dist/packages/tui/src/canvas.js +7 -0
  128. package/dist/packages/tui/src/fmt.js +53 -0
  129. package/dist/packages/tui/src/keymap.js +4 -0
  130. package/dist/packages/tui/src/widgets.js +9 -4
  131. package/package.json +1 -1
@@ -0,0 +1,401 @@
1
+ // task-1443 — the PAPER AGENT DESK: the one surface an untrusted agent (Claude Code, a custom bot) uses to
2
+ // open, size, confirm and close a PAPER position, and to read paper positions and paper PnL — strictly inside
3
+ // a user-armed mandate's caps (SPEC §6.13, ROADMAP Pillar 4: "agents are untrusted and can never arm, sign or
4
+ // raise caps").
5
+ //
6
+ // Hard boundaries this module keeps, which the public claim "no MCP tool can sign or send" rests on:
7
+ // (a) PAPER LEDGER ONLY. The only trade op is `executor.execute`, a `TradeExecutor` whose `kind` MUST be
8
+ // 'paper' (asserted at construction). This file imports NOTHING from the signer, the live executor, the
9
+ // swap BUILD adapter, the rpc sender or the bridge — only the paper ledger (./paper.ts), the mandate
10
+ // SCHEMA (../plans/mandate.ts), the audit journal, the panic kill-switch and the keyless quote TYPE. It
11
+ // is therefore structurally unable to reach any live / wallet / signer / send path (enforced by the
12
+ // no-sign registry test, task-1443).
13
+ // (b) CAPS COME FROM THE USER-SET MANDATE. Every limit is read from the armed `Mandate`, RE-READ on every
14
+ // call (in production: replayed from the hash-chained plans journal the app arms it in, so a revoke or a
15
+ // panic in the app halts the agent at once); there is NO method that changes a cap. An agent runs at the most-restricted confirmation tier (`AGENT_TIER`), so
16
+ // its per-trade cap is the mandate's per-trade cap times that tier's multiplier — it can never meet or
17
+ // exceed a human keypress tier.
18
+ // (c) EVERY CALL IS VALIDATED, JOURNALED AND HALTABLE. Before any state change the desk re-reads the control
19
+ // surface (pause / take-over / stop) and the panic file and re-checks the mandate is alive; a refusal is
20
+ // journaled too, so the spectator view and the audit log see it. The user always wins.
21
+ import { systemClock } from "../../clock.js";
22
+ import { mandateAlive, TIERS } from "../plans/mandate.js";
23
+ import { KillSwitch } from "../kill-switch.js";
24
+ import { capsHash } from "../plans/mandate.js";
25
+ import { SOL_MINT, buildQuoteRequest, lamportsToSol, solToLamports, rawToTokens, } from "../../paper.js";
26
+ /** The confirmation tier an agent is pinned to: the weakest, so an agent can never match a human keypress. */
27
+ export const AGENT_TIER = 'hands-free';
28
+ /** A refusal from the desk. `reason` is a stable code; `message` is human text (both are journaled). */
29
+ export class PaperDeskError extends Error {
30
+ reason;
31
+ constructor(reason, message) {
32
+ super(message);
33
+ this.name = 'PaperDeskError';
34
+ this.reason = reason;
35
+ }
36
+ }
37
+ export const DEFAULT_QUOTE_RETRY = { maxAttempts: 4, maxWaitMs: 6_000, maxTotalMs: 25_000 };
38
+ /** A keyless rate limit (a 429, or the data layer's own 429 cool-down / bucket pause), which is worth a bounded re-quote. */
39
+ export function isRateLimitedQuoteError(e) {
40
+ if (!(e instanceof Error))
41
+ return false;
42
+ if (e.name === 'JupiterRateLimitedError')
43
+ return true;
44
+ if (e.name === 'SourceUnavailableError')
45
+ return /jupiter/.test(e.message);
46
+ return e.name === 'HttpError' && e.status === 429;
47
+ }
48
+ const isFiniteNum = (x) => typeof x === 'number' && Number.isFinite(x);
49
+ /**
50
+ * The paper agent desk. One instance per armed mandate + paper wallet. All four verbs (open / size / confirm /
51
+ * close) and both reads (positions / pnl) run here; nothing else is exposed, and there is no cap setter.
52
+ */
53
+ export class PaperAgentDesk {
54
+ mandateSrc;
55
+ /** The mandate the current call runs under (set by `gate`, which every mutating call runs first). */
56
+ mandate;
57
+ wallet;
58
+ executor;
59
+ quote;
60
+ solUsd;
61
+ control;
62
+ journal;
63
+ kill;
64
+ clock;
65
+ agent;
66
+ quoteRetry;
67
+ onQuoteRetry;
68
+ tickets = new Map();
69
+ ticketSeq = 0;
70
+ constructor(deps) {
71
+ if (deps.executor.kind !== 'paper') {
72
+ // The one invariant that makes the "no MCP tool can sign or send" claim true: the desk refuses to hold a
73
+ // live executor. There is no code path from here to a signer, so a live executor is a programming error.
74
+ throw new PaperDeskError('not-paper', `the paper agent desk only accepts a paper executor, got "${deps.executor.kind}"`);
75
+ }
76
+ const m = deps.mandate;
77
+ this.mandateSrc = typeof m === 'function' ? m : () => m;
78
+ this.wallet = deps.wallet;
79
+ this.executor = deps.executor;
80
+ this.quote = deps.quote;
81
+ this.solUsd = deps.solUsd;
82
+ this.control = deps.control;
83
+ this.journal = deps.journal;
84
+ this.kill = deps.killSwitch ?? new KillSwitch();
85
+ this.clock = deps.clock ?? systemClock;
86
+ this.agent = deps.agent ?? 'mcp-agent';
87
+ const r = deps.quoteRetry ?? DEFAULT_QUOTE_RETRY;
88
+ this.quoteRetry = { maxAttempts: Math.max(1, Math.floor(r.maxAttempts)), maxWaitMs: Math.max(0, r.maxWaitMs), maxTotalMs: Math.max(0, r.maxTotalMs) };
89
+ this.onQuoteRetry = deps.onQuoteRetry ?? null;
90
+ }
91
+ /** The agent's effective per-trade cap: the mandate cap times the weakest-tier multiplier. */
92
+ perTradeCapUsd() {
93
+ return this.mandate.budget.perTradeUsd * TIERS[AGENT_TIER].perTradeMultiplier;
94
+ }
95
+ /** Journal one audit event (kept out of the hot path's failure mode: never throws). */
96
+ note(type, data) {
97
+ try {
98
+ this.journal.append(type, { mandateId: this.mandate?.id ?? null, data: { agent: this.agent, ...data } });
99
+ }
100
+ catch { /* audit is best-effort */ }
101
+ }
102
+ /** Refuse (journaling the refusal) — the one place a PaperDeskError is raised for a gate failure. */
103
+ refuse(stage, reason, message, extra = {}) {
104
+ this.note('agent.paper.refused', { stage, reason, message, ...extra });
105
+ throw new PaperDeskError(reason, message);
106
+ }
107
+ /**
108
+ * The gate every mutating call runs first: panic file → control surface → mandate alive. A failure here is
109
+ * journaled and throws, so a paused / taken-over / stopped session (or a dead mandate) halts the agent.
110
+ */
111
+ gate(stage, extra = {}) {
112
+ const panic = this.kill.engaged();
113
+ if (panic)
114
+ this.refuse(stage, 'panic', `trading is halted (panic): ${panic.reason}`, extra);
115
+ const c = this.control();
116
+ if (c.state !== 'running') {
117
+ const msg = c.state === 'takeover' ? 'the user has taken over — agent paper trading is paused'
118
+ : c.state === 'paused' ? `agent paper trading is paused${c.reason ? `: ${c.reason}` : ''}`
119
+ : `agent paper trading is stopped${c.reason ? `: ${c.reason}` : ''}`;
120
+ this.refuse(stage, c.state, msg, extra);
121
+ }
122
+ const m = this.mandateSrc();
123
+ if (!m)
124
+ this.refuse(stage, 'no-mandate', 'no armed mandate is available for agent paper trading', extra);
125
+ this.mandate = m;
126
+ const alive = mandateAlive(this.mandate, this.clock.now());
127
+ if (!alive.ok)
128
+ this.refuse(stage, alive.reason, `the mandate is not usable (${alive.reason})`, extra);
129
+ }
130
+ requireAction(stage, side, extra = {}) {
131
+ if (!this.mandate.allowed.actions.includes(side)) {
132
+ this.refuse(stage, 'action-not-allowed', `${side} is not permitted by the mandate`, extra);
133
+ }
134
+ }
135
+ requireMintAllowed(stage, mint, extra = {}) {
136
+ if (this.mandate.allowed.mints && !this.mandate.allowed.mints.includes(mint)) {
137
+ this.refuse(stage, 'mint-not-allowed', 'this mint is not on the mandate allow-list', extra);
138
+ }
139
+ }
140
+ price(stage, extra = {}) {
141
+ const p = this.solUsd();
142
+ if (!isFiniteNum(p) || p <= 0)
143
+ this.refuse(stage, 'no-usd-price', 'no fresh SOL/USD price — cannot price the mandate caps', extra);
144
+ return p;
145
+ }
146
+ // ── USD exposure derived from the paper wallet (source of truth), so caps survive a restart ──
147
+ costUsd(pos, solUsd) {
148
+ return pos ? lamportsToSol(pos.costLamports) * solUsd : 0;
149
+ }
150
+ openCostUsd(solUsd) {
151
+ return this.wallet.positions().reduce((s, p) => s + lamportsToSol(p.costLamports) * solUsd, 0);
152
+ }
153
+ /** Realized LOSS (USD, positive) booked since the mandate was armed. Over the whole paper wallet. */
154
+ realizedLossUsd(solUsd) {
155
+ const since = this.mandate.armedAt ?? this.mandate.createdAt;
156
+ let lamports = 0n;
157
+ for (const t of this.wallet.trades(1000)) {
158
+ if (t.t < since)
159
+ continue;
160
+ if (t.realizedLamports < 0n)
161
+ lamports += -t.realizedLamports;
162
+ }
163
+ return lamportsToSol(lamports) * solUsd;
164
+ }
165
+ /** Check the buy caps for `usd`, throwing a cap-named refusal on any breach. */
166
+ admitBuy(stage, mint, usd, solUsd, extra) {
167
+ const perTrade = this.perTradeCapUsd();
168
+ if (usd > perTrade + 1e-9)
169
+ this.refuse(stage, 'per-trade-cap', `$${usd.toFixed(2)} exceeds the agent per-trade cap $${perTrade.toFixed(2)}`, extra);
170
+ const coin = this.costUsd(this.wallet.position(mint), solUsd);
171
+ if (coin + usd > this.mandate.budget.perCoinUsd + 1e-9) {
172
+ this.refuse(stage, 'per-coin-cap', `$${(coin + usd).toFixed(2)} on this coin exceeds the per-coin cap $${this.mandate.budget.perCoinUsd.toFixed(2)}`, extra);
173
+ }
174
+ const total = this.openCostUsd(solUsd);
175
+ if (total + usd > this.mandate.budget.totalUsd + 1e-9) {
176
+ this.refuse(stage, 'total-cap', `$${(total + usd).toFixed(2)} deployed exceeds the total budget $${this.mandate.budget.totalUsd.toFixed(2)}`, extra);
177
+ }
178
+ const held = this.wallet.positions().filter((p) => p.tokensRaw > 0n);
179
+ if (!held.some((p) => p.mint === mint) && held.length >= this.mandate.limits.maxOpenPositions) {
180
+ this.refuse(stage, 'open-positions-cap', `${held.length} open paper positions reached the mandate limit ${this.mandate.limits.maxOpenPositions}`, extra);
181
+ }
182
+ const loss = this.realizedLossUsd(solUsd);
183
+ if (loss >= this.mandate.budget.dailyLossUsd - 1e-9) {
184
+ this.refuse(stage, 'daily-loss-limit', `realized loss $${loss.toFixed(2)} has reached the daily loss limit $${this.mandate.budget.dailyLossUsd.toFixed(2)}; no new buys`, extra);
185
+ }
186
+ }
187
+ // ───────────────────────────── the four verbs ─────────────────────────────
188
+ /** Open an inert PAPER ticket for a mint + side. Validates the gate, the action and the allow-list only. */
189
+ open(input) {
190
+ const side = input.side;
191
+ this.gate('open', { mint: input.mint, side });
192
+ if (typeof input.mint !== 'string' || input.mint.length < 32)
193
+ this.refuse('open', 'bad-request', 'a base58 mint is required');
194
+ if (!isFiniteNum(input.decimals) || input.decimals < 0 || input.decimals > 18)
195
+ this.refuse('open', 'bad-request', 'token decimals (0..18) are required');
196
+ this.requireAction('open', side, { mint: input.mint });
197
+ this.requireMintAllowed('open', input.mint, { side });
198
+ const t = {
199
+ id: `pt_${++this.ticketSeq}_${this.clock.now().toString(36)}`,
200
+ mint: input.mint, symbol: input.symbol, decimals: input.decimals, side, usd: null, fraction: null, openedAt: this.clock.now(),
201
+ };
202
+ this.tickets.set(t.id, t);
203
+ this.note('agent.paper.open', { ticketId: t.id, mint: t.mint, symbol: t.symbol, side });
204
+ return { ...t };
205
+ }
206
+ /** Size a ticket. Buy: `usd` (checked against the caps, never raised). Sell: `fraction` in (0, 1]. */
207
+ size(ticketId, input) {
208
+ const t = this.tickets.get(ticketId);
209
+ if (!t)
210
+ this.refuse('size', 'unknown-ticket', 'no such open ticket');
211
+ this.gate('size', { ticketId });
212
+ if (t.side === 'buy') {
213
+ const usd = input.usd;
214
+ if (!isFiniteNum(usd) || usd <= 0)
215
+ this.refuse('size', 'bad-request', 'a positive USD size is required for a buy', { ticketId });
216
+ const solUsd = this.price('size', { ticketId });
217
+ this.admitBuy('size', t.mint, usd, solUsd, { ticketId });
218
+ t.usd = usd;
219
+ t.fraction = null;
220
+ }
221
+ else {
222
+ const frac = input.fraction;
223
+ if (!isFiniteNum(frac) || !(frac > 0 && frac <= 1))
224
+ this.refuse('size', 'bad-request', 'a sell fraction in (0, 1] is required', { ticketId });
225
+ t.fraction = frac;
226
+ t.usd = null;
227
+ }
228
+ this.note('agent.paper.size', { ticketId, side: t.side, usd: t.usd, fraction: t.fraction });
229
+ return { ...t };
230
+ }
231
+ /** Confirm a sized ticket: re-check the gate and the caps, fetch a FRESH quote, book the PAPER fill. */
232
+ async confirm(ticketId) {
233
+ const t = this.tickets.get(ticketId);
234
+ if (!t)
235
+ this.refuse('confirm', 'unknown-ticket', 'no such open ticket');
236
+ this.gate('confirm', { ticketId });
237
+ this.requireAction('confirm', t.side, { ticketId });
238
+ this.requireMintAllowed('confirm', t.mint, { ticketId });
239
+ const order = await this.buildOrder('confirm', t);
240
+ const { rec, quoteAttempts } = await this.book('confirm', order, { ticketId });
241
+ this.tickets.delete(ticketId);
242
+ return { fill: rec, position: this.positionView(this.wallet.position(t.mint)), quoteAttempts };
243
+ }
244
+ /** Close a fraction of a held PAPER position in one call (open sell → size fraction → confirm). */
245
+ async close(input) {
246
+ this.gate('close', { mint: input.mint });
247
+ this.requireAction('close', 'sell', { mint: input.mint });
248
+ this.requireMintAllowed('close', input.mint, {});
249
+ const pos = this.wallet.position(input.mint);
250
+ if (!pos || pos.tokensRaw <= 0n)
251
+ this.refuse('close', 'no-position', 'no open paper position in this mint to close', { mint: input.mint });
252
+ const fraction = input.fraction ?? 1;
253
+ if (!isFiniteNum(fraction) || !(fraction > 0 && fraction <= 1))
254
+ this.refuse('close', 'bad-request', 'fraction must be in (0, 1]', { mint: input.mint });
255
+ const t = { id: `pt_close_${++this.ticketSeq}`, mint: input.mint, symbol: pos.symbol, decimals: pos.decimals, side: 'sell', usd: null, fraction, openedAt: this.clock.now() };
256
+ this.note('agent.paper.close', { mint: input.mint, fraction });
257
+ const order = await this.buildOrder('close', t);
258
+ const { rec, quoteAttempts } = await this.book('close', order, { mint: input.mint });
259
+ return { fill: rec, position: this.positionView(this.wallet.position(input.mint)), quoteAttempts };
260
+ }
261
+ /** Build the paper order from a ticket, re-checking the buy caps against the live wallet at execute time. */
262
+ async buildOrder(stage, t) {
263
+ if (t.side === 'buy') {
264
+ if (!isFiniteNum(t.usd) || t.usd <= 0)
265
+ this.refuse(stage, 'not-sized', 'the ticket has no size — call size first', { ticketId: t.id });
266
+ const solUsd = this.price(stage, { ticketId: t.id });
267
+ this.admitBuy(stage, t.mint, t.usd, solUsd, { ticketId: t.id });
268
+ const amountRaw = solToLamports(t.usd / solUsd);
269
+ if (amountRaw <= 0n)
270
+ this.refuse(stage, 'bad-request', 'the sized amount rounds to zero lamports', { ticketId: t.id });
271
+ return { mint: t.mint, symbol: t.symbol, side: 'buy', decimals: t.decimals, amountRaw, slippageBps: this.mandate.limits.maxSlippageBps };
272
+ }
273
+ const pos = this.wallet.position(t.mint);
274
+ if (!pos || pos.tokensRaw <= 0n)
275
+ this.refuse(stage, 'no-position', 'no paper tokens to sell', { ticketId: t.id });
276
+ const frac = t.fraction ?? 1;
277
+ let amountRaw = BigInt(Math.round(Number(pos.tokensRaw) * frac));
278
+ if (amountRaw > pos.tokensRaw)
279
+ amountRaw = pos.tokensRaw;
280
+ if (amountRaw <= 0n)
281
+ this.refuse(stage, 'bad-request', 'the sell fraction rounds to zero tokens', { ticketId: t.id });
282
+ return { mint: t.mint, symbol: pos.symbol, side: 'sell', decimals: pos.decimals, amountRaw, slippageBps: this.mandate.limits.maxSlippageBps };
283
+ }
284
+ /**
285
+ * Fetch a FRESH quote for this call. A short rate limit is re-quoted inside the bounded `quoteRetry` budget:
286
+ * each retry is journaled and reported first, waits min(stated wait, maxWaitMs), then re-runs the gate, so a
287
+ * pause during the wait still halts the agent. Any other error, the last allowed attempt, or the spent budget
288
+ * (an attempt still in flight is aborted) refuses with the verbatim message. No quote outlives the call.
289
+ */
290
+ async freshQuote(stage, order, extra) {
291
+ const { maxAttempts, maxWaitMs, maxTotalMs } = this.quoteRetry;
292
+ const started = this.clock.now();
293
+ let lastRateLimit = null;
294
+ for (let attempt = 1;; attempt++) {
295
+ const budget = new AbortController();
296
+ const disarm = new AbortController();
297
+ void this.clock.sleep(Math.max(1, maxTotalMs - (this.clock.now() - started)), disarm.signal)
298
+ .then(() => { if (!disarm.signal.aborted)
299
+ budget.abort(); });
300
+ let quote = null;
301
+ let failure = null;
302
+ try {
303
+ quote = await this.quote(buildQuoteRequest(order), budget.signal);
304
+ }
305
+ catch (e) {
306
+ failure = e;
307
+ }
308
+ finally {
309
+ disarm.abort();
310
+ }
311
+ if (quote && !budget.signal.aborted)
312
+ return { quote, attempts: attempt };
313
+ const msg = failure instanceof Error ? failure.message : failure === null ? 'no quote' : String(failure);
314
+ const elapsed = this.clock.now() - started;
315
+ const tried = attempt > 1 ? ` (${attempt} quote attempts in ${Math.round(elapsed / 1000)}s)` : '';
316
+ if (budget.signal.aborted) {
317
+ this.refuse(stage, 'quote-failed', `could not price the trade within ${Math.round(maxTotalMs / 1000)}s${lastRateLimit ? `: ${lastRateLimit}` : ''}${tried}`, { ...extra, quoteAttempts: attempt });
318
+ }
319
+ if (!isRateLimitedQuoteError(failure))
320
+ this.refuse(stage, 'quote-failed', `could not price the trade: ${msg}${tried}`, { ...extra, quoteAttempts: attempt });
321
+ lastRateLimit = msg;
322
+ const stated = failure.retryAfterMs;
323
+ const wait = Math.min(maxWaitMs, Math.max(1_000, typeof stated === 'number' && Number.isFinite(stated) ? stated : 2_000 * attempt));
324
+ if (attempt >= maxAttempts || elapsed + wait >= maxTotalMs) {
325
+ this.refuse(stage, 'quote-failed', `could not price the trade: ${msg}${tried}`, { ...extra, quoteAttempts: attempt });
326
+ }
327
+ const info = { stage, mint: order.mint, side: order.side, attempt: attempt + 1, maxAttempts, waitMs: wait, message: msg };
328
+ this.note('agent.paper.quote-retry', { ...extra, ...info });
329
+ try {
330
+ this.onQuoteRetry?.(info);
331
+ }
332
+ catch { /* the spectator is best-effort */ }
333
+ await this.clock.sleep(wait);
334
+ this.gate(stage, extra);
335
+ }
336
+ }
337
+ /** Fetch a fresh quote and book the fill through the PAPER executor; journal the booked fill. */
338
+ async book(stage, order, extra) {
339
+ const { quote, attempts } = await this.freshQuote(stage, order, extra);
340
+ if (attempts > 1)
341
+ extra = { ...extra, quoteAttempts: attempts };
342
+ // Buys honour the mandate's price-impact ceiling (SwapQuote.priceImpactPct is already a PERCENT — the source
343
+ // converts Jupiter's 0..1 fraction once). Exits are never trapped by it, like the plan engine's stop leg.
344
+ if (order.side === 'buy' && isFiniteNum(quote.priceImpactPct) && quote.priceImpactPct > this.mandate.limits.maxPriceImpactPct + 1e-9) {
345
+ this.refuse(stage, 'price-impact', `price impact ${quote.priceImpactPct.toFixed(2)}% exceeds the mandate max ${this.mandate.limits.maxPriceImpactPct}%`, extra);
346
+ }
347
+ // The control surface may have flipped while the quote was in flight: re-check right before booking.
348
+ this.gate(stage, extra);
349
+ let rec;
350
+ try {
351
+ rec = await this.executor.execute(order, quote);
352
+ }
353
+ catch (e) {
354
+ this.refuse(stage, 'fill-failed', e instanceof Error ? e.message : String(e), extra);
355
+ }
356
+ this.note('agent.paper.fill', {
357
+ ...extra, mint: rec.mint, symbol: rec.symbol, side: rec.side, paper: true,
358
+ solDelta: rec.solDelta.toString(), tokenDelta: rec.tokenDelta.toString(),
359
+ priceSol: rec.priceSol, priceUsd: rec.priceUsd, realizedLamports: rec.realizedLamports.toString(), fillId: rec.id,
360
+ });
361
+ return { rec: rec, quoteAttempts: attempts };
362
+ }
363
+ // ───────────────────────────── the two reads ─────────────────────────────
364
+ positionView(pos) {
365
+ if (!pos)
366
+ return null;
367
+ const tokens = rawToTokens(pos.tokensRaw, pos.decimals);
368
+ const costSol = lamportsToSol(pos.costLamports);
369
+ return {
370
+ mint: pos.mint, symbol: pos.symbol, decimals: pos.decimals, tokens,
371
+ costSol, realizedSol: lamportsToSol(pos.realizedLamports),
372
+ avgEntrySol: tokens > 0 ? costSol / tokens : null,
373
+ };
374
+ }
375
+ /** Read paper positions (cost basis + realized; the caller may layer a live mark). Read-only. */
376
+ positions() {
377
+ return this.wallet.positions().map((p) => this.positionView(p)).filter(Boolean);
378
+ }
379
+ /** Read the paper PnL snapshot (balance, realized, open cost, per-position). Read-only, always PAPER. */
380
+ pnl() {
381
+ const s = this.wallet.summary();
382
+ const m = this.mandateSrc();
383
+ const positions = this.positions();
384
+ const realizedSol = positions.reduce((a, p) => a + p.realizedSol, 0);
385
+ const openCostSol = positions.reduce((a, p) => a + p.costSol, 0);
386
+ return {
387
+ paper: true,
388
+ balanceSol: lamportsToSol(s.balanceLamports),
389
+ startSol: lamportsToSol(s.startLamports),
390
+ realizedSol,
391
+ openCostSol,
392
+ positions,
393
+ mandate: m ? {
394
+ id: m.id, capsHash: capsHash(m), perTradeUsd: m.budget.perTradeUsd * TIERS[AGENT_TIER].perTradeMultiplier, perCoinUsd: m.budget.perCoinUsd,
395
+ totalUsd: m.budget.totalUsd, dailyLossUsd: m.budget.dailyLossUsd, maxOpenPositions: m.limits.maxOpenPositions,
396
+ maxPriceImpactPct: m.limits.maxPriceImpactPct, expiresAt: m.expiresAt,
397
+ } : null,
398
+ };
399
+ }
400
+ }
401
+ export { SOL_MINT };
@@ -0,0 +1,161 @@
1
+ // task-1443 — the rendezvous a running dexterm writes so an EXTERNAL `dexterm mcp` server (spawned by the
2
+ // user's own agent: Claude Code, Cursor, a custom bot) may paper-trade inside an armed mandate.
3
+ //
4
+ // Agent paper trading is OFF unless the user turns it on. When the user starts the app with the spectator
5
+ // view open and names a mandate they ARMED by keypress (`--spectate-mcp --agent-paper <mandate-id>`), the app
6
+ // writes `~/.dexterm/paper-agent.json` naming the shared paper wallet DB and the mandate ID — never the caps.
7
+ // The MCP side REPLAYS that mandate from the hash-chained plans journal the app armed it in (verifying the
8
+ // chain and the caps hash), so editing the rendezvous cannot raise a cap, and a revoke / panic in the app is
9
+ // seen on the next call. A companion `~/.dexterm/paper-agent.control` carries the live control state (running /
10
+ // paused / stopped / takeover), re-read on every mutating call. Agent calls journal to their OWN chain
11
+ // (`paper-agent.journal`), so the app's plans journal keeps a single writer. All files are local and 0600;
12
+ // nothing here is a key, a signer or a send path.
13
+ import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync, statSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { dirname, join } from 'node:path';
16
+ import { Journal } from "../plans/journal.js";
17
+ import { capsHash } from "../plans/mandate.js";
18
+ export const PAPER_AGENT_RENDEZVOUS = 'paper-agent.json';
19
+ export const PAPER_AGENT_CONTROL = 'paper-agent.control';
20
+ export const PAPER_AGENT_JOURNAL = 'paper-agent.journal';
21
+ function home(env) {
22
+ return env.DEXTERM_HOME && env.DEXTERM_HOME.trim() ? env.DEXTERM_HOME : join(homedir(), '.dexterm');
23
+ }
24
+ export function paperAgentRendezvousPath(env = process.env) { return join(home(env), PAPER_AGENT_RENDEZVOUS); }
25
+ export function paperAgentControlPath(env = process.env) { return join(home(env), PAPER_AGENT_CONTROL); }
26
+ export function paperAgentJournalPath(env = process.env) { return join(home(env), PAPER_AGENT_JOURNAL); }
27
+ /** The plans/mandate journal (same location as plans/service.ts `defaultPlansJournalPath`, which this module
28
+ * deliberately does not import: the service graph reaches the live executor). Never taken from the rendezvous. */
29
+ export function plansJournalPathFor(env = process.env) { return join(home(env), 'plans.journal'); }
30
+ /** Turn agent paper trading ON: write the rendezvous (0600) and a `running` control state. */
31
+ export function writePaperAgentRendezvous(rv, env = process.env) {
32
+ const p = paperAgentRendezvousPath(env);
33
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
34
+ writeFileSync(p, JSON.stringify({ v: 2, db: rv.db, mandateId: rv.mandateId, ...(rv.agent ? { agent: rv.agent } : {}) }), { mode: 0o600 });
35
+ writeDeskControl({ state: 'running' }, env);
36
+ }
37
+ /** Turn it OFF: remove both files. Idempotent. */
38
+ export function clearPaperAgentRendezvous(env = process.env) {
39
+ rmSync(paperAgentRendezvousPath(env), { force: true });
40
+ rmSync(paperAgentControlPath(env), { force: true });
41
+ }
42
+ /** Read the rendezvous, or null when agent paper trading is not enabled (or the file is malformed). Never throws. */
43
+ export function readPaperAgentRendezvous(env = process.env) {
44
+ const p = paperAgentRendezvousPath(env);
45
+ if (!existsSync(p))
46
+ return null;
47
+ try {
48
+ const o = JSON.parse(readFileSync(p, 'utf8'));
49
+ if (!o || o.v !== 2 || typeof o.db !== 'string' || !o.db || typeof o.mandateId !== 'string' || !o.mandateId)
50
+ return null;
51
+ return { v: 2, db: o.db, mandateId: o.mandateId, ...(typeof o.agent === 'string' ? { agent: o.agent.slice(0, 40) } : {}) };
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ const VALID_STATES = ['running', 'paused', 'stopped', 'takeover'];
58
+ /** Write the live control state (0600). The app calls this on pause / take-over / stop / resume. */
59
+ export function writeDeskControl(control, env = process.env) {
60
+ const p = paperAgentControlPath(env);
61
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
62
+ writeFileSync(p, JSON.stringify(control), { mode: 0o600 });
63
+ }
64
+ /**
65
+ * Read the control state, re-read on EVERY call (never cached), fail-closed:
66
+ * - file absent → stopped (the app writes `running` when it enables agent paper trading)
67
+ * - a known state → that state
68
+ * - present but broken → stopped
69
+ */
70
+ export function readDeskControl(env = process.env) {
71
+ const p = paperAgentControlPath(env);
72
+ if (!existsSync(p))
73
+ return { state: 'stopped', reason: 'agent paper trading is not running in the app' };
74
+ try {
75
+ const o = JSON.parse(readFileSync(p, 'utf8'));
76
+ const state = typeof o.state === 'string' && VALID_STATES.includes(o.state) ? o.state : 'stopped';
77
+ return { state, reason: typeof o.reason === 'string' ? o.reason.slice(0, 200) : undefined };
78
+ }
79
+ catch {
80
+ return { state: 'stopped', reason: 'control file unreadable (treated as stopped)' };
81
+ }
82
+ }
83
+ /** Freshest mtime of a file (for mtime caches); 0 when absent. */
84
+ export function fileMtimeMs(path) {
85
+ try {
86
+ return statSync(path).mtimeMs;
87
+ }
88
+ catch {
89
+ return 0;
90
+ }
91
+ }
92
+ export function rendezvousMtimeMs(env = process.env) { return fileMtimeMs(paperAgentRendezvousPath(env)); }
93
+ /**
94
+ * Replay ONE mandate from the hash-chained plans journal, read-only. Fails closed (null + reason) when the
95
+ * journal is absent, its chain does not verify, the mandate was never drafted, or the caps hash recorded at
96
+ * arming does not match the drafted caps. Status follows the journal: draft → armed → revoked/expired.
97
+ */
98
+ export function loadMandateFromJournal(path, mandateId, clock) {
99
+ if (!existsSync(path))
100
+ return { mandate: null, reason: 'no plans journal (arm a mandate first)' };
101
+ let j;
102
+ try {
103
+ j = new Journal({ path, clock });
104
+ }
105
+ catch {
106
+ return { mandate: null, reason: 'plans journal unreadable' };
107
+ }
108
+ const v = j.verify();
109
+ if (!v.ok)
110
+ return { mandate: null, reason: `plans journal chain broken at seq ${v.atSeq} (${v.why})` };
111
+ let m = null;
112
+ for (const e of j.events) {
113
+ if (e.mandateId !== mandateId)
114
+ continue;
115
+ const d = (e.data ?? {});
116
+ if (e.type === 'mandate.draft') {
117
+ const drafted = structuredClone(d.mandate);
118
+ if (!drafted || drafted.id !== mandateId || capsHash(drafted) !== d.capsHash)
119
+ return { mandate: null, reason: 'mandate draft does not match its caps hash' };
120
+ m = drafted;
121
+ }
122
+ else if (e.type === 'mandate.armed' && m) {
123
+ if (d.capsHash !== capsHash(m))
124
+ return { mandate: null, reason: 'mandate armed with different caps than drafted' };
125
+ m.status = 'armed';
126
+ m.armedAt = d.armedAt;
127
+ m.armedBy = d.armedBy;
128
+ }
129
+ else if (e.type === 'mandate.revoked' && m) {
130
+ m.status = d.status ?? 'revoked';
131
+ m.revokedAt = e.ts;
132
+ m.revokedReason = d.reason ?? null;
133
+ }
134
+ }
135
+ if (!m)
136
+ return { mandate: null, reason: `no mandate ${mandateId} in the plans journal` };
137
+ Object.freeze(m.budget);
138
+ Object.freeze(m.limits);
139
+ Object.freeze(m.fees);
140
+ Object.freeze(m.allowed);
141
+ return { mandate: Object.freeze(m) };
142
+ }
143
+ /**
144
+ * A journal that may have several writers (one per `dexterm mcp` process): it re-loads the chain from disk
145
+ * before every append so each event links to the true head. Append-only, 0600 directory, same hash chain and
146
+ * `redact()` as the plans journal.
147
+ */
148
+ export class SharedJournal {
149
+ path;
150
+ j;
151
+ constructor(path, clock) { this.path = path; this.j = new Journal({ path, clock }); }
152
+ append(type, fields = {}) {
153
+ if (existsSync(this.path))
154
+ this.j.load(this.path);
155
+ return this.j.append(type, fields);
156
+ }
157
+ verify() { if (existsSync(this.path))
158
+ this.j.load(this.path); return this.j.verify(); }
159
+ types() { if (existsSync(this.path))
160
+ this.j.load(this.path); return this.j.types(); }
161
+ }
@@ -268,6 +268,8 @@ export class PlanEngine {
268
268
  authorizeCopy(mandateId, proof, wallet) {
269
269
  if (!this.gate.handsFreeAllowed())
270
270
  throw new PolicyRefusal('gate-on', 'copy automation needs the less-safe opt-in');
271
+ if (this.mandates.get(mandateId)?.m.paperOnly)
272
+ throw new PolicyRefusal('paper-only-mandate', 'a paper-only mandate is for the agent paper desk; copy automation cannot use it');
271
273
  this.armMandate(mandateId, proof);
272
274
  return (input, supply, ref) => {
273
275
  const check = () => {
@@ -315,6 +317,9 @@ export class PlanEngine {
315
317
  const alive = mandateAlive(x.m, this.clock.now());
316
318
  if (!alive.ok)
317
319
  refuse(alive.reason, 'mandate is not alive');
320
+ // task-1469: a paper-only mandate (PAPER_HARD ceilings) feeds the agent paper desk only; no plan arms into it.
321
+ if (x.m.paperOnly)
322
+ refuse('paper-only-mandate', 'a paper-only mandate is for the agent paper desk; plans cannot arm into it');
318
323
  if (TIERS[tier].rank > TIERS[x.m.maxTier].rank)
319
324
  refuse('tier-not-allowed', `this mandate accepts up to ${x.m.maxTier}; ${tier} is weaker`);
320
325
  const v = this.admit(r, x, tier);
@@ -599,6 +604,8 @@ export class PlanEngine {
599
604
  const alive = mandateAlive(m, now);
600
605
  if (!alive.ok)
601
606
  return no(alive.reason, 'mandate not alive');
607
+ if (m.paperOnly)
608
+ return no('paper-only-mandate', 'a paper-only mandate never fires a plan');
602
609
  if (now > p.expiresAt)
603
610
  return no('plan-expired', 'plan expired');
604
611
  if (!m.allowed.actions.includes(side))