dexterm-cli 0.1.4 → 0.1.5

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 (63) hide show
  1. package/dist/packages/cli/src/agent-paper.js +60 -0
  2. package/dist/packages/cli/src/analyst/backend.js +183 -0
  3. package/dist/packages/cli/src/analyst/codex.js +301 -0
  4. package/dist/packages/cli/src/analyst/session.js +9 -2
  5. package/dist/packages/cli/src/app-help-gen.js +15 -6
  6. package/dist/packages/cli/src/doctor.js +58 -2
  7. package/dist/packages/cli/src/live/dev-history.js +99 -0
  8. package/dist/packages/cli/src/live/newpairs.js +12 -0
  9. package/dist/packages/cli/src/live/screens/token-live.js +11 -1
  10. package/dist/packages/cli/src/live/token.js +1 -0
  11. package/dist/packages/cli/src/main.js +122 -8
  12. package/dist/packages/cli/src/mcp-follow.js +37 -7
  13. package/dist/packages/cli/src/record-cmd.js +2 -0
  14. package/dist/packages/cli/src/rules.js +25 -2
  15. package/dist/packages/cli/src/screens/analyst-session.js +5 -3
  16. package/dist/packages/cli/src/screens/analyst.js +4 -3
  17. package/dist/packages/cli/src/version.js +1 -1
  18. package/dist/packages/custom/src/rules.js +2 -2
  19. package/dist/packages/custom/src/schema.js +5 -1
  20. package/dist/packages/data/src/fixtures.js +28 -6
  21. package/dist/packages/data/src/index.js +3 -0
  22. package/dist/packages/data/src/sources/jupiter.js +1 -0
  23. package/dist/packages/data/src/trade/agent/paper-desk.js +336 -0
  24. package/dist/packages/data/src/trade/agent/rendezvous.js +161 -0
  25. package/dist/packages/mcp/assets/kb/app-help/agent-paper-trade.md +38 -0
  26. package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +1 -1
  27. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +1 -1
  28. package/dist/packages/mcp/assets/kb/app-help/dev-history.md +32 -0
  29. package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +1 -1
  30. package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +4 -1
  31. package/dist/packages/mcp/assets/kb/app-help/screen-token.md +1 -0
  32. package/dist/packages/mcp/assets/kb/app-help/use-codex-analyst.md +31 -0
  33. package/dist/packages/mcp/assets/kb/docs/docs-agents-agent-paper-trading.md +50 -0
  34. package/dist/packages/mcp/assets/kb/docs/docs-agents-directory-metadata-prepared-not-submitted.md +36 -0
  35. package/dist/packages/mcp/assets/kb/docs/docs-agents-install-in-one-command.md +2 -0
  36. package/dist/packages/mcp/assets/kb/docs/docs-analyst-if-claude-is-missing.md +1 -1
  37. package/dist/packages/mcp/assets/kb/docs/docs-analyst-use-codex-instead-of-claude.md +35 -0
  38. package/dist/packages/mcp/assets/kb/docs/docs-beginners-macos-or-linux-npm.md +1 -1
  39. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
  40. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-powershell-experimental.md +1 -1
  41. package/dist/packages/mcp/assets/kb/docs/docs-configuration-environment-variables.md +2 -0
  42. package/dist/packages/mcp/assets/kb/docs/docs-configuration-rules-engine.md +1 -0
  43. package/dist/packages/mcp/assets/kb/docs/docs-configuration-the-analyst-s-launch.md +2 -0
  44. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +13 -3
  45. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +1 -1
  46. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -2
  47. package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +0 -2
  48. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-letting-an-agent-paper-trade-experimental.md +32 -0
  49. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-what-the-live-path-keeps.md +1 -1
  50. package/dist/packages/mcp/assets/kb/docs/docs-screens-dev-history.md +44 -0
  51. package/dist/packages/mcp/assets/kb/docs/docs-screens-how-well-it-predicts-measured-held-out.md +46 -0
  52. package/dist/packages/mcp/assets/kb/docs/docs-spectator-follow-over-mcp-in-the-running-terminal.md +1 -1
  53. package/dist/packages/mcp/assets/kb/docs/docs-troubleshooting-doctor-lines.md +2 -0
  54. package/dist/packages/mcp/src/index.js +5 -2
  55. package/dist/packages/mcp/src/paper-agent.js +92 -0
  56. package/dist/packages/mcp/src/server.js +206 -12
  57. package/dist/packages/mcp/src/spectator-sink.js +73 -4
  58. package/dist/packages/mcp/src/user-content.js +1 -0
  59. package/dist/packages/recorder/src/dev-history.js +129 -0
  60. package/dist/packages/recorder/src/index.js +1 -0
  61. package/dist/packages/recorder/src/recorder.js +23 -0
  62. package/dist/packages/recorder/src/schema.js +11 -0
  63. package/package.json +1 -1
