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
@@ -13,6 +13,8 @@ import { lowerTier, isTier, toolsForTier } from "./tiers.js";
13
13
  import { toolSchema, agentTool, toolsForTierList } from "./tools.js";
14
14
  import { isTopic, scrubStrings } from "./executor.js";
15
15
  import { TOOL_TO_KIND } from "./proposals.js";
16
+ import { NAVIGATION_TOOLS } from "./tiers.js";
17
+ import { maxDataAgeSec } from "./spectator.js";
16
18
  import { firewallText } from '../../data/src/index.js';
17
19
  export const BUS_PROTOCOL = 'dexterm-agent-bus/1';
18
20
  /** A connection that does not authenticate within this window is closed. */
@@ -27,6 +29,7 @@ export class AgentBus {
27
29
  executor;
28
30
  proposals;
29
31
  feed;
32
+ spectator;
30
33
  gate;
31
34
  now;
32
35
  paused = new Set();
@@ -39,6 +42,7 @@ export class AgentBus {
39
42
  this.executor = opts.executor;
40
43
  this.proposals = opts.proposals;
41
44
  this.feed = opts.feed ?? null;
45
+ this.spectator = opts.spectator ?? null;
42
46
  this.now = opts.now ?? (() => Date.now());
43
47
  this.discoveryPath = opts.discoveryPath === undefined ? join(dextermHome(opts.env), 'agent-bus.json') : opts.discoveryPath;
44
48
  this.gate = new AgentGate({
@@ -86,6 +90,7 @@ export class AgentBus {
86
90
  this.paused.add(a.id);
87
91
  this.registry.setPaused(name, true);
88
92
  this.audit.append({ agentId: a.id, agentName: name, session: null, kind: 'pause', tool: '-', detail: 'paused by the user', ok: false });
93
+ this.spectator?.emit({ phase: 'control', agent: name, tool: 'pause', summary: 'paused by the user', ok: true });
89
94
  return true;
90
95
  }
91
96
  resumeAgent(name) {
@@ -96,6 +101,7 @@ export class AgentBus {
96
101
  this.registry.setPaused(name, false);
97
102
  this.gate.clearFlood(a.id);
98
103
  this.audit.append({ agentId: a.id, agentName: name, session: null, kind: 'resume', tool: '-', detail: 'resumed by the user', ok: true });
104
+ this.spectator?.emit({ phase: 'control', agent: name, tool: 'resume', summary: 'resumed by the user', ok: true });
99
105
  return true;
100
106
  }
101
107
  /** Kick: close every live session for an agent now (the token keeps working — use `rotate`/`remove` to revoke it). */
@@ -115,6 +121,7 @@ export class AgentBus {
115
121
  n++;
116
122
  }
117
123
  this.audit.append({ agentId: a.id, agentName: name, session: null, kind: 'kick', tool: '-', detail: `kicked ${n} session(s)`, ok: true });
124
+ this.spectator?.emit({ phase: 'control', agent: name, tool: 'kick', summary: `killed — ${n} session(s) closed`, ok: true });
118
125
  return n;
119
126
  }
120
127
  /** For the roster (tier 4): live sessions + usage per connected agent. */
@@ -154,8 +161,10 @@ export class AgentBus {
154
161
  onClose(conn) {
155
162
  if (conn.helloTimer)
156
163
  clearTimeout(conn.helloTimer);
157
- if (conn.session)
164
+ if (conn.session) {
158
165
  this.audit.append({ agentId: conn.session.agentId, agentName: conn.session.agentName, session: conn.session.id, kind: 'disconnect', tool: '-', detail: 'socket closed', ok: true });
166
+ this.spectator?.emit({ phase: 'disconnect', agent: conn.session.agentName, tier: conn.session.tier, transport: 'bus', summary: conn.session.closedReason ? `disconnected (${conn.session.closedReason})` : 'disconnected', ok: true });
167
+ }
159
168
  this.conns.delete(conn);
160
169
  }
161
170
  reply(conn, id, result) { conn.ws.send({ jsonrpc: '2.0', id, result }); }
@@ -192,7 +201,7 @@ export class AgentBus {
192
201
  return;
193
202
  }
194
203
  if (msg.method === 'tools/list') {
195
- this.reply(conn, id, { tools: toolsForTierList(conn.session.tier) });
204
+ this.reply(conn, id, { tools: this.visibleToolDefs(conn.session) });
196
205
  return;
197
206
  }
198
207
  if (msg.method === 'tools/call') {
@@ -245,14 +254,16 @@ export class AgentBus {
245
254
  if (rec.paused)
246
255
  this.paused.add(rec.id);
247
256
  this.gate.setAgentLimits(rec.id, rec.limits);
248
- const session = { id: `sess_${(++this.sessionSeq).toString(36)}`, agentId: rec.id, agentName: rec.name, tier, transport: 'bus', openedAt: this.now(), lastActivity: this.now(), closed: false, closedReason: null, client, topics: new Set() };
257
+ const scope = rec.scope && rec.scope.length ? new Set(rec.scope) : null;
258
+ const session = { id: `sess_${(++this.sessionSeq).toString(36)}`, agentId: rec.id, agentName: rec.name, tier, transport: 'bus', openedAt: this.now(), lastActivity: this.now(), closed: false, closedReason: null, client, topics: new Set(), scope };
249
259
  conn.session = session;
250
260
  if (conn.helloTimer) {
251
261
  clearTimeout(conn.helloTimer);
252
262
  conn.helloTimer = null;
253
263
  }
254
- this.audit.append({ agentId: rec.id, agentName: rec.name, session: session.id, kind: 'connect', tool: '-', detail: `tier ${tier} · ${client} · ${conn.ws.remote}`, ok: true });
255
- this.reply(conn, id, { protocol: BUS_PROTOCOL, agent: rec.name, tier, grantedTier: rec.tier, tools: toolsForTier(tier), paused: rec.paused });
264
+ this.audit.append({ agentId: rec.id, agentName: rec.name, session: session.id, kind: 'connect', tool: '-', detail: `tier ${tier}${scope ? ` · mandate[${[...scope].join(',')}]` : ''} · ${client} · ${conn.ws.remote}`, ok: true });
265
+ this.spectator?.emit({ phase: 'connect', agent: rec.name, tier, transport: 'bus', summary: `connected · ${client}${scope ? ` · mandate[${[...scope].join(',')}]` : ''}`, ok: true });
266
+ this.reply(conn, id, { protocol: BUS_PROTOCOL, agent: rec.name, tier, grantedTier: rec.tier, tools: this.visibleTools(session), mandate: scope ? [...scope] : null, paused: rec.paused });
256
267
  }
257
268
  async onCall(conn, id, params) {
258
269
  const session = conn.session;
@@ -264,9 +275,12 @@ export class AgentBus {
264
275
  const args = (params.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments)) ? params.arguments : {};
265
276
  const g = this.gate.check(session, { tool: name, args, schema: toolSchema(name) });
266
277
  if (!g.ok) {
278
+ // The tool name is agent-supplied; cap it (redactSpectatorEvent still masks any address/key in it).
279
+ this.spectator?.emit({ phase: 'refused', agent: session.agentName, tier: session.tier, transport: 'bus', tool: (name || '?').slice(0, 64), summary: `refused: ${g.error}`, refusal: g.code, ok: false });
267
280
  this.fail(conn, id, -32020, g.error, { code: g.code });
268
281
  return;
269
282
  }
283
+ const t0 = this.now();
270
284
  try {
271
285
  // bus-native observe
272
286
  if (name === 'agent_whoami') {
@@ -284,6 +298,7 @@ export class AgentBus {
284
298
  if (name === 'list_proposals') {
285
299
  const limit = Math.min(Number(args.limit ?? 50), 100);
286
300
  this.gate.record(session, name, `listed proposals`, 'call');
301
+ this.spectator?.emit({ phase: 'result', agent: session.agentName, tier: session.tier, transport: 'bus', tool: name, summary: 'listed proposals', ms: this.now() - t0, ok: true });
287
302
  this.reply(conn, id, { proposals: this.proposals.list({ agentId: session.agentId, limit }) });
288
303
  return;
289
304
  }
@@ -293,6 +308,7 @@ export class AgentBus {
293
308
  if (duplicate)
294
309
  this.gate.refundProposal(session.agentId);
295
310
  this.audit.append({ agentId: session.agentId, agentName: session.agentName, session: session.id, kind: 'proposal', tool: name, detail: `${duplicate ? 'duplicate ' : ''}${proposal.summary}`, ok: true });
311
+ this.spectator?.emit({ phase: 'proposal', agent: session.agentName, tier: session.tier, transport: 'bus', tool: name, summary: `${duplicate ? 'duplicate ' : ''}${proposal.summary}`, ms: this.now() - t0, nav: navFor(name, args), ok: true });
296
312
  this.reply(conn, id, { proposal, duplicate, note: 'This is a PROPOSAL. The user confirms it with a keypress; you can never arm, confirm or send it.' });
297
313
  return;
298
314
  }
@@ -301,6 +317,9 @@ export class AgentBus {
301
317
  const result = scrubStrings(await this.executor.exec(session, name, args));
302
318
  const isErr = typeof result.error === 'string';
303
319
  this.gate.record(session, name, isErr ? `error: ${String(result.error).slice(0, 80)}` : summarise(name, args), name === 'agent_note' ? 'note' : 'call');
320
+ const nav = navFor(name, args);
321
+ const phase = nav ? 'nav' : name === 'agent_note' ? 'note' : 'result';
322
+ this.spectator?.emit({ phase, agent: session.agentName, tier: session.tier, transport: 'bus', tool: name, summary: isErr ? `error: ${String(result.error).slice(0, 80)}` : summarise(name, args), ms: this.now() - t0, dataAgeSec: isErr ? null : maxDataAgeSec(result), nav, ok: !isErr });
304
323
  this.reply(conn, id, result);
305
324
  return;
306
325
  }
@@ -309,7 +328,21 @@ export class AgentBus {
309
328
  }
310
329
  }
311
330
  whoami(session) {
312
- return { agent: session.agentName, tier: session.tier, tools: toolsForTier(session.tier).map((t) => ({ name: t, tier: agentTool(t)?.tier })), usage: this.gate.usage(session.agentId), subscriptions: [...session.topics], note: 'Your identity and tier come from the server. You cannot change them.' };
331
+ return { agent: session.agentName, tier: session.tier, mandate: session.scope ? [...session.scope] : null, tools: this.visibleTools(session).map((t) => ({ name: t, tier: agentTool(t)?.tier })), usage: this.gate.usage(session.agentId), subscriptions: [...session.topics], note: 'Your identity, tier and mandate come from the server. You cannot change them.' };
332
+ }
333
+ /** Tool names this session may call: its tier surface, narrowed by a mandate (+ agent_whoami, always). */
334
+ visibleTools(session) {
335
+ const all = toolsForTier(session.tier);
336
+ if (!session.scope)
337
+ return all;
338
+ return all.filter((t) => t === 'agent_whoami' || session.scope.has(t));
339
+ }
340
+ /** The advertised tool DEFINITIONS for tools/list, narrowed the same way. */
341
+ visibleToolDefs(session) {
342
+ const defs = toolsForTierList(session.tier);
343
+ if (!session.scope)
344
+ return defs;
345
+ return defs.filter((t) => t.name === 'agent_whoami' || session.scope.has(t.name));
313
346
  }
314
347
  subscribe(conn, args) {
315
348
  const session = conn.session;
@@ -325,6 +358,7 @@ export class AgentBus {
325
358
  }
326
359
  }
327
360
  this.audit.append({ agentId: session.agentId, agentName: session.agentName, session: session.id, kind: 'subscribe', tool: 'subscribe', detail: `+[${added.join(',')}]`, ok: true });
361
+ this.spectator?.emit({ phase: 'subscribe', agent: session.agentName, tier: session.tier, transport: 'bus', tool: 'subscribe', summary: `subscribed +[${added.join(',')}]`, ok: true });
328
362
  // push an immediate snapshot per newly-added topic, if the feed offers one
329
363
  if (this.feed?.snapshot)
330
364
  for (const t of added) {
@@ -367,6 +401,23 @@ export class AgentBus {
367
401
  catch { /* discovery is best-effort; the port can also be passed explicitly */ }
368
402
  }
369
403
  }
404
+ /** The screen move a navigation / watchlist / ticket tool asks for (task-1406, for follow mode). */
405
+ function navFor(tool, args) {
406
+ const s = (v) => (typeof v === 'string' ? v : undefined);
407
+ if (tool === 'open_token')
408
+ return { screen: 'token', mint: s(args.mint) };
409
+ if (tool === 'switch_feed')
410
+ return { screen: 'screener', feed: s(args.feed) };
411
+ if (tool === 'show_in_screener')
412
+ return { screen: 'screener', filter: s(args.filter), highlight: s(args.highlight) };
413
+ if (tool === 'add_to_watchlist')
414
+ return { screen: 'watchlist', mint: s(args.mint) };
415
+ if (tool === 'open_ticket')
416
+ return { screen: 'ticket', mint: s(args.mint), side: s(args.side) };
417
+ if (NAVIGATION_TOOLS.has(tool))
418
+ return { screen: 'screener' };
419
+ return null;
420
+ }
370
421
  function summarise(name, args) {
371
422
  const mint = typeof args.mint === 'string' ? args.mint.slice(0, 6) + '…' : '';
372
423
  if (mint)
@@ -116,6 +116,31 @@ export class FixtureExecutor {
116
116
  const [token, rug, trades] = await Promise.all([this.exec(session, 'get_token', { mint: r.mint }), this.exec(session, 'rugcheck', { mint: r.mint }), this.exec(session, 'get_trades', { mint: r.mint, window: 'h1' })]);
117
117
  return { mint: r.mint, interval: args.interval ?? '1h', token, rugcheck: rug, trades, availability: { market: true, security: true, tape: true, canVerdict: true } };
118
118
  }
119
+ case 'analyze_token': {
120
+ const r = byMint(args.mint) ?? FIXTURE_ROWS.find((x) => x.symbol.toUpperCase() === String(args.symbol ?? '').replace(/^\$/, '').toUpperCase());
121
+ if (!r)
122
+ return { error: 'unknown token (fixtures)' };
123
+ // A deterministic verdict from the fixture row's audit (mirrors the real pack's rule flags).
124
+ const flags = [];
125
+ if (r.freeze)
126
+ flags.push('freeze_authority');
127
+ if (r.mintAuth)
128
+ flags.push('mint_authority');
129
+ if (r.lpLockedPct < 100)
130
+ flags.push('lp_unlocked');
131
+ if (r.top10Pct > 40)
132
+ flags.push('top10_concentrated');
133
+ if (r.organicScore < 40)
134
+ flags.push('organic_below_40');
135
+ if (r.ageSec < 600)
136
+ flags.push('under_10_minutes');
137
+ const verdict = r.rugScore >= 60 ? 'AVOID' : r.rugScore >= 25 ? 'SPECULATIVE' : 'WATCH';
138
+ return { pack: 'token-dossier', pack_version: 'fixtures', model_output: false, mint: r.mint, symbol: fw(r.symbol), deterministic_verdict: verdict, rug_probability: r.rugScore / 100, rug_model: 'H1-v0.1', flags, injection_suspected: false, note: 'Deterministic code stage (no model) on fixtures.', source: 'fixtures' };
139
+ }
140
+ case 'narrative_board':
141
+ return { pack: 'narrative-board', model_output: false, narratives: [], note: 'No recorded history on fixtures (the board needs a running recorder).' };
142
+ case 'rug_backtest':
143
+ return { pack: 'rug-backtest', model_output: false, note: 'No recorded history on fixtures (a backtest needs recorded t0 snapshots with labelled outcomes).' };
119
144
  case 'open_token':
120
145
  case 'show_in_screener':
121
146
  case 'switch_feed':
@@ -0,0 +1,105 @@
1
+ // @dexterm/agents — follow mode (task-1406). The in-terminal, local view of an agent driving dexterm:
2
+ // it folds the spectator event stream into a SCREEN MODEL (what the terminal should be showing) and an
3
+ // ACTIVITY panel (recent calls with agent badge, timing and data ages), and exposes the owner's
4
+ // controls: pause / take over / kill (panic). Controls delegate to an AgentControls interface the bus
5
+ // implements (pauseAgent / resumeAgent / kickAgent) — this module can never arm, sign or raise a cap;
6
+ // it only drives the SAME user-side controls the roster popup already has.
7
+ function emptyScreen() {
8
+ return { screen: 'screener', selectedMint: null, feed: null, filter: null, highlight: null, ticketSide: null, watchlist: [], lastNavAt: null };
9
+ }
10
+ export class FollowController {
11
+ controls;
12
+ activityCap;
13
+ screen = emptyScreen();
14
+ activityRing = [];
15
+ agents = new Map();
16
+ control = 'agent';
17
+ lastSeq = 0;
18
+ constructor(opts = {}) {
19
+ this.controls = opts.controls ?? null;
20
+ this.activityCap = Math.max(1, opts.activityCap ?? 50);
21
+ }
22
+ /** Fold one spectator event into the screen model + activity + per-agent state. */
23
+ apply(ev) {
24
+ this.lastSeq = Math.max(this.lastSeq, ev.seq);
25
+ this.activityRing.push(ev);
26
+ if (this.activityRing.length > this.activityCap)
27
+ this.activityRing.splice(0, this.activityRing.length - this.activityCap);
28
+ const st = this.agents.get(ev.agent) ?? { name: ev.agent, tier: ev.tier ?? null, status: 'active', lastAt: ev.t };
29
+ st.lastAt = ev.t;
30
+ if (ev.tier)
31
+ st.tier = ev.tier;
32
+ if (ev.phase === 'connect')
33
+ st.status = 'active';
34
+ if (ev.phase === 'disconnect' && st.status !== 'killed')
35
+ st.status = st.status === 'paused' ? 'paused' : 'active';
36
+ this.agents.set(ev.agent, st);
37
+ // The agent driving the screen (navigation tools emit a nav block).
38
+ if (ev.phase === 'nav' && ev.nav) {
39
+ const n = ev.nav;
40
+ this.screen.lastNavAt = ev.t;
41
+ if (n.screen)
42
+ this.screen.screen = n.screen;
43
+ if (n.mint !== undefined)
44
+ this.screen.selectedMint = n.mint ?? null;
45
+ if (n.feed !== undefined)
46
+ this.screen.feed = n.feed ?? null;
47
+ if (n.filter !== undefined)
48
+ this.screen.filter = n.filter ?? null;
49
+ if (n.highlight !== undefined)
50
+ this.screen.highlight = n.highlight ?? null;
51
+ if (n.side !== undefined)
52
+ this.screen.ticketSide = n.side ?? null;
53
+ if (n.screen === 'watchlist' && n.mint && !this.screen.watchlist.includes(n.mint))
54
+ this.screen.watchlist.push(n.mint);
55
+ }
56
+ }
57
+ // ----- the owner's controls (pause / take over / kill). Each delegates to the bus. -----
58
+ /** Pause the agent: its next call is refused; it stays connected and visible. */
59
+ pause(name) {
60
+ const ok = this.controls?.pauseAgent(name) ?? false;
61
+ if (ok)
62
+ this.setStatus(name, 'paused');
63
+ return ok ? `paused ${name}` : `no agent ${name}`;
64
+ }
65
+ resume(name) {
66
+ const ok = this.controls?.resumeAgent(name) ?? false;
67
+ if (ok) {
68
+ this.setStatus(name, 'active');
69
+ this.control = 'agent';
70
+ }
71
+ return ok ? `resumed ${name}` : `no agent ${name}`;
72
+ }
73
+ /** Take over: pause the agent and mark the human as driving. The agent is frozen but still visible. */
74
+ takeOver(name) {
75
+ const ok = this.controls?.pauseAgent(name) ?? false;
76
+ if (ok) {
77
+ this.setStatus(name, 'paused');
78
+ this.control = 'user';
79
+ }
80
+ return ok ? `took over from ${name} — you are driving (agent frozen)` : `no agent ${name}`;
81
+ }
82
+ /** Kill (panic): close every live session for the agent now, and keep it paused so a reconnect is frozen. */
83
+ kill(name) {
84
+ const n = this.controls?.kickAgent(name) ?? 0;
85
+ this.controls?.pauseAgent(name);
86
+ this.setStatus(name, 'killed');
87
+ this.control = 'user';
88
+ return `killed ${name} (${n} session${n === 1 ? '' : 's'} closed; paused)`;
89
+ }
90
+ setStatus(name, status) {
91
+ const st = this.agents.get(name) ?? { name, tier: null, status, lastAt: Date.now() };
92
+ st.status = status;
93
+ this.agents.set(name, st);
94
+ }
95
+ view() {
96
+ return {
97
+ screen: { ...this.screen, watchlist: [...this.screen.watchlist] },
98
+ activity: [...this.activityRing],
99
+ agents: [...this.agents.values()].map((a) => ({ ...a })),
100
+ control: this.control,
101
+ };
102
+ }
103
+ get screenModel() { return { ...this.screen, watchlist: [...this.screen.watchlist] }; }
104
+ get controlState() { return this.control; }
105
+ }
@@ -95,6 +95,9 @@ export class AgentGate {
95
95
  return refuse('unknown-tool', `unknown tool ${JSON.stringify(String(name).slice(0, 40))}`);
96
96
  if (!tierAtLeast(session.tier, need))
97
97
  return refuse('tier', `${name} needs tier ${need}; this agent is ${session.tier}`);
98
+ // Per-agent mandate (task-1405): a scoped agent may call only its listed tools (+ agent_whoami). Narrow-only.
99
+ if (session.scope && name !== 'agent_whoami' && !session.scope.has(name))
100
+ return refuse('mandate', `${name} is outside this agent's mandate (allowed: ${[...session.scope].join(', ')})`);
98
101
  // spoofing: identity / capability fields in the arguments are never honoured
99
102
  const args = req.args === undefined ? {} : req.args;
100
103
  if (typeof args === 'object' && args !== null && !Array.isArray(args)) {
@@ -10,11 +10,30 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
10
10
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from 'node:fs';
11
11
  import { dirname, join } from 'node:path';
12
12
  import { homedir } from 'node:os';
13
- import { isTier } from "./tiers.js";
13
+ import { isTier, TOOL_TIERS } from "./tiers.js";
14
14
  export const AGENT_NAME_RE = /^[a-z][a-z0-9-]{0,23}$/;
15
15
  export const TOKEN_PREFIX = 'dxa_';
16
16
  /** Hard cap on registered agents (a flood of `agent add` is still bounded). */
17
17
  export const MAX_AGENTS = 32;
18
+ /** Validate + normalise a mandate scope: known tool names only, deduped; agent_whoami is implicit. Throws on an unknown tool. */
19
+ export function sanitizeScope(tools) {
20
+ const seen = new Set();
21
+ const out = [];
22
+ for (const raw of tools) {
23
+ const t = String(raw).trim();
24
+ if (!t || t === 'agent_whoami')
25
+ continue;
26
+ if (!Object.hasOwn(TOOL_TIERS, t))
27
+ throw new Error(`unknown tool "${t.slice(0, 40)}" — a mandate may only list agent tools (${Object.keys(TOOL_TIERS).join(', ')})`);
28
+ if (!seen.has(t)) {
29
+ seen.add(t);
30
+ out.push(t);
31
+ }
32
+ }
33
+ if (!out.length)
34
+ throw new Error('a mandate needs at least one tool (use --clear to remove a mandate)');
35
+ return out;
36
+ }
18
37
  export function dextermHome(env = process.env) {
19
38
  return env.DEXTERM_HOME && env.DEXTERM_HOME.trim() ? env.DEXTERM_HOME : join(homedir(), '.dexterm');
20
39
  }
@@ -60,7 +79,16 @@ export class AgentRegistry {
60
79
  const list = Array.isArray(f?.agents) ? f.agents : [];
61
80
  // Defensive re-validation: a hand-edited file cannot grant a tier outside the three or a bad name.
62
81
  this.agents = list.filter((a) => !!a && typeof a === 'object' && typeof a.id === 'string' && typeof a.name === 'string' && AGENT_NAME_RE.test(a.name)
63
- && isTier(a.tier) && typeof a.tokenHash === 'string' && typeof a.salt === 'string').map((a) => ({ ...a, paused: a.paused === true, rotatedAt: typeof a.rotatedAt === 'number' ? a.rotatedAt : null }));
82
+ && isTier(a.tier) && typeof a.tokenHash === 'string' && typeof a.salt === 'string').map((a) => {
83
+ // A hand-edited scope is re-validated: keep only known tool names; an empty result drops the mandate.
84
+ const scope = Array.isArray(a.scope) ? a.scope.filter((t) => typeof t === 'string' && Object.hasOwn(TOOL_TIERS, t)) : undefined;
85
+ const rec = { ...a, paused: a.paused === true, rotatedAt: typeof a.rotatedAt === 'number' ? a.rotatedAt : null };
86
+ if (scope && scope.length)
87
+ rec.scope = [...new Set(scope)];
88
+ else
89
+ delete rec.scope;
90
+ return rec;
91
+ });
64
92
  }
65
93
  save() {
66
94
  if (this.path === ':memory:')
@@ -142,6 +170,17 @@ export class AgentRegistry {
142
170
  a.limits = sanitizeLimits(limits);
143
171
  this.save();
144
172
  }
173
+ /** Set (or clear, with null) the per-agent tool mandate. Throws on an unknown tool name. */
174
+ setScope(name, tools) {
175
+ const a = this.get(name);
176
+ if (!a)
177
+ throw new Error(`no agent "${name}"`);
178
+ if (tools === null)
179
+ delete a.scope;
180
+ else
181
+ a.scope = sanitizeScope(tools);
182
+ this.save();
183
+ }
145
184
  /**
146
185
  * Verify a presented name + token. Constant-time on the hash; the same `null` for an unknown name,
147
186
  * a bad token shape and a wrong token (no oracle). Re-reads the file first so a rotate / remove from
@@ -13,3 +13,8 @@ export * from "./ws.js";
13
13
  export * from "./executor.js";
14
14
  export * from "./proposals.js";
15
15
  export * from "./bus.js";
16
+ // task-1406 "watch the agent cook": the spectator event model + redaction + save/replay, the local
17
+ // follow-mode controller (pause / take over / kill), and the read-only loopback spectator stream.
18
+ export * from "./spectator.js";
19
+ export * from "./follow.js";
20
+ export * from "./spectator-server.js";
@@ -0,0 +1,153 @@
1
+ // @dexterm/agents — the read-only spectator STREAM (task-1406). A loopback-only WebSocket a streamer (or
2
+ // a capture tool) can watch the agent's session through. It is OUTBOUND ONLY: the server is constructed
3
+ // with a SpectatorHub and nothing else — no bus, no registry, no executor, no controls — so there is no
4
+ // code path from the socket to anything mutable. The ONLY inbound messages it honors are `hello` (auth
5
+ // with the per-session token) and `ping`; every other inbound frame is counted as `dropped` and ignored.
6
+ // Every event leaving the server is REDACTED (wallet addresses, keys, sensitive fields). This is how the
7
+ // stream "cannot send commands back" — structurally, not by a filter that could be bypassed.
8
+ import { writeFileSync, rmSync, mkdirSync, chmodSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
11
+ import { createWsServer } from "./ws.js";
12
+ import { dextermHome } from "./identity.js";
13
+ import { SPECTATOR_PROTOCOL, redactSpectatorEvent } from "./spectator.js";
14
+ /** A connection that does not authenticate within this window is closed. */
15
+ export const SPECTATOR_HELLO_TIMEOUT_MS = 10_000;
16
+ /** Overall cap on simultaneous spectator sockets. */
17
+ export const SPECTATOR_MAX_CONNS = 32;
18
+ export class SpectatorServer {
19
+ server = null;
20
+ conns = new Set();
21
+ hub;
22
+ now;
23
+ backlog;
24
+ discoveryPath;
25
+ token;
26
+ droppedCount = 0;
27
+ constructor(opts) {
28
+ this.hub = opts.hub;
29
+ this.now = opts.now ?? (() => Date.now());
30
+ this.backlog = Math.max(0, opts.backlog ?? 200);
31
+ this.token = opts.token ?? randomBytes(32).toString('base64url');
32
+ this.discoveryPath = opts.discoveryPath === undefined ? join(dextermHome(opts.env), 'spectator.json') : opts.discoveryPath;
33
+ }
34
+ get port() { return this.server?.port ?? 0; }
35
+ get connectionCount() { return this.conns.size; }
36
+ /** Inbound frames that were ignored because the stream is outbound-only (anything but hello/ping). */
37
+ get dropped() { return this.droppedCount; }
38
+ async start() {
39
+ this.server = await createWsServer({ port: undefined, onConnection: (ws) => this.onConnection(ws) });
40
+ this.writeDiscovery();
41
+ return this.server.port;
42
+ }
43
+ async close() {
44
+ for (const c of [...this.conns]) {
45
+ c.off?.();
46
+ if (c.helloTimer)
47
+ clearTimeout(c.helloTimer);
48
+ try {
49
+ c.ws.close(1001);
50
+ }
51
+ catch { /* ignore */ }
52
+ }
53
+ this.conns.clear();
54
+ if (this.discoveryPath) {
55
+ try {
56
+ rmSync(this.discoveryPath);
57
+ }
58
+ catch { /* ignore */ }
59
+ }
60
+ if (this.server) {
61
+ await this.server.close();
62
+ this.server = null;
63
+ }
64
+ }
65
+ onConnection(ws) {
66
+ if (this.conns.size >= SPECTATOR_MAX_CONNS) {
67
+ ws.close(1013);
68
+ return;
69
+ }
70
+ const conn = { ws, authed: false, off: null, helloTimer: null };
71
+ this.conns.add(conn);
72
+ conn.helloTimer = setTimeout(() => { if (!conn.authed) {
73
+ try {
74
+ ws.close(4001);
75
+ }
76
+ catch { /* ignore */ }
77
+ } }, SPECTATOR_HELLO_TIMEOUT_MS);
78
+ conn.helloTimer.unref?.();
79
+ ws.onMessage = (text) => this.onMessage(conn, text);
80
+ ws.onClose = () => this.onClose(conn);
81
+ }
82
+ onClose(conn) {
83
+ if (conn.helloTimer)
84
+ clearTimeout(conn.helloTimer);
85
+ conn.off?.();
86
+ this.conns.delete(conn);
87
+ }
88
+ onMessage(conn, text) {
89
+ if (text.length > 64 * 1024) {
90
+ conn.ws.close(1009);
91
+ return;
92
+ }
93
+ let msg;
94
+ try {
95
+ msg = JSON.parse(text);
96
+ }
97
+ catch {
98
+ this.droppedCount++;
99
+ return;
100
+ }
101
+ const method = msg && typeof msg === 'object' ? msg.method : undefined;
102
+ if (method === 'hello') {
103
+ this.onHello(conn, msg.token);
104
+ return;
105
+ }
106
+ if (method === 'ping') {
107
+ conn.ws.send({ type: 'pong', t: this.now() });
108
+ return;
109
+ }
110
+ // Outbound-only: anything else is ignored. There is NO branch that reaches a control.
111
+ this.droppedCount++;
112
+ }
113
+ onHello(conn, token) {
114
+ if (conn.authed)
115
+ return;
116
+ if (typeof token !== 'string' || !this.tokenOk(token)) {
117
+ try {
118
+ conn.ws.close(4002);
119
+ }
120
+ catch { /* ignore */ }
121
+ return;
122
+ }
123
+ conn.authed = true;
124
+ if (conn.helloTimer) {
125
+ clearTimeout(conn.helloTimer);
126
+ conn.helloTimer = null;
127
+ }
128
+ conn.ws.send({ type: 'welcome', protocol: SPECTATOR_PROTOCOL, redacted: true, t: this.now() });
129
+ for (const ev of this.hub.recent(this.backlog))
130
+ conn.ws.send(this.frame(ev, true));
131
+ conn.off = this.hub.on((ev) => conn.ws.send(this.frame(ev, false)));
132
+ }
133
+ frame(ev, snapshot) {
134
+ return { type: 'event', snapshot, event: redactSpectatorEvent(ev) };
135
+ }
136
+ tokenOk(given) {
137
+ const a = Buffer.from(given), b = Buffer.from(this.token);
138
+ return a.length === b.length && timingSafeEqual(a, b);
139
+ }
140
+ writeDiscovery() {
141
+ if (!this.discoveryPath)
142
+ return;
143
+ try {
144
+ mkdirSync(dirname(this.discoveryPath), { recursive: true, mode: 0o700 });
145
+ writeFileSync(this.discoveryPath, JSON.stringify({ protocol: SPECTATOR_PROTOCOL, port: this.port, token: this.token, pid: process.pid, startedAt: this.now() }, null, 2) + '\n', { mode: 0o600 });
146
+ try {
147
+ chmodSync(this.discoveryPath, 0o600);
148
+ }
149
+ catch { /* ignore */ }
150
+ }
151
+ catch { /* discovery is best-effort */ }
152
+ }
153
+ }