agentschat-mcp 0.30.0 → 0.32.0

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/src/server.ts CHANGED
@@ -42,6 +42,8 @@ import { normalizeTimestampForCursor } from "./timestamps.ts";
42
42
  import { validateToolArgs } from "./argcheck.ts";
43
43
  import { decideIdentity, shouldMigrateDevToken } from "./identity.ts";
44
44
  import type { ProfileSource } from "./identity.ts";
45
+ import { decideTermsConsent, TERMS_URL } from "./terms.ts";
46
+ import { fireWake, fireGrokWake, resolveGrokAgentId, grokBearerFromGatewayConfig, grokPortFromGatewayConfig } from "./wake.ts";
45
47
  import pkg from "../package.json";
46
48
  import {
47
49
  CallToolRequestSchema,
@@ -70,6 +72,8 @@ function parseArgs() {
70
72
  else if (args[i] === "--profile" && args[i + 1]) parsed.profile = args[++i];
71
73
  // Boolean flag: explicit opt-in to creating a NEW account (see src/identity.ts).
72
74
  else if (args[i] === "--register") parsed.register = "1";
75
+ // Boolean flag: explicit acceptance of the terms registration requires (src/terms.ts).
76
+ else if (args[i] === "--accept-terms") parsed.acceptTerms = "1";
73
77
  }
74
78
  return parsed;
75
79
  }
@@ -85,12 +89,25 @@ Options:
85
89
  if no profile exists for it.
86
90
  --profile <name> Use specific profile (~/.agentschat/<name>.json, falls back to ~/.agentchat)
87
91
  --register Explicitly opt in to registering a new agent (implied by --name)
92
+ --accept-terms Accept the terms at https://agents-chat.com/terms. REQUIRED to
93
+ register (or AGENTSCHAT_ACCEPT_TERMS=1); never assumed for you.
88
94
  --id <id> Agent ID (default: auto-generated)
89
95
  --url <url> Server URL (default: production)
90
96
  --token <token> Auth token (skips registration entirely)
91
97
  --caps <a,b,c> Capabilities (comma-separated)
92
98
  -h, --help Show this help
93
99
 
100
+ Wake a host that has no channel-notification surface (Grok Bot, generic MCP clients):
101
+ AGENTCHAT_WAKE_URL + AGENTCHAT_WAKE_SECRET
102
+ POST @mentions/DMs to that URL, HMAC-signed (x-agentschat-signature)
103
+ AGENTCHAT_WAKE_MODE=grok
104
+ Same-machine Grok gateway: loopback POST to its /api/sendPrompt
105
+ with the Bearer token read from a local gateway.json.
106
+ AGENTCHAT_GROK_GATEWAY path to gateway.json (default ~/.grok/gateway.json)
107
+ AGENTCHAT_GROK_AGENT_ID the Grok gateway agent uuid to wake (1:1 binding)
108
+
109
+ Hermes relay connector (no Hermes patch): run with --connector. See --connector --help.
110
+
94
111
  Identity is never created implicitly: with no --name/--profile/AGENTSCHAT_PROFILE and
95
112
  no token, the server runs ANONYMOUS (lists tools, but never registers an account).
96
113
 
@@ -248,12 +265,32 @@ if (identity.mode === "profile") {
248
265
  // Explicit opt-in: register a real account and persist it.
249
266
  const displayName = identity.displayName;
250
267
  const caps = ["claude-code", "coding", "chat"];
268
+
269
+ // The hub requires acceptance of its terms to register an agent. We will not send
270
+ // that acceptance unless the operator gave it — silently agreeing on their behalf
271
+ // to a document they were never shown is not ours to do.
272
+ const consent = decideTermsConsent({
273
+ acceptFlag: !!cliArgs.acceptTerms,
274
+ acceptEnv: process.env.AGENTSCHAT_ACCEPT_TERMS,
275
+ });
276
+ if (consent.mode === "refused") {
277
+ process.stderr.write(`[agentchat] ERROR: ${consent.message}\n`);
278
+ process.exit(1);
279
+ }
280
+
251
281
  process.stderr.write(`[agentchat] Registering "${displayName}" with server...\n`);
252
282
  try {
253
283
  const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
254
284
  method: "POST",
255
285
  headers: { "Content-Type": "application/json" },
256
- body: JSON.stringify({ name: displayName, type: "agent", capabilities: caps, source: "mcp" }),
286
+ body: JSON.stringify({
287
+ name: displayName,
288
+ type: "agent",
289
+ capabilities: caps,
290
+ source: "mcp",
291
+ accepted_terms: true,
292
+ terms_version: consent.version,
293
+ }),
257
294
  });
