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.
- package/dist/packages/agents/src/bus.js +57 -6
- package/dist/packages/agents/src/executor.js +25 -0
- package/dist/packages/agents/src/follow.js +105 -0
- package/dist/packages/agents/src/gate.js +3 -0
- package/dist/packages/agents/src/identity.js +41 -2
- package/dist/packages/agents/src/index.js +5 -0
- package/dist/packages/agents/src/spectator-server.js +153 -0
- package/dist/packages/agents/src/spectator.js +212 -0
- package/dist/packages/agents/src/tiers.js +5 -0
- package/dist/packages/agents/src/tools.js +3 -0
- package/dist/packages/cli/assets/claude/analyst-system.md +6 -1
- package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +11 -3
- package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +13 -3
- package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +3 -3
- package/dist/packages/cli/src/agent-cmd.js +107 -10
- package/dist/packages/cli/src/agent-spectator.js +37 -0
- package/dist/packages/cli/src/alerts/adapters/desktop.js +40 -0
- package/dist/packages/cli/src/alerts/adapters/discord.js +36 -0
- package/dist/packages/cli/src/alerts/adapters/telegram.js +43 -0
- package/dist/packages/cli/src/alerts/adapters/toast.js +16 -0
- package/dist/packages/cli/src/alerts/creds.js +83 -0
- package/dist/packages/cli/src/alerts/format.js +66 -0
- package/dist/packages/cli/src/alerts/index.js +10 -0
- package/dist/packages/cli/src/alerts/notifier.js +53 -0
- package/dist/packages/cli/src/alerts/route.js +19 -0
- package/dist/packages/cli/src/alerts/types.js +1 -0
- package/dist/packages/cli/src/alerts-cmd.js +212 -0
- package/dist/packages/cli/src/app-help-gen.js +365 -0
- package/dist/packages/cli/src/backtest-cmd.js +62 -3
- package/dist/packages/cli/src/coin-target.js +2 -0
- package/dist/packages/cli/src/doctor.js +21 -6
- package/dist/packages/cli/src/export.js +235 -0
- package/dist/packages/cli/src/follow-cmd.js +107 -1
- package/dist/packages/cli/src/help-cmd.js +48 -0
- package/dist/packages/cli/src/live/index.js +66 -3
- package/dist/packages/cli/src/live/screens/token-live.js +24 -0
- package/dist/packages/cli/src/live/signals.js +109 -0
- package/dist/packages/cli/src/live/trade.js +24 -1
- package/dist/packages/cli/src/main.js +596 -49
- package/dist/packages/cli/src/mcp-cmd.js +321 -0
- package/dist/packages/cli/src/pnl-app.js +221 -0
- package/dist/packages/cli/src/pnl-card.js +615 -0
- package/dist/packages/cli/src/record-cmd.js +455 -0
- package/dist/packages/cli/src/rules.js +69 -2
- package/dist/packages/cli/src/screens/follows.js +91 -2
- package/dist/packages/cli/src/screens/overlay.js +3 -0
- package/dist/packages/cli/src/screens/positions.js +1 -1
- package/dist/packages/cli/src/screens/rules.js +1 -1
- package/dist/packages/cli/src/skill-eval-build.js +109 -0
- package/dist/packages/cli/src/spectate-cmd.js +104 -0
- package/dist/packages/cli/src/state.js +1 -1
- package/dist/packages/cli/src/update.js +294 -0
- package/dist/packages/cli/src/version.js +3 -0
- package/dist/packages/cli/src/voice-cmd.js +194 -0
- package/dist/packages/cli/src/voice-hud.js +1 -1
- package/dist/packages/cli/src/watch-cmd.js +190 -0
- package/dist/packages/custom/src/rules.js +3 -2
- package/dist/packages/custom/src/schema.js +24 -1
- package/dist/packages/data/src/analyst-actions.js +130 -3
- package/dist/packages/data/src/coin-target.js +82 -0
- package/dist/packages/data/src/fast-lane.js +16 -2
- package/dist/packages/data/src/index.js +4 -0
- package/dist/packages/data/src/intel/helius-history.js +12 -1
- package/dist/packages/data/src/intel/live-signals.js +185 -0
- package/dist/packages/data/src/larp/link-baseline.js +196 -0
- package/dist/packages/data/src/sources/dexscreener.js +14 -0
- package/dist/packages/data/src/sources/pumpportal.js +6 -1
- package/dist/packages/data/src/trade/copy/index.js +1 -1
- package/dist/packages/data/src/trade/copy/stats.js +36 -2
- package/dist/packages/data/src/trade/copy/tracker.js +3 -2
- package/dist/packages/mcp/assets/kb/app-help/app-help-command.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +43 -0
- package/dist/packages/mcp/assets/kb/app-help/backtest.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/bubble-map.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/calm-mode.md +31 -0
- package/dist/packages/mcp/assets/kb/app-help/change-voice-speed.md +31 -0
- package/dist/packages/mcp/assets/kb/app-help/change-voice.md +30 -0
- package/dist/packages/mcp/assets/kb/app-help/chart-popup-selftest.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/coin-images.md +29 -0
- package/dist/packages/mcp/assets/kb/app-help/configure-rpc-key.md +28 -0
- package/dist/packages/mcp/assets/kb/app-help/connect-an-agent-registry.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +39 -0
- package/dist/packages/mcp/assets/kb/app-help/custom-keymap.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/edit-kb-or-skill.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/edit-rules.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/export-a-view.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/follow-a-wallet.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/getting-started.md +37 -0
- package/dist/packages/mcp/assets/kb/app-help/kol-lists.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/larp-check.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/link-a-wallet.md +41 -0
- package/dist/packages/mcp/assets/kb/app-help/login-with-x.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/narrative-board.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/open-a-coin.md +43 -0
- package/dist/packages/mcp/assets/kb/app-help/open-chart-popup.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/order-plans.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/price-or-marketcap.md +38 -0
- package/dist/packages/mcp/assets/kb/app-help/record-headless.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/recorder-history.md +39 -0
- package/dist/packages/mcp/assets/kb/app-help/rug-check.md +39 -0
- package/dist/packages/mcp/assets/kb/app-help/run-doctor.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +37 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-analyst.md +181 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-kol.md +190 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-newpairs.md +181 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +180 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-screener.md +199 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-token.md +240 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-trending.md +192 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-watchlist.md +181 -0
- package/dist/packages/mcp/assets/kb/app-help/set-a-filter.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/setup-neural-voice.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/share-packs.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/sort-the-screener.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/standing-mandates.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/switch-theme.md +42 -0
- package/dist/packages/mcp/assets/kb/app-help/turn-on-alerts.md +37 -0
- package/dist/packages/mcp/assets/kb/app-help/update-dexterm.md +30 -0
- package/dist/packages/mcp/assets/kb/app-help/use-presets.md +42 -0
- package/dist/packages/mcp/assets/kb/app-help/use-voice-mode.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/watch-a-coin.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/watch-headless.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/what-dexterm-shows-agents.md +31 -0
- package/dist/packages/mcp/assets/kb/app-help/workspaces.md +38 -0
- package/dist/packages/mcp/src/app-help.js +135 -0
- package/dist/packages/mcp/src/index.js +3 -0
- package/dist/packages/mcp/src/server.js +179 -5
- package/dist/packages/mcp/src/spectator-sink.js +72 -0
- package/dist/packages/packs/src/index.js +3 -0
- package/dist/packages/packs/src/skill-backtest.js +300 -0
- package/dist/packages/packs/src/skill-care.js +206 -0
- package/dist/packages/popup/src/pages/spectator.css +45 -0
- package/dist/packages/popup/src/pages/spectator.html +25 -0
- package/dist/packages/popup/src/pages/spectator.js +74 -0
- package/dist/packages/popup/src/selftest.js +19 -0
- package/dist/packages/popup/src/server.js +7 -1
- package/dist/packages/tui/src/index.js +3 -0
- package/dist/packages/tui/src/keymap.js +12 -0
- package/dist/packages/tui/src/raster-font.js +195 -0
- package/dist/packages/tui/src/raster.js +213 -0
- package/dist/packages/voice/src/controller.js +57 -4
- package/dist/packages/voice/src/doctor.js +96 -11
- package/dist/packages/voice/src/download.js +184 -0
- package/dist/packages/voice/src/engines.js +55 -8
- package/dist/packages/voice/src/index.js +9 -3
- package/dist/packages/voice/src/speakable.js +111 -0
- package/dist/packages/voice/src/stt/whisper.js +14 -4
- package/dist/packages/voice/src/tts/espeak.js +131 -0
- package/dist/packages/voice/src/tts/piper-setup.js +229 -0
- package/dist/packages/voice/src/tts/piper.js +449 -0
- package/dist/packages/voice/src/tts/say.js +72 -35
- package/dist/packages/voice/src/tts/voices.js +209 -0
- 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:
|
|
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
|
|
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.
|
|
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:
|
|
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) =>
|
|
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
|
+
}
|