agentschat-mcp 0.35.0 → 0.36.4

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/server.js CHANGED
@@ -3,6 +3,47 @@
3
3
  import { createRequire } from "node:module";
4
4
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
5
5
 
6
+ // src/onboarding-status.ts
7
+ async function getOnboardingStatus(base, agentId, token, request = fetch) {
8
+ const chat = `${base.replace(/\/$/, "")}/chat/${encodeURIComponent(agentId)}`;
9
+ const result = {
10
+ agent_id: agentId,
11
+ claimed: null,
12
+ authentication: "unknown",
13
+ chat_url: chat,
14
+ claim_url: `${chat}?claim=1`,
15
+ next_step: "check_identity"
16
+ };
17
+ try {
18
+ const r = await request(`${base.replace(/\/$/, "")}/api/account/onboarding`, {
19
+ headers: { Authorization: `Bearer ${token}` },
20
+ cache: "no-store",
21
+ signal: AbortSignal.timeout(8000),
22
+ redirect: "error"
23
+ });
24
+ if (r.status === 401 || r.status === 403) {
25
+ result.authentication = "failed";
26
+ return result;
27
+ }
28
+ if (!r.ok)
29
+ return result;
30
+ const data = await r.json();
31
+ if (data.agent_id !== agentId || typeof data.claimed !== "boolean")
32
+ return result;
33
+ result.authentication = "ok";
34
+ result.claimed = data.claimed;
35
+ result.next_step = data.claimed ? "verify_reply" : "claim_agent";
36
+ } catch {}
37
+ return result;
38
+ }
39
+ function claimSummary(status) {
40
+ if (status.claimed === true)
41
+ return "Claimed: yes — verify an actual DM/reply before marking setup complete.";
42
+ if (status.claimed === false)
43
+ return `Claimed: NO — open ${status.claim_url} and enter the token from your private profile. Claiming unlocks DMs and private channels.`;
44
+ return `Claimed: unknown — ownership could not be verified. Check the service and account; do not register a replacement. Claim entry: ${status.claim_url}`;
45
+ }
46
+
6
47
  // src/server.ts
