dexterm-cli 0.1.1 → 0.1.3

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 (155) hide show
  1. package/dist/packages/agents/src/bus.js +57 -6
  2. package/dist/packages/agents/src/executor.js +25 -0
  3. package/dist/packages/agents/src/follow.js +105 -0
  4. package/dist/packages/agents/src/gate.js +3 -0
  5. package/dist/packages/agents/src/identity.js +41 -2
  6. package/dist/packages/agents/src/index.js +5 -0
  7. package/dist/packages/agents/src/spectator-server.js +153 -0
  8. package/dist/packages/agents/src/spectator.js +212 -0
  9. package/dist/packages/agents/src/tiers.js +5 -0
  10. package/dist/packages/agents/src/tools.js +3 -0
  11. package/dist/packages/cli/assets/claude/analyst-system.md +6 -1
  12. package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +11 -3
  13. package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +13 -3
  14. package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +3 -3
  15. package/dist/packages/cli/src/agent-cmd.js +107 -10
  16. package/dist/packages/cli/src/agent-spectator.js +37 -0
  17. package/dist/packages/cli/src/alerts/adapters/desktop.js +40 -0
  18. package/dist/packages/cli/src/alerts/adapters/discord.js +36 -0
  19. package/dist/packages/cli/src/alerts/adapters/telegram.js +43 -0
  20. package/dist/packages/cli/src/alerts/adapters/toast.js +16 -0
  21. package/dist/packages/cli/src/alerts/creds.js +83 -0
  22. package/dist/packages/cli/src/alerts/format.js +66 -0
  23. package/dist/packages/cli/src/alerts/index.js +10 -0
  24. package/dist/packages/cli/src/alerts/notifier.js +53 -0
  25. package/dist/packages/cli/src/alerts/route.js +19 -0
  26. package/dist/packages/cli/src/alerts/types.js +1 -0
  27. package/dist/packages/cli/src/alerts-cmd.js +212 -0
  28. package/dist/packages/cli/src/app-help-gen.js +365 -0
  29. package/dist/packages/cli/src/backtest-cmd.js +62 -3
  30. package/dist/packages/cli/src/coin-target.js +2 -0
  31. package/dist/packages/cli/src/doctor.js +21 -6
  32. package/dist/packages/cli/src/export.js +235 -0
  33. package/dist/packages/cli/src/follow-cmd.js +107 -1
  34. package/dist/packages/cli/src/help-cmd.js +48 -0
  35. package/dist/packages/cli/src/live/index.js +66 -3
  36. package/dist/packages/cli/src/live/screens/token-live.js +24 -0
  37. package/dist/packages/cli/src/live/signals.js +109 -0
  38. package/dist/packages/cli/src/live/trade.js +24 -1
  39. package/dist/packages/cli/src/main.js +596 -49
  40. package/dist/packages/cli/src/mcp-cmd.js +321 -0
  41. package/dist/packages/cli/src/pnl-app.js +221 -0
  42. package/dist/packages/cli/src/pnl-card.js +615 -0
  43. package/dist/packages/cli/src/record-cmd.js +455 -0
  44. package/dist/packages/cli/src/rules.js +69 -2
  45. package/dist/packages/cli/src/screens/follows.js +91 -2
  46. package/dist/packages/cli/src/screens/overlay.js +3 -0
  47. package/dist/packages/cli/src/screens/positions.js +1 -1
  48. package/dist/packages/cli/src/screens/rules.js +1 -1
  49. package/dist/packages/cli/src/skill-eval-build.js +109 -0
  50. package/dist/packages/cli/src/spectate-cmd.js +104 -0
  51. package/dist/packages/cli/src/state.js +1 -1
  52. package/dist/packages/cli/src/update.js +294 -0
  53. package/dist/packages/cli/src/version.js +3 -0
  54. package/dist/packages/cli/src/voice-cmd.js +194 -0
  55. package/dist/packages/cli/src/voice-hud.js +1 -1
  56. package/dist/packages/cli/src/watch-cmd.js +190 -0
  57. package/dist/packages/custom/src/rules.js +3 -2
  58. package/dist/packages/custom/src/schema.js +24 -1
  59. package/dist/packages/data/src/analyst-actions.js +130 -3
  60. package/dist/packages/data/src/coin-target.js +82 -0
  61. package/dist/packages/data/src/fast-lane.js +16 -2
  62. package/dist/packages/data/src/index.js +4 -0
  63. package/dist/packages/data/src/intel/helius-history.js +12 -1
  64. package/dist/packages/data/src/intel/live-signals.js +185 -0
  65. package/dist/packages/data/src/larp/link-baseline.js +196 -0
  66. package/dist/packages/data/src/sources/dexscreener.js +14 -0
  67. package/dist/packages/data/src/sources/pumpportal.js +6 -1
  68. package/dist/packages/data/src/trade/copy/index.js +1 -1
  69. package/dist/packages/data/src/trade/copy/stats.js +36 -2
  70. package/dist/packages/data/src/trade/copy/tracker.js +3 -2
  71. package/dist/packages/mcp/assets/kb/app-help/app-help-command.md +35 -0
  72. package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +43 -0
  73. package/dist/packages/mcp/assets/kb/app-help/backtest.md +33 -0
  74. package/dist/packages/mcp/assets/kb/app-help/bubble-map.md +35 -0
  75. package/dist/packages/mcp/assets/kb/app-help/calm-mode.md +31 -0
  76. package/dist/packages/mcp/assets/kb/app-help/change-voice-speed.md +31 -0
  77. package/dist/packages/mcp/assets/kb/app-help/change-voice.md +30 -0
  78. package/dist/packages/mcp/assets/kb/app-help/chart-popup-selftest.md +32 -0
  79. package/dist/packages/mcp/assets/kb/app-help/coin-images.md +29 -0
  80. package/dist/packages/mcp/assets/kb/app-help/configure-rpc-key.md +28 -0
  81. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent-registry.md +35 -0
  82. package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +39 -0
  83. package/dist/packages/mcp/assets/kb/app-help/custom-keymap.md +32 -0
  84. package/dist/packages/mcp/assets/kb/app-help/edit-kb-or-skill.md +36 -0
  85. package/dist/packages/mcp/assets/kb/app-help/edit-rules.md +34 -0
  86. package/dist/packages/mcp/assets/kb/app-help/export-a-view.md +36 -0
  87. package/dist/packages/mcp/assets/kb/app-help/follow-a-wallet.md +33 -0
  88. package/dist/packages/mcp/assets/kb/app-help/getting-started.md +37 -0
  89. package/dist/packages/mcp/assets/kb/app-help/kol-lists.md +34 -0
  90. package/dist/packages/mcp/assets/kb/app-help/larp-check.md +36 -0
  91. package/dist/packages/mcp/assets/kb/app-help/link-a-wallet.md +41 -0
  92. package/dist/packages/mcp/assets/kb/app-help/login-with-x.md +33 -0
  93. package/dist/packages/mcp/assets/kb/app-help/narrative-board.md +32 -0
  94. package/dist/packages/mcp/assets/kb/app-help/open-a-coin.md +43 -0
  95. package/dist/packages/mcp/assets/kb/app-help/open-chart-popup.md +33 -0
  96. package/dist/packages/mcp/assets/kb/app-help/order-plans.md +33 -0
  97. package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +36 -0
  98. package/dist/packages/mcp/assets/kb/app-help/price-or-marketcap.md +38 -0
  99. package/dist/packages/mcp/assets/kb/app-help/record-headless.md +34 -0
  100. package/dist/packages/mcp/assets/kb/app-help/recorder-history.md +39 -0
  101. package/dist/packages/mcp/assets/kb/app-help/rug-check.md +39 -0
  102. package/dist/packages/mcp/assets/kb/app-help/run-doctor.md +32 -0
  103. package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +37 -0
  104. package/dist/packages/mcp/assets/kb/app-help/screen-analyst.md +181 -0
  105. package/dist/packages/mcp/assets/kb/app-help/screen-kol.md +190 -0
  106. package/dist/packages/mcp/assets/kb/app-help/screen-newpairs.md +181 -0
  107. package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +180 -0
  108. package/dist/packages/mcp/assets/kb/app-help/screen-screener.md +199 -0
  109. package/dist/packages/mcp/assets/kb/app-help/screen-token.md +240 -0
  110. package/dist/packages/mcp/assets/kb/app-help/screen-trending.md +192 -0
  111. package/dist/packages/mcp/assets/kb/app-help/screen-watchlist.md +181 -0
  112. package/dist/packages/mcp/assets/kb/app-help/set-a-filter.md +36 -0
  113. package/dist/packages/mcp/assets/kb/app-help/setup-neural-voice.md +36 -0
  114. package/dist/packages/mcp/assets/kb/app-help/share-packs.md +34 -0
  115. package/dist/packages/mcp/assets/kb/app-help/sort-the-screener.md +34 -0
  116. package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +36 -0
  117. package/dist/packages/mcp/assets/kb/app-help/standing-mandates.md +33 -0
  118. package/dist/packages/mcp/assets/kb/app-help/switch-theme.md +42 -0
  119. package/dist/packages/mcp/assets/kb/app-help/turn-on-alerts.md +37 -0
  120. package/dist/packages/mcp/assets/kb/app-help/update-dexterm.md +30 -0
  121. package/dist/packages/mcp/assets/kb/app-help/use-presets.md +42 -0
  122. package/dist/packages/mcp/assets/kb/app-help/use-voice-mode.md +36 -0
  123. package/dist/packages/mcp/assets/kb/app-help/watch-a-coin.md +34 -0
  124. package/dist/packages/mcp/assets/kb/app-help/watch-headless.md +35 -0
  125. package/dist/packages/mcp/assets/kb/app-help/what-dexterm-shows-agents.md +31 -0
  126. package/dist/packages/mcp/assets/kb/app-help/workspaces.md +38 -0
  127. package/dist/packages/mcp/src/app-help.js +135 -0
  128. package/dist/packages/mcp/src/index.js +3 -0
  129. package/dist/packages/mcp/src/server.js +179 -5
  130. package/dist/packages/mcp/src/spectator-sink.js +72 -0
  131. package/dist/packages/packs/src/index.js +3 -0
  132. package/dist/packages/packs/src/skill-backtest.js +300 -0
  133. package/dist/packages/packs/src/skill-care.js +206 -0
  134. package/dist/packages/popup/src/pages/spectator.css +45 -0
  135. package/dist/packages/popup/src/pages/spectator.html +25 -0
  136. package/dist/packages/popup/src/pages/spectator.js +74 -0
  137. package/dist/packages/popup/src/selftest.js +19 -0
  138. package/dist/packages/popup/src/server.js +7 -1
  139. package/dist/packages/tui/src/index.js +3 -0
  140. package/dist/packages/tui/src/keymap.js +12 -0
  141. package/dist/packages/tui/src/raster-font.js +195 -0
  142. package/dist/packages/tui/src/raster.js +213 -0
  143. package/dist/packages/voice/src/controller.js +57 -4
  144. package/dist/packages/voice/src/doctor.js +96 -11
  145. package/dist/packages/voice/src/download.js +184 -0
  146. package/dist/packages/voice/src/engines.js +55 -8
  147. package/dist/packages/voice/src/index.js +9 -3
  148. package/dist/packages/voice/src/speakable.js +111 -0
  149. package/dist/packages/voice/src/stt/whisper.js +14 -4
  150. package/dist/packages/voice/src/tts/espeak.js +131 -0
  151. package/dist/packages/voice/src/tts/piper-setup.js +229 -0
  152. package/dist/packages/voice/src/tts/piper.js +449 -0
  153. package/dist/packages/voice/src/tts/say.js +72 -35
  154. package/dist/packages/voice/src/tts/voices.js +209 -0
  155. package/package.json +5 -9