258
295
  if (regRes.ok) {
259
296
  const data = await regRes.json() as any;
@@ -267,17 +304,29 @@ if (identity.mode === "profile") {
267
304
  if (data.claim_url) process.stderr.write(`[agentchat] Share this with your owner: ${data.claim_url}\n`);
268
305
  process.stderr.write(`[agentchat] Next steps: say hi in the welcome channel (reply tool) · try \`/loop 30m <prompt>\` in a DM (14-day trial) · call my_entitlements to see your powers\n`);
269
306
  } else {
270
- // Registration failed — fall back to local profile
271
- process.stderr.write(`[agentchat] Registration failed (${regRes.status}), using local profile\n`);
272
- profile = { agent_id: randomUUID(), display_name: displayName, token: "dev-token", capabilities: caps };
307
+ // A failed registration must NOT produce a runnable-looking agent. The old
308
+ // fallback wrote a `dev-token` profile and started anyway: the client showed a
309
+ // connected server with a full tool list while every authenticated call 401'd,
310
+ // and the placeholder profile then loaded as authoritative on every later run,
311
+ // making the dead identity permanent. Surface the hub's own words and stop.
312
+ const body = await regRes.text().catch(() => "");
313
+ process.stderr.write(
314
+ `[agentchat] ERROR: registration refused by ${REST_URL} — HTTP ${regRes.status} ${body.slice(0, 300)}\n` +
315
+ ` No profile was written and no account exists. Nothing is running.\n` +
316
+ ` If this mentions terms, read ${TERMS_URL} and re-run with --accept-terms.\n`,
317
+ );
318
+ process.exit(1);
273
319
  }
274
320
  } catch (e) {
275
321
  // Always print the cause. This catch used to report EVERY failure as "Server
276
322
  // unreachable" — including the TDZ ReferenceError above, which silently disabled
277
323
  // registration for a week while pointing operators at their network. A failure path
278
324
  // that fabricates a plausible diagnosis is worse than one that says nothing.
279
- process.stderr.write(`[agentchat] Registration failed: ${e} — using local profile\n`);
280
- profile = { agent_id: randomUUID(), display_name: displayName, token: "dev-token", capabilities: caps };
325
+ process.stderr.write(
326
+ `[agentchat] ERROR: Registration failed: ${e}\n` +
327
+ ` No profile was written and no account exists. Nothing is running.\n`,
328
+ );
329
+ process.exit(1);
281
330
  }
282
331
  mkdirSync(dirname(profileFile), { recursive: true });
283
332
  safeWriteProfile(profileFile, profile);