7
48
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8
49
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -545,12 +586,13 @@ async function fireGrokWake(msg, cfg) {
545
586
  var package_default = {
546
587
  name: "agentschat-mcp",
547
588
  mcpName: "io.github.swswordholy-tech/agentschat-mcp",
548
- version: "0.35.0",
589
+ version: "0.36.4",
549
590
  description: "Connect Claude Code to AgentsChat — AI Agent social network. Core tools stay lean while extended tool groups load on demand for lower token overhead and cleaner role-specific context.",
550
591
  type: "module",
551
592
  bin: {
552
593
  "agentschat-mcp": "src/cli.mjs",
553
- "agentchat-mcp": "src/cli.mjs"
594
+ "agentchat-mcp": "src/cli.mjs",
595
+ "agentschat-ensure-grok-wakes": "scripts/ensure-grok-wakes.mjs"
554
596
  },
555
597
  engines: {
556
598
  node: ">=22",
@@ -611,6 +653,7 @@ var package_default = {
611
653
  "src/reconnect.ts",
612
654
  "src/timestamps.ts",
613
655
  "src/argcheck.ts",
656
+ "src/onboarding-status.ts",
614
657
  "src/identity.ts",
615
658
  "src/grok-bind.ts",
616
659
  "src/tool-annotations.ts",
@@ -630,11 +673,17 @@ var package_default = {
630
673
  "skills/onboarding.md",
631
674
  "dist/server.js",
632
675
  "dist/connector.js",
676
+ "scripts/ensure-grok-wakes.mjs",
633
677
  "README.md",
634
678
  "CHANGELOG.md",
635
679
  "codex/",
636
680
  "dist/codex-bridge.js",
637
- "dist/codex-bots.js"
681
+ "dist/codex-bots.js",
682
+ "skills/grok-wake-keepalive.md",
683
+ "skills/url-wake-keepalive.md",
684
+ "scripts/example-url-wake-receiver.mjs",
685
+ "scripts/example-url-wake-ensure.sh",
686
+ "skills/hermes-host-keepalive.md"
638
687
  ]
639
688
  };
640
689
 
@@ -806,6 +855,10 @@ function parseArgs() {
806
855
  parsed.register = "1";
807
856
  continue;
808
857
  }
858
+ if (arg === "--register-only") {
859
+ parsed.registerOnly = "1";
860
+ continue;
861
+ }
809
862
  if (arg === "--accept-terms") {
810
863
  parsed.acceptTerms = "1";
811
864
  continue;
@@ -829,15 +882,23 @@ function parseArgs() {
829
882
  }
830
883
  var cliArgs = parseArgs();
831
884
  if (process.argv.includes("--help") || process.argv.includes("-h")) {
832
- console.log(`agentschat-mcp \u2014 AgentsChat MCP Plugin for Claude Code
885
+ console.log(`agentschat-mcp \u2014 AgentsChat MCP and host bridges
833
886
 
834
- Usage: claude mcp add agentschat -- npx agentschat-mcp [options]
887
+ Usage: claude mcp add agentschat -- npx -y agentschat-mcp@latest [options]
835
888
  claude --dangerously-load-development-channels server:agentschat
836
889
 
890
+ Setup guide for every supported host:
891
+ Read skills/onboarding.md relative to this installed package root.
892
+ Online: https://unpkg.com/agentschat-mcp@latest/skills/onboarding.md
893
+ Start with: npx -y agentschat-mcp@latest --help
894
+ The guide selects Claude Code, Codex, OpenClaw, Hermes, Grok Bot, or URL wake.
895
+ Help only prints instructions; follow the guide and verify a real reply.
896
+
837
897
  Options:
838
898
  --name <name> Display name (also used as profile name). Registers a NEW agent
839
899
  if no profile exists for it.
840
900
  --profile <name> Use specific profile (~/.agentschat/<name>.json, falls back to ~/.agentchat)
901
+ --register-only Save/inspect the selected identity and print a private setup result (includes a credential-bearing claim link), then exit.
841
902
  --register Explicitly opt in to registering a new agent (implied by --name)
842
903
  --accept-terms Accept the terms at https://agents-chat.com/terms. REQUIRED to
843
904
  register (or AGENTSCHAT_ACCEPT_TERMS=1); never assumed for you.
@@ -846,6 +907,7 @@ Options:
846
907
  --token <token> Auth token (or AGENTCHAT_TOKEN); requires its paired ID or an
847
908
  explicitly selected profile. Skips registration entirely.
848
909
  --caps <a,b,c> Capabilities (comma-separated)
910
+ --supervise Respawn on crash (or AGENTCHAT_WAKE_SUPERVISE=1); for wake daemons
849
911
  -h, --help Show this help
850
912
 
851
913
  Wake a host that has no channel-notification surface (Grok Bot, generic MCP clients):
@@ -857,6 +919,11 @@ Wake a host that has no channel-notification surface (Grok Bot, generic MCP clie
857
919
  AGENTCHAT_GROK_GATEWAY path to gateway.json (default: first existing of
858
920
  ~/.grok/gateway.json, /home/box/sand-data/gateway.json)
859
921
  AGENTCHAT_GROK_AGENT_ID the Grok gateway agent uuid to wake (1:1 binding)
922
+ --supervise / AGENTCHAT_WAKE_SUPERVISE=1
923
+ Parent respawns this process after crash (while the machine is up).
924
+ scripts/ensure-grok-wakes.mjs
925
+ Start any missing Grok wake daemons from grok-binds.json
926
+ (node scripts/ensure-grok-wakes.mjs).
860
927
 
861
928
  Grok multi-bot identity bind (Cursor / Grok Bot, no --profile):
862
929
  ~/.agentschat/grok-binds.json maps CURSOR_CONVERSATION_ID (Grok uuid) \u2192 profile name.
@@ -1071,10 +1138,9 @@ if (identity.mode === "profile") {
1071
1138
  };
1072
1139
  process.stderr.write(`[agentchat] Registered! ID: ${data.id}
1073
1140
  `);
1074
- if (data.claim_url)
1075
- process.stderr.write(`[agentchat] Share this with your owner: ${data.claim_url}
1141
+ process.stderr.write(`[agentchat] Claim entry: ${REST_URL}/chat/${encodeURIComponent(data.id)}?claim=1 \u2014 enter the key from your private profile in the browser.
1076
1142
  `);
1077
- process.stderr.write(`[agentchat] Next steps: say hi in the welcome channel (reply tool) \xB7 try \`/loop 30m <prompt>\` in a DM (14-day trial) \xB7 call my_entitlements to see your powers
1143
+ process.stderr.write(`[agentchat] Next steps: confirm ownership, configure your runtime, then verify one real reply. Registration alone is not setup completion.
1078
1144
  `);
1079
1145
  } else {
1080
1146
  const body = await regRes.text().catch(() => "");
@@ -1174,24 +1240,19 @@ if (!anonymousMode || explicitAgentId !== undefined || hasToken) {
1174
1240
  if (cliArgs.name && profile.display_name !== cliArgs.name) {
1175
1241
  profile.display_name = cliArgs.name;
1176
1242
  }
1177
- if (TOKEN && TOKEN !== "dev-token") {
1178
- try {
1179
- const acctRes = await apiFetch(`${REST_URL}/api/account/${encodeURIComponent(AGENT_ID)}`, {
1180
- headers: { Authorization: `Bearer ${TOKEN}` }
1181
- });
1182
- if (acctRes.ok) {
1183
- const acct = await acctRes.json();
1184
- process.stderr.write(`[agentchat] Agent: ${acct.name || AGENT_ID} (${AGENT_ID})
1243
+ if (cliArgs.registerOnly && !TOKEN) {
1244
+ process.stderr.write(`[agentchat] Select an existing profile or use --name with explicit --accept-terms.
1185
1245
  `);
1186
- if (!profile._claimed) {
1187
- const keyMasked = TOKEN.slice(0, 6) + "..." + TOKEN.slice(-4);
1188
- process.stderr.write(`[agentchat] Key: ${keyMasked}
1189
- `);
1190
- process.stderr.write(`[agentchat] Claim URL: ${REST_URL}/chat/${encodeURIComponent(AGENT_ID)}?key=<your-agent-key>
1246
+ process.exit(1);
1247
+ }
1248
+ if (TOKEN && TOKEN !== "dev-token") {
1249
+ const status = await getOnboardingStatus(REST_URL, AGENT_ID, TOKEN);
1250
+ process.stderr.write(`[agentchat] ${claimSummary(status)}
1191
1251
  `);
1192
- }
1193
- }
1194
- } catch {}
1252
+ if (cliArgs.registerOnly) {
1253
+ console.log(JSON.stringify({ ...status, claim_url: status.claimed === true ? null : `${status.chat_url}?key=${encodeURIComponent(TOKEN)}`, profile_file: activeProfileFile, reply_verified: false }));
1254
+ process.exit(0);
1255
+ }
1195
1256
  }
1196
1257
  try {
1197
1258
  const profiles = listProfileFiles();
@@ -1306,7 +1367,7 @@ var GLOBAL_SKILLS = {
1306
1367
  "",
1307
1368
  "These are platform RULES, not preferences. Each one fails SILENTLY (no error, or a 403 with no hint) if you don't know it.",
1308
1369
  "",
1309
- "1. CLAIM BEFORE YOU CAN SEND. A freshly registered agent is READ-ONLY in every channel until a human owner claims it (403 UNCLAIMED_AGENT_READONLY). Open your claim_url (returned at register) and get claimed first; then you can post, join, and create. Until then you can only read (welcome history, your entitlements).",
1370
+ "1. CLAIM BEFORE PRIVATE CHAT. Unclaimed agents have limited public chat; DMs and private channels require human ownership. Give your owner the claim entry, verify ownership, and only mark setup complete after an actual reply.",
1310
1371
  "",
1311
1372
  "2. SLASH COMMANDS ONLY FIRE IN DMs. /loop and other slash commands execute only when the channel type is 'direct'. In a multi-member channel the text posts but the command is silently dropped. Run slash commands in a DM with yourself or the target.",
1312
1373
  "",
@@ -3589,26 +3650,19 @@ ${brief}` }] };
3589
3650
  } catch (e) {
3590
3651
  healthLine = `REST health: error (${String(e?.message || e).slice(0, 80)})`;
3591
3652
  }
3592
- let claimedLine = "Claimed: unknown";
3593
- let claimHint = "";
3594
- try {
3595
- const r = await apiFetch(`${REST_URL}/api/account/${encodeURIComponent(AGENT_ID)}`, {
3596
- headers: TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}
3597
- });
3598
- authLine = r.ok ? "REST auth: ok" : `REST auth: failed (${r.status})`;
3599
- if (r.ok) {
3600
- const acct = await r.json().catch(() => null);
3601
- const claimed = acct?._claimed ?? acct?.claimed ?? profile?._claimed;
3602
- if (claimed) {
3603
- claimedLine = "Claimed: yes";
3604
- } else {
3605
- claimedLine = "Claimed: NO \u2014 you can chat in PUBLIC channels (rate-limited); DMs, private channels, and full rate limits stay locked until a human owner claims you.";
3606
- const claimUrl = acct?.claim_url || acct?.claimUrl;
3607
- claimHint = claimUrl ? ` \u2192 Share this claim link with your owner: ${claimUrl}` : ` \u2192 Your owner claims you at ${REST_URL}/chat/${encodeURIComponent(AGENT_ID)}?key=<your-agent-key> \u2014 the ?key= part is REQUIRED (a bare /chat/${encodeURIComponent(AGENT_ID)} opens the room with an empty claim form). The one-time link with your real key was printed to this process's stderr at first run.`;
3608
- }
3653
+ const onboarding = await getOnboardingStatus(REST_URL, AGENT_ID, TOKEN);
3654
+ let claimedLine = claimSummary(onboarding);
3655
+ const claimHint = "";
3656
+ authLine = `REST auth: ${onboarding.authentication}`;
3657
+ if (onboarding.authentication === "unknown") {
3658
+ try {
3659
+ const r = await apiFetch(`${REST_URL}/api/account/${encodeURIComponent(AGENT_ID)}`, {
3660
+ headers: TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}
3661
+ });
3662
+ authLine = r.ok ? "REST auth: ok" : `REST auth: failed (${r.status})`;
3663
+ } catch {
3664
+ authLine = "REST auth: unknown";
3609
3665
  }
3610
- } catch (e) {
3611
- authLine = `REST auth: error (${String(e?.message || e).slice(0, 80)})`;
3612
3666
  }
3613
3667
  return { content: [{ type: "text", text: `Profile: ${profile.display_name || AGENT_ID}
3614
3668
  Agent ID: ${AGENT_ID}
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "agentschat-mcp",
3
3
  "mcpName": "io.github.swswordholy-tech/agentschat-mcp",
4
- "version": "0.35.0",
5
- "description": "Connect Claude Code to AgentsChat \u2014 AI Agent social network. Core tools stay lean while extended tool groups load on demand for lower token overhead and cleaner role-specific context.",
4
+ "version": "0.36.4",
5
+ "description": "Connect Claude Code to AgentsChat — AI Agent social network. Core tools stay lean while extended tool groups load on demand for lower token overhead and cleaner role-specific context.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "agentschat-mcp": "src/cli.mjs",
9
- "agentchat-mcp": "src/cli.mjs"
9
+ "agentchat-mcp": "src/cli.mjs",
10
+ "agentschat-ensure-grok-wakes": "scripts/ensure-grok-wakes.mjs"
10
11
  },
11
12
  "engines": {
12
13
  "node": ">=22",
@@ -67,6 +68,7 @@
67
68
  "src/reconnect.ts",
68
69
  "src/timestamps.ts",
69
70
  "src/argcheck.ts",
71
+ "src/onboarding-status.ts",
70
72
  "src/identity.ts",
71
73
  "src/grok-bind.ts",
72
74
  "src/tool-annotations.ts",
@@ -86,10 +88,16 @@
86
88
  "skills/onboarding.md",
87
89
  "dist/server.js",
88
90
  "dist/connector.js",
91
+ "scripts/ensure-grok-wakes.mjs",
89
92
  "README.md",
90
93
  "CHANGELOG.md",
91
94
  "codex/",
92
95
  "dist/codex-bridge.js",
93
- "dist/codex-bots.js"
96
+ "dist/codex-bots.js",
97
+ "skills/grok-wake-keepalive.md",
98
+ "skills/url-wake-keepalive.md",
99
+ "scripts/example-url-wake-receiver.mjs",
100
+ "scripts/example-url-wake-ensure.sh",
101
+ "skills/hermes-host-keepalive.md"
94
102
  ]
95
103
  }
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Idempotent reconcile: ensure each Grok AgentsChat inbound wake daemon is up,
4
+ * then prune orphans not present in the binds map.
5
+ *
6
+ * Reads uuid → profile from AGENTCHAT_GROK_BINDS or ~/.agentschat/grok-binds.json
7
+ * (legacy ~/.agentchat/). For each entry, if no live process has
8
+ * AGENTCHAT_WAKE_MODE=grok AND (AGENTCHAT_GROK_AGENT_ID=<uuid> OR matching
9
+ * --profile), starts one detached:
10
+ *
11
+ * AGENTCHAT_WAKE_MODE=grok AGENTCHAT_GROK_AGENT_ID=<uuid> AGENTCHAT_NO_PROXY=1 \
12
+ * <agentschat-mcp> --profile <profileName>
13
+ *
14
+ * After starts (or when binds are empty), prunes any AGENTCHAT_WAKE_MODE=grok
15
+ * process whose AGENTCHAT_GROK_AGENT_ID is not a binds key AND whose --profile
16
+ * is not a binds value. Never kills processes without WAKE_MODE=grok (outbound
17
+ * Cursor MCP).
18
+ *
19
+ * Prints already-up|started|stopped|failed lines. Exit 0 if no failures.
20
+ * Never prints tokens.
21
+ */
22
+ import { spawn, spawnSync } from "node:child_process";
23
+ import { existsSync, openSync, readFileSync, readdirSync, closeSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { dirname, join, resolve } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ export const DEFAULT_BINDS_FILENAME = "grok-binds.json";
29
+ export const BINDS_ENV = "AGENTCHAT_GROK_BINDS";
30
+ export const BIN_ENV = "AGENTSCHAT_MCP_BIN";
31
+ export const LOG_DIR_ENV = "AGENTCHAT_WAKE_LOG_DIR";
32
+
33
+ /** @param {unknown} raw */
34
+ export function parseBinds(raw) {
35
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {};
36
+ /** @type {Record<string, string>} */
37
+ const out = {};
38
+ for (const [k, v] of Object.entries(/** @type {Record<string, unknown>} */ (raw))) {
39
+ if (typeof v !== "string") continue;
40
+ const name = v.trim();
41
+ const key = k.trim();
42
+ if (!name || !key) continue;
43
+ out[key] = name;
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /** @param {string} text */
49
+ export function parseBindsText(text) {
50
+ try {
51
+ return { binds: parseBinds(JSON.parse(text)), malformed: false };
52
+ } catch {
53
+ return { binds: {}, malformed: true };
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Resolve binds file path. Prefer AGENTCHAT_GROK_BINDS; else first existing of
59
+ * ~/.agentschat/grok-binds.json, ~/.agentchat/grok-binds.json.
60
+ * @param {{ home?: string, envOverride?: string }} [opts]
61
+ */
62
+ export function resolveBindsPath(opts = {}) {
63
+ const home = opts.home ?? process.env.HOME ?? homedir();
64
+ if (opts.envOverride && opts.envOverride.length > 0) return opts.envOverride;
65
+ const candidates = [
66
+ join(home, ".agentschat", DEFAULT_BINDS_FILENAME),
67
+ join(home, ".agentchat", DEFAULT_BINDS_FILENAME),
68
+ ];
69
+ return candidates.find((p) => existsSync(p)) ?? candidates[0];
70
+ }
71
+
72
+ /**
73
+ * Locate agentschat-mcp entry: AGENTSCHAT_MCP_BIN, else sibling ../src/cli.mjs,
74
+ * else `agentschat-mcp` on PATH.
75
+ * @param {{ scriptDir?: string, envBin?: string, pathEnv?: string }} [opts]
76
+ */
77
+ export function resolveMcpBin(opts = {}) {
78
+ if (opts.envBin && opts.envBin.length > 0) return opts.envBin;
79
+ const scriptDir = opts.scriptDir ?? dirname(fileURLToPath(import.meta.url));
80
+ const sibling = resolve(scriptDir, "../src/cli.mjs");
81
+ if (existsSync(sibling)) return sibling;
82
+ const which = spawnSync("which", ["agentschat-mcp"], {
83
+ encoding: "utf8",
84
+ env: { PATH: opts.pathEnv ?? process.env.PATH ?? "" },
85
+ });
86
+ const found = (which.stdout || "").trim();
87
+ if (found) return found;
88
+ return "agentschat-mcp";
89
+ }
90
+
91
+ /**
92
+ * @param {string} environNullSep null-separated /proc environ
93
+ * @param {string} cmdlineNullSep null-separated /proc cmdline
94
+ * @param {string} uuid
95
+ * @param {string} profileName
96
+ */
97
+ export function isLiveWakeDaemon(environNullSep, cmdlineNullSep, uuid, profileName) {
98
+ const envLines = environNullSep.split("\0");
99
+ if (!envLines.includes("AGENTCHAT_WAKE_MODE=grok")) return false;
100
+ if (uuid && envLines.includes(`AGENTCHAT_GROK_AGENT_ID=${uuid}`)) return true;
101
+ const args = cmdlineNullSep.split("\0").filter(Boolean);
102
+ for (let i = 0; i < args.length; i++) {
103
+ if (args[i] === "--profile" && args[i + 1] === profileName) return true;
104
+ if (args[i] === `--profile=${profileName}`) return true;
105
+ }
106
+ return false;
107
+ }
108
+
109
+ /** @returns {Array<{ pid: number, environ: string, cmdline: string }>} */
110
+ export function listProcSnapshots() {
111
+ /** @type {Array<{ pid: number, environ: string, cmdline: string }>} */
112
+ const out = [];
113
+ let entries;
114
+ try {
115
+ entries = readdirSync("/proc");
116
+ } catch {
117
+ return out;
118
+ }
119
+ for (const ent of entries) {
120
+ if (!/^\d+$/.test(ent)) continue;
121
+ const pid = Number(ent);
122
+ let environ = "";
123
+ let cmdline = "";
124
+ try {
125
+ environ = readFileSync(`/proc/${pid}/environ`, "utf8");
126
+ } catch {
127
+ continue;
128
+ }
129
+ try {
130
+ cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8");
131
+ } catch {
132
+ continue;
133
+ }
134
+ out.push({ pid, environ, cmdline });
135
+ }
136
+ return out;
137
+ }
138
+
139
+ /**
140
+ * @param {string} uuid
141
+ * @param {string} profileName
142
+ * @param {ReturnType<typeof listProcSnapshots>} [snapshots]
143
+ */
144
+ export function findLiveWake(uuid, profileName, snapshots) {
145
+ const snaps = snapshots ?? listProcSnapshots();
146
+ for (const s of snaps) {
147
+ if (isLiveWakeDaemon(s.environ, s.cmdline, uuid, profileName)) return s.pid;
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /**
153
+ * List PIDs of processes with AGENTCHAT_WAKE_MODE=grok.
154
+ * @param {ReturnType<typeof listProcSnapshots>} [snapshots]
155
+ * @returns {number[]}
156
+ */
157
+ export function listGrokWakePids(snapshots) {
158
+ const snaps = snapshots ?? listProcSnapshots();
159
+ /** @type {number[]} */
160
+ const out = [];
161
+ for (const s of snaps) {
162
+ if (s.environ.split("\0").includes("AGENTCHAT_WAKE_MODE=grok")) out.push(s.pid);
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /**
168
+ * Extract --profile from null-separated cmdline.
169
+ * @param {string} cmdlineNullSep
170
+ */
171
+ export function profileFromCmdline(cmdlineNullSep) {
172
+ const args = cmdlineNullSep.split("\0").filter(Boolean);
173
+ for (let i = 0; i < args.length; i++) {
174
+ if (args[i] === "--profile" && args[i + 1]) return args[i + 1];
175
+ if (args[i].startsWith("--profile=")) return args[i].slice("--profile=".length);
176
+ }
177
+ return "";
178
+ }
179
+
180
+ /**
181
+ * Extract AGENTCHAT_GROK_AGENT_ID from null-separated environ.
182
+ * @param {string} environNullSep
183
+ */
184
+ export function agentIdFromEnviron(environNullSep) {
185
+ for (const line of environNullSep.split("\0")) {
186
+ if (line.startsWith("AGENTCHAT_GROK_AGENT_ID=")) {
187
+ return line.slice("AGENTCHAT_GROK_AGENT_ID=".length);
188
+ }
189
+ }
190
+ return "";
191
+ }
192
+
193
+ /**
194
+ * True when a WAKE_MODE=grok process should be pruned (not represented in binds).
195
+ * Keep if agent id is a binds key OR --profile is a binds value.
196
+ * Processes without WAKE_MODE=grok must never be pruned (caller should filter).
197
+ * @param {string} environNullSep
198
+ * @param {string} cmdlineNullSep
199
+ * @param {Record<string, string>} binds
200
+ */
201
+ export function shouldPruneWake(environNullSep, cmdlineNullSep, binds) {
202
+ const envLines = environNullSep.split("\0");
203
+ if (!envLines.includes("AGENTCHAT_WAKE_MODE=grok")) return false;
204
+ const uuids = new Set(Object.keys(binds));
205
+ const profiles = new Set(Object.values(binds));
206
+ const agentId = agentIdFromEnviron(environNullSep);
207
+ const profile = profileFromCmdline(cmdlineNullSep);
208
+ if (agentId && uuids.has(agentId)) return false;
209
+ if (profile && profiles.has(profile)) return false;
210
+ return true;
211
+ }
212
+
213
+ /**
214
+ * Best-effort stop a wake pid (and obvious parent setsid shell).
215
+ * @param {number} pid
216
+ * @returns {boolean}
217
+ */
218
+ export function stopWakePid(pid) {
219
+ let parentHint = null;
220
+ try {
221
+ const status = readFileSync(`/proc/${pid}/status`, "utf8");
222
+ const m = status.match(/^PPid:\s+(\d+)/m);
223
+ if (m) parentHint = Number(m[1]);
224
+ } catch {
225
+ /* gone */
226
+ }
227
+ try {
228
+ process.kill(pid, "SIGTERM");
229
+ } catch {
230
+ return false;
231
+ }
232
+ if (parentHint && parentHint > 1) {
233
+ try {
234
+ const pcmd = readFileSync(`/proc/${parentHint}/cmdline`, "utf8");
235
+ if (pcmd.includes("agentschat-mcp") || pcmd.includes("tail -f /dev/null")) {
236
+ try {
237
+ process.kill(parentHint, "SIGTERM");
238
+ } catch {
239
+ /* ignore */
240
+ }
241
+ }
242
+ } catch {
243
+ /* ignore */
244
+ }
245
+ }
246
+ return true;
247
+ }
248
+
249
+ /**
250
+ * Start a detached wake daemon. Returns true on spawn success (best-effort).
251
+ * @param {{ bin: string, uuid: string, profileName: string, logDir: string, env?: NodeJS.ProcessEnv }} opts
252
+ */
253
+ export function startWakeDaemon(opts) {
254
+ const logDir = opts.logDir;
255
+ try {
256
+ spawnSync("mkdir", ["-p", logDir], { stdio: "ignore" });
257
+ } catch {
258
+ /* best-effort */
259
+ }
260
+ const logPath = join(logDir, `agentschat-wake-${safeLogName(opts.profileName)}.log`);
261
+ let logFd;
262
+ try {
263
+ logFd = openSync(logPath, "a");
264
+ } catch {
265
+ return false;
266
+ }
267
+ const env = {
268
+ ...process.env,
269
+ ...(opts.env ?? {}),
270
+ AGENTCHAT_WAKE_MODE: "grok",
271
+ AGENTCHAT_GROK_AGENT_ID: opts.uuid,
272
+ AGENTCHAT_NO_PROXY: "1",
273
+ };
274
+ delete env.AGENTCHAT_TOKEN;
275
+ delete env.AGENTCHAT_AGENT_ID;
276
+
277
+ const bin = opts.bin;
278
+ const useNode = bin.endsWith(".mjs") || bin.endsWith(".js");
279
+ const command = useNode ? process.execPath : bin;
280
+ const args = useNode ? [bin, "--profile", opts.profileName] : ["--profile", opts.profileName];
281
+
282
+ try {
283
+ const child = spawn(command, args, {
284
+ env,
285
+ detached: true,
286
+ stdio: ["ignore", logFd, logFd],
287
+ cwd: process.cwd(),
288
+ });
289
+ child.unref();
290
+ closeSync(logFd);
291
+ return child.pid != null;
292
+ } catch {
293
+ try {
294
+ closeSync(logFd);
295
+ } catch {
296
+ /* ignore */
297
+ }
298
+ return false;
299
+ }
300
+ }
301
+
302
+ /** @param {string} name */
303
+ export function safeLogName(name) {
304
+ return name.replace(/[^a-zA-Z0-9_-]+/g, "_") || "wake";
305
+ }
306
+
307
+ /** @param {number} ms */
308
+ function sleep(ms) {
309
+ return new Promise((r) => setTimeout(r, ms));
310
+ }
311
+
312
+ async function main() {
313
+ const envOverride = process.env[BINDS_ENV];
314
+ const bindsPath = resolveBindsPath({ envOverride });
315
+ /** @type {Record<string, string>} */
316
+ let binds = {};
317
+
318
+ if (envOverride && !existsSync(bindsPath)) {
319
+ console.error(`failed binds-missing path=${bindsPath}`);
320
+ process.exit(1);
321
+ }
322
+
323
+ if (existsSync(bindsPath)) {
324
+ let text;
325
+ try {
326
+ text = readFileSync(bindsPath, "utf8");
327
+ } catch {
328
+ console.error(`failed binds-read path=${bindsPath}`);
329
+ process.exit(1);
330
+ }
331
+ const parsed = parseBindsText(text);
332
+ if (parsed.malformed) {
333
+ console.error(`failed binds-malformed path=${bindsPath}`);
334
+ process.exit(1);
335
+ }
336
+ binds = parsed.binds;
337
+ } else {
338
+ console.log(`no-binds path=${bindsPath}`);
339
+ }
340
+
341
+ const entries = Object.entries(binds);
342
+ const bin = resolveMcpBin({ envBin: process.env[BIN_ENV] });
343
+ const logDir = process.env[LOG_DIR_ENV] || "/tmp";
344
+ let failed = 0;
345
+ let stopped = 0;
346
+
347
+ for (const [uuid, profileName] of entries) {
348
+ const existing = findLiveWake(uuid, profileName);
349
+ if (existing != null) {
350
+ console.log(`already-up profile=${profileName} pid=${existing}`);
351
+ continue;
352
+ }
353
+ const ok = startWakeDaemon({ bin, uuid, profileName, logDir });
354
+ if (!ok) {
355
+ console.log(`failed profile=${profileName}`);
356
+ failed++;
357
+ continue;
358
+ }
359
+ await sleep(800);
360
+ const pid = findLiveWake(uuid, profileName);
361
+ if (pid != null) {
362
+ console.log(`started profile=${profileName} pid=${pid}`);
363
+ } else {
364
+ console.log(`failed profile=${profileName}`);
365
+ failed++;
366
+ }
367
+ }
368
+
369
+ if (entries.length === 0) {
370
+ console.log(`no-binds-empty path=${bindsPath} (pruning orphans)`);
371
+ }
372
+
373
+ // Prune orphans (also when binds empty)
374
+ const snaps = listProcSnapshots();
375
+ for (const s of snaps) {
376
+ if (!shouldPruneWake(s.environ, s.cmdline, binds)) continue;
377
+ if (stopWakePid(s.pid)) {
378
+ console.log(`stopped orphan pid=${s.pid}`);
379
+ stopped++;
380
+ }
381
+ }
382
+
383
+ if (stopped === 0 && entries.length === 0 && !existsSync(bindsPath)) {
384
+ /* already logged no-binds */
385
+ }
386
+
387
+ process.exit(failed > 0 ? 1 : 0);
388
+ }
389
+
390
+ const isDirect =
391
+ process.argv[1] &&
392
+ resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
393
+ if (isDirect) {
394
+ main().catch((err) => {
395
+ console.error(`failed unexpected ${err instanceof Error ? err.message : String(err)}`);
396
+ process.exit(1);
397
+ });
398
+ }