agentschat-mcp 0.36.10 → 0.36.13

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/connector.js CHANGED
@@ -118,7 +118,7 @@ function matchesLiveLoop(msg, agentId, response) {
118
118
  const rows = response?.loops;
119
119
  if (!Array.isArray(rows))
120
120
  return false;
121
- const matches = rows.filter((row2) => row2?.loop_id === tick.loop_id);
121
+ const matches = rows.filter((row) => row?.loop_id === tick.loop_id);
122
122
  if (matches.length !== 1)
123
123
  return false;
124
124
  const row = matches[0];
@@ -194,7 +194,7 @@ class IdentityTable {
194
194
  }
195
195
  }
196
196
  function requireIdentity(identities, botId) {
197
- const id = identities.find((id2) => id2.botId === botId);
197
+ const id = identities.find((id) => id.botId === botId);
198
198
  if (!id)
199
199
  throw new Error(`unknown identity: ${botId}`);
200
200
  return id;
@@ -761,13 +761,13 @@ function parseIdentitiesJson(raw, source) {
761
761
  function loadIdentities() {
762
762
  const file = (process.env.RELAY_IDENTITIES_FILE || "").trim();
763
763
  if (file) {
764
- let raw2;
764
+ let raw;
765
765
  try {
766
- raw2 = readFileSync2(file, "utf8");
766
+ raw = readFileSync2(file, "utf8");
767
767
  } catch {
768
768
  throw new Error("RELAY_IDENTITIES_FILE could not be read");
769
769
  }
770
- return parseIdentitiesJson(raw2, "RELAY_IDENTITIES_FILE");
770
+ return parseIdentitiesJson(raw, "RELAY_IDENTITIES_FILE");
771
771
  }
772
772
  const raw = (process.env.RELAY_IDENTITIES || "").trim();
773
773
  if (raw) {
package/dist/server.js CHANGED
@@ -676,7 +676,7 @@ async function fireGrokWake(msg, cfg) {
676
676
  var package_default = {
677
677
  name: "agentschat-mcp",
678
678
  mcpName: "io.github.swswordholy-tech/agentschat-mcp",
679
- version: "0.36.10",
679
+ version: "0.36.13",
680
680
  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.",
681
681
  type: "module",
682
682
  bin: {
@@ -696,8 +696,11 @@ var package_default = {
696
696
  typecheck: "tsc --noEmit",
697
697
  build: "bun scripts/build.mjs",
698
698
  "check:version": "bun scripts/check-version-sync.mjs",
699
- verify: "bun run build && bun scripts/check-version-sync.mjs && tsc --noEmit && bun test",
700
- prepublishOnly: "bun run build"
699
+ "check:docs": "bun scripts/sync-doc-versions.mjs --check",
700
+ "sync:docs": "bun scripts/sync-doc-versions.mjs --write",
701
+ version: "bun run build && bun scripts/sync-doc-versions.mjs --write --git-add",
702
+ verify: "bun run build && bun scripts/check-version-sync.mjs && bun scripts/sync-doc-versions.mjs --check && tsc --noEmit && bun test",
703
+ prepublishOnly: "bun run check:version && bun run check:docs && bun test tests/doc-versions.test.ts tests/packaging.test.ts"
701
704
  },
702
705
  keywords: [
703
706
  "agentchat",
@@ -779,6 +782,7 @@ var package_default = {
779
782
  "skills/url-wake-keepalive.md",
780
783
  "scripts/example-url-wake-receiver.mjs",
781
784
  "scripts/example-url-wake-ensure.sh",
785
+ "scripts/example-agy-mcp-wrapper.sh",
782
786
  "skills/hermes-host-keepalive.md"
783
787
  ]
784
788
  };
@@ -886,6 +890,23 @@ function advanceGuardRecord(prev, current, ts, trusted) {
886
890
  }
887
891
  return next;
888
892
  }
893
+ var EXPECT_AGENT_ID_ENV = ["AGENTSCHAT_EXPECT_AGENT_ID", "AGENTCHAT_EXPECT_AGENT_ID"];
894
+ function expectedAgentIdFromEnv(env) {
895
+ for (const k of EXPECT_AGENT_ID_ENV) {
896
+ const v = (env[k] ?? "").trim();
897
+ if (v)
898
+ return v;
899
+ }
900
+ return null;
901
+ }
902
+ var EXPECT_PIN_EXEMPT_TOOLS = new Set(["whoami", "switch_profile"]);
903
+ function expectedAgentIdViolation(expected, liveAgentId) {
904
+ if (!expected)
905
+ return null;
906
+ if (liveAgentId === expected)
907
+ return null;
908
+ return `IDENTITY PIN: this MCP process is "${liveAgentId || "(none)"}" but AGENTCHAT_EXPECT_AGENT_ID is "${expected}". ` + `Write tools are refused so nothing posts under the wrong identity. ` + `Call switch_profile with the profile for ${expected} (then whoami), or relaunch with the correct --profile / AGENTSCHAT_PROFILE.`;
909
+ }
889
910
 
890
911
  // src/read-cursor.ts
891
912
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
@@ -1044,13 +1065,15 @@ var cliArgs = parseArgs();
1044
1065
  if (process.argv.includes("--help") || process.argv.includes("-h")) {
1045
1066
  console.log(`agentschat-mcp \u2014 AgentsChat MCP and host bridges
1046
1067
 
1047
- Usage: claude mcp add agentschat -- npx -y agentschat-mcp@latest [options]
1068
+ Usage: claude mcp add agentschat -- npx -y agentschat-mcp@${package_default.version} --profile <Bot> [options]
1048
1069
  claude --dangerously-load-development-channels server:agentschat
1070
+ One MCP server entry per bot, each pinning --profile and this version
1071
+ (not @latest, which drifts).
1049
1072
 
1050
1073
  Setup guide for every supported host:
1051
1074
  Read skills/onboarding.md relative to this installed package root.
1052
- Online: https://unpkg.com/agentschat-mcp@latest/skills/onboarding.md
1053
- Start with: npx -y agentschat-mcp@latest --help
1075
+ Online (this version): https://unpkg.com/agentschat-mcp@${package_default.version}/skills/onboarding.md
1076
+ Newest release (first install): npx -y agentschat-mcp@latest --help
1054
1077
  The guide selects Claude Code, Codex, OpenClaw, Hermes, Grok Bot, URL wake,
1055
1078
  or the separately deployed dot remote MCP + Events integration.
1056
1079
  Help only prints instructions; follow the guide and verify a real reply.
@@ -1086,12 +1109,13 @@ Wake a host that has no channel-notification surface (Grok Bot, generic MCP clie
1086
1109
  Start any missing Grok wake daemons from grok-binds.json
1087
1110
  (node scripts/ensure-grok-wakes.mjs).
1088
1111
 
1089
- Grok multi-bot identity bind (Cursor / Grok Bot, no --profile):
1090
- ~/.agentschat/grok-binds.json maps CURSOR_CONVERSATION_ID (Grok uuid) \u2192 profile name.
1091
- AGENTCHAT_GROK_BINDS overrides that path. Profile names resolve like --profile.
1092
- Auto-bind does NOT set AGENTCHAT_WAKE_MODE \u2014 a Cursor-tool MCP should unset it
1093
- (per-identity wake daemons already POST sendPrompt). If the operator set
1094
- WAKE_MODE, it is left as-is.
1112
+ Grok multi-bot outbound identity: one MCP connection per Grok bot, each with
1113
+ its own --profile (e.g. agentschat-jack \u2192 --profile Jack). Do NOT pick the send
1114
+ identity from grok-binds.json / select-profile-mcp.sh: account-level MCP
1115
+ connections are shared across chats and get no CURSOR_CONVERSATION_ID, so they
1116
+ would run anonymous. ~/.agentschat/grok-binds.json (CURSOR_CONVERSATION_ID \u2192
1117
+ profile; AGENTCHAT_GROK_BINDS overrides the path) is the inbound wake-daemon
1118
+ table only.
1095
1119
 
1096
1120
  Hermes relay connector (no Hermes patch): run with --connector. See --connector --help.
1097
1121
 
@@ -1465,6 +1489,15 @@ var guardBootIdentity = {
1465
1489
  if (AGENT_ID)
1466
1490
  persistGuardRecord(guardRecord);
1467
1491
  }
1492
+ var EXPECTED_AGENT_ID = expectedAgentIdFromEnv(process.env);
1493
+ {
1494
+ const v = AGENT_ID ? expectedAgentIdViolation(EXPECTED_AGENT_ID, AGENT_ID) : null;
1495
+ if (v)
1496
+ process.stderr.write(`
1497
+ [agentchat] \uD83D\uDEA8 ${v}
1498
+
1499
+ `);
1500
+ }
1468
1501
  if (cliArgs.name && profile.display_name !== cliArgs.name) {
1469
1502
  profile.display_name = cliArgs.name;
1470
1503
  }
@@ -3005,6 +3038,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3005
3038
  if (identityGateMessage && name !== "whoami" && TOOL_ANNOTATIONS[name]?.readOnlyHint !== true) {
3006
3039
  return { content: [{ type: "text", text: `\uD83D\uDEA8 ${identityGateMessage}` }], isError: true };
3007
3040
  }
3041
+ if (EXPECTED_AGENT_ID && !EXPECT_PIN_EXEMPT_TOOLS.has(name) && TOOL_ANNOTATIONS[name]?.readOnlyHint !== true) {
3042
+ const pinViolation = expectedAgentIdViolation(EXPECTED_AGENT_ID, AGENT_ID);
3043
+ if (pinViolation) {
3044
+ return { content: [{ type: "text", text: `\uD83D\uDEA8 ${pinViolation}` }], isError: true };
3045
+ }
3046
+ }
3008
3047
  if (name === "list_skills") {
3009
3048
  const { chat_id } = args || {};
3010
3049
  const out = {
@@ -3930,7 +3969,10 @@ Capabilities: ${CAPABILITIES.join(", ")}
3930
3969
  Profile file: ${activeProfileFile ?? (anonymousMode ? "(none \u2014 anonymous, no profile written)" : "(none \u2014 credentials from environment)")}`;
3931
3970
  if (wasGated)
3932
3971
  confirmLiveIdentityTrusted();
3933
- return { content: [{ type: "text", text: gateNotice + text }] };
3972
+ const pinViolation = expectedAgentIdViolation(EXPECTED_AGENT_ID, AGENT_ID);
3973
+ const pinLine = EXPECTED_AGENT_ID ? `
3974
+ Expected agent (AGENTCHAT_EXPECT_AGENT_ID): ${EXPECTED_AGENT_ID} \u2014 ${pinViolation ? "MISMATCH, write tools refused" : "ok"}` : "";
3975
+ return { content: [{ type: "text", text: gateNotice + text + pinLine }] };
3934
3976
  }
3935
3977
  if (name === "list_channels") {
3936
3978
  const { limit = 50 } = args;
@@ -4119,7 +4161,10 @@ ${list}` }] };
4119
4161
  }
4120
4162
  const newProfile = readIdentityProfile(targetFile);
4121
4163
  applyIdentityFromProfile(newProfile, targetFile);
4122
- return { content: [{ type: "text", text: `Switched to profile "${profile_name}" (${AGENT_ID}). Reconnecting...` }] };
4164
+ const pinNote = EXPECTED_AGENT_ID && AGENT_ID !== EXPECTED_AGENT_ID ? `
4165
+ \uD83D\uDEA8 Still not the expected agent "${EXPECTED_AGENT_ID}" (AGENTCHAT_EXPECT_AGENT_ID) \u2014 write tools stay refused.` : "";
4166
+ return { content: [{ type: "text", text: `Switched to profile "${profile_name}" (${AGENT_ID}). Reconnecting...
4167
+ Scope: this switch applies only to THIS MCP process. It is not saved \u2014 a new or restarted MCP process starts again from its launch profile (--profile / AGENTSCHAT_PROFILE). Re-check with whoami at the start of every wake.${pinNote}` }] };
4123
4168
  }
4124
4169
  if (name === "list_channel_docs") {
4125
4170
  const { chat_id, level } = args;
@@ -5077,7 +5122,7 @@ async function checkVersionStaleness() {
5077
5122
  return;
5078
5123
  const latest = (await r.json())?.version;
5079
5124
  if (typeof latest === "string" && latest !== package_default.version) {
5080
- process.stderr.write(`[agentchat] Update available: running agentschat-mcp ${package_default.version}, latest published is ${latest}. New tools/capabilities load only on a SESSION RESTART (hot-reload isn't possible); update (bunx agentschat-mcp@latest / reinstall) then restart this session to pick them up.
5125
+ process.stderr.write(`[agentchat] Update available: running agentschat-mcp ${package_default.version}, latest published is ${latest}. New tools/capabilities load only on a SESSION RESTART (hot-reload isn't possible); update deliberately (bump the pinned agentschat-mcp@<version> in each MCP entry / reinstall) then restart this session to pick them up.
5081
5126
  `);
5082
5127
  }
5083
5128
  } catch {} finally {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentschat-mcp",
3
3
  "mcpName": "io.github.swswordholy-tech/agentschat-mcp",
4
- "version": "0.36.10",
4
+ "version": "0.36.13",
5
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": {
@@ -21,8 +21,11 @@
21
21
  "typecheck": "tsc --noEmit",
22
22
  "build": "bun scripts/build.mjs",
23
23
  "check:version": "bun scripts/check-version-sync.mjs",
24
- "verify": "bun run build && bun scripts/check-version-sync.mjs && tsc --noEmit && bun test",
25
- "prepublishOnly": "bun run build"
24
+ "check:docs": "bun scripts/sync-doc-versions.mjs --check",
25
+ "sync:docs": "bun scripts/sync-doc-versions.mjs --write",
26
+ "version": "bun run build && bun scripts/sync-doc-versions.mjs --write --git-add",
27
+ "verify": "bun run build && bun scripts/check-version-sync.mjs && bun scripts/sync-doc-versions.mjs --check && tsc --noEmit && bun test",
28
+ "prepublishOnly": "bun run check:version && bun run check:docs && bun test tests/doc-versions.test.ts tests/packaging.test.ts"
26
29
  },
27
30
  "keywords": [
28
31
  "agentchat",
@@ -104,6 +107,7 @@
104
107
  "skills/url-wake-keepalive.md",
105
108
  "scripts/example-url-wake-receiver.mjs",
106
109
  "scripts/example-url-wake-ensure.sh",
110
+ "scripts/example-agy-mcp-wrapper.sh",
107
111
  "skills/hermes-host-keepalive.md"
108
112
  ]
109
113
  }
@@ -23,10 +23,25 @@
23
23
  *
24
24
  * Prints already-up|started|stopped|failed lines. Exit 0 if no failures.
25
25
  * Never prints tokens.
26
+ *
27
+ * Single-flight: a non-blocking run lock (O_EXCL lockfile holding our pid, with a
28
+ * stale-pid check) makes an overlapping run print `skip: another run in progress`
29
+ * and exit 0 — host cron / resident loops / on-wake hooks may all fire at once.
30
+ * The whole run is capped by AGENTCHAT_ENSURE_TIMEOUT_MS (default 240000) so a
31
+ * hung /proc read or spawn can never pile up runs. Lock path:
32
+ * AGENTCHAT_ENSURE_LOCK, else <tmpdir>/agentschat-ensure-grok-wakes-<uid>.lock.
26
33
  */
27
34
  import { spawn, spawnSync } from "node:child_process";
28
- import { existsSync, openSync, readFileSync, readdirSync, closeSync } from "node:fs";
29
- import { homedir } from "node:os";
35
+ import {
36
+ existsSync,
37
+ openSync,
38
+ readFileSync,
39
+ readdirSync,
40
+ closeSync,
41
+ writeSync,
42
+ unlinkSync,
43
+ } from "node:fs";
44
+ import { homedir, tmpdir } from "node:os";
30
45
  import { dirname, join, resolve } from "node:path";
31
46
  import { fileURLToPath } from "node:url";
32
47
 
@@ -34,6 +49,98 @@ export const DEFAULT_BINDS_FILENAME = "grok-binds.json";
34
49
  export const BINDS_ENV = "AGENTCHAT_GROK_BINDS";
35
50
  export const BIN_ENV = "AGENTSCHAT_MCP_BIN";
36
51
  export const LOG_DIR_ENV = "AGENTCHAT_WAKE_LOG_DIR";
52
+ export const LOCK_ENV = "AGENTCHAT_ENSURE_LOCK";
53
+ export const TIMEOUT_ENV = "AGENTCHAT_ENSURE_TIMEOUT_MS";
54
+ export const DEFAULT_RUN_TIMEOUT_MS = 240_000;
55
+ export const LOCK_OWNER_MARKER = "ensure-grok-wakes";
56
+
57
+ /** Default run-lock path (per uid so users on one host never block each other). */
58
+ export function defaultLockPath() {
59
+ const uid = typeof process.getuid === "function" ? process.getuid() : "u";
60
+ return join(tmpdir(), `agentschat-ensure-grok-wakes-${uid}.lock`);
61
+ }
62
+
63
+ /** @param {string | undefined} raw */
64
+ export function resolveRunTimeoutMs(raw) {
65
+ const n = Number(raw);
66
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_RUN_TIMEOUT_MS;
67
+ }
68
+
69
+ /**
70
+ * Whether the pid recorded in a run lock still belongs to a live ensure run.
71
+ * A dead pid (or a recycled pid whose cmdline is not an ensure run, when /proc is
72
+ * readable) is stale.
73
+ * @param {number} pid
74
+ * @param {{ kill?: (pid: number, sig: number) => void, readCmdline?: (pid: number) => string | null }} [deps]
75
+ */
76
+ export function isLockOwnerAlive(pid, deps = {}) {
77
+ if (!Number.isInteger(pid) || pid <= 0) return false;
78
+ const kill = deps.kill ?? ((p, sig) => process.kill(p, sig));
79
+ try {
80
+ kill(pid, 0);
81
+ } catch (e) {
82
+ // EPERM: exists but not ours — treat as alive (never steal a lock we can't vet).
83
+ if (!(e && /** @type {any} */ (e).code === "EPERM")) return false;
84
+ }
85
+ const readCmdline =
86
+ deps.readCmdline ??
87
+ ((p) => {
88
+ try {
89
+ return readFileSync(`/proc/${p}/cmdline`, "utf8");
90
+ } catch {
91
+ return null;
92
+ }
93
+ });
94
+ const cmd = readCmdline(pid);
95
+ if (cmd === null) return true; // no /proc: trust kill(0)
96
+ return cmd.includes(LOCK_OWNER_MARKER) || cmd.includes("agentschat-ensure-grok-wakes");
97
+ }
98
+
99
+ /**
100
+ * Non-blocking run lock. Returns a release() on success, or null when another
101
+ * live run holds it. Stale locks (dead/recycled pid, unreadable content) are
102
+ * removed once and acquisition retried.
103
+ * @param {string} lockPath
104
+ * @param {{ pid?: number, isAlive?: (pid: number) => boolean }} [opts]
105
+ * @returns {(() => void) | null}
106
+ */
107
+ export function acquireRunLock(lockPath, opts = {}) {
108
+ const pid = opts.pid ?? process.pid;
109
+ const isAlive = opts.isAlive ?? ((p) => isLockOwnerAlive(p));
110
+ for (let attempt = 0; attempt < 2; attempt++) {
111
+ try {
112
+ const fd = openSync(lockPath, "wx", 0o600);
113
+ writeSync(fd, String(pid));
114
+ closeSync(fd);
115
+ let released = false;
116
+ return () => {
117
+ if (released) return;
118
+ released = true;
119
+ try {
120
+ if (readFileSync(lockPath, "utf8").trim() === String(pid)) unlinkSync(lockPath);
121
+ } catch {
122
+ /* already gone */
123
+ }
124
+ };
125
+ } catch (e) {
126
+ if (!(e && /** @type {any} */ (e).code === "EEXIST")) throw e;
127
+ let holder = NaN;
128
+ try {
129
+ holder = Number(readFileSync(lockPath, "utf8").trim());
130
+ } catch {
131
+ /* unreadable → stale */
132
+ }
133
+ if (holder === pid) return null; // never re-enter our own lock
134
+ if (isAlive(holder)) return null;
135
+ try {
136
+ unlinkSync(lockPath);
137
+ } catch {
138
+ /* raced with another cleaner */
139
+ }
140
+ }
141
+ }
142
+ return null;
143
+ }
37
144
 
38
145
  /** @param {unknown} raw */
39
146
  export function parseBinds(raw) {
@@ -151,10 +258,17 @@ export function isLiveWakeDaemon(environNullSep, cmdlineNullSep, uuid, profileNa
151
258
  return false;
152
259
  }
153
260
 
154
- /** @returns {Array<{ pid: number, environ: string, cmdline: string }>} */
155
- export function listProcSnapshots() {
261
+ /**
262
+ * One pass over /proc. With `envNeedle` (e.g. "AGENTCHAT_WAKE_MODE=grok"), pids
263
+ * whose environ lacks it are skipped before cmdline is read — every caller here
264
+ * only cares about grok wakes, so this halves the reads on a busy box.
265
+ * @param {{ envNeedle?: string | null }} [opts]
266
+ * @returns {Array<{ pid: number, environ: string, cmdline: string }>}
267
+ */
268
+ export function listProcSnapshots(opts = {}) {
156
269
  /** @type {Array<{ pid: number, environ: string, cmdline: string }>} */
157
270
  const out = [];
271
+ const envNeedle = opts.envNeedle ?? null;
158
272
  let entries;
159
273
  try {
160
274
  entries = readdirSync("/proc");
@@ -171,6 +285,7 @@ export function listProcSnapshots() {
171
285
  } catch {
172
286
  continue;
173
287
  }
288
+ if (envNeedle && !environ.includes(envNeedle)) continue;
174
289
  try {
175
290
  cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8");
176
291
  } catch {
@@ -354,7 +469,21 @@ function sleep(ms) {
354
469
  return new Promise((r) => setTimeout(r, ms));
355
470
  }
356
471
 
472
+ const GROK_ENV_NEEDLE = "AGENTCHAT_WAKE_MODE=grok";
473
+
357
474
  async function main() {
475
+ const release = acquireRunLock(process.env[LOCK_ENV] || defaultLockPath());
476
+ if (!release) {
477
+ console.log("skip: another run in progress");
478
+ process.exit(0);
479
+ }
480
+ process.on("exit", release);
481
+ const timeoutMs = resolveRunTimeoutMs(process.env[TIMEOUT_ENV]);
482
+ setTimeout(() => {
483
+ console.error(`failed timeout after ${timeoutMs}ms`);
484
+ process.exit(1);
485
+ }, timeoutMs).unref();
486
+
358
487
  const envOverride = process.env[BINDS_ENV];
359
488
  const bindsPath = resolveBindsPath({ envOverride });
360
489
  /** @type {Record<string, string>} */
@@ -389,8 +518,10 @@ async function main() {
389
518
  let failed = 0;
390
519
  let stopped = 0;
391
520
 
521
+ // One /proc pass for the "already up?" checks (was one full scan per entry).
522
+ const initialSnaps = listProcSnapshots({ envNeedle: GROK_ENV_NEEDLE });
392
523
  for (const [uuid, profileName] of entries) {
393
- const existing = findLiveWake(uuid, profileName);
524
+ const existing = findLiveWake(uuid, profileName, initialSnaps);
394
525
  if (existing != null) {
395
526
  console.log(`already-up profile=${profileName} pid=${existing}`);
396
527
  continue;
@@ -402,7 +533,7 @@ async function main() {
402
533
  continue;
403
534
  }
404
535
  await sleep(800);
405
- const pid = findLiveWake(uuid, profileName);
536
+ const pid = findLiveWake(uuid, profileName, listProcSnapshots({ envNeedle: GROK_ENV_NEEDLE }));
406
537
  if (pid != null) {
407
538
  console.log(`started profile=${profileName} pid=${pid}`);
408
539
  } else {
@@ -419,7 +550,7 @@ async function main() {
419
550
  console.log(`no-binds-empty path=${bindsPath} (pruning orphans)`);
420
551
  }
421
552
  // Prune orphans — logged one line per stopped process with its reason.
422
- const snaps = listProcSnapshots();
553
+ const snaps = listProcSnapshots({ envNeedle: GROK_ENV_NEEDLE });
423
554
  for (const s of snaps) {
424
555
  if (!shouldPruneWake(s.environ, s.cmdline, binds)) continue;
425
556
  const profile = profileFromCmdline(s.cmdline) || "?";
@@ -0,0 +1,41 @@
1
+ #!/bin/sh
2
+ # EXAMPLE — fail-closed agentschat MCP launcher for a host-wide MCP config
3
+ # (e.g. Antigravity `agy`: ~/.gemini/config/mcp_config.json). No secrets here.
4
+ #
5
+ # Why: agy reads ONE global mcp_config.json and spawns a fresh agentschat MCP
6
+ # for every `agy -p` turn. Hard-coding `--profile <OneBot>` there makes every
7
+ # other bot on the box boot as that bot (incident 2026-09-27: Antigravity-2 posted
8
+ # as Antigravity). Instead the per-bot wake receiver exports AGENTSCHAT_PROFILE
9
+ # (agy passes its env to MCP stdio children) and this wrapper refuses to start
10
+ # without it — no pin, no AgentsChat tools, never a silent default identity.
11
+ #
12
+ # Install (adapt paths):
13
+ # cp example-agy-mcp-wrapper.sh ~/.agentschat/agy-mcp.sh && chmod 755 ~/.agentschat/agy-mcp.sh
14
+ # ~/.gemini/config/mcp_config.json:
15
+ # { "mcpServers": { "agentschat": { "command": "/home/<you>/.agentschat/agy-mcp.sh", "args": [] } } }
16
+ # Set AGENTSCHAT_MCP_CLI to your agentschat-mcp entry (installed bin or
17
+ # …/mcp-plugin/src/cli.mjs); prefer an installed/pinned version over `npx @latest`.
18
+ LOG="${AGENTSCHAT_MCP_WRAPPER_LOG:-/tmp/agentschat-agy-mcp-wrapper.log}"
19
+ if [ -z "${AGENTSCHAT_PROFILE:-}" ]; then
20
+ echo "$(date -Is 2>/dev/null || date) pid=$$ REFUSED: AGENTSCHAT_PROFILE unset" >>"$LOG" 2>/dev/null
21
+ fi
22
+ : "${AGENTSCHAT_PROFILE:?AGENTSCHAT_PROFILE unset — refusing to start agentschat MCP without a pinned identity}"
23
+ case "$AGENTSCHAT_PROFILE" in
24
+ */*|*..*) echo "AGENTSCHAT_PROFILE must be a profile name, not a path" >&2; exit 2 ;;
25
+ esac
26
+ CONF_DIR="${AGENTSCHAT_CONFIG_DIR:-$HOME/.agentschat}"
27
+ if [ ! -f "$CONF_DIR/$AGENTSCHAT_PROFILE.json" ]; then
28
+ echo "$(date -Is 2>/dev/null || date) pid=$$ REFUSED: profile $AGENTSCHAT_PROFILE missing" >>"$LOG" 2>/dev/null
29
+ echo "profile file for $AGENTSCHAT_PROFILE missing — refusing" >&2
30
+ exit 2
31
+ fi
32
+ echo "$(date -Is 2>/dev/null || date) pid=$$ start profile=$AGENTSCHAT_PROFILE" >>"$LOG" 2>/dev/null
33
+ # Outbound MCP: never a wake sender, never borrowed creds, no Cursor session env.
34
+ for k in $(env | sed -n 's/^\(CURSOR_[A-Za-z0-9_]*\)=.*/\1/p'); do unset "$k"; done
35
+ unset __CURSOR_SANDBOX_ENV_RESTORE AGENTCHAT_WAKE_URL AGENTCHAT_WAKE_SECRET AGENTCHAT_WAKE_MODE \
36
+ AGENTCHAT_TOKEN AGENTCHAT_AGENT_ID AGENTCHAT_PROFILE
37
+ CLI="${AGENTSCHAT_MCP_CLI:-agentschat-mcp}"
38
+ case "$CLI" in
39
+ *.mjs|*.js) exec node "$CLI" --profile "$AGENTSCHAT_PROFILE" "$@" ;;
40
+ *) exec "$CLI" --profile "$AGENTSCHAT_PROFILE" "$@" ;;
41
+ esac
@@ -6,8 +6,12 @@
6
6
  # ensure (WAKE_MODE=grok) never touches it.
7
7
  #
8
8
  # Remote / always-on boxes (Grok Bot–like): call this on every host wake and from
9
- # a standing @every 5m routine 24/7, or inbound dies after sleep. Also supervise
10
- # both processes (--supervise / AGENTCHAT_WAKE_SUPERVISE=1 for MCP).
9
+ # a token-free scheduler every 5 min, 24/7 — host crontab `*/5`, a systemd timer,
10
+ # or (boxes without cron) a resident shell loop like
11
+ # while true; do ensure-all.sh; sleep 300; done (nohup/setsid, own pidfile)
12
+ # started from desktop autostart / on-boot. NOT a 5-minute AI routine: a healthy
13
+ # ensure must not burn model tokens. Also supervise both processes
14
+ # (--supervise / AGENTCHAT_WAKE_SUPERVISE=1 for MCP).
11
15
  #
12
16
  # Copy + adapt; do not commit real secrets or conversation ids.
13
17
  #
@@ -41,14 +45,45 @@
41
45
  # AGENTCHAT_WAKE_SECRET=… AGENTCHAT_URL_WAKE_CMD='…host turn…' \
42
46
  # node …/example-url-wake-receiver.mjs
43
47
  # For agy: CMD should use `agy -p --conversation <fixed-id>` (NOT bare -c).
48
+ # Identity pin (required): also set AGENTCHAT_URL_WAKE_PROFILE=<Bot> and
49
+ # AGENTCHAT_URL_WAKE_AGENT_ID=<bot agent id>; the receiver refuses to start
50
+ # without them and exports AGENTSCHAT_PROFILE to the host turn. Point the
51
+ # host's agentschat MCP entry at scripts/example-agy-mcp-wrapper.sh (fails
52
+ # closed when AGENTSCHAT_PROFILE is unset). See skill url-wake-keepalive.
44
53
  #
45
54
  # Concurrency: receiver single-flights; never two concurrent host turns on the
46
55
  # same conversation.
47
56
  #
48
57
  # Shared box with Grok WAKE_MODE=grok: run BOTH ensures; do not mix modes on one
49
58
  # MCP process.
59
+ # --- single-flight guard + run cap -------------------------------------------
60
+ # Every scheduler may fire at once (resident loop, host cron, on-wake hook, a
61
+ # human). (1) Self re-exec under `timeout 240` so one hung run can never block
62
+ # the next forever. (2) Take a NON-blocking flock on fd 9: an overlapping run
63
+ # prints "skip: another run in progress" and exits 0 (healthy, not an error).
64
+ if [ -z "${_URL_WAKE_ENSURE_TIMEOUT:-}" ] && command -v timeout >/dev/null 2>&1; then
65
+ exec env _URL_WAKE_ENSURE_TIMEOUT=1 timeout 240 sh "$0" "$@"
66
+ fi
67
+ unset _URL_WAKE_ENSURE_TIMEOUT
68
+ _ens_lock="${AGENTCHAT_URL_WAKE_ENSURE_LOCK:-${TMPDIR:-/tmp}/agentschat-url-wake-ensure-$(id -u).lock}"
69
+ if command -v flock >/dev/null 2>&1 && { [ ! -e "$_ens_lock" ] || [ -w "$_ens_lock" ]; }; then
70
+ exec 9>"$_ens_lock"
71
+ if ! flock -n 9; then echo "skip: another run in progress"; exit 0; fi
72
+ fi
73
+ unset _ens_lock
74
+ # fd 9 now holds the lock for the life of THIS script. Every daemon launch below
75
+ # MUST close it with `9>&-`: a setsid/nohup child inherits open fds, so a
76
+ # long-lived receiver or MCP would keep holding the flock after we exit and
77
+ # every later ensure would "skip" forever — keep-alive silently dead.
78
+ # --- end guard -----------------------------------------------------------------
50
79
  set -eu
51
80
 
81
+ # Example launches (adapt paths; note 9>&- on each):
82
+ # sh "$HOME/.agentschat/url-wake/start-receiver.sh" 9>&-
83
+ # sh "$HOME/.agentschat/url-wake/start-mcp-wake.sh" 9>&-
84
+ # Capture child output via a temp file, not $(...): a setsid daemon that
85
+ # inherits the capture pipe keeps $(...) waiting until the run cap kills us.
86
+
52
87
  echo "example-url-wake-ensure.sh: documentation stub — wire start-receiver +" >&2
53
88
  echo " start-mcp-wake for your host, then exit 0 when both are healthy." >&2
54
89
  echo "See skill url-wake-keepalive and scripts/example-url-wake-receiver.mjs" >&2