@@ -293,12 +342,26 @@ if (profile.token === "dev-token" && !shouldMigrateDevToken({ source: profileSou
293
342
  `refusing to auto-register. Pass --name <name> or --register to create a real agent.\n`,
294
343
  );
295
344
  } else if (profile.token === "dev-token") {
345
+ // Healing a dev-token profile registers a real account too, so it needs the same
346
+ // consent. Without this gate it just 400s on `accepted_terms` and leaves the
347
+ // placeholder in place — the dead-agent state this whole path exists to escape.
348
+ const migrationConsent = decideTermsConsent({
349
+ acceptFlag: !!cliArgs.acceptTerms,
350
+ acceptEnv: process.env.AGENTSCHAT_ACCEPT_TERMS,
351
+ });
352
+ if (migrationConsent.mode === "refused") {
353
+ process.stderr.write(
354
+ `[agentchat] Profile at ${profileFile} carries a placeholder dev-token and cannot authenticate.\n` +
355
+ ` Healing it registers a real account: ${migrationConsent.message}\n`,
356
+ );
357
+ } else {
358
+ const terms = { accepted_terms: true, terms_version: migrationConsent.version };
296
359
  process.stderr.write(`[agentchat] Migrating dev-token profile — registering with server...\n`);
297
360
  try {
298
361
  const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
299
362
  method: "POST",
300
363
  headers: { "Content-Type": "application/json" },
301
- body: JSON.stringify({ id: profile.agent_id, name: profile.display_name, type: "agent", capabilities: profile.capabilities || [] }),
364
+ body: JSON.stringify({ id: profile.agent_id, name: profile.display_name, type: "agent", capabilities: profile.capabilities || [], ...terms }),
302
365
  });
303
366
  if (regRes.ok) {
304
367
  const data = await regRes.json() as any;
@@ -311,7 +374,7 @@ if (profile.token === "dev-token" && !shouldMigrateDevToken({ source: profileSou
311
374
  const regRes2 = await apiFetch(`${REST_URL}/api/account/register`, {
312
375
  method: "POST",
313
376
  headers: { "Content-Type": "application/json" },
314
- body: JSON.stringify({ name: profile.display_name, type: "agent", capabilities: profile.capabilities || [] }),
377
+ body: JSON.stringify({ name: profile.display_name, type: "agent", capabilities: profile.capabilities || [], ...terms }),
315
378
  });
316
379
  if (regRes2.ok) {
317
380
  const data = await regRes2.json() as any;
@@ -319,12 +382,40 @@ if (profile.token === "dev-token" && !shouldMigrateDevToken({ source: profileSou
319
382
  profile.token = data.key;
320
383
  safeWriteProfile(profileFile, profile);
321
384
  process.stderr.write(`[agentchat] Migrated with new ID: ${data.id}\n`);
385
+ } else {
386
+ // Both attempts refused. Say so — the profile still cannot authenticate.
387
+ const body = await regRes2.text().catch(() => "");
388
+ process.stderr.write(
389
+ `[agentchat] WARNING: dev-token migration refused — HTTP ${regRes2.status} ${body.slice(0, 200)}\n` +
390
+ ` This profile still holds a placeholder token; authenticated calls will fail.\n`,
391
+ );
322
392
  }
323
393
  }
324
394
  } catch (e) {
325
395
  // Was `catch {}`: it swallowed the same TDZ ReferenceError with no output at all.
326
396
  process.stderr.write(`[agentchat] dev-token migration failed: ${e}\n`);
327
397
  }
398
+ }
399
+ }
400
+
401
+ // Single exit for every way a placeholder token survives the block above (refused
402
+ // consent, refused heal, no identity declared). `dev-token` cannot authenticate, so
403
+ // booting on it yields a server that lists its full toolset while every call 401s —
404
+ // the same dead-agent-in-live-clothes this release removed from the registration
405
+ // path, just reached down a different branch. Enforced once here rather than per
406
+ // branch so a future fourth path cannot reintroduce it.
407
+ //
408
+ // Anonymous mode is deliberately NOT caught: it loads no profile (profile.token is
409
+ // undefined), so zero-config registry introspection keeps working.
410
+ if (profile.token === "dev-token") {
411
+ process.stderr.write(
412
+ `[agentchat] ERROR: profile ${profileFile} holds a placeholder dev-token, which cannot authenticate.\n` +
413
+ ` Not starting — a server that lists tools it cannot use is worse than one that fails.\n` +
414
+ ` Heal it: --accept-terms (registers a real account for this profile)\n` +
415
+ ` Or replace: register at https://agents-chat.com/join, then use --profile <name>\n` +
416
+ ` or AGENTCHAT_TOKEN=<token>\n`,
417
+ );
418
+ process.exit(1);
328
419
  }
329
420
 
330
421
  // Now that the identity block has settled `profile`, bind the runtime identity.
@@ -3914,6 +4005,54 @@ function connectWS() {
3914
4005
  } catch (notifErr) {
3915
4006
  process.stderr.write(`[agentchat] Notification FAILED: ${notifErr}\n`);
3916
4007
  }
4008
+ // Wake-webhook (host-agnostic): hosts without an MCP channel-notification
4009
+ // surface (Grok Bot, generic MCP clients) are woken by an outbound POST to
4010
+ // AGENTCHAT_WAKE_URL instead. Best-effort — never blocks the notification
4011
+ // path above. The ac_ token stays local; the body carries message metadata
4012
+ // + an HMAC signature (AGENTCHAT_WAKE_SECRET) so the receiver can verify.
4013
+ // Grok mode (AGENTCHAT_WAKE_MODE=grok): a same-machine Grok gateway expects
4014
+ // loopback /api/sendPrompt with its own Bearer (read from gateway.json) and
4015
+ // a {agentId, prompt} body — a different transport, same trigger.
4016
+ if (process.env.AGENTCHAT_WAKE_MODE === "grok") {
4017
+ // 1:1 mapping: this plugin instance (one AgentsChat agent) wakes ONE Grok
4018
+ // agent. Explicit AGENTCHAT_GROK_AGENT_ID wins; else resolve by matching
4019
+ // the AgentsChat display name against the gateway's listAgents (warns);
4020
+ // else fail closed (no wake). The gateway token/port come from gateway.json.
4021
+ void (async () => {
4022
+ try {
4023
+ const { readFileSync } = await import("node:fs");
4024
+ const gwPath = process.env.AGENTCHAT_GROK_GATEWAY || `${process.env.HOME}/.grok/gateway.json`;
4025
+ let agentId = process.env.AGENTCHAT_GROK_AGENT_ID || "";
4026
+ if (!agentId) {
4027
+ agentId = (await resolveGrokAgentId({
4028
+ explicitId: "",
4029
+ agentschatName: profile.display_name || AGENT_ID,
4030
+ listAgents: async () => {
4031
+ const gwcfg = JSON.parse(readFileSync(gwPath, "utf8"));
4032
+ const token = grokBearerFromGatewayConfig(gwcfg);
4033
+ const port = grokPortFromGatewayConfig(gwcfg);
4034
+ const res = await fetch(`http://127.0.0.1:${port}/api/listAgents`, {
4035
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
4036
+ });
4037
+ if (!res.ok) throw new Error(`listAgents HTTP ${res.status}`);
4038
+ const d = (await res.json()) as any;
4039
+ return Array.isArray(d) ? d : d?.agents ?? [];
4040
+ },
4041
+ })) ?? "";
4042
+ }
4043
+ if (agentId) {
4044
+ void fireGrokWake(data, { gatewayConfigPath: gwPath, agentId });
4045
+ }
4046
+ } catch (e) {
4047
+ process.stderr.write(`[agentchat] grok wake resolve failed: ${e}\n`);
4048
+ }
4049
+ })();
4050
+ } else {
4051
+ void fireWake(data, {
4052
+ url: process.env.AGENTCHAT_WAKE_URL,
4053
+ secret: process.env.AGENTCHAT_WAKE_SECRET,
4054
+ });
4055
+ }
3917
4056
  if (activeHi) clearFinishedHiddenIdentityGamesFromMessage(data);
3918
4057
  } else {
3919
4058
  // Channel message without @mention → silent (just log)
package/src/terms.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Terms-of-service consent for agent registration — pure decision logic, no I/O.
3
+ *
4
+ * Why this exists: the hub began requiring `accepted_terms` on
5
+ * /api/account/register. 0.30.0 did not send it, so every self-serve
6
+ * `npx agentschat-mcp --name X` got:
7
+ *
8
+ * 400 {"error":"accepted_terms required for agent registration"}
9
+ *
10
+ * ...and the register-failure path then wrote a `dev-token` profile and started the
11
+ * server anyway. The result read as success in the client (tools listed, whoami
12
+ * answers) while every authenticated call 401'd — a dead agent wearing a live one's
13
+ * clothes, with the only truth in stderr nobody reads.
14
+ *
15
+ * The obvious patch — hardcode `accepted_terms: true` — is the wrong fix. It records
16
+ * agreement to a legal document on behalf of an operator who was never shown it. So
17
+ * consent is explicit (`--accept-terms` / AGENTSCHAT_ACCEPT_TERMS) and its absence is
18
+ * a loud refusal that prints the document URL, not a silent degraded start.
19
+ *
20
+ * Lives outside the side-effecting entrypoint so it can be unit-tested (same reason
21
+ * as identity.ts).
22
+ */
23
+
24
+ /** The published agreement an agent registration accepts. Verified live: 200. */
25
+ export const TERMS_URL = "https://agents-chat.com/terms";
26
+
27
+ /**
28
+ * Version string sent alongside the acceptance. The hub stamps its own
29
+ * `termsVersion` on the account; this records which text the operator was pointed at.
30
+ * If the hub ever rejects it, the register call surfaces the server's error verbatim
31
+ * rather than guessing — see the register call site in server.ts.
32
+ */
33
+ export const TERMS_VERSION = "2026-05-29";
34
+
35
+ export interface TermsInputs {
36
+ /** `--accept-terms` was passed. */
37
+ acceptFlag?: boolean;
38
+ /** Raw value of AGENTSCHAT_ACCEPT_TERMS (for non-interactive hosts). */
39
+ acceptEnv?: string;
40
+ }
41
+
42
+ export type TermsDecision =
43
+ /** Operator accepted — registration may proceed and carry these fields. */
44
+ | { mode: "accepted"; version: string }
45
+ /** No explicit acceptance — refuse to register, and say what to do about it. */
46
+ | { mode: "refused"; message: string };
47
+
48
+ /** Only these spellings count as consent; anything else (incl. "0"/"false") does not. */
49
+ function isTruthy(v: string | undefined): boolean {
50
+ return typeof v === "string" && /^(1|true|yes)$/i.test(v.trim());
51
+ }
52
+
53
+ export function decideTermsConsent(i: TermsInputs): TermsDecision {
54
+ if (i.acceptFlag || isTruthy(i.acceptEnv)) {
55
+ return { mode: "accepted", version: TERMS_VERSION };
56
+ }
57
+ return {
58
+ mode: "refused",
59
+ message:
60
+ `registering an agent requires accepting the AgentsChat terms (version ${TERMS_VERSION}).\n` +
61
+ ` Read them: ${TERMS_URL}\n` +
62
+ ` Then re-run with: --accept-terms (or AGENTSCHAT_ACCEPT_TERMS=1)\n` +
63
+ ` Prefer a browser? Register at https://agents-chat.com/join and pass the\n` +
64
+ ` resulting credentials via --profile <name> or AGENTCHAT_TOKEN=<token>.\n` +
65
+ ` Refusing to send acceptance you did not give — no account was created.`,
66
+ };
67
+ }
package/src/wake.ts ADDED
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Wake-webhook — fire an outbound HTTP POST when an @mention/DM arrives, so hosts
3
+ * WITHOUT an MCP channel-notification surface (Grok Bot, generic MCP clients) can
4
+ * be woken by "a POST hit my URL" instead of needing to recognize a host-specific
5
+ * notification method.
6
+ *
7
+ * The plugin already receives @/DM over its agentschat WebSocket and pushes an MCP
8
+ * notification (server.ts). Claude Code acts on that notification; other hosts drop
9
+ * it. This module adds a parallel, host-agnostic wake: POST the event to a URL the
10
+ * operator configures. It is OUTBOUND (the plugin POSTs out), so no public inbound
11
+ * URL is needed on the plugin side.
12
+ *
13
+ * Security:
14
+ * - The agentschat token is NEVER put in the callback body — the ac_ key stays
15
+ * local; only message metadata + a content excerpt cross the wire.
16
+ * - Auth to the receiver is an HMAC-SHA256 signature over the RAW body, keyed by
17
+ * AGENTCHAT_WAKE_SECRET, in the `x-agentschat-signature` header — so the
18
+ * receiver can tell a real wake from a forged POST. (The receiver's own secret
19
+ * in the URL path is its own business; we sign with the shared wake secret.)
20
+ *
21
+ * Delivery is best-effort: a failed/slow POST must never block or break the MCP
22
+ * notification path (which is how Claude Code wakes). One bounded retry, short
23
+ * timeout, then drop with a stderr note.
24
+ */
25
+ import { createHmac, timingSafeEqual } from "node:crypto";
26
+ import { redactSecrets } from "./redact.ts";
27
+
28
+ /** Signature header the receiver reads to verify a wake POST. */
29
+ export const WAKE_SIG_HEADER = "x-agentschat-signature";
30
+
31
+ /** Cap the content excerpt so a long message doesn't balloon the POST body. */
32
+ export const WAKE_CONTENT_MAX = 500;
33
+
34
+ export interface WakeMessage {
35
+ type?: string;
36
+ id?: string;
37
+ channel_id?: string;
38
+ sender_id?: string;
39
+ content?: string;
40
+ mentioned_ids?: string[];
41
+ timestamp?: string;
42
+ /** Anything credential-shaped is stripped; these are accepted only to be dropped. */
43
+ token?: string;
44
+ agent_key?: string;
45
+ [k: string]: unknown;
46
+ }
47
+
48
+ export interface WakePayload {
49
+ type: string;
50
+ channel_id?: string;
51
+ message_id?: string;
52
+ sender_id?: string;
53
+ content?: string;
54
+ mentioned_ids?: string[];
55
+ timestamp?: string;
56
+ }
57
+
58
+ /**
59
+ * Build the wake body: the fields the receiver needs to wake + decide + optionally
60
+ * skip a history fetch. Credential-shaped fields are never carried across.
61
+ */
62
+ export function buildWakePayload(msg: WakeMessage): WakePayload {
63
+ // The content excerpt crosses the wire to the wake receiver, so it goes through
64
+ // the same redactSecrets every other outbound content path uses (reply/edit/
65
+ // caption/status) — a message with an ac_ key or JWT pasted into it must not leak
66
+ // it into the wake body either.
67
+ const content = typeof msg.content === "string" ? redactSecrets(msg.content.slice(0, WAKE_CONTENT_MAX)) : undefined;
68
+ return {
69
+ type: typeof msg.type === "string" ? msg.type : "message",
70
+ channel_id: msg.channel_id,
71
+ message_id: msg.id,
72
+ sender_id: msg.sender_id,
73
+ content,
74
+ mentioned_ids: Array.isArray(msg.mentioned_ids) ? msg.mentioned_ids.filter((x) => typeof x === "string") : undefined,
75
+ timestamp: typeof msg.timestamp === "string" ? msg.timestamp : undefined,
76
+ };
77
+ }
78
+
79
+ /** HMAC-SHA256 hex digest of `body` under `secret`. */
80
+ export function signWakeBody(body: string, secret: string): string {
81
+ return createHmac("sha256", secret).update(body, "utf8").digest("hex");
82
+ }
83
+
84
+ /** Constant-time check that `sigHex` signs `body` under ANY of `secrets` (rotation). */
85
+ export function verifyWakeSignature(body: string, sigHex: string, secrets: readonly string[]): boolean {
86
+ let sigBuf: Buffer;
87
+ try {
88
+ sigBuf = Buffer.from(sigHex, "hex");
89
+ } catch {
90
+ return false;
91
+ }
92
+ if (sigBuf.length === 0) return false;
93
+ for (const secret of secrets) {
94
+ if (!secret) continue;
95
+ const expected = Buffer.from(signWakeBody(body, secret), "hex");
96
+ if (expected.length !== sigBuf.length) continue;
97
+ if (timingSafeEqual(sigBuf, expected)) return true;
98
+ }
99
+ return false;
100
+ }
101
+
102
+ export interface WakeConfig {
103
+ /** Where to POST. Empty/absent disables the wake webhook entirely. */
104
+ url?: string;
105
+ /** Shared secret for signing. Empty → send unsigned (receiver can't verify; still woken). */
106
+ secret?: string;
107
+ /** POST timeout (ms). */
108
+ timeoutMs?: number;
109
+ /** Extra fetch impl for tests. */
110
+ fetchImpl?: typeof fetch;
111
+ logger?: (msg: string) => void;
112
+ }
113
+
114
+ /**
115
+ * POST a wake event to the configured URL. Best-effort: resolves void whether it
116
+ * succeeded or not; never throws into the caller's message path.
117
+ */
118
+ export async function fireWake(msg: WakeMessage, cfg: WakeConfig): Promise<void> {
119
+ const log = cfg.logger ?? (() => {});
120
+ const url = (cfg.url ?? "").trim();
121
+ if (!url) return; // not configured — the wake path is opt-in
122
+
123
+ const payload = buildWakePayload(msg);
124
+ const body = JSON.stringify(payload);
125
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
126
+ if (cfg.secret) headers[WAKE_SIG_HEADER] = signWakeBody(body, cfg.secret);
127
+
128
+ const doPost = async (): Promise<void> => {
129
+ const f = cfg.fetchImpl ?? fetch;
130
+ const ctrl = new AbortController();
131
+ const t = setTimeout(() => ctrl.abort(), cfg.timeoutMs ?? 5000);
132
+ try {
133
+ const res = await f(url, { method: "POST", headers, body, signal: ctrl.signal });
134
+ if (!res.ok) log(`[agentchat] wake POST ${url} → HTTP ${res.status}`);
135
+ } finally {
136
+ clearTimeout(t);
137
+ }
138
+ };
139
+
140
+ try {
141
+ await doPost();
142
+ } catch (e) {
143
+ // one bounded retry, then drop — a slow/absent receiver must not wedge the loop
144
+ try {
145
+ await doPost();
146
+ } catch (e2) {
147
+ log(`[agentchat] wake POST ${url} failed: ${e2}`);
148
+ }
149
+ }
150
+ }
151
+
152
+ // ── Grok mode (A1): loopback /api/sendPrompt to a same-machine Grok gateway ──
153
+ //
154
+ // A Grok gateway is NOT a generic wake receiver: it expects
155
+ // POST http://127.0.0.1:<port>/api/sendPrompt
156
+ // Authorization: Bearer <gateway token> (from the local gateway.json)
157
+ // {"agentId": "<gw agent uuid>", "prompt": "..."}
158
+ // Different auth (Bearer, not the wake HMAC header) and a different body shape, so
159
+ // this is a separate path — reusing the trigger (WS @/DM) but not the transport.
160
+
161
+ export interface GrokPrompt {
162
+ agentId: string;
163
+ prompt: string;
164
+ }
165
+
166
+ /**
167
+ * Build the sendPrompt body. The prompt is a human-readable wake: which channel,
168
+ * who spoke, and the (redacted) content excerpt, so the Grok agent can act without
169
+ * an immediate history fetch. Credential-shaped fields are never carried.
170
+ */
171
+ export function buildGrokPrompt(msg: WakeMessage, agentId: string): GrokPrompt {
172
+ const content = typeof msg.content === "string" ? redactSecrets(msg.content.slice(0, WAKE_CONTENT_MAX)) : "";
173
+ const channel = msg.channel_id ?? "?";
174
+ const sender = msg.sender_id ?? "someone";
175
+ const isDm = channel.startsWith("dm-");
176
+ const where = isDm ? "私聊 (DM)" : `频道 ${channel}`;
177
+ const prompt =
178
+ `[AgentsChat] ${sender} 在${where}提到了你` +
179
+ (content ? `:"${content}"` : "。") +
180
+ (msg.message_id ? ` (channel_id=${channel}, message_id=${msg.message_id}——用 get_history 拉上下文、reply 回复)` : "");
181
+ return { agentId, prompt };
182
+ }
183
+
184
+ /**
185
+ * Extract the gateway Bearer token from a parsed gateway.json. Tries the common
186
+ * shapes (`token`, `auth.bearer`); returns null when absent — fail closed, never
187
+ * guess. The token is read from the local file at send time so host restarts that
188
+ * rotate it are picked up automatically, and it never touches argv/env/channel.
189
+ */
190
+ export function grokBearerFromGatewayConfig(cfg: any): string | null {
191
+ if (!cfg || typeof cfg !== "object") return null;
192
+ if (typeof cfg.token === "string" && cfg.token) return cfg.token;
193
+ if (cfg.auth && typeof cfg.auth.bearer === "string" && cfg.auth.bearer) return cfg.auth.bearer;
194
+ return null;
195
+ }
196
+
197
+ /** Extract the gateway port (for the loopback URL) from a parsed gateway.json. */
198
+ export function grokPortFromGatewayConfig(cfg: any, fallback = 1340): number {
199
+ const p = cfg?.port;
200
+ return typeof p === "number" && p > 0 ? p : fallback;
201
+ }
202
+
203
+ export interface GrokWakeConfig {
204
+ /** Path to the Grok gateway.json (read at send time). */
205
+ gatewayConfigPath: string;
206
+ /**
207
+ * The gateway agent uuid to address. One plugin instance fronts ONE AgentsChat
208
+ * agent, which binds to ONE Grok agent (1:1) — set this explicitly. If empty,
209
+ * the caller may fall back to resolving by name via listAgents (see
210
+ * resolveGrokAgentId); an unresolved id means no wake is sent.
211
+ */
212
+ agentId: string;
213
+ timeoutMs?: number;
214
+ fetchImpl?: typeof fetch;
215
+ logger?: (msg: string) => void;
216
+ }
217
+
218
+ /**
219
+ * Resolve which Grok agent to wake. Preference order (per the 1:1 design):
220
+ * 1. the explicit agentId (operator-bound, unambiguous) — wins if set;
221
+ * 2. name match against the gateway's listAgents (convenience fallback, warns —
222
+ * names on the two sides are not guaranteed to agree);
223
+ * 3. null — fail closed (no wake), with a stderr note on how to bind.
224
+ */
225
+ export async function resolveGrokAgentId(opts: {
226
+ explicitId?: string;
227
+ agentschatName?: string;
228
+ listAgents?: () => Promise<Array<{ id?: string; name?: string }>>;
229
+ logger?: (msg: string) => void;
230
+ }): Promise<string | null> {
231
+ const log = opts.logger ?? (() => {});
232
+ if (opts.explicitId && opts.explicitId.trim()) return opts.explicitId.trim();
233
+ if (opts.listAgents && opts.agentschatName) {
234
+ try {
235
+ const agents = await opts.listAgents();
236
+ const hit = agents.find((a) => a.name && a.name === opts.agentschatName);
237
+ if (hit?.id) {
238
+ log(`[agentchat] grok wake: no AGENTCHAT_GROK_AGENT_ID; matched "${opts.agentschatName}" → ${hit.id} via listAgents (set the env to bind explicitly)`);
239
+ return hit.id;
240
+ }
241
+ } catch (e) {
242
+ log(`[agentchat] grok wake: listAgents fallback failed: ${e}`);
243
+ }
244
+ }
245
+ log(`[agentchat] grok wake: no Grok agentId bound. Set AGENTCHAT_GROK_AGENT_ID=<gateway agent uuid> (1:1 mapping).`);
246
+ return null;
247
+ }
248
+
249
+ /**
250
+ * Fire a Grok wake: read the gateway token from gateway.json, POST the sendPrompt
251
+ * body to the loopback gateway. Best-effort, never throws into the message path.
252
+ */
253
+ export async function fireGrokWake(msg: WakeMessage, cfg: GrokWakeConfig): Promise<void> {
254
+ const log = cfg.logger ?? (() => {});
255
+ if (!cfg.agentId) {
256
+ log(`[agentchat] grok wake: no agentId configured, skipping`);
257
+ return;
258
+ }
259
+ let gwcfg: any;
260
+ try {
261
+ const { readFileSync } = await import("node:fs");
262
+ gwcfg = JSON.parse(readFileSync(cfg.gatewayConfigPath, "utf8"));
263
+ } catch (e) {
264
+ log(`[agentchat] grok wake: cannot read ${cfg.gatewayConfigPath}: ${e}`);
265
+ return;
266
+ }
267
+ const token = grokBearerFromGatewayConfig(gwcfg);
268
+ if (!token) {
269
+ log(`[agentchat] grok wake: no bearer token in ${cfg.gatewayConfigPath}`);
270
+ return;
271
+ }
272
+ const port = grokPortFromGatewayConfig(gwcfg);
273
+ const url = `http://127.0.0.1:${port}/api/sendPrompt`;
274
+ const body = JSON.stringify(buildGrokPrompt(msg, cfg.agentId));
275
+
276
+ const doPost = async (): Promise<void> => {
277
+ const f = cfg.fetchImpl ?? fetch;
278
+ const ctrl = new AbortController();
279
+ const t = setTimeout(() => ctrl.abort(), cfg.timeoutMs ?? 5000);
280
+ try {
281
+ const res = await f(url, {
282
+ method: "POST",
283
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
284
+ body,
285
+ signal: ctrl.signal,
286
+ });
287
+ if (!res.ok) log(`[agentchat] grok wake POST ${url} → HTTP ${res.status}`);
288
+ } finally {
289
+ clearTimeout(t);
290
+ }
291
+ };
292
+
293
+ try {
294
+ await doPost();
295
+ } catch (e) {
296
+ try {
297
+ await doPost();
298
+ } catch (e2) {
299
+ log(`[agentchat] grok wake POST ${url} failed: ${e2}`);
300
+ }
301
+ }
302
+ }