@@ -0,0 +1,212 @@
1
+ // @dexterm/agents — the spectator event model, redaction, hub and save/replay (task-1406, "watch the
2
+ // agent cook"). One normalized record per observable agent action is emitted by the tool-call path (the
3
+ // bus's onCall, and the MCP tools/call path via a file sink) into a SpectatorHub. The hub keeps the raw
4
+ // events in a ring buffer and fans them out; each consumer redacts for its audience:
5
+ // * follow mode (local, the owner's own terminal) sees the RAW events;
6
+ // * the spectator STREAM and the SAVED file redact wallet addresses, keys and sensitive-named fields.
7
+ // Nothing here can arm, sign, confirm or raise a cap — a spectator event only describes a call the gate
8
+ // already allowed; there is no control surface in this module (see spectator-server.ts: read-only).
9
+ import { appendFileSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
10
+ import { dirname } from 'node:path';
11
+ export const SPECTATOR_PROTOCOL = 'dexterm-spectator/1';
12
+ export const SPECTATOR_SCHEMA = 1;
13
+ // ---------------------------------------------------------------------------------------------------
14
+ // Redaction. Addresses are truncated (still watchable); keys are removed entirely; sensitive-named
15
+ // fields are blanked. A full wallet address never appears verbatim in a redacted string.
16
+ /** base58 alphabet (Bitcoin / Solana): no 0 O I l. */
17
+ const BASE58 = '[1-9A-HJ-NP-Za-km-z]';
18
+ /** A base58 run of 32–44 chars is a Solana pubkey (wallet or mint); ≥45 is key material. */
19
+ const ADDR_RE = new RegExp(`${BASE58}{32,44}`, 'g');
20
+ const LONG_B58_RE = new RegExp(`${BASE58}{45,}`, 'g');
21
+ /** A hex run of ≥64 chars is a 32-byte key / signature. */
22
+ const HEX_KEY_RE = /\b[0-9a-fA-F]{64,}\b/g;
23
+ export const SENSITIVE_KEY_RE = /(secret|private|privkey|mnemonic|seed|passphrase|password|signature|sig|apikey|api[_-]?key|keypair|token)/i;
24
+ /** Mask one address the way the product's shortAddr does (head4…tail4). */
25
+ export function maskAddress(s) {
26
+ return s.length <= 10 ? s : `${s.slice(0, 4)}…${s.slice(-4)}`;
27
+ }
28
+ /** Redact every address / key in a string. Keys (long runs) first so a 45-char run is not truncated as a 44. */
29
+ export function redactString(s) {
30
+ return s
31
+ .replace(LONG_B58_RE, '[key]')
32
+ .replace(HEX_KEY_RE, '[key]')
33
+ .replace(ADDR_RE, (m) => maskAddress(m));
34
+ }
35
+ /** Deep-redact a JSON value: sensitive-named keys → "[redacted]", strings scrubbed of addresses/keys. */
36
+ export function redactDeep(value, depth = 0) {
37
+ if (depth > 8)
38
+ return value;
39
+ if (typeof value === 'string')
40
+ return redactString(value);
41
+ if (Array.isArray(value))
42
+ return value.map((v) => redactDeep(v, depth + 1));
43
+ if (value && typeof value === 'object') {
44
+ const out = {};
45
+ for (const [k, v] of Object.entries(value)) {
46
+ out[k] = SENSITIVE_KEY_RE.test(k) ? '[redacted]' : redactDeep(v, depth + 1);
47
+ }
48
+ return out;
49
+ }
50
+ return value;
51
+ }
52
+ /** A redacted COPY of an event, safe for a shared stream or a saved file. The original is never mutated.
53
+ * EVERY string field is scrubbed — not just the summary — because the producer (bus.ts onCall) copies
54
+ * the agent-supplied tool name straight into `tool`/`refusal` on a refused call, so an untrusted agent
55
+ * can put a wallet address or key there; `agent` and the nav strings are redacted too so no path can
56
+ * leak a full address / key verbatim into a shared stream or a saved file. */
57
+ export function redactSpectatorEvent(ev) {
58
+ const out = { ...ev, redacted: true };
59
+ if (ev.sensitive) {
60
+ out.summary = '[sensitive]';
61
+ }
62
+ else if (typeof ev.summary === 'string') {
63
+ out.summary = redactString(ev.summary);
64
+ }
65
+ // Scrub the remaining string fields. Addresses are masked (still watchable), key material → "[key]".
66
+ if (typeof ev.tool === 'string')
67
+ out.tool = redactString(ev.tool);
68
+ if (typeof ev.refusal === 'string')
69
+ out.refusal = redactString(ev.refusal);
70
+ if (typeof ev.agent === 'string')
71
+ out.agent = redactString(ev.agent);
72
+ if (ev.nav) {
73
+ out.nav = { ...ev.nav };
74
+ if (out.nav.screen)
75
+ out.nav.screen = redactString(out.nav.screen);
76
+ if (out.nav.mint)
77
+ out.nav.mint = maskAddress(out.nav.mint);
78
+ if (out.nav.feed)
79
+ out.nav.feed = redactString(out.nav.feed);
80
+ if (out.nav.filter)
81
+ out.nav.filter = redactString(out.nav.filter);
82
+ if (out.nav.highlight)
83
+ out.nav.highlight = maskAddress(out.nav.highlight);
84
+ if (out.nav.side)
85
+ out.nav.side = redactString(out.nav.side);
86
+ }
87
+ return out;
88
+ }
89
+ // ---------------------------------------------------------------------------------------------------
90
+ // Freshness: the oldest/largest `ageSec` anywhere in a tool result, so the activity panel can show a
91
+ // "data age" like the terminal does. Deterministic, bounded depth; returns null when nothing is dated.
92
+ export function maxDataAgeSec(result, depth = 0) {
93
+ if (depth > 6 || !result || typeof result !== 'object')
94
+ return null;
95
+ let max = null;
96
+ const take = (n) => { if (typeof n === 'number' && Number.isFinite(n) && n >= 0)
97
+ max = max === null ? n : Math.max(max, n); };
98
+ for (const [k, v] of Object.entries(result)) {
99
+ if (k === 'ageSec')
100
+ take(v);
101
+ else {
102
+ const inner = maxDataAgeSec(v, depth + 1);
103
+ if (inner !== null)
104
+ take(inner);
105
+ }
106
+ }
107
+ return max;
108
+ }
109
+ export class SpectatorHub {
110
+ listeners = new Set();
111
+ ring = [];
112
+ now;
113
+ cap;
114
+ seq = 0;
115
+ constructor(opts = {}) {
116
+ this.now = opts.now ?? (() => Date.now());
117
+ this.cap = Math.max(1, opts.cap ?? 500);
118
+ }
119
+ /** Emit one event. Assigns v/seq/t and defaults ok:true. Returns the stored (raw) event. */
120
+ emit(input) {
121
+ const ev = { v: SPECTATOR_SCHEMA, seq: ++this.seq, t: input.t ?? this.now(), ok: input.ok ?? true, ...stripUndef(input) };
122
+ ev.v = SPECTATOR_SCHEMA;
123
+ ev.seq = this.seq; // keep after spread in case input carried stray keys
124
+ this.ring.push(ev);
125
+ if (this.ring.length > this.cap)
126
+ this.ring.splice(0, this.ring.length - this.cap);
127
+ for (const l of this.listeners) {
128
+ try {
129
+ l(ev);
130
+ }
131
+ catch { /* one bad listener never stops the fan-out */ }
132
+ }
133
+ return ev;
134
+ }
135
+ /** Subscribe; returns an unsubscribe fn. */
136
+ on(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
137
+ /** The most recent events, oldest-first (for replay-on-connect / the activity panel). */
138
+ recent(limit = this.cap) { return this.ring.slice(Math.max(0, this.ring.length - limit)); }
139
+ clear() { this.ring.length = 0; }
140
+ get size() { return this.ring.length; }
141
+ }
142
+ function stripUndef(o) {
143
+ const out = {};
144
+ for (const [k, v] of Object.entries(o))
145
+ if (v !== undefined)
146
+ out[k] = v;
147
+ return out;
148
+ }
149
+ /** Attach to a hub and append each event to a sink (default: a file, 0600). Redacts unless redact:false. */
150
+ export class SpectatorRecorder {
151
+ off = null;
152
+ redact;
153
+ write;
154
+ count = 0;
155
+ constructor(opts) {
156
+ this.redact = opts.redact ?? true;
157
+ const now = opts.now ?? (() => Date.now());
158
+ if (opts.write) {
159
+ this.write = opts.write;
160
+ }
161
+ else if (opts.path) {
162
+ const p = opts.path;
163
+ mkdirSync(dirname(p), { recursive: true });
164
+ writeFileSync(p, '', { mode: 0o600 });
165
+ this.write = (line) => appendFileSync(p, line + '\n', { mode: 0o600 });
166
+ }
167
+ else {
168
+ throw new Error('SpectatorRecorder needs a path or a write sink');
169
+ }
170
+ const meta = { protocol: SPECTATOR_PROTOCOL, schema: SPECTATOR_SCHEMA, savedAt: now(), redacted: this.redact, note: opts.note };
171
+ this.write(JSON.stringify({ meta }));
172
+ }
173
+ /** Record one event (redacting a copy when redact is on). */
174
+ record(ev) { this.write(JSON.stringify(this.redact ? redactSpectatorEvent(ev) : ev)); this.count++; }
175
+ /** Start recording every event a hub emits from now on. */
176
+ attach(hub) { this.off = hub.on((ev) => this.record(ev)); return this; }
177
+ stop() { this.off?.(); this.off = null; return this.count; }
178
+ get written() { return this.count; }
179
+ }
180
+ /** Parse a JSONL recording (header line + events). Tolerant: bad lines are skipped. */
181
+ export function parseSpectatorRecording(text) {
182
+ const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
183
+ let meta = null;
184
+ const events = [];
185
+ for (const line of lines) {
186
+ let obj;
187
+ try {
188
+ obj = JSON.parse(line);
189
+ }
190
+ catch {
191
+ continue;
192
+ }
193
+ if (!meta && obj.meta && typeof obj.meta === 'object') {
194
+ meta = obj.meta;
195
+ continue;
196
+ }
197
+ if (typeof obj.seq === 'number' && typeof obj.phase === 'string')
198
+ events.push(obj);
199
+ }
200
+ events.sort((a, b) => a.seq - b.seq);
201
+ return { meta: meta ?? { protocol: SPECTATOR_PROTOCOL, schema: SPECTATOR_SCHEMA, savedAt: 0, redacted: false }, events };
202
+ }
203
+ /** Load a recording from a file. */
204
+ export function loadSpectatorRecording(path) {
205
+ return parseSpectatorRecording(readFileSync(path, 'utf8'));
206
+ }
207
+ /** Replay a recording's events into a handler (a FollowController.apply, a hub.emit, or a sink), in order. */
208
+ export function replaySpectator(recording, handle) {
209
+ for (const ev of recording.events)
210
+ handle(ev);
211
+ return recording.events.length;
212
+ }
@@ -42,6 +42,11 @@ export const TOOL_TIERS = Object.freeze({
42
42
  get_candles: 'observe',
43
43
  rugcheck: 'observe',
44
44
  get_dev_history: 'observe',
45
+ // deterministic analysis packs (task-1356): read-only, no model, no token cost. Exposed to external
46
+ // agents in task-1405 so a bring-your-own-agent gets the same code-stage analysis the built-in analyst does.
47
+ analyze_token: 'observe',
48
+ narrative_board: 'observe',
49
+ rug_backtest: 'observe',
45
50
  kb_lookup: 'observe',
46
51
  kb_search: 'observe',
47
52
  case_lookup: 'observe',
@@ -26,6 +26,9 @@ const TOOLS_DEF = [
26
26
  { name: 'get_candles', tier: 'observe', description: 'Candles plus computed summaries (range, drawdown, VWAP, change%).', inputSchema: { type: 'object', properties: { mint: MINT, interval: { type: 'string', enum: ['1m', '5m', '15m', '1h', '4h', '1d'] }, limit: { type: 'integer', minimum: 1, maximum: 300 } }, required: ['mint'], additionalProperties: false } },
27
27
  { name: 'rugcheck', tier: 'observe', description: 'RugCheck-style summary: risks[] (firewalled), normalised score, lpLockedPct, tokenProgram.', inputSchema: { type: 'object', properties: { mint: MINT }, required: ['mint'], additionalProperties: false } },
28
28
  { name: 'get_dev_history', tier: 'observe', description: 'Creator address and prior-launch forensics. Tier-1 fields need a key and are null on the keyless tier.', inputSchema: { type: 'object', properties: { mint: MINT }, required: ['mint'], additionalProperties: false } },
29
+ { name: 'analyze_token', tier: 'observe', description: 'Deterministic token-dossier analysis pack: numeric features, named rule flags, a WATCH/SPECULATIVE/AVOID verdict, a p(rug) heuristic and data ages. No model, no token cost; a reasoning stage may only tighten the verdict, never loosen it.', inputSchema: { type: 'object', properties: { mint: MINT, symbol: SYMBOL, interval: { type: 'string', enum: ['1m', '5m', '15m', '1h', '4h', '1d'] } }, additionalProperties: false } },
30
+ { name: 'narrative_board', tier: 'observe', description: 'The narrative board over recorded + trending tokens: per-narrative volume, new launches, runners and a "what is working" score. No model; returns a note when there is no recorded history yet.', inputSchema: { type: 'object', properties: { window: { type: 'string', enum: ['1h', '24h'] }, limit: { type: 'integer', minimum: 1, maximum: 50 }, summary: { type: 'boolean' } }, additionalProperties: false } },
31
+ { name: 'rug_backtest', tier: 'observe', description: 'Backtest the rug model or recorded pack verdicts against RECORDED outcomes: honest precision/recall, Brier, AUC and calibration. Leakage-free by construction; returns a note when there is not enough history. Read-only.', inputSchema: { type: 'object', properties: { kind: { type: 'string', enum: ['heuristic', 'pack_verdicts'] }, horizon_sec: { type: 'integer', enum: [3600, 7200, 21600, 86400, 259200] }, positive_labels: { type: 'array', items: { type: 'string', enum: ['rug', 'dead', 'fade', 'flat', 'runner'] }, maxItems: 5 } }, additionalProperties: false } },
29
32
  { name: 'kb_lookup', tier: 'observe', description: 'One knowledge-base entry by id (mechanics / patterns / glossary). KB never holds facts about a live token.', inputSchema: { type: 'object', properties: { id: { type: 'string', maxLength: 64 } }, required: ['id'], additionalProperties: false } },
30
33
  { name: 'kb_search', tier: 'observe', description: 'Search the knowledge base; returns ids, titles and an excerpt.', inputSchema: { type: 'object', properties: { query: { type: 'string', maxLength: 200 }, limit: { type: 'integer', minimum: 1, maximum: 10 } }, required: ['query'], additionalProperties: false } },
31
34
  { name: 'case_lookup', tier: 'observe', description: 'Historical case files (verified on-chain references + outcome). By id, or list all with no id.', inputSchema: { type: 'object', properties: { id: { type: 'string', maxLength: 64 } }, additionalProperties: false } },
@@ -51,9 +51,14 @@ Write long form (short sections or bullets, a table if it helps, at most ~25 lin
51
51
  - When levels or risks come from young or missing data, lower conf rather than inventing precision.
52
52
  - A symbol can mean several tokens. Use the SELECTED/HOVERED coin from the CONTEXT block for "this coin" / "it" / "the highlighted one" without asking. Only ask the user to choose when the question genuinely names an ambiguous symbol and nothing in context resolves it.
53
53
 
54
+ ## Helping the user use the app ("how do I …")
55
+ - For ANY question about how dexterm itself works — "how do I X", "what key does Y", "what does this screen show", "where is Z" — call `app_help` FIRST (it searches a knowledge base generated from the app's own keymap, help overlay, MCP tools and CLI). Answer with the EXACT key or command from the result (its `keys`, `commands`, `flags` fields). Never invent or guess a key, flag or command. If nothing matches, say plainly "I don't see that in the app" rather than making one up.
56
+ - Call `app_state` (read-only) to fit the answer to what the user currently sees: the screen, selected coin, theme, price mode, voice, whether scan/calm are on, and which alert channels are configured (yes/no). It never exposes any secret.
57
+
54
58
  ## You cannot trade, only analyse and navigate
55
59
  - You cannot sign, send, or confirm a transaction. Trading in dexterm is PAPER only and every ticket needs the user's own confirmation. If asked to buy/sell, open the PAPER ticket with `open_ticket` (pre-filled, side set) and tell the user in a few words to confirm it themselves. Never claim you executed a trade.
56
- - You may drive non-financial navigation with the UI-action tools to help the user see what you mean: `open_token` (put a coin on their screen), `show_in_screener` (filter/sort/highlight), `add_to_watchlist`, `switch_feed`, `open_ticket` (opens, never confirms). These act only on tokens already on screen (see the CONTEXT block) and the app shows "analyst did X".
60
+ - You may drive non-financial navigation with the UI-action tools to help the user see what you mean: `open_token` (put a coin on their screen), `show_in_screener` (filter/sort/highlight/preset), `add_to_watchlist`, `switch_feed`, `switch_screen`, `switch_workspace`, `chart_popup`, `toggle_calm`, `set_scan`, `export_view`, `open_ticket` (opens, never confirms). These apply immediately and the app shows "analyst did X"; they act only on screens/tokens/presets already available (see the CONTEXT block).
61
+ - A few settings are PERSISTED and need the user's go-ahead: `set_theme`, `set_price_mode`, `set_intensity` do NOT change anything by themselves — they put a one-line proposal on screen that the user accepts with a keypress (and `ctrl+z` undoes). Nothing you can call touches wallets, keys, credentials, alert channels, signing, caps, mandates, network/RPC settings, the VOICE speed or name (only the keyboard — `<`/`>` and the voice picker — sets that), or installing/updating software; those are not actions you have.
57
62
 
58
63
  ## Plain-language filtering (turn a request into a screener filter)
59
64
  When the user describes the coins they want in words ("show me coins under 5M mcap with over 100k liquidity that are up on the day and not rugs"), translate it into dexterm's screener filter grammar and apply it with `show_in_screener` (the `filter` field). The app re-parses your string with the real grammar and rejects anything malformed, so use ONLY the fields and syntax below — never invent a field name.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: larp-check
3
3
  description: Vet the website and socials behind a Solana token to judge whether there is a real product or it is a LARP (claims a product that does not exist). Use when the user asks "is this real", "larp check", "is the product real", "check the website/socials".
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  argument-hint: <mint or symbol>
6
6
  kind: skill
7
7
  output_schema: schemas/larp.schema.json
@@ -30,7 +30,7 @@ You are judging one thing: **does a real product exist behind this coin, or is t
30
30
  2. `get_token` with `{mint}` for a base58 address (32–44 chars), else `{symbol}`.
31
31
  - If the result has `candidates`: stop. Verdict `AMBIGUOUS`, `mint` = "", list the candidates, ask the user to pick a mint. Analyse none of them.
32
32
  - If the result has `error`: stop with `UNVERIFIABLE` and say why.
33
- 3. `get_links` `{mint}` — the website and socials the coin advertises. Note any `mismatch`.
33
+ 3. `get_links` `{mint}` — the website and socials the coin advertises. Note any `mismatch` and read `baseline` (below).
34
34
  4. `fetch_page` `{url, symbol, name}` on the primary `website` (pass the token symbol and name so the mention check runs). Read `signals` and the firewalled `excerpt`.
35
35
  - Follow at most two more `fetch_page` calls: a `docs`/`whitepaper` link and an in-site app/demo link if the page advertises one. Do not crawl.
36
36
  5. `check_domain` `{url}` on the primary website.
@@ -45,6 +45,14 @@ Do not call X/Twitter or Telegram through `fetch_page` expecting profile data: t
45
45
  - **LARP signals:** a near-empty or default/parked/site-builder placeholder page; the site never mentions the token symbol or name; broken/unreachable advertised links; a domain registered the same day as the token behind privacy; a GitHub that is empty, archived, a fork, or has no commits since launch; claims of an app with no reachable app; advertised links that mismatch across sources.
46
46
  - **Drainer/scam signals:** `drainerFlags` set, EVM wallet calls on a Solana token, wallet-connect as the only content. These make it worse than a LARP — flag `wallet_drainer_risk`.
47
47
 
48
+ ## The links-only baseline (backtested, task-1401)
49
+
50
+ `get_links.baseline` grades the three primary links with a rubric tuned on recorded history (`grade`, `signals`, and the held-out rate in `measured`). It is a weak prior, not evidence about the product:
51
+ - On held-out coins its `LARP` grade was rare (14 of 328) and not reliably better than average; its `EARLY_BUT_REAL` coins went bad less often than average (10 % against 19 %). Never return `LARP` from the baseline alone: a `LARP` verdict needs a fetched-page, domain or GitHub fact that contradicts a claim. If nothing could be fetched, the verdict is `UNVERIFIABLE`.
52
+ - The link signals that went with bad outcomes in the backtest: an X link to a single post or a community instead of an account, no X at all, no Telegram. A social or launchpad page posted as the website did not, so fetch it before reading anything into it.
53
+ - No links at all is `UNVERIFIABLE`, and say plainly it is the riskier case: coins with no links went bad more often than average in the backtest.
54
+ - When the verdict rests mostly on links (pages blocked or thin), keep `confidence` at 0.6 or lower.
55
+
48
56
  ## Grounding rules (hard)
49
57
 
50
58
  - Every claim, number, date, boolean or label you state comes from a tool result in this session. Put each in `facts[]` with the `tool`, the `field` path, and the `value` verbatim. If you cannot point at a tool field, do not state it.
@@ -64,4 +72,4 @@ Do not call X/Twitter or Telegram through `fetch_page` expecting profile data: t
64
72
 
65
73
  ## Output
66
74
 
67
- Return only the JSON object required by the schema (`skill: "larp-check"`, `skill_version: "0.1.0"`). Fill `claims[]`, `facts[]` and `missing_data[]` completely. `summary` is two to four short lines for a 40-column panel: the verdict in plain words, the one or two strongest pieces of evidence (each with its source), and what was missing only if it matters. Do not repeat the confidence number (the app renders it), and add no disclaimer or boilerplate line.
75
+ Return only the JSON object required by the schema (`skill: "larp-check"`, `skill_version: "0.2.0"`). Fill `claims[]`, `facts[]` and `missing_data[]` completely. `summary` is two to four short lines for a 40-column panel: the verdict in plain words, the one or two strongest pieces of evidence (each with its source), and what was missing only if it matters. Do not repeat the confidence number (the app renders it), and add no disclaimer or boilerplate line.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: rug-check
3
3
  description: Rug and honeypot forensics on one token - authorities, token program, LP, concentration, creator history, tape - with a rug probability. Use for "is this a rug", "safe?", "check it".
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  argument-hint: <mint or symbol>
6
6
  kind: skill
7
7
  output_schema: schemas/verdict.schema.json
@@ -32,7 +32,17 @@ Authorities → token program and extensions → LP → concentration and cluste
32
32
  - Freeze authority enabled, mint authority enabled, or a Token-2022 program with a dangerous extension named in `security.risks[]`: verdict `AVOID`, `rug_probability ≥ 0.8`. The verdict is settled.
33
33
  - A tape with almost no sells on a token hours old and many buyers (`tape.sells` under 5 % of `tape.buys` with `count` above 50) is a honeypot signal even when the freeze field is null: flag `sells_suppressed`.
34
34
  - LP below 100 % locked on a Raydium/Meteora pair: flag `lp_unlocked`; combined with `concentration.top10PctExLp` above 50 % it is `AVOID`.
35
- - Dev above 10 %, or a concentration/creator red flag: `SPECULATIVE` at best; raise `rug_probability` accordingly.
35
+ - Liquidity under $5,000 (`market.pair.liquidityUsd`): `SPECULATIVE` at best. This was the strongest signal in the backtest (task-1401, train split: 80 % of such coins went rug or dead within 72 h, against 40 % overall).
36
+ - Dev above 10 %, organic score below 40 and top-10 above 30 % did not go with a worse 72 h outcome in the backtest. Report them in `flags` and lower `confidence`, but on their own they do not make a coin `SPECULATIVE`. A serial-rugger creator still does (the backtest has no creator history, so that rule is untested).
37
+
38
+ ## Anchoring `rug_probability` (backtest, task-1401)
39
+
40
+ Start from the train-split 72 h bad-outcome rate of the rule that decided the verdict, then move it with the tape and creator facts you can cite:
41
+ - liquidity under $5,000: anchor 0.8.
42
+ - no disqualifier and liquidity at or above $5,000: anchor 0.27.
43
+ - the authority disqualifiers keep `AVOID` and their floor. The backtest cannot score them: its labels are price outcomes, which miss honeypots and frozen holders, and the coins with an authority still enabled were almost all older tokens off pump.fun (median age 259 days).
44
+
45
+ On the later, held-out coins both anchored rates ran lower (55 % and 14 %) because fewer coins went bad overall: the ranking held, the level did not. These rates come from one machine's recorder over a few days and drift with the market; never round up toward certainty.
36
46
 
37
47
  ## Grounding rules (hard)
38
48
 
@@ -57,7 +67,7 @@ Authorities → token program and extensions → LP → concentration and cluste
57
67
 
58
68
  ## Output
59
69
 
60
- Only the JSON object required by the schema (`skill: "rug-check"`, `skill_version: "0.2.0"`, include `rug_probability`). Fill every structured field completely (`bias`, `key_levels`, `invalidation`, `size_hint`, `facts`, `missing_data`). Brevity applies to the prose only:
70
+ Only the JSON object required by the schema (`skill: "rug-check"`, `skill_version: "0.3.0"`, include `rug_probability`). Fill every structured field completely (`bias`, `key_levels`, `invalidation`, `size_hint`, `facts`, `missing_data`). Brevity applies to the prose only:
61
71
 
62
72
  - `summary` is two to four short lines for a 40-column panel: line 1 answers "is it a rug" in plain words (the disqualifier if there is one, otherwise the main risk or "no disqualifier"), line 2 is the one or two strongest facts with their source fields. Add a short `missing:` line only when a gap changes the call.
63
73
  - Do not repeat the verdict, rug probability, bias, key levels, invalidation or size in `summary`. The app renders those fields right beside it. Do not restate numbers the user already sees on the screener row.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: token-dossier
3
3
  description: Full read-only dossier on one Solana token (market, holders, creator, risk, tape) with a WATCH/SPECULATIVE/AVOID verdict. Use when the user asks "what is this", "dossier", "look at $X".
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  argument-hint: <mint or symbol>
6
6
  kind: skill
7
7
  output_schema: schemas/verdict.schema.json
@@ -39,7 +39,7 @@ Read `availability` first: `present` lists the sections you have, `missing` the
39
39
  Judge with what you have. Missing sections lower `confidence`; they do not block a verdict.
40
40
 
41
41
  - `AVOID` when any disqualifier is present: freeze authority enabled, mint authority enabled, Token-2022 honeypot extension, LP locked below 100 % on a non-pump pair with top-10 ex-LP above 50 %, or wash tape (`tape.top2WalletVolumeSharePct` above 60 with `buyUsd ≈ sellUsd`).
42
- - `SPECULATIVE` when there is no disqualifier but a material risk (dev above 10 %, serial creator, organic score below 40, top-10 ex-LP above 50 %, or a thin/young market).
42
+ - `SPECULATIVE` when there is no disqualifier but a material risk: liquidity under $5,000 (the strongest signal in the task-1401 backtest), a serial creator, or top-10 ex-LP above 50 %. Dev above 10 % and organic score below 40 did not go with a worse 72 h outcome in that backtest: name them as risks and lower `confidence`, but they alone do not make a coin `SPECULATIVE`.
43
43
  - `WATCH` otherwise.
44
44
  - `INSUFFICIENT_DATA` **only** when `availability.canVerdict` is false — i.e. no price and no liquidity from any source. A null creator history, missing holders, no candles or no tape is NOT insufficient data on its own: produce a verdict from the rest and note what was missing.
45
45
  - `missing_data`: copy every entry of `availability.missing` and `availability.knownUnavailable` here. The keyless creator-history Tier-1 gap belongs here every time, not in the verdict.
@@ -56,7 +56,7 @@ Judge with what you have. Missing sections lower `confidence`; they do not block
56
56
 
57
57
  ## Output
58
58
 
59
- Return only the JSON object required by the schema (`skill: "token-dossier"`, `skill_version: "0.2.0"`). Fill every structured field completely (`bias`, `setup_quality`, `key_levels`, `invalidation`, `size_hint`, `facts`, `missing_data`). Brevity applies to the prose only:
59
+ Return only the JSON object required by the schema (`skill: "token-dossier"`, `skill_version: "0.3.0"`). Fill every structured field completely (`bias`, `setup_quality`, `key_levels`, `invalidation`, `size_hint`, `facts`, `missing_data`). Brevity applies to the prose only:
60
60
 
61
61
  - `summary` is two to four short lines for a 40-column panel: line 1 is the call in plain words (bias plus the one-clause reason), line 2 is the one or two facts that decide it, each with its source field. Add a short `missing:` line only when a gap changes the call.
62
62
  - Do not repeat the verdict, bias, setup, key levels, invalidation or size in `summary`. The app renders those fields right beside it. Do not restate numbers the user already sees on the screener row (price, % change, liquidity, volume).
@@ -15,7 +15,7 @@
15
15
  // config; it is never stored in plaintext and never echoed again. The token for `serve`/`connect` auth is
16
16
  // read ONLY from the $DEXTERM_AGENT_TOKEN environment variable — never a command-line flag — so it cannot
17
17
  // leak through a process list. No agent path can arm, sign, send, raise a cap or touch a wallet/mandate.
18
- import { AgentRegistry, SqliteAuditLog, MemoryProposalStore, SqliteProposalStore, AgentBus, FixtureExecutor, FixtureFeed, TIERS, isTier, } from '../../agents/src/index.js';
18
+ import { AgentRegistry, SqliteAuditLog, MemoryProposalStore, SqliteProposalStore, AgentBus, FixtureExecutor, FixtureFeed, SpectatorHub, SpectatorServer, SpectatorRecorder, FollowController, TIERS, isTier, } from '../../agents/src/index.js';
19
19
  import { connectAgent } from '../../agents/src/sdk.js';
20
20
  import { defaultDbPath } from '../../data/src/index.js';
21
21
  function flag(rest, name) { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; }
@@ -24,10 +24,11 @@ function intFlag(rest, name) { const v = flag(rest, name); const n = v === undef
24
24
  const USAGE = `dexterm agent <command>
25
25
  add <name> --tier observe|annotate|propose [--note T] [--reads N] [--proposals N] [--connections N]
26
26
  list | rotate <name> | remove <name> | tier <name> <tier> | pause <name> | resume <name>
27
+ mandate <name> --tools a,b,c | mandate <name> --clear (narrow an agent to a named set of tools)
27
28
  audit [--agent NAME] [--limit N]
28
29
  proposals [--agent NAME] [--status pending|confirmed|dismissed] [--limit N] [--db PATH]
29
30
  packs
30
- serve [--fixtures|--live] [--roster] [--port N] [--db PATH]
31
+ serve [--fixtures|--live] [--roster] [--spectate] [--spectator-popup] [--record FILE] [--port N] [--db PATH]
31
32
  connect --name NAME [--port N] [--url ws://127.0.0.1:PORT] [--watch SECONDS] (token via $DEXTERM_AGENT_TOKEN)`;
32
33
  export async function runAgent(rest, io) {
33
34
  const cmd = rest[0];
@@ -64,9 +65,12 @@ export async function runAgent(rest, io) {
64
65
  out('no agents registered. Add one: dexterm agent add <name> --tier observe');
65
66
  return 0;
66
67
  }
67
- out('name tier paused created note');
68
- for (const a of list)
69
- out(`${a.name.padEnd(24)} ${a.tier.padEnd(9)} ${(a.paused ? 'yes' : 'no').padEnd(7)} ${new Date(a.createdAt).toISOString().slice(0, 19)} ${a.note ?? ''}`);
68
+ out('name tier paused created mandate / note');
69
+ for (const a of list) {
70
+ const mandate = a.scope && a.scope.length ? `mandate: ${a.scope.join(',')}` : '';
71
+ const tail = [mandate, a.note ?? ''].filter(Boolean).join(' · ');
72
+ out(`${a.name.padEnd(24)} ${a.tier.padEnd(9)} ${(a.paused ? 'yes' : 'no').padEnd(7)} ${new Date(a.createdAt).toISOString().slice(0, 19)} ${tail}`);
73
+ }
70
74
  return 0;
71
75
  }
72
76
  case 'rotate': {
@@ -131,6 +135,35 @@ export async function runAgent(rest, io) {
131
135
  return 1;
132
136
  }
133
137
  }
138
+ case 'mandate': {
139
+ const name = rest[1];
140
+ if (!name) {
141
+ errOut('usage: dexterm agent mandate <name> --tools a,b,c | --clear');
142
+ return 2;
143
+ }
144
+ try {
145
+ if (has(rest, '--clear')) {
146
+ registry.setScope(name, null);
147
+ out(`cleared the mandate for "${name}" (it may now use its full tier surface).`);
148
+ return 0;
149
+ }
150
+ const tools = flag(rest, '--tools');
151
+ if (!tools) {
152
+ errOut('usage: dexterm agent mandate <name> --tools a,b,c | --clear');
153
+ return 2;
154
+ }
155
+ const list = tools.split(',').map((t) => t.trim()).filter(Boolean);
156
+ registry.setScope(name, list);
157
+ const rec = registry.get(name);
158
+ out(`"${name}" is now mandated to: ${rec.scope.join(', ')}.`);
159
+ out('the agent can call only these tools (plus agent_whoami); a mandate only narrows the tier, never widens it.');
160
+ return 0;
161
+ }
162
+ catch (e) {
163
+ errOut(`dexterm agent mandate: ${e.message}`);
164
+ return 1;
165
+ }
166
+ }
134
167
  case 'audit': {
135
168
  const dbPath = flag(rest, '--db') || defaultDbPath(io.env);
136
169
  const log = new SqliteAuditLog(dbPath);
@@ -224,12 +257,36 @@ async function serve(rest, io, registry) {
224
257
  else {
225
258
  executor = new FixtureExecutor();
226
259
  }
227
- const bus = new AgentBus({ registry, audit, executor, proposals, feed, port, env: io.env });
260
+ // task-1406: the spectator hub observes every agent action; it feeds follow mode, the read-only stream
261
+ // and the save file. Created whenever any spectator surface is asked for (cheap; off otherwise).
262
+ const wantSpectate = has(rest, '--spectate');
263
+ const wantRecord = flag(rest, '--record');
264
+ const wantSpectatorPopup = has(rest, '--spectator-popup');
265
+ const hub = (wantSpectate || wantRecord || wantSpectatorPopup) ? new SpectatorHub() : undefined;
266
+ const bus = new AgentBus({ registry, audit, executor, proposals, feed, spectator: hub, port, env: io.env });
228
267
  const boundPort = await bus.start();
229
268
  feed.start(1500);
230
269
  io.stdout.write(`dexterm agent bus listening on ws://127.0.0.1:${boundPort} (${live ? 'live keyless reads' : 'fixtures'})\n`);
231
270
  io.stdout.write(`discovery: ${process.env.DEXTERM_HOME ?? '~/.dexterm'}/agent-bus.json · ${registry.list().length} registered agent(s)\n`);
232
271
  io.stdout.write('agents connect with their name + $DEXTERM_AGENT_TOKEN. Ctrl-C to stop.\n');
272
+ // --record FILE: save the session (redacted) for demos / bug reports; replay with `dexterm spectate --replay`.
273
+ let recorder = null;
274
+ if (hub && wantRecord) {
275
+ try {
276
+ recorder = new SpectatorRecorder({ path: wantRecord, note: 'dexterm agent serve' }).attach(hub);
277
+ io.stdout.write(`recording the session to ${wantRecord} (redacted).\n`);
278
+ }
279
+ catch (e) {
280
+ io.stderr.write(`could not record: ${e.message}\n`);
281
+ }
282
+ }
283
+ // --spectate: start the read-only, loopback, token-gated spectator stream (SPECTATOR.md).
284
+ let stream = null;
285
+ if (hub && wantSpectate) {
286
+ stream = new SpectatorServer({ hub, env: io.env });
287
+ const sp = await stream.start();
288
+ io.stdout.write(`spectator stream (read-only) on ws://127.0.0.1:${sp} · token in ${process.env.DEXTERM_HOME ?? '~/.dexterm'}/spectator.json (0600) · redacts addresses/keys\n`);
289
+ }
233
290
  // --roster: open the agent roster popup and keep it live. The popup can only send pause/resume/kick.
234
291
  let roster = null;
235
292
  if (has(rest, '--roster')) {
@@ -254,11 +311,51 @@ async function serve(rest, io, registry) {
254
311
  io.stderr.write(`roster popup unavailable: ${e.message}\n`);
255
312
  }
256
313
  }
314
+ // --spectator-popup: open the spectator popup ("watch the agent cook"), fed by a follow controller that
315
+ // folds the hub's events into the screen model + activity. It sends only spectator.* (pause/takeover/kill).
316
+ let spectatorPopup = null;
317
+ if (hub && wantSpectatorPopup) {
318
+ try {
319
+ const { createPopupManager } = await import('../../popup/src/index.js');
320
+ const { buildSpectatorVM, applySpectatorEvent } = await import("./agent-spectator.js");
321
+ const { THEMES, DEFAULT_THEME } = await import('../../tui/src/index.js');
322
+ const { popupThemeFrom } = await import("./popup.js");
323
+ const follow = new FollowController({ controls: bus, activityCap: 200 });
324
+ const off = hub.on((ev) => follow.apply(ev));
325
+ const mgr = createPopupManager({ theme: popupThemeFrom(THEMES[DEFAULT_THEME]), env: io.env, onWarn: (m) => io.stderr.write(`spectator popup: ${m}\n`),
326
+ onEvent: (ev) => { if (ev.ev === 'popup.event' && ev.name.startsWith('spectator.'))
327
+ io.stdout.write(`spectator: ${applySpectatorEvent(follow, ev.name, String(ev.payload.agent))}\n`); } });
328
+ const handle = await mgr.open({ kind: 'spectator', title: 'watch the agent cook', data: buildSpectatorVM(follow) });
329
+ const timer = setInterval(() => { try {
330
+ mgr.setData(handle.id, buildSpectatorVM(follow));
331
+ }
332
+ catch { /* popup closed */ } }, 1000);
333
+ timer.unref?.();
334
+ spectatorPopup = { shutdown: () => mgr.shutdown(), timer, off };
335
+ io.stdout.write(`spectator popup open (${mgr.native ? 'native window' : 'browser fallback'}).\n`);
336
+ }
337
+ catch (e) {
338
+ io.stderr.write(`spectator popup unavailable: ${e.message}\n`);
339
+ }
340
+ }
257
341
  await new Promise((resolve) => {
258
- const stop = () => { if (roster) {
259
- clearInterval(roster.timer);
260
- void roster.shutdown();
261
- } feed.stop(); audit.close(); proposals.close(); dataClose?.(); void bus.close().then(resolve); };
342
+ const stop = () => {
343
+ if (roster) {
344
+ clearInterval(roster.timer);
345
+ void roster.shutdown();
346
+ }
347
+ if (spectatorPopup) {
348
+ clearInterval(spectatorPopup.timer);
349
+ spectatorPopup.off();
350
+ void spectatorPopup.shutdown();
351
+ }
352
+ recorder?.stop();
353
+ feed.stop();
354
+ audit.close();
355
+ proposals.close();
356
+ dataClose?.();
357
+ void (stream ? stream.close() : Promise.resolve()).then(() => bus.close().then(resolve));
358
+ };
262
359
  process.once('SIGINT', stop);
263
360
  process.once('SIGTERM', stop);
264
361
  });
@@ -0,0 +1,37 @@
1
+ // task-1406 "watch the agent cook": bridge the follow-mode controller to the spectator popup VM, and map
2
+ // the popup's spectator.* UI events onto the controller's user-side controls. Nothing here can arm, sign
3
+ // or raise a cap — the controls are exactly the bus's pause/resume/kick, the same as the roster popup.
4
+ import { redactSpectatorEvent, maskAddress } from '../../agents/src/index.js';
5
+ function activityVM(ev) {
6
+ return {
7
+ seq: ev.seq, t: ev.t, phase: ev.phase, agent: ev.agent, tier: ev.tier ?? null,
8
+ tool: ev.tool ?? null, summary: ev.summary ?? '', ms: ev.ms ?? null, dataAgeSec: ev.dataAgeSec ?? null,
9
+ ok: ev.ok, refusal: ev.refusal ?? null,
10
+ };
11
+ }
12
+ /** Build the spectator popup VM from the follow controller. Redacts addresses/keys unless redact:false. */
13
+ export function buildSpectatorVM(follow, opts = {}) {
14
+ const redact = opts.redact ?? true;
15
+ const v = follow.view();
16
+ const s = v.screen;
17
+ const mask = (x) => (x && redact ? maskAddress(x) : x);
18
+ return {
19
+ title: opts.title ?? 'watch the agent cook',
20
+ now: Date.now(),
21
+ redacted: redact,
22
+ control: v.control,
23
+ screen: { screen: s.screen, selectedMint: mask(s.selectedMint), feed: s.feed, filter: s.filter, highlight: mask(s.highlight) },
24
+ agents: v.agents.map((a) => ({ name: a.name, tier: a.tier, status: a.status, lastAt: a.lastAt })),
25
+ activity: v.activity.map((ev) => activityVM(redact ? redactSpectatorEvent(ev) : ev)),
26
+ };
27
+ }
28
+ /** Apply a validated spectator popup event to the follow controller. Returns a short log line. */
29
+ export function applySpectatorEvent(follow, name, agent) {
30
+ switch (name) {
31
+ case 'spectator.pause': return follow.pause(agent);
32
+ case 'spectator.resume': return follow.resume(agent);
33
+ case 'spectator.takeover': return follow.takeOver(agent);
34
+ case 'spectator.kill': return follow.kill(agent);
35
+ default: return `ignored ${name}`;
36
+ }
37
+ }
@@ -0,0 +1,40 @@
1
+ // dexterm — the `desktop` adapter (task-1398). macOS `osascript -e 'display notification …'`, Linux
2
+ // `notify-send`. The command is run via execFile with an argv array (NO shell), so a crafted title/body
3
+ // cannot inject a command. macOS osascript takes one AppleScript string on argv; the already-firewalled
4
+ // title/body are additionally AppleScript-escaped (\\, ") and newline-stripped there. The runner is
5
+ // injectable so tests assert the command without spawning anything.
6
+ import { execFile } from 'node:child_process';
7
+ import { deliveryLine } from "../format.js";
8
+ const defaultRunner = (cmd, args) => new Promise((resolve, reject) => {
9
+ execFile(cmd, args, { timeout: 5000 }, (err) => (err ? reject(new Error(err.code === 'ENOENT' ? `${cmd} not found` : err.message)) : resolve()));
10
+ });
11
+ /** AppleScript string literal body: escape backslash and quote, drop any residual control chars. */
12
+ function osaString(s) {
13
+ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\u0000-\u001f\u007f]/g, ' ') + '"';
14
+ }
15
+ export function createDesktopAdapter(deps = {}) {
16
+ const platform = deps.platform ?? process.platform;
17
+ const run = deps.run ?? defaultRunner;
18
+ return {
19
+ name: 'desktop',
20
+ available: () => platform === 'darwin' || platform === 'linux',
21
+ deliver: async (d) => {
22
+ try {
23
+ if (platform === 'darwin') {
24
+ const script = `display notification ${osaString(d.body)} with title ${osaString(d.title)}`;
25
+ await run('osascript', ['-e', script]);
26
+ }
27
+ else if (platform === 'linux') {
28
+ await run('notify-send', ['--app-name=dexterm', d.title, d.body || deliveryLine(d)]);
29
+ }
30
+ else {
31
+ return { ok: false, skipped: true, reason: `desktop notifications unsupported on ${platform}` };
32
+ }
33
+ return { ok: true };
34
+ }
35
+ catch (e) {
36
+ return { ok: false, reason: e.message };
37
+ }
38
+ },
39
+ };
40
+ }