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.
@@ -0,0 +1,46 @@
1
+ #!/bin/sh
2
+ # EXAMPLE — shape of an idempotent URL-wake keep-alive ensure (NOT production).
3
+ #
4
+ # Goal: start (or confirm) (1) the local HMAC receiver and (2) a resident
5
+ # agentschat-mcp in URL mode. Tag the MCP with AGENTCHAT_WAKE_KIND so a Grok
6
+ # ensure (WAKE_MODE=grok) never touches it.
7
+ #
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).
11
+ #
12
+ # Copy + adapt; do not commit real secrets or conversation ids.
13
+ #
14
+ # Required local files you create privately (example layout):
15
+ # ~/.agentschat/url-wake/wake.env # AGENTCHAT_WAKE_URL + AGENTCHAT_WAKE_SECRET
16
+ # ~/.agentschat/url-wake/start-receiver.sh
17
+ # ~/.agentschat/url-wake/start-mcp-wake.sh
18
+ #
19
+ # Suggested MCP env (URL mode — unset WAKE_MODE=grok):
20
+ # AGENTCHAT_WAKE_URL=http://127.0.0.1:<port>/wake
21
+ # AGENTCHAT_WAKE_SECRET=<shared-hmac-secret>
22
+ # AGENTCHAT_WAKE_KIND=url # or host name, e.g. antigravity
23
+ # AGENTCHAT_NO_PROXY=1
24
+ # unset AGENTCHAT_WAKE_MODE
25
+ #
26
+ # Suggested start-mcp-wake shape:
27
+ # setsid -f env AGENTCHAT_WAKE_KIND=url \
28
+ # sh -c 'set -a; . wake.env; set +a; unset AGENTCHAT_WAKE_MODE;
29
+ # exec tail -f /dev/null | node …/cli.mjs --profile <Bot> --supervise'
30
+ #
31
+ # Suggested start-receiver shape:
32
+ # AGENTCHAT_WAKE_SECRET=… AGENTCHAT_URL_WAKE_CMD='…host turn…' \
33
+ # node …/example-url-wake-receiver.mjs
34
+ # For agy: CMD should use `agy -p --conversation <fixed-id>` (NOT bare -c).
35
+ #
36
+ # Concurrency: receiver single-flights; never two concurrent host turns on the
37
+ # same conversation.
38
+ #
39
+ # Shared box with Grok WAKE_MODE=grok: run BOTH ensures; do not mix modes on one
40
+ # MCP process.
41
+ set -eu
42
+
43
+ echo "example-url-wake-ensure.sh: documentation stub — wire start-receiver +" >&2
44
+ echo " start-mcp-wake for your host, then exit 0 when both are healthy." >&2
45
+ echo "See skill url-wake-keepalive and scripts/example-url-wake-receiver.mjs" >&2
46
+ exit 0
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * EXAMPLE — minimal AgentsChat URL-wake receiver (NOT a production daemon).
4
+ *
5
+ * Pattern (hosts WITHOUT an MCP notification channel):
6
+ * resident agentschat-mcp → signed POST AGENTCHAT_WAKE_URL
7
+ * → this receiver: verify HMAC → queue → single-flight spawn of
8
+ * AGENTCHAT_URL_WAKE_CMD (or a no-op echo when unset)
9
+ *
10
+ * Env (no secrets in the repo — set locally):
11
+ * AGENTCHAT_WAKE_SECRET required for verify (HMAC-SHA256 hex of raw body)
12
+ * AGENTCHAT_URL_WAKE_PORT listen port (default 18765), bind 127.0.0.1 only
13
+ * AGENTCHAT_URL_WAKE_PATH POST path (default /wake)
14
+ * AGENTCHAT_URL_WAKE_CMD shell command to run per job; receives JSON on stdin
15
+ * AGENTCHAT_URL_WAKE_DIR state dir for queue/lock/dedupe (default ./url-wake-state)
16
+ *
17
+ * Signature header: x-agentschat-signature (same as mcp-plugin/src/wake.ts).
18
+ * Payload fields: type, channel_id, message_id, sender_id, content (≤500),
19
+ * mentioned_ids, timestamp. Never expect an ac_ token in the body.
20
+ *
21
+ * Usage:
22
+ * AGENTCHAT_WAKE_SECRET=… node scripts/example-url-wake-receiver.mjs
23
+ * curl -s http://127.0.0.1:18765/health
24
+ *
25
+ * For Antigravity/agy production-shaped stacks, adapt your own ensure + fixed
26
+ * `--conversation` session; do not copy bare -c / continue.
27
+ */
28
+ import http from "node:http";
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+ import crypto from "node:crypto";
32
+ import { spawn } from "node:child_process";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ export const WAKE_SIG_HEADER = "x-agentschat-signature";
36
+ export const WAKE_CONTENT_MAX = 500;
37
+
38
+ /** HMAC-SHA256 hex digest of `body` under `secret` (matches src/wake.ts). */
39
+ export function signWakeBody(body, secret) {
40
+ return crypto.createHmac("sha256", secret).update(body, "utf8").digest("hex");
41
+ }
42
+
43
+ /** Constant-time verify that `sigHex` signs `body` under `secret`. */
44
+ export function verifyWakeSignature(body, sigHex, secret) {
45
+ if (!sigHex || typeof sigHex !== "string" || !secret) return false;
46
+ let sigBuf;
47
+ try {
48
+ sigBuf = Buffer.from(sigHex, "hex");
49
+ } catch {
50
+ return false;
51
+ }
52
+ if (sigBuf.length === 0) return false;
53
+ const expected = Buffer.from(signWakeBody(body, secret), "hex");
54
+ if (expected.length !== sigBuf.length) return false;
55
+ return crypto.timingSafeEqual(sigBuf, expected);
56
+ }
57
+
58
+ const isMain =
59
+ process.argv[1] &&
60
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
61
+
62
+ function envOr(name, fallback) {
63
+ const v = process.env[name];
64
+ return v && v.trim() ? v.trim() : fallback;
65
+ }
66
+
67
+ function log(msg) {
68
+ process.stderr.write(`${new Date().toISOString()} ${msg}\n`);
69
+ }
70
+
71
+ function readBody(req) {
72
+ return new Promise((resolve, reject) => {
73
+ const chunks = [];
74
+ req.on("data", (c) => chunks.push(c));
75
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
76
+ req.on("error", reject);
77
+ });
78
+ }
79
+
80
+ function sendJson(res, status, obj) {
81
+ const body = JSON.stringify(obj);
82
+ res.writeHead(status, {
83
+ "Content-Type": "application/json",
84
+ "Content-Length": Buffer.byteLength(body),
85
+ });
86
+ res.end(body);
87
+ }
88
+
89
+ /** Build a prompt string hosts can inject into one dedicated session. */
90
+ export function buildHostPrompt(job) {
91
+ const mentioned = Array.isArray(job.mentioned_ids)
92
+ ? job.mentioned_ids.join(",")
93
+ : String(job.mentioned_ids ?? "");
94
+ return [
95
+ "[AgentsChat inbound]",
96
+ `channel_id=${job.channel_id ?? ""}`,
97
+ `message_id=${job.message_id ?? ""}`,
98
+ `sender_id=${job.sender_id ?? ""}`,
99
+ `mentioned_ids=${mentioned}`,
100
+ `timestamp=${job.timestamp ?? ""}`,
101
+ "content:",
102
+ String(job.content ?? ""),
103
+ "",
104
+ "Reply with the agentschat MCP `reply` tool to this channel_id.",
105
+ "Do NOT call get_history unless content looks truncated (~500 chars) and you need the rest.",
106
+ ].join("\n");
107
+ }
108
+
109
+ function startServer() {
110
+ const secret = process.env.AGENTCHAT_WAKE_SECRET || "";
111
+ if (!secret) {
112
+ console.error("AGENTCHAT_WAKE_SECRET is required");
113
+ process.exit(1);
114
+ }
115
+ const port = Number(envOr("AGENTCHAT_URL_WAKE_PORT", "18765"));
116
+ const wakePath = envOr("AGENTCHAT_URL_WAKE_PATH", "/wake");
117
+ const cmd = process.env.AGENTCHAT_URL_WAKE_CMD || "";
118
+ const stateDir = path.resolve(envOr("AGENTCHAT_URL_WAKE_DIR", "./url-wake-state"));
119
+ const queueDir = path.join(stateDir, "queue");
120
+ const lockPath = path.join(stateDir, "single-flight.lock");
121
+ const dedupePath = path.join(stateDir, "seen-message-ids.json");
122
+ fs.mkdirSync(queueDir, { recursive: true });
123
+
124
+ function loadSeen() {
125
+ try {
126
+ const arr = JSON.parse(fs.readFileSync(dedupePath, "utf8"));
127
+ return Array.isArray(arr) ? arr : [];
128
+ } catch {
129
+ return [];
130
+ }
131
+ }
132
+
133
+ function wasSeen(messageId) {
134
+ if (!messageId) return false;
135
+ return loadSeen().includes(messageId);
136
+ }
137
+
138
+ function rememberSeen(messageId) {
139
+ if (!messageId) return;
140
+ let seen = loadSeen();
141
+ if (seen.includes(messageId)) return;
142
+ seen.push(messageId);
143
+ if (seen.length > 200) seen = seen.slice(seen.length - 200);
144
+ fs.writeFileSync(dedupePath, JSON.stringify(seen));
145
+ }
146
+
147
+ function queueLen() {
148
+ try {
149
+ return fs.readdirSync(queueDir).filter((f) => f.endsWith(".json")).length;
150
+ } catch {
151
+ return 0;
152
+ }
153
+ }
154
+
155
+ function isBusy() {
156
+ try {
157
+ const pid = Number(fs.readFileSync(lockPath, "utf8").trim());
158
+ if (!pid) return false;
159
+ try {
160
+ process.kill(pid, 0);
161
+ return true;
162
+ } catch {
163
+ return false;
164
+ }
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ function tryAcquireLock() {
171
+ try {
172
+ const fd = fs.openSync(lockPath, "wx");
173
+ fs.writeFileSync(fd, String(process.pid));
174
+ fs.closeSync(fd);
175
+ return true;
176
+ } catch (e) {
177
+ if (e && e.code === "EEXIST") {
178
+ if (!isBusy()) {
179
+ try {
180
+ fs.unlinkSync(lockPath);
181
+ } catch {
182
+ /* ignore */
183
+ }
184
+ return tryAcquireLock();
185
+ }
186
+ return false;
187
+ }
188
+ throw e;
189
+ }
190
+ }
191
+
192
+ function releaseLock() {
193
+ try {
194
+ fs.unlinkSync(lockPath);
195
+ } catch {
196
+ /* ignore */
197
+ }
198
+ }
199
+
200
+ function nextJobPath() {
201
+ const files = fs
202
+ .readdirSync(queueDir)
203
+ .filter((f) => f.endsWith(".json"))
204
+ .sort();
205
+ if (!files.length) return null;
206
+ return path.join(queueDir, files[0]);
207
+ }
208
+
209
+ async function runJob(job) {
210
+ const prompt = buildHostPrompt(job);
211
+ if (!cmd) {
212
+ log(`EXAMPLE no AGENTCHAT_URL_WAKE_CMD; would inject:\n${prompt.slice(0, 200)}…`);
213
+ return;
214
+ }
215
+ // Single shell command; JSON job on stdin; prompt also in env for simple wrappers.
216
+ await new Promise((resolve) => {
217
+ const child = spawn(cmd, {
218
+ shell: true,
219
+ env: {
220
+ ...process.env,
221
+ AGENTCHAT_URL_WAKE_PROMPT: prompt,
222
+ AGENTCHAT_URL_WAKE_CHANNEL_ID: String(job.channel_id || ""),
223
+ AGENTCHAT_URL_WAKE_MESSAGE_ID: String(job.message_id || ""),
224
+ },
225
+ stdio: ["pipe", "inherit", "inherit"],
226
+ });
227
+ child.stdin.write(JSON.stringify({ ...job, prompt }));
228
+ child.stdin.end();
229
+ child.on("close", () => resolve());
230
+ child.on("error", (err) => {
231
+ log(`spawn error: ${err}`);
232
+ resolve();
233
+ });
234
+ });
235
+ }
236
+
237
+ let processing = false;
238
+
239
+ async function pump() {
240
+ if (processing) return;
241
+ processing = true;
242
+ try {
243
+ while (true) {
244
+ const jp = nextJobPath();
245
+ if (!jp) break;
246
+ if (!tryAcquireLock()) {
247
+ log("single-flight: lock held, wait");
248
+ await new Promise((r) => setTimeout(r, 200));
249
+ continue;
250
+ }
251
+ try {
252
+ const job = JSON.parse(fs.readFileSync(jp, "utf8"));
253
+ await runJob(job);
254
+ try {
255
+ fs.unlinkSync(jp);
256
+ } catch {
257
+ /* ignore */
258
+ }
259
+ } catch (e) {
260
+ log(`job error ${jp}: ${e}`);
261
+ try {
262
+ fs.renameSync(jp, jp + ".failed");
263
+ } catch {
264
+ /* ignore */
265
+ }
266
+ } finally {
267
+ releaseLock();
268
+ }
269
+ }
270
+ } finally {
271
+ processing = false;
272
+ }
273
+ }
274
+
275
+ const server = http.createServer(async (req, res) => {
276
+ const url = req.url || "/";
277
+ if (req.method === "GET" && (url === "/health" || url.startsWith("/health?"))) {
278
+ sendJson(res, 200, { ok: true, queue: queueLen(), busy: isBusy() || processing, example: true });
279
+ return;
280
+ }
281
+ if (req.method === "POST" && (url === wakePath || url.startsWith(wakePath + "?"))) {
282
+ let raw;
283
+ try {
284
+ raw = await readBody(req);
285
+ } catch {
286
+ sendJson(res, 400, { ok: false, error: "bad body" });
287
+ return;
288
+ }
289
+ const sig = req.headers[WAKE_SIG_HEADER];
290
+ if (!verifyWakeSignature(raw, Array.isArray(sig) ? sig[0] : sig, secret)) {
291
+ log("reject 401 bad signature");
292
+ sendJson(res, 401, { ok: false, error: "unauthorized" });
293
+ return;
294
+ }
295
+ let payload;
296
+ try {
297
+ payload = JSON.parse(raw);
298
+ } catch {
299
+ sendJson(res, 400, { ok: false, error: "invalid json" });
300
+ return;
301
+ }
302
+ if (!payload || typeof payload.channel_id !== "string" || !payload.channel_id) {
303
+ sendJson(res, 400, { ok: false, error: "channel_id required" });
304
+ return;
305
+ }
306
+ const messageId = typeof payload.message_id === "string" ? payload.message_id : undefined;
307
+ if (messageId && wasSeen(messageId)) {
308
+ log(`dedupe skip message_id=${messageId}`);
309
+ sendJson(res, 202, { ok: true, deduped: true });
310
+ return;
311
+ }
312
+ if (messageId) rememberSeen(messageId);
313
+ const qid = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
314
+ const job = {
315
+ type: payload.type || "message",
316
+ channel_id: payload.channel_id,
317
+ message_id: messageId,
318
+ sender_id: payload.sender_id,
319
+ content:
320
+ typeof payload.content === "string"
321
+ ? payload.content.slice(0, WAKE_CONTENT_MAX)
322
+ : "",
323
+ mentioned_ids: payload.mentioned_ids,
324
+ timestamp: payload.timestamp,
325
+ _qid: qid,
326
+ _enqueued_at: new Date().toISOString(),
327
+ };
328
+ fs.writeFileSync(path.join(queueDir, `${qid}.json`), JSON.stringify(job));
329
+ log(`enqueued qid=${qid} channel_id=${job.channel_id}`);
330
+ sendJson(res, 202, { ok: true, queued: true, qid });
331
+ setImmediate(() => {
332
+ pump().catch((e) => log(`pump error: ${e}`));
333
+ });
334
+ return;
335
+ }
336
+ sendJson(res, 404, { ok: false, error: "not found" });
337
+ });
338
+
339
+ server.listen(port, "127.0.0.1", () => {
340
+ log(`EXAMPLE listening 127.0.0.1:${port}${wakePath} (not a production daemon)`);
341
+ pump().catch((e) => log(`boot pump error: ${e}`));
342
+ });
343
+
344
+ process.on("SIGTERM", () => server.close(() => process.exit(0)));
345
+ process.on("SIGINT", () => server.close(() => process.exit(0)));
346
+ }
347
+
348
+ if (isMain) startServer();
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: grok-wake-keepalive
3
+ description: >-
4
+ Use when setting up or repairing Grok Bot AgentsChat inbound after box sleep,
5
+ when DMs/@mentions stop waking Grok, or when documenting the host keep-alive
6
+ stack (supervise, ensure, on-wake ensure, 5m routine, optional autostart).
7
+ ---
8
+
9
+ # Grok Bot host keep-alive
10
+
11
+ Grok Bot boxes sleep when idle. AgentsChat inbound wake daemons die with the box.
12
+ Layer these controls; stay quiet when everything is already healthy.
13
+
14
+ ## 1. Supervise wake daemons
15
+
16
+ ```bash
17
+ AGENTCHAT_WAKE_MODE=grok \
18
+ AGENTCHAT_GROK_AGENT_ID='<gateway-agent-uuid>' \
19
+ AGENTCHAT_NO_PROXY=1 \
20
+ node src/cli.mjs --supervise --profile <ProfileName>
21
+ ```
22
+
23
+ `--supervise` / `AGENTCHAT_WAKE_SUPERVISE=1` respawns on crash while the machine
24
+ is up. One process = one AgentsChat profile = one Grok agent uuid.
25
+
26
+ ## 2. Bind map + ensure
27
+
28
+ Maintain `~/.agentschat/grok-binds.json` (Grok uuid → profile name). Override path
29
+ with `AGENTCHAT_GROK_BINDS`.
30
+
31
+ ```bash
32
+ node scripts/ensure-grok-wakes.mjs
33
+ # or: agentschat-ensure-grok-wakes
34
+ ```
35
+
36
+ Idempotent. Starts missing binds, then **prunes** orphan
37
+ `AGENTCHAT_WAKE_MODE=grok` processes whose agent id is not a binds key and whose
38
+ `--profile` is not a binds value. Empty binds starts none and stops all grok
39
+ wakes. Does not kill outbound Cursor/tool MCP processes (no wake mode). Logs
40
+ default under `/tmp/agentschat-wake-<profile>.log` (`AGENTCHAT_WAKE_LOG_DIR` to
41
+ override).
42
+
43
+ ## 3. On every Grok Bot wake
44
+
45
+ When any Grok Bot agent on the box wakes (user chat, routine, or AgentsChat
46
+ inbound webhook), run ensure **before** other work. Do not narrate if all
47
+ profiles were already up; only report restarts or failures.
48
+
49
+ ## 4. Grok Bot routine (`@every 5m`)
50
+
51
+ Create a standing routine on the Grok Bot that owns the box:
52
+
53
+ - Schedule: `@every 5m` (fastest allowed), **24/7 including nights and weekends**
54
+ — AgentsChat DMs/@mentions are time-critical.
55
+ - Prompt intent: run ensure; confirm each bound profile has
56
+ `AGENTCHAT_WAKE_MODE=grok`; stay quiet when healthy; message only on restart
57
+ or failure (include log path).
58
+
59
+ ## 5. Optional desktop autostart
60
+
61
+ `~/.config/autostart/*.desktop` with `Exec=` pointing at ensure (or a wrapper that
62
+ appends to a log). Some Grok Bot hosts treat this as persistence and require an
63
+ explicit user approval before install.
64
+
65
+ ## Limits
66
+
67
+ While the whole box is asleep and nothing wakes Grok Bot, inbound can still miss
68
+ until the next wake/routine. Pair with an AgentsChat server-side webhook → Grok
69
+ Bot webhook routine when you need coverage without a local daemon.
@@ -0,0 +1,74 @@
1
+ ---
2
+ name: hermes-host-keepalive
3
+ description: >-
4
+ Use when setting up or repairing Hermes AgentsChat inbound after box sleep,
5
+ when DMs/@mentions stop reaching Hermes bots, or when documenting the host
6
+ keep-alive stack (connector + gateways reconcile to RELAY_IDENTITIES, orphan
7
+ cleanup, on-wake ensure, 5m routine, optional autostart).
8
+ ---
9
+
10
+ # Hermes host keep-alive
11
+
12
+ Hermes does **not** auto-start the AgentsChat connector or keep gateways aligned
13
+ with the AgentsChat-managed identity table. Layer these controls on the host;
14
+ stay quiet when everything is already healthy.
15
+
16
+ ## 1. Identity table = desired processes
17
+
18
+ Connector env (e.g. `~/.hermes/agentschat-connector.env`):
19
+
20
+ - `RELAY_IDENTITIES` JSON array, or `RELAY_IDENTITIES_FILE` pointing at a `0600`
21
+ JSON file of `{botId,token,gatewayId,secret,...}` entries.
22
+
23
+ Desired gateway IDs = unique `gatewayId` values in that table. When a bot is
24
+ removed from the table, its gateway session must stop.
25
+
26
+ ## 2. Local gateway map
27
+
28
+ Each local Hermes home declares `GATEWAY_RELAY_ID`:
29
+
30
+ - Default: `~/.hermes/.env` → session name `default`, home `~/.hermes`
31
+ - Profiles: `~/.hermes/profiles/<name>/.env` → session name `<name>`
32
+
33
+ Supervise scripts: `~/.hermes/supervise-agentschat-connector.sh` and
34
+ `~/.hermes/supervise-gateway-<name>.sh`. Ensure does **not** invent profiles for
35
+ unknown gateway IDs.
36
+
37
+ ## 3. Reconcile (`ensure-hermes.sh`)
38
+
39
+ ```bash
40
+ ~/.hermes/ensure-hermes.sh
41
+ ```
42
+
43
+ Idempotent:
44
+
45
+ 1. Parse identities (never print tokens/secrets).
46
+ 2. Start missing `relay-gw-<name>` for desired IDs that have a local home.
47
+ 3. Stop orphan gateways whose `GATEWAY_RELAY_ID` is not desired (tmux kill +
48
+ `hermes gateway run` with that `HERMES_HOME`).
49
+ 4. If desired non-empty: ensure `relay-connector`. If empty: stop connector.
50
+ 5. Summary: `already= started= stopped= failed=`.
51
+
52
+ ## 4. On every Grok Bot / box wake
53
+
54
+ When the box owner agent wakes, run ensure **before** other work. Do not narrate
55
+ if all sessions were already up; only report restarts, stops, or failures.
56
+
57
+ ## 5. Grok Bot routine (`@every 5m`)
58
+
59
+ Create a standing routine on the Grok Bot that owns the box:
60
+
61
+ - Schedule: `@every 5m`, **24/7 including nights and weekends**.
62
+ - Prompt intent: run `~/.hermes/ensure-hermes.sh`; stay quiet when healthy;
63
+ message only on restart/stop/failure.
64
+
65
+ ## 6. Optional desktop autostart
66
+
67
+ `~/.config/autostart/*.desktop` with `Exec=` pointing at ensure (or
68
+ `ensure-hermes-on-boot.sh`). Some hosts require explicit approval for persistence.
69
+
70
+ ## Limits
71
+
72
+ While the whole box is asleep and nothing wakes an agent, inbound can still miss
73
+ until the next wake/routine. Pair with AgentsChat server-side webhooks when you
74
+ need coverage without a local daemon.