@@ -85,7 +85,7 @@ export function appTools() {
85
85
  const SCREENS = [
86
86
  { screen: 'screener', title: 'The screener screen', feature: 'screener', tools: ['screener_query'],
87
87
  blurb: 'The live table of Solana tokens (DexScreener + Jupiter, refreshed every ~10 s): price, 5m/1h/6h/24h change, volume, liquidity, market cap, age, holders, organic score, flags and a sparkline, with a p(rug) column. Cells whose source is down show a dash and the caption says why — never a fabricated number.' },
88
- { screen: 'token', title: 'The token screen', feature: 'token-intel', tools: ['get_dossier', 'get_token', 'get_holders', 'get_trades', 'get_candles', 'get_dev_history', 'rugcheck'],
88
+ { screen: 'token', title: 'The token screen', feature: 'token-intel', tools: ['get_dossier', 'get_token', 'get_holders', 'get_trades', 'get_candles', 'get_dev_history', 'dev_history', 'rugcheck'],
89
89
  blurb: 'One coin in depth: market stats, a candle chart, the trades tape, mint/freeze authority, top holders with their class (pool/dev/linked/…), RugCheck + GoPlus, snipers/bundled from the wallet-intel scan, and the ANALYST panel. Open it with ⏎ on any live row or `--mint <CA|link>`.' },
90
90
  { screen: 'newpairs', title: 'The new pairs screen', feature: 'new-pairs', tools: ['screener_query'],
91
91
  blurb: 'A live feed of just-launched tokens (PumpPortal new-token + migration push, plus Jupiter/RugCheck/DexScreener polls). Bonding progress is a create-time estimate and says so; SNIPE/BUNDLE columns fill in from the wallet-intel scan of the row the cursor rests on (Helius-keyed).' },
@@ -93,8 +93,8 @@ const SCREENS = [
93
93
  blurb: 'The coins you watch, persisted in SQLite, priced live every ~10 s with a LIVE/POLL badge. Add with `w` on any coin; remove with `d`.' },
94
94
  { screen: 'analyst', title: 'The analyst screen', feature: 'analyst', tools: ['get_dossier', 'kb_search', 'app_help'],
95
95
  blurb: 'Your own `claude` CLI as the dexterm analyst: it reads the read-only dexterm MCP tools, grounds every fact in a tool result, and answers in a few terse lines. ⏎ on a coin runs a full dossier; `a` runs a rug-check. Facts never come from model memory; token text passes the prompt firewall.' },
96
- { screen: 'positions', title: 'The paper positions screen', feature: 'positions', tools: [],
97
- blurb: 'Your simulated (PAPER) positions and their P&L. dexterm holds no keys and signs nothing; paper trading is a simulation for learning the flow.' },
96
+ { screen: 'positions', title: 'The paper positions screen', feature: 'positions', tools: ['paper_positions', 'paper_pnl'],
97
+ blurb: 'Your simulated (PAPER) positions and their P&L. dexterm holds no keys and signs nothing; paper trading is a simulation for learning the flow. An agent you enabled can read these over MCP with the read-only paper_positions and paper_pnl tools.' },
98
98
  { screen: 'trending', title: 'The trending screen', feature: 'trending-board', tools: ['narrative_board'],
99
99
  blurb: 'What is trending in the space — narratives and movers aggregated from the feed.' },
100
100
  { screen: 'kol', title: 'The KOL lists screen', feature: 'kol-lists', tools: [],
@@ -179,10 +179,16 @@ const TOPICS = [
179
179
  { id: 'ask-the-analyst', title: 'Ask the analyst a question', screen: 'analyst', tags: ['analyst', 'ask', 'claude', 'ai', 'question', 'dossier', 'chat'],
180
180
  actions: [{ action: 'analyst.toggle' }, { action: 'open', screen: 'screener' }], tools: ['get_dossier', 'kb_search', 'app_help', 'case_lookup'], feature: 'analyst',
181
181
  flags: ['--no-ai'],
182
- body: 'The analyst is your own `claude` login. ⏎ on a coin runs a full dossier; `a` runs a rug-check (or a trench-scan of the screener when nothing is selected). Type a follow-up and ⏎ to send. If `claude` is missing or logged out the panel shows the one-line fix and the rest of dexterm keeps working. Start without AI using `--no-ai`.' },
182
+ body: 'The analyst is your own `claude` login (or your own `codex` login: `/engine codex`, see "Run the analyst on Codex"). ⏎ on a coin runs a full dossier; `a` runs a rug-check (or a trench-scan of the screener when nothing is selected). Type a follow-up and ⏎ to send. If `claude` is missing or logged out the panel shows the one-line fix and the rest of dexterm keeps working. Start without AI using `--no-ai`.' },
183
183
  { id: 'connect-an-agent', title: 'Connect your own agent (MCP)', screen: 'all', tags: ['mcp', 'agent', 'agents', 'connect', 'claude', 'tool', 'server', 'bot'],
184
184
  commands: ['mcp'], tools: ['screen_state', 'get_dossier', 'kb_search', 'app_help', 'app_state'], feature: 'mcp',
185
- body: '`dexterm mcp` is a plain stdio MCP server exposing the same read-only tools the analyst uses. Add it to any Claude Code with `claude mcp add dexterm -- node packages/mcp/bin/dexterm-mcp.mjs`. Agents are untrusted: every tool is read-only and an agent can never sign, arm, raise a cap or touch keys. Use `app_help` for "how do I" questions and `app_state` to see what the user sees.' },
185
+ body: '`dexterm mcp` is a plain stdio MCP server exposing the same read-only tools the analyst uses. Add it to any Claude Code with `claude mcp add dexterm -- node packages/mcp/bin/dexterm-mcp.mjs`, or set up Claude Code, Claude Desktop, Cursor or Codex in one command with `dexterm mcp install <client>`. Agents are untrusted: every tool is read-only and an agent can never sign, arm, raise a cap or touch keys. Use `app_help` for "how do I" questions and `app_state` to see what the user sees.' },
186
+ { id: 'use-codex-analyst', title: 'Run the analyst on Codex instead of Claude', screen: 'all', tags: ['codex', 'openai', 'chatgpt', 'engine', 'analyst', 'claude', 'switch', 'backend'],
187
+ flags: ['--analyst-engine'], feature: 'analyst',
188
+ body: 'The analyst can run on your own Codex CLI login instead of Claude (0.1.5). Pick it with `--analyst-engine codex`, `DEXTERM_ANALYST_ENGINE=codex`, `"analyst": {"engine": "codex"}` in `~/.dexterm/config.json`, or type `/engine codex` in the chat box (new conversations; `/engine` alone shows the engine and what is installed). The default `auto` uses claude when it is installed, else codex. dexterm stores no credentials: your `codex login` does the work. The Codex run is locked read-only: only the read-only dexterm tools (plus the UI actions the session allows, none in scan mode), no shell, no file edits, no web search, no other MCP server; a turn that tries anything else is stopped. `dexterm doctor` shows a Codex CLI line (installed, version, logged in). Codex is also supported as an MCP client: `dexterm mcp install codex`.' },
189
+ { id: 'dev-history', title: 'Check if a coin\'s creator rugged before (dev history)', screen: 'token', tags: ['dev', 'deployer', 'creator', 'serial', 'rugger', 'rugged', 'history', 'launches'],
190
+ tools: ['dev_history', 'get_dev_history'], feature: 'token-intel',
191
+ body: 'The token screen\'s ON-CHAIN panel shows the creator\'s recorded track record (0.1.5): `Dev rugs (recorded)` (rugs out of labelled prior launches, with the sample n), `Dev launches seen`, `Dev sold its bag` (median minutes to the dev\'s exit) and `Dev linked` (funder or cluster from the wallet-intel scan). It comes from dexterm\'s own local history recorder (keyless creator links + code-only outcome labels), so it says `unknown` when there is no record; it is a risk flag from observed history, never a certificate. Alert on it with the rule field `devrugs`, e.g. `when devrugs >= 2 then alert(warn, "new coin from a wallet that rugged before") scope newpairs`. Agents and the analyst use the `dev_history` MCP tool.' },
186
192
  { id: 'configure-rpc-key', title: 'Configure a Helius / RPC key for holders', screen: 'all', tags: ['helius', 'rpc', 'key', 'holders', 'free', 'api', 'ranked'],
187
193
  feature: 'keyless',
188
194
  body: 'dexterm is free-forever and keyless by default. The one real keyless gap is the ranked top-holder list: add a free RPC key for it. Save a Helius key ONLY at `~/.dexterm/helius.key` (mode 600, never a repo or command line), or set `DEXTERM_RPC_URL` to any read-only RPC. `dexterm doctor` prints the exact command. Without a key the holders panel shows count + top-10% via Jupiter.' },
@@ -194,7 +200,10 @@ const TOPICS = [
194
200
  body: '`config.keymap` in `~/.dexterm/config.json` re-binds actions to keys; `dexterm keymap [screen]` shows the effective bindings including your overrides. Reserved keys (ctrl+c, esc, enter, y, !) and the confirm/dismiss/panic/quit actions cannot be rebound, and collisions refuse the whole keymap.' },
195
201
  { id: 'paper-trade', title: 'Paper trade a coin', screen: 'token', tags: ['paper', 'trade', 'buy', 'sell', 'ticket', 'simulate', 'practice'],
196
202
  actions: [{ action: 'trade.buy', screen: 'token' }, { action: 'trade.sell', screen: 'token' }], feature: 'paper-ticket',
197
- body: 'Press `B` / `S` on the token screen to open a PAPER buy/sell ticket in its editing phase. dexterm holds no keys and signs nothing — paper trading is a simulation. Confirming a ticket needs your explicit keypress (the gate); a stray key never confirms. The analyst can open a PAPER ticket but can never size, confirm or send it.' },
203
+ body: 'Press `B` / `S` on the token screen to open a PAPER buy/sell ticket in its editing phase. dexterm holds no keys and signs nothing — paper trading is a simulation. Confirming a ticket needs your explicit keypress (the gate); a stray key never confirms. The in-app analyst can open a PAPER ticket but can never size, confirm or send it. If you separately arm a mandate and enable agent paper trading, a connected agent can open, size, confirm and close PAPER trades inside your caps (never raising them, never signing) — see "Let an agent paper trade".' },
204
+ { id: 'agent-paper-trade', title: 'Let an agent paper trade', screen: 'all', tags: ['agent', 'mcp', 'paper', 'autonomous', 'experimental', 'confirm', 'close', 'simulated'],
205
+ flags: ['--agent-paper'], tools: ['paper_open_ticket', 'paper_size_ticket', 'paper_confirm', 'paper_close', 'paper_positions', 'paper_pnl'], feature: 'mcp',
206
+ body: 'Experimental, paper only, off by default. Arm a mandate yourself (its caps, by keypress), then start `dexterm --spectate-mcp --agent-paper <mandate-id>`: an agent connected through `dexterm mcp` can then open, size, confirm and close SIMULATED (paper) trades inside those caps with paper_open_ticket / paper_size_ticket / paper_confirm / paper_close, and read paper_positions / paper_pnl. The agent is untrusted: it can never sign, send a real transaction, touch keys, or raise a cap; its per-trade cap is a quarter of the mandate\'s, and a size over any cap is refused. Caps are re-read from your armed mandate on every call, so revoking it or the panic key stops the agent. Every call is journaled and shows in this terminal\'s agent activity; Ctrl+P pauses, Ctrl+K stops for the rest of the run, any key takes over, and quitting turns it off. Figures are simulated and are not a result.' },
198
207
  { id: 'run-doctor', title: 'Diagnose problems with doctor', screen: 'all', tags: ['doctor', 'diagnose', 'check', 'troubleshoot', 'broken', 'fix', 'problem'],
199
208
  commands: ['doctor'], feature: 'doctor',
200
209
  body: '`dexterm doctor` is the first stop for any problem: non-interactive, never touches your keys, exits 0 when dexterm will run and 1 when something must be fixed. Every non-pass line prints the exact fix. `dexterm doctor --offline` skips the network probes.' },
@@ -17,6 +17,7 @@ import { XTokenStore } from '../../data/src/index.js';
17
17
  import { SayTts, voiceDoctor, resolveVoice, WHISPER_MODEL, resolvePiperRuntime, pickPlayer, PIPER_MODEL } from '../../voice/src/index.js';
18
18
  import { loadConfig, dextermHome } from '../../custom/src/index.js';
19
19
  import { kbStore, skillStore } from "./kb-skill-cmd.js";
20
+ import { analystCliOnPath, CODEX_MIN_VERSION, parseEngineChoice, resolveEngine, versionAtLeast } from "./analyst/codex.js";
20
21
  /** The minimum Node that runs dexterm (type-stripping of the .ts entry needs 22.18). */
21
22
  export const MIN_NODE = [22, 18, 0];
22
23
  /** Cheap PATH lookup (no spawn). Windows (task-1423): PATH is `;`-separated and commands carry an extension
@@ -141,6 +142,32 @@ export async function claudeProbe(timeoutMs = 4000, platform = process.platform,
141
142
  }
142
143
  });
143
144
  }
145
+ /**
146
+ * Default codex probe (0.1.5): PATH (spawnable form only), then `codex --version` and `codex login status`. The
147
+ * status command is the user's own CLI reporting on its own login; dexterm never reads the credential itself.
148
+ */
149
+ export async function codexProbe(timeoutMs = 4000, env = process.env) {
150
+ if (!analystCliOnPath('codex', env))
151
+ return { state: 'missing' };
152
+ const { execFile } = await import('node:child_process');
153
+ const run = (args) => new Promise((resolve) => {
154
+ try {
155
+ const child = execFile('codex', args, { timeout: timeoutMs, env }, (err, stdout, stderr) => {
156
+ const code = err ? (typeof err.code === 'number' ? err.code : null) : 0;
157
+ resolve({ code, out: `${stdout ?? ''}${stderr ?? ''}` });
158
+ });
159
+ child.on('error', () => resolve({ code: null, out: '' }));
160
+ }
161
+ catch {
162
+ resolve({ code: null, out: '' });
163
+ }
164
+ });
165
+ const v = await run(['--version']);
166
+ const version = /(\d+\.\d+\.\d+[\w.-]*)/.exec(v.out)?.[1] ?? null;
167
+ const st = await run(['login', 'status']);
168
+ const loggedIn = st.code === 0 ? !/not logged in/i.test(st.out) : st.code === null ? null : false;
169
+ return { state: 'present', version, loggedIn };
170
+ }
144
171
  /** Default writable check: create the directory, write and remove a temp file. Returns an error message or null. */
145
172
  export function writableProbe(dir) {
146
173
  try {
@@ -162,6 +189,8 @@ export function defaultDeps(env = process.env, isTTY = process.stdout.isTTY ===
162
189
  nodeVersion: process.versions.node,
163
190
  platform: process.platform,
164
191
  claude: () => claudeProbe(),
192
+ codex: () => codexProbe(4000, env),
193
+ analystEngine: env.DEXTERM_ANALYST_ENGINE ?? loadConfig(dextermHome(env)).config.analyst?.engine ?? null,
165
194
  probe: (url) => livingProbe(url),
166
195
  checkWritable: writableProbe,
167
196
  offline: env.DEXTERM_OFFLINE === '1' || env.DEXTERM_OFFLINE === 'true',
@@ -230,11 +259,20 @@ export async function runDoctor(deps) {
230
259
  }
231
260
  // 3. Claude CLI (the analyst). Not required to browse; required for the `a` analyst.
232
261
  const claude = await deps.claude();
233
- if (claude === 'missing') {
262
+ // 0.1.5: codex is the optional second engine; a missing claude is fine when codex can run the analyst.
263
+ const codex = deps.codex ? await deps.codex() : null;
264
+ const codexUsable = codex?.state === 'present';
265
+ if (claude === 'missing' && codexUsable) {
266
+ checks.push({
267
+ name: 'Claude CLI', status: 'pass',
268
+ detail: 'not found on PATH (optional now): the analyst runs on your codex CLI instead',
269
+ });
270
+ }
271
+ else if (claude === 'missing') {
234
272
  checks.push({
235
273
  name: 'Claude CLI', status: 'warn',
236
274
  detail: 'not found on PATH; the analyst (a) is disabled, the rest of dexterm works',
237
- fix: 'Install Claude Code (https://claude.com/claude-code) and run `claude` once to log in.',
275
+ fix: 'Install Claude Code (https://claude.com/claude-code) and run `claude` once to log in, or use Codex: `npm i -g @openai/codex && codex login`.',
238
276
  });
239
277
  }
240
278
  else {
@@ -243,6 +281,24 @@ export async function runDoctor(deps) {
243
281
  detail: 'found on PATH (the analyst runs your local `claude`; log in once with `claude` if the analyst errors)',
244
282
  });
245
283
  }
284
+ // 3b. Codex CLI (0.1.5): optional second analyst engine, the user's own codex login. Read-only lock needs >= CODEX_MIN_VERSION.
285
+ if (codex) {
286
+ const choice = parseEngineChoice(deps.analystEngine) ?? 'auto';
287
+ const engine = resolveEngine(choice, (b) => b === 'claude' ? claude !== 'missing' : codexUsable);
288
+ const eng = `analyst engine: ${engine}${choice === 'auto' ? ' (auto)' : ''}`;
289
+ if (codex.state === 'missing') {
290
+ checks.push({ name: 'Codex CLI', status: engine === 'codex' ? 'warn' : 'pass', detail: `not installed (optional second analyst engine) · ${eng}`, ...(engine === 'codex' ? { fix: 'npm i -g @openai/codex && codex login, or set analyst.engine to claude' } : {}) });
291
+ }
292
+ else if (codex.version && !versionAtLeast(codex.version, CODEX_MIN_VERSION)) {
293
+ checks.push({ name: 'Codex CLI', status: 'warn', detail: `codex-cli ${codex.version} is older than ${CODEX_MIN_VERSION}; the read-only analyst lock needs ${CODEX_MIN_VERSION}+ · ${eng}`, fix: 'npm i -g @openai/codex@latest' });
294
+ }
295
+ else if (codex.loggedIn === false) {
296
+ checks.push({ name: 'Codex CLI', status: 'warn', detail: `found${codex.version ? ` (codex-cli ${codex.version})` : ''}, not logged in · ${eng}`, fix: 'codex login' });
297
+ }
298
+ else {
299
+ checks.push({ name: 'Codex CLI', status: 'pass', detail: `found${codex.version ? ` (codex-cli ${codex.version})` : ''}${codex.loggedIn ? ', logged in' : ''}; runs the analyst read-only on your own codex login (/engine codex) · ${eng}` });
300
+ }
301
+ }
246
302
  // 4b. Login with X (optional, task-1357): the user's own X app + login unlock X posts in the SOCIAL panel / TRENDING.
247
303
  {
248
304
  const xs = new XTokenStore({ env: deps.env });
@@ -0,0 +1,99 @@
1
+ import { creatorOf, devHistoryLine, queryDevHistory } from '../../../recorder/src/index.js';
2
+ export const DEV_TTL_MS = 60_000;
3
+ /** Short creator/funder address for the panel (never a full address in a 40-column cell). */
4
+ export const shortAddr = (a) => (a ? `${a.slice(0, 4)}…${a.slice(-4)}` : '—');
5
+ export class DevHistoryBook {
6
+ cache = new Map();
7
+ db;
8
+ now;
9
+ ttlMs;
10
+ constructor(db, now = () => Date.now(), ttlMs = DEV_TTL_MS) {
11
+ this.db = db;
12
+ this.now = now;
13
+ this.ttlMs = ttlMs;
14
+ }
15
+ /**
16
+ * The entry for `mint` (cached for `ttlMs`). `creatorHint` is the dev wallet a source named on screen (Jupiter
17
+ * `dev`) for a coin the recorder has not linked yet; `intel` is the coin's settled wallet-intel scan, if any.
18
+ */
19
+ forMint(mint, creatorHint, intel) {
20
+ const now = this.now();
21
+ const hit = this.cache.get(mint);
22
+ if (hit && now - hit.at < this.ttlMs && (hit.creator || !creatorHint) && (hit.linked || !intel))
23
+ return hit;
24
+ const db = this.db() ?? null;
25
+ let creator = db ? creatorOf(db, mint) : null;
26
+ if (!creator && creatorHint && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(creatorHint))
27
+ creator = { mint, creator: creatorHint, source: 'jupiter-tokens', firstT: now, createdAt: null };
28
+ let report = null;
29
+ let linked = null;
30
+ if (creator && db) {
31
+ try {
32
+ report = queryDevHistory(db, { creator: creator.creator, mint, asOf: now });
33
+ }
34
+ catch {
35
+ report = null;
36
+ }
37
+ }
38
+ if (creator && intel)
39
+ linked = linkedFrom(intel, creator.creator, db);
40
+ const line = !db ? 'unknown: the history recorder is off (--no-recorder / DEXTERM_RECORDER=0)'
41
+ : !creator ? 'unknown: no source named the creator yet'
42
+ : report ? devHistoryLine(report) : 'unknown: dev history unreadable';
43
+ const e = { mint, creator, report, linked, line, at: now };
44
+ this.cache.set(mint, e);
45
+ if (this.cache.size > 2000)
46
+ this.cache.delete(this.cache.keys().next().value);
47
+ return e;
48
+ }
49
+ /** The `devrugs` rule field: labelled prior rugs by this coin's creator; null = unknown (clauses on it are false). */
50
+ rugsFor(mint) {
51
+ const e = this.forMint(mint);
52
+ return e.report && e.report.labelled.n > 0 ? e.report.labelled.rug : null;
53
+ }
54
+ }
55
+ /** Linked wallets from a settled wallet-intel scan: the creator's funder and any multi-wallet cluster it sits in. */
56
+ export function linkedFrom(a, creator, db) {
57
+ const h = a.holders.find((x) => x.owner === creator);
58
+ const fundedBy = h?.funder?.address ?? a.funding[creator]?.funder ?? null;
59
+ const c = a.clusters.find((x) => x.members.includes(creator) && x.members.length > 1) ?? null;
60
+ let linkedCreators = 0, linkedRugs = 0;
61
+ if (c && db) {
62
+ try {
63
+ const others = c.members.filter((m) => m !== creator).slice(0, 50);
64
+ const launched = db.prepare('SELECT COUNT(*) AS n FROM creators WHERE creator = ?');
65
+ for (const w of others) {
66
+ if (Number(launched.get(w).n) <= 0)
67
+ continue;
68
+ linkedCreators++;
69
+ linkedRugs += queryDevHistory(db, { creator: w, devExit: false }).labelled.rug;
70
+ }
71
+ }
72
+ catch { /* an old DB without the table: nothing linked */ }
73
+ }
74
+ return { fundedBy, cluster: c ? { name: c.name, kind: c.kind, members: c.members.length, pct: c.pct } : null, linkedCreators, linkedRugs, source: h || c || a.funding[creator] ? 'wallet-intel scan' : null };
75
+ }
76
+ /** Rows for the token screen's DEV block: [label, value, level]. Every value names its source/sample/age or says unknown. */
77
+ export function devRows(e) {
78
+ const rows = [];
79
+ const r = e.report;
80
+ const rugLevel = () => !r || r.labelled.n === 0 ? 'na' : r.serialRugger ? 'bad' : r.labelled.rug > 0 || (r.badRate ?? 0) >= 0.5 ? 'warn' : 'ok';
81
+ rows.push(['Dev rugs (recorded)', r && r.labelled.n ? `${r.labelled.rug}/${r.labelled.n} · n=${r.labelled.n}` : 'unknown', rugLevel()]);
82
+ rows.push(['Dev launches seen', r && r.status === 'ok' ? `${r.launches.n} prior · ${e.creator?.source === 'jupiter-tokens' ? 'jup' : e.creator?.source ?? ''}` : 'unknown', 'na']);
83
+ if (r && r.devExit.n)
84
+ rows.push(['Dev sold its bag', `median ${Math.round(r.devExit.medianMin)}m · n=${r.devExit.n}`, r.devExit.medianMin <= 60 ? 'warn' : 'ok']);
85
+ const l = e.linked;
86
+ if (l && (l.fundedBy || l.cluster))
87
+ rows.push(['Dev linked', l.cluster ? `${l.cluster.kind} ${l.cluster.members}w${l.linkedCreators ? ` · ${l.linkedCreators} devs ${l.linkedRugs} rugs` : ''}` : `funded by ${shortAddr(l.fundedBy)}`, l.linkedRugs > 0 ? 'bad' : 'na']);
88
+ return rows;
89
+ }
90
+ /** The app's one book (created on first use), reading the live recorder's DB. Null when there is no recorder. */
91
+ export function devBookOf(state) {
92
+ if (!state.live?.recorder)
93
+ return null;
94
+ if (!state.devBook) {
95
+ const live = state.live;
96
+ state.devBook = new DevHistoryBook(() => live?.recorder?.database ?? null);
97
+ }
98
+ return state.devBook;
99
+ }
@@ -20,6 +20,8 @@ export class NewPairsLive {
20
20
  dl;
21
21
  now;
22
22
  onChange;
23
+ /** 0.1.5 dev history: called once per row when its creator becomes known (the recorder stores the link). */
24
+ onCreator;
23
25
  limit;
24
26
  polls;
25
27
  rows = new Map();
@@ -84,6 +86,12 @@ export class NewPairsLive {
84
86
  this.mode = 'replay';
85
87
  return this.client.replay(rec, speed);
86
88
  }
89
+ tellCreator(mint, creator, source, createdAt) {
90
+ try {
91
+ this.onCreator?.(mint, creator, source, createdAt);
92
+ }
93
+ catch { /* the recorder must never break the feed */ }
94
+ }
87
95
  addCreate(c) {
88
96
  const rowAt = this.now();
89
97
  const existing = this.rows.get(c.mint);
@@ -105,6 +113,8 @@ export class NewPairsLive {
105
113
  }
106
114
  else {
107
115
  this.rows.set(c.mint, row);
116
+ if (row.creator)
117
+ this.tellCreator(c.mint, row.creator, c.creator ? 'pumpportal' : 'rugcheck', row.createdAt);
108
118
  this.latencies.push(row.latencyMs);
109
119
  if (this.latencies.length > 500)
110
120
  this.latencies.shift();
@@ -227,6 +237,8 @@ export class NewPairsLive {
227
237
  const row = this.rows.get(t.mint);
228
238
  if (!row)
229
239
  continue;
240
+ if (!row.creator && t.creator)
241
+ this.tellCreator(t.mint, t.creator, 'rugcheck', row.createdAt);
230
242
  row.creator = row.creator ?? t.creator;
231
243
  if (t.mintAuthority !== null)
232
244
  row.mintAuth = t.mintAuthority;
@@ -15,6 +15,7 @@ import { drawCoin, imagesOn } from "../../screens/coin.js";
15
15
  import { COIN_HERO_W, COIN_HERO_H } from '../../../../tui/src/index.js';
16
16
  import { renderSocialPanel } from "../../screens/social-panel.js";
17
17
  import { launchIntelCells, holderTags } from "../intel.js";
18
+ import { devBookOf, devRows } from "../dev-history.js";
18
19
  import { EX_POOL_TAGS } from '../../../../data/src/index.js';
19
20
  import { layoutFor } from "../../custom.js";
20
21
  import { chartMode, priceModeOf, mcapAt, positionPriceCells } from "../../display.js";
@@ -178,6 +179,12 @@ export function renderTokenLive(c, state, v) {
178
179
  const top10Rows = tags.top10
179
180
  ? [['Top 10 ex-pool', `${tags.top10.exPool.toFixed(1)}%`, t10lvl(tags.top10.exPool)], ['Top 10 raw', `${tags.top10.raw.toFixed(1)}%`, 'na']]
180
181
  : [['Top 10 holders', ...(v.top10 === null ? na(v.panels.holders.state === 'needs-key' ? 'needs key' : dash) : [`${v.top10.toFixed(1)}%${v.panels.holders.state === 'ok' ? '' : ' (jup)'}`, t10lvl(v.top10)])]];
182
+ // 0.1.5 dev history (task-1425): the creator's recorded track record from the local recorder (keyless). The rugs
183
+ // row sits with the other rug signals; launches / bag-exit / linked wallets follow the safety list.
184
+ const book = devBookOf(state);
185
+ const devAll = book ? devRows(book.forMint(v.mint, v.devAddress ?? null, state.live?.intel?.analysis(v.mint) ?? null)) : [];
186
+ const devHead = devAll.slice(0, 1);
187
+ const devTail = devAll.slice(1);
181
188
  const sniperRow = [cells.snipers.text, cells.snipers.level];
182
189
  const bundleRow = [cells.bundle.text, cells.bundle.level];
183
190
  const rows = [
@@ -189,8 +196,10 @@ export function renderTokenLive(c, state, v) {
189
196
  ['Mint authority', ...(v.mintAuth === null ? na(dash) : [v.mintAuth ? 'ENABLED' : 'revoked', v.mintAuth ? 'bad' : 'ok'])],
190
197
  ['Freeze authority', ...(v.freezeAuth === null ? na(dash) : [v.freezeAuth ? 'ENABLED' : 'revoked', v.freezeAuth ? 'bad' : 'ok'])],
191
198
  ['Dev history', ...(v.devMints === null ? needsHistory : [`${v.devMints} mints by dev (jup)`, v.devMints > 3 ? 'warn' : 'ok'])],
199
+ ...devHead,
192
200
  ['RugCheck score', ...(v.rug === null || v.rug.score === null ? na(v.panels.rugcheck.state === 'error' ? `unavailable: ${v.panels.rugcheck.reason ?? ''}`.trim() : dash) : [`${Math.round(v.rug.score)}/100`, v.rug.score < 30 ? 'ok' : v.rug.score < 60 ? 'warn' : 'bad'])],
193
201
  ['GoPlus', ...goplusRow(v)],
202
+ ...devTail,
194
203
  ];
195
204
  for (const [label, val0, lvl] of rows) {
196
205
  if (sy >= sb.y + sb.h)
@@ -435,7 +444,8 @@ function analystPlaceholder(c, x, y, w, h, v, state) {
435
444
  // task-1311 (QA M5): the pre-run state. Opening a token no longer spends a paid dossier; this panel
436
445
  // waits until you ask for one with `a` (dossier) or type a question with `i`. No fake verdicts.
437
446
  const t = c.theme;
438
- const box = c.box(x, y, w, h, { title: '✦ ANALYST · claude', titleFg: t.ai, caption: 'read-only · local claude · on demand', captionFg: t.dim, border: mix(t.border, t.ai, 0.35) });
447
+ const eng = state.analystEngine ?? 'claude'; // 0.1.5: the user's own CLI that will answer
448
+ const box = c.box(x, y, w, h, { title: `✦ ANALYST · ${eng}`, titleFg: t.ai, caption: `read-only · local ${eng} · on demand`, captionFg: t.dim, border: mix(t.border, t.ai, 0.35) });
439
449
  const bg = box.bg;
440
450
  const inner = box.w - 2;
441
451
  let cy = box.y;
@@ -266,6 +266,7 @@ export class TokenLive {
266
266
  v.top10 = audit?.top10Pct ?? v.top10;
267
267
  v.dev = audit?.devPct ?? null;
268
268
  v.devMints = audit?.devMints ?? null;
269
+ v.devAddress = audit?.devAddress ?? v.devAddress ?? null;
269
270
  v.sources = [...new Set([...v.sources, ...r.sources])];
270
271
  const SHORT = { dexscreener: 'ds', 'jupiter-tokens': 'jup', 'jupiter-price': 'jup-price' };
271
272
  this.ok('market', r.sources.map((x) => SHORT[x] ?? x).join('+'), pair.fetchedAt, r.stale);
@@ -29,7 +29,9 @@ import { makeFixtureState, makeFeedState, syncFeed, renderFrame, isScreenKey, ac
29
29
  import { mulberry32, tick, spawnPair, shock } from "./fixtures.js";
30
30
  import { createLiveFeed, createReplayFeed } from "./feed.js";
31
31
  import { LiveContext, INTERVALS, WATCH_REFRESH_MS, POSITIONS_REFRESH_MS, popupBubbleVM } from "./live/index.js";
32
+ import { FOLLOW_NAV_ACTIONS } from '../../mcp/src/index.js';
32
33
  import { McpFollowSession } from "./mcp-follow.js";
34
+ import { AgentPaperSwitch } from "./agent-paper.js";
33
35
  import { TOKEN_REFRESH_MS, CandlePrefetcher } from "./live/token.js";
34
36
  import { PRESETS, presetSpec } from "./screener/presets.js";
35
37
  import { parseSortArg, SORT_KEYS } from "./screener/sort.js";
@@ -46,6 +48,7 @@ import { copyAlertToDelivery } from "./alerts/format.js";
46
48
  import { readTelegramCreds, readDiscordCreds } from "./alerts/creds.js";
47
49
  import { launchIntelCells, launchRetryable } from "./live/intel.js";
48
50
  import { AnalystSession } from "./analyst/session.js";
51
+ import { analystCliOnPath, parseEngineChoice, resolveEngine } from "./analyst/codex.js";
49
52
  import { resolveAnalystResults } from "./analyst/results.js";
50
53
  import { parseScanCommand, SCAN_FLAGS, checkScanFlag, SCAN_HELP } from "./scan/command.js";
51
54
  import { parseWalletCommand, WALLET_HELP } from "./wallet-command.js";
@@ -101,6 +104,21 @@ export function appStateBlock(state) {
101
104
  alerts, // booleans only — never a token, webhook or key
102
105
  };
103
106
  }
107
+ /**
108
+ * task-1443: the reduced screen snapshot a terminal following an EXTERNAL agent (`--spectate-mcp`) publishes for
109
+ * that agent's `dexterm mcp` server: what is on screen plus a VIEW-ONLY nav allow-list. Never the watchlist,
110
+ * tickets, positions or app settings, and only the four follow nav action types (no watchlist add, no ticket).
111
+ */
112
+ export function followSnapshot(state) {
113
+ const ss = buildScreenState(state);
114
+ const a = ss.actions;
115
+ return {
116
+ screen: ss.screen, selected: ss.selected, selectedDetail: ss.selectedDetail, filter: ss.filter, sort: ss.sort,
117
+ visible: ss.visible, visibleMints: ss.visibleMints,
118
+ actions: { enabled: a.enabled, mints: a.mints, feeds: a.feeds, sortKeys: a.sortKeys, screens: a.screens, workspaces: [], themes: [], types: [...FOLLOW_NAV_ACTIONS] },
119
+ now: ss.now,
120
+ };
121
+ }
104
122
  export function buildScreenState(state) {
105
123
  const live = state.live;
106
124
  const sc = state.screener;
@@ -288,6 +306,9 @@ export const HELP = `dexterm ${VERSION} (live: screener, token, new pairs, watch
288
306
  --record FILE save every live response (and the PumpPortal session) to FILE (replay it with --replay)
289
307
  --spectate-mcp follow an MCP agent's allowed navigation in this terminal; Ctrl+P pauses, Ctrl+K stops,
290
308
  and any normal user keypress takes control (refused actions never move the UI)
309
+ --agent-paper MANDATE_ID (experimental, with --spectate-mcp) let that MCP agent PAPER-trade inside a mandate you
310
+ armed: simulated fills only, never signs or sends, never raises a cap; Ctrl+P / Ctrl+K /
311
+ any key also pause, stop or take over its paper trading
291
312
  --max-requests N hard cap on live requests for this run (default: none)
292
313
  --max-credits N hard cap on Helius credits for this run (default: 5000)
293
314
  --mint MINT|LINK token to open on the token screen (a CA or a dexscreener / pump.fun / solscan / birdeye link)
@@ -302,6 +323,8 @@ export const HELP = `dexterm ${VERSION} (live: screener, token, new pairs, watch
302
323
  --selected N highlighted row / token index
303
324
  --seed N fixture PRNG seed (default 7)
304
325
  --no-ai hide the analyst panel on the token screen
326
+ --analyst-engine E auto | claude | codex: which of YOUR installed CLIs runs the analyst (default auto = claude
327
+ if installed, else codex; DEXTERM_ANALYST_ENGINE or config analyst.engine too; /engine in chat)
305
328
 
306
329
  --intensity LEVEL off | normal | fever (default fever: rain, trails, glitch, blink, scanlines at 30 fps;
307
330
  normal: 15 fps, sparser rain, no idle aberration; off = calm: neon theme, no motion)
@@ -389,14 +412,14 @@ export function parseArgs(argv) {
389
412
  screen: null, fixtures: false, replay: null, record: null, filter: null, sort: null, preset: null, maxRequests: 0, maxCredits: 0, db: null,
390
413
  mint: null, interval: '1h', listen: 20, verbose: false,
391
414
  width: null, height: null, html: null, export: null, theme: DEFAULT_THEME, selected: 0, seed: 7,
392
- noAi: false, title: null, plain: false, ansi: false, color: null, tz: null,
415
+ noAi: false, analystEngine: null, title: null, plain: false, ansi: false, color: null, tz: null,
393
416
  calm: false, intensity: null, fps: null, frame: 0, t: 1, event: null, ai: null, mood: null, activity: null, noBoot: false, fit: null, fontPx: null, asciiRain: false, noImages: false,
394
417
  scan: false, scanConfig: {},
395
418
  noRecorder: false,
396
419
  sound: false,
397
420
  trade: [], live: true,
398
421
  workspace: null, priceMode: null,
399
- spectateMcp: false,
422
+ spectateMcp: false, agentPaper: null,
400
423
  help: false, version: false, unknown: [],
401
424
  };
402
425
  for (let i = 0; i < argv.length; i++) {
@@ -485,6 +508,13 @@ export function parseArgs(argv) {
485
508
  }
486
509
  else if (k === '--no-ai')
487
510
  a.noAi = true;
511
+ else if (k === '--analyst-engine') {
512
+ const s = str();
513
+ const e = parseEngineChoice(s);
514
+ if (!e)
515
+ throw new Error(`unknown analyst engine ${s} (expected auto|claude|codex)`);
516
+ a.analystEngine = e;
517
+ }
488
518
  else if (k === '--calm')
489
519
  a.calm = true;
490
520
  else if (k === '--intensity') {
@@ -581,6 +611,13 @@ export function parseArgs(argv) {
581
611
  // task-1421: follow an agent driving over MCP (the running terminal moves with it; a read-only stream opens).
582
612
  else if (k === '--spectate-mcp')
583
613
  a.spectateMcp = true;
614
+ // task-1443: agent PAPER trading inside a mandate the user armed (validated against the plans journal at start).
615
+ else if (k === '--agent-paper') {
616
+ const s = str();
617
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(s))
618
+ throw new Error(`--agent-paper expects a mandate id (see \`dexterm mandate list\`), got ${JSON.stringify(s.slice(0, 20))}`);
619
+ a.agentPaper = s;
620
+ }
584
621
  else if (k === '--workspace') {
585
622
  const s = str();
586
623
  if (!/^[a-z][a-z0-9-]{0,39}(\/[a-z][a-z0-9-]{0,39})?$/.test(s))
@@ -618,6 +655,10 @@ export function parseArgs(argv) {
618
655
  throw new Error('--scan needs live data or a recording (drop --fixtures)');
619
656
  if (a.scan && a.screen)
620
657
  throw new Error('--scan is interactive (drop --screen)');
658
+ if (a.agentPaper && !a.spectateMcp)
659
+ throw new Error('--agent-paper needs --spectate-mcp (every agent paper trade must be visible in this terminal)');
660
+ if (a.agentPaper && a.db === ':memory:')
661
+ throw new Error('--agent-paper needs an on-disk --db (the agent books into the same paper wallet)');
621
662
  return a;
622
663
  }
623
664
  /**
@@ -1019,6 +1060,8 @@ export async function main(argv, io = process, proc = process) {
1019
1060
  });
1020
1061
  recorder.attach(feed.dl.store);
1021
1062
  ctx.recorder = recorder;
1063
+ // 0.1.5 dev history: PumpPortal / RugCheck creators of new pairs go into the creator index too.
1064
+ ctx.newpairs.onCreator = (mint, creator, source, createdAt) => { recorder.recordCreator(mint, creator, source, undefined, createdAt); };
1022
1065
  }
1023
1066
  if (args.verbose)
1024
1067
  io.stderr.write(`dexterm: rpc ${rpc.provider} (${rpc.basis}) · db ${dbPath} · recorder ${ctx.recorder ? 'on' : 'off'} · live trading ${args.live && !io.env.DEXTERM_OFFLINE ? 'available' : 'off'}\n`);
@@ -2639,6 +2682,50 @@ export function applyScanAnalystAction(state, raw) {
2639
2682
  const res = validateAnalystAction(raw, { ...allow, types: SCAN_ACTION_TYPES });
2640
2683
  return { ok: false, error: res.ok ? 'AI scan mode is observe-only' : res.error };
2641
2684
  }
2685
+ /**
2686
+ * The analyst engine for a run (0.1.5): `--analyst-engine`, then DEXTERM_ANALYST_ENGINE, then config
2687
+ * `analyst.engine`, then auto (claude when installed, else codex). Only the user picks it; models cannot.
2688
+ */
2689
+ export function analystEngineFor(flag, env, configured, has = (b) => analystCliOnPath(b, env)) {
2690
+ const choice = parseEngineChoice(flag) ?? parseEngineChoice(env.DEXTERM_ANALYST_ENGINE) ?? parseEngineChoice(configured) ?? 'auto';
2691
+ return resolveEngine(choice, has);
2692
+ }
2693
+ /** `/engine [auto|claude|codex]` in the chat box (0.1.5). Null when the input is not this command. */
2694
+ export function parseEngineCommand(q) {
2695
+ const m = /^\/engine(?:\s+(\S+))?\s*$/i.exec(q.trim());
2696
+ if (!m)
2697
+ return null;
2698
+ if (!m[1])
2699
+ return { show: true };
2700
+ const c = parseEngineChoice(m[1]);
2701
+ return c ? { choice: c } : { error: `unknown analyst engine "${m[1].slice(0, 16)}" (use /engine auto|claude|codex)` };
2702
+ }
2703
+ /**
2704
+ * Apply `/engine`: switch the CLI for NEW conversations this run (cached per-coin chats are dropped so the next
2705
+ * `a` starts on the chosen engine; a running turn finishes where it started). Returns the line to show.
2706
+ */
2707
+ export function engineCommand(state, cmd, env, has = (b) => analystCliOnPath(b, env)) {
2708
+ const installed = ['claude', 'codex'].filter((b) => has(b));
2709
+ const list = installed.length ? installed.join(' + ') : 'neither claude nor codex';
2710
+ if ('error' in cmd)
2711
+ return cmd.error;
2712
+ const cur = state.analystEngine ?? 'claude';
2713
+ if ('show' in cmd)
2714
+ return `analyst engine: ${cur} · installed: ${list} · /engine claude|codex|auto switches (config analyst.engine keeps it)`;
2715
+ const next = resolveEngine(cmd.choice, has);
2716
+ if (cmd.choice !== 'auto' && !has(next))
2717
+ return `${next} is not installed (installed: ${list}); ${next === 'codex' ? 'npm i -g @openai/codex && codex login' : 'install Claude Code, then run claude once to log in'}`;
2718
+ if (next !== cur) {
2719
+ for (const [k, sess] of state.analystByMint ?? []) {
2720
+ if (!sess.busy)
2721
+ state.analystByMint.delete(k);
2722
+ }
2723
+ if (state.analyst && !state.analyst.busy && state.analyst.engine !== next)
2724
+ state.analyst = null;
2725
+ }
2726
+ state.analystEngine = next;
2727
+ return `analyst engine: ${next} (your own ${next} CLI and login) for new conversations · set analyst.engine in config.json to keep it`;
2728
+ }
2642
2729
  /**
2643
2730
  * The ONE way a scan-driven analyst conversation is built (task-1324; the loop and the safety tests both
2644
2731
  * use it): the locked coin as the selected coin, `actionTypes: []` (observe-only at the validator, the
@@ -2652,7 +2739,7 @@ export function createScanSession(state, mint, symbol, hooks = {}, deps = {}) {
2652
2739
  const type = typeof raw.type === 'string' ? raw.type.replace(/[^a-z_]/gi, '').slice(0, 24) : '?';
2653
2740
  hooks.onBlocked?.(type, r.error ?? 'refused');
2654
2741
  };
2655
- return new AnalystSession({ skill: 'token-dossier', mint, symbol, screenState: ss, onAction, actionTypes: SCAN_ACTION_TYPES, scan: true }, hooks.onChange ?? (() => { }), deps);
2742
+ return new AnalystSession({ skill: 'token-dossier', mint, symbol, screenState: ss, onAction, actionTypes: SCAN_ACTION_TYPES, scan: true, ...(state.analystEngine === 'codex' ? { engine: 'codex' } : {}) }, hooks.onChange ?? (() => { }), deps);
2656
2743
  }
2657
2744
  /**
2658
2745
  * Resolve a mint to an openable seed from what the app already knows (screener view → new pairs →
@@ -3476,7 +3563,14 @@ export function terminalGuard(io, onFatal = () => { }, proc = process, hooks = {
3476
3563
  proc.exit(1);
3477
3564
  };
3478
3565
  const onExit = () => restore();
3479
- const onSignal = (sig) => { restore(); proc.exit(sig === 'SIGTERM' ? 143 : 129); };
3566
+ const onSignal = (sig) => {
3567
+ restore();
3568
+ try {
3569
+ hooks.onSignal?.();
3570
+ }
3571
+ catch { /* cleanup is best-effort; the exit still happens */ }
3572
+ proc.exit(sig === 'SIGTERM' ? 143 : 129);
3573
+ };
3480
3574
  const onTerm = () => onSignal('SIGTERM');
3481
3575
  const onHup = () => onSignal('SIGHUP');
3482
3576
  let installed = false;
@@ -3513,6 +3607,8 @@ export function fpsFor(args, intensity) {
3513
3607
  const SYNC_ON = '\x1b[?2026h';
3514
3608
  const SYNC_OFF = '\x1b[?2026l';
3515
3609
  export function interactive(state, io, depth, args, proc = process) {
3610
+ // 0.1.5: the analyst engine for new conversations (the user's own claude or codex CLI; never a shipped key).
3611
+ state.analystEngine ??= analystEngineFor(args.analystEngine, io.env, state.custom?.config.analyst?.engine);
3516
3612
  const out = io.stdout;
3517
3613
  const inp = io.stdin;
3518
3614
  const rnd = mulberry32(99);
@@ -3816,6 +3912,8 @@ export function interactive(state, io, depth, args, proc = process) {
3816
3912
  // task-1421: the MCP follow session (set below when `--spectate-mcp` is on). Declared here so the quit /
3817
3913
  // terminal-guard teardown closures can stop it.
3818
3914
  let followSession = null;
3915
+ // task-1443: `--agent-paper <mandate-id>` (with --spectate-mcp) lets that agent PAPER-trade inside the mandate.
3916
+ const agentPaper = args.spectateMcp && args.agentPaper ? new AgentPaperSwitch({ env: io.env, mandateId: args.agentPaper, db: args.db ?? defaultDbPath(io.env) }) : null;
3819
3917
  // Idle economy (task-1303, retuned for task-1319 AC3): between events the ambient rain redraws at
3820
3918
  // IDLE_FPS instead of the full frame cap, cutting idle CPU. A keypress opens a KEY_WAKE_S full-rate
3821
3919
  // window (`wakeUntil`) so input and transitions stay smooth; data landings paint at once on their own
@@ -3844,10 +3942,13 @@ export function interactive(state, io, depth, args, proc = process) {
3844
3942
  say(`trade moment failed: ${describeError(e)}`, 'warn');
3845
3943
  }
3846
3944
  };
3847
- const guard = terminalGuard(io, () => { done = true; prefetcher?.close(); if (timer)
3848
- clearInterval(timer); void followSession?.stop(); state.analyst?.cancel(); if (state.analystByMint)
3945
+ const teardown = () => { done = true; prefetcher?.close(); if (timer)
3946
+ clearInterval(timer); void followSession?.stop(); agentPaper?.stop(); state.analyst?.cancel(); if (state.analystByMint)
3849
3947
  for (const s of state.analystByMint.values())
3850
- s.cancel(); state.popups?.shutdown(); voice?.disable(); images?.close(); live?.close(); feed?.close(); }, proc, { beforeLeave: () => layer?.teardown() ?? '' });
3948
+ s.cancel(); state.popups?.shutdown(); voice?.disable(); images?.close(); live?.close(); feed?.close(); };
3949
+ // task-1443: SIGTERM/SIGHUP (a closed terminal window) runs the same teardown as a fatal error, so the agent-paper
3950
+ // rendezvous, the spectator rendezvous and the --record file never outlive the terminal that owns them.
3951
+ const guard = terminalGuard(io, teardown, proc, { beforeLeave: () => layer?.teardown() ?? '', onSignal: teardown });
3851
3952
  let lastTokenRefresh = 0;
3852
3953
  let lastWatchRefresh = 0;
3853
3954
  let lastPositionsRefresh = 0;
@@ -4043,9 +4144,13 @@ export function interactive(state, io, depth, args, proc = process) {
4043
4144
  },
4044
4145
  onStatus: (line) => { say(line, 'ai', 4); scheduleDraw(); },
4045
4146
  onEvent: () => scheduleDraw(),
4147
+ onControl: agentPaper ? (st) => agentPaper.control(st) : undefined,
4148
+ snapshot: () => followSnapshot(state),
4046
4149
  });
4047
4150
  void followSession.start();
4048
4151
  say('following agents over MCP · ctrl+p pause · ctrl+k stop · any key takes over', 'ai', 6);
4152
+ if (agentPaper)
4153
+ say(agentPaper.start(), 'ai', 8);
4049
4154
  }
4050
4155
  // Launch a real Claude analyst session when a key asked for one (applyAction cannot spawn). A
4051
4156
  // `question` means start it as a chat turn, not a paid structured skill run (task-1311, QA B3).
@@ -4113,7 +4218,7 @@ export function interactive(state, io, depth, args, proc = process) {
4113
4218
  maybeSpeakVoiceAnswer(session); // task-1282: speak a voice-initiated answer when it settles
4114
4219
  scheduleDraw();
4115
4220
  };
4116
- const session = new AnalystSession({ skill: p.skill, mint: p.mint, symbol: p.symbol, screenState: buildScreenState(state), model: p.model, onAction }, onChange);
4221
+ const session = new AnalystSession({ skill: p.skill, mint: p.mint, symbol: p.symbol, screenState: buildScreenState(state), model: p.model, onAction, ...(state.analystEngine === 'codex' ? { engine: 'codex' } : {}) }, onChange);
4117
4222
  if (p.question)
4118
4223
  session.ask(p.question, buildScreenState(state));
4119
4224
  else
@@ -5037,6 +5142,7 @@ export function interactive(state, io, depth, args, proc = process) {
5037
5142
  if (timer)
5038
5143
  clearInterval(timer);
5039
5144
  void followSession?.stop();
5145
+ agentPaper?.stop();
5040
5146
  guard.restore();
5041
5147
  guard.uninstall();
5042
5148
  cancelAllAnalysts();
@@ -5635,6 +5741,14 @@ export function interactive(state, io, depth, args, proc = process) {
5635
5741
  draw();
5636
5742
  continue;
5637
5743
  }
5744
+ // 0.1.5: `/engine [auto|claude|codex]` switches the analyst CLI for new conversations; never sent to a model.
5745
+ const en0 = q.startsWith('/') ? parseEngineCommand(q) : null;
5746
+ if (en0) {
5747
+ state.aiInput = '';
5748
+ say(engineCommand(state, en0, io.env), typeof en0 === 'object' && 'error' in en0 ? 'warn' : 'info', 6);
5749
+ draw();
5750
+ continue;
5751
+ }
5638
5752
  // task-1324: `/scan …` in the chat input is a command, never sent to claude.
5639
5753
  const sc0 = q.startsWith('/') ? parseScanCommand(q) : null;
5640
5754
  if (sc0) {