message-cp 0.1.1 → 0.2.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/README.md CHANGED
@@ -8,18 +8,19 @@ message-cp does not sign people in. Over HTTP it accepts only bearer tokens it i
8
8
 
9
9
  ## Quick start
10
10
 
11
- Needs Node 22.14 or later and git.
11
+ Needs Node 22.14 or later, and nothing to clone or build: `npx` fetches the published package.
12
12
 
13
13
  ```sh
14
- git clone https://github.com/colinhallett/message-cp.git
15
- cd message-cp
16
- npm ci --ignore-scripts
17
- npm run build
18
- npm test
19
- npm install -g .
14
+ npx -y message-cp init
20
15
  ```
21
16
 
22
- `--ignore-scripts` skips compiling the SQLite driver, `better-sqlite3`, which ships prebuilt binaries for Linux, macOS and Windows on x64 and arm64; without it, `npm ci` needs `make` and a C++ compiler. That gives you `dist/index.js`, the stdio server, and `dist/cli.js`, the `message-cp` command for everything else (`message-cp serve`, `wait`, `account`, `token`; `message-cp help` lists them all). `npm install -g .` links that command onto your `PATH` from the clone, compiling and downloading nothing. On Linux or macOS, if npm's global folder needs root, that step fails with `EACCES`; `npm install -g . --prefix ~/.local` puts the command in `~/.local/bin` instead, which must be on your `PATH`. Without either, `node dist/cli.js` in the clone does the same, for example `node dist/cli.js account list`. Add the stdio server to an MCP client as [Add it to an MCP client](https://message-cp.vercel.app/clients.html#add-it-to-an-mcp-client) in the reference says (the agents are the client's sessions; there is no separate chat command), open two sessions, and ask one to "register as alice" and the other to "register as bob and send alice a message".
17
+ `init` adds message-cp to Claude Code, Codex, Cursor or Claude Desktop; a `message-cp` entry that runs something else is left as it is, and `init` stops and says how to remove it. Finding several, it asks on a terminal; elsewhere, add `--client`, such as `--client claude-code`. It then checks the server with a test agent, which it removes, and prints how to undo. By hand, for Claude Code:
18
+
19
+ ```sh
20
+ claude mcp add message-cp -- npx -y message-cp
21
+ ```
22
+
23
+ Each of the client's sessions registers as its own agent, and all share one database; there is no separate chat command. Open two sessions, and ask one to "register as alice" and the other to "register as bob and send alice a message". `npx -y message-cp help` lists the other commands. For a team server or Docker, see [Getting started](https://message-cp.vercel.app/getting-started.html); for Cursor by hand or Claude Code over HTTP, [Add it to an MCP client](https://message-cp.vercel.app/clients.html#add-it-to-an-mcp-client). To build from a clone, see [CONTRIBUTING.md](CONTRIBUTING.md#building-and-testing).
23
24
 
24
25
  ## Documentation
25
26
 
package/dist/cli.js CHANGED
@@ -17,7 +17,7 @@ const USAGE = {
17
17
  serve: "message-cp serve [--host <address>] [--port <port>] [--public-url <url>]",
18
18
  wait: "message-cp wait <name>",
19
19
  account: "message-cp account add <name> | message-cp account list | message-cp token add <account> [--read-only | --agent-only | --person <name>] | message-cp token list | message-cp token revoke <token id>",
20
- webhook: "message-cp webhook add --url <url> --on <#channel | mention:<agent> | agent:<agent>> [--account <name>] [--no-text] [--header-stdin] [--max-per-hour <n>] [--body <json>] | message-cp webhook list [--account <name>] | message-cp webhook remove <webhook id>",
20
+ webhook: "message-cp webhook add --url <url> --on <#channel | mention:<agent> | agent:<agent> | agent:*> [--account <name>] [--no-text] [--header-stdin] [--max-per-hour <n>] [--body <json>] | message-cp webhook list [--account <name>] | message-cp webhook remove <webhook id>",
21
21
  person: "message-cp person add <name> [--account <name>] [--admin] | message-cp person admin <name> [--off] [--account <name>] | message-cp person remove <name> [--account <name>] | message-cp person list [--account <name>]",
22
22
  agent: "message-cp agent list [--account <name>] | message-cp agent remove <name> [--account <name>] | message-cp agent key reset <name> [--account <name>]",
23
23
  connect: "message-cp connect <agent> --url <public url> [--account <name>] [--new-key]",
@@ -352,8 +352,9 @@ else if (command === "agent") {
352
352
  db.close();
353
353
  }
354
354
  else if (command === "connect") {
355
- // Run on the server, like `agent`. The token and the agent's key are printed this once, to stdout, inside the client
356
- // setup; stderr says what was made and how to revoke the token, and never carries a token, a key or a hash.
355
+ // Run on the server, like `agent`. The token is printed this once, to stdout, inside the client setup, and the agent's
356
+ // key never is, since the token is bound to the agent; stderr says what was made and how to revoke the token, and
357
+ // never carries a token, a key or a hash.
357
358
  const usage = () => {
358
359
  console.error(`usage: ${USAGE.connect}`);
359
360
  process.exit(2);
@@ -385,7 +386,7 @@ else if (command === "connect") {
385
386
  console.error(c.reset
386
387
  ? `message-cp: new key for agent '${c.name}' of account '${c.account}'; its old key no longer works and its current sessions have ended.`
387
388
  : `message-cp: agent '${c.name}' in account '${c.account}'.`);
388
- console.error(`message-cp: agent token ${c.tokenId} for account '${c.account}'. The output holds this token and the agent's key, shown only now; keep them. To revoke the token: message-cp token list, then message-cp token revoke ${c.tokenId}.`);
389
+ console.error(`message-cp: agent token ${c.tokenId} for account '${c.account}'. The output holds this token, shown only now; keep it. To revoke the token: message-cp token list, then message-cp token revoke ${c.tokenId}.`);
389
390
  console.log(clientSetup(endpoint, c).replace(/\n$/, ""));
390
391
  }
391
392
  catch (err) {
@@ -505,7 +506,7 @@ else if (command === "init") {
505
506
  run: realRunner,
506
507
  interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
507
508
  ask: askOnTerminal,
508
- prove: () => (remote === undefined ? proveServer(ownServer()) : proveRemote(remote)),
509
+ prove: (checked) => (checked === undefined ? proveServer(ownServer()) : proveRemote(checked)),
509
510
  out: (line) => console.log(line),
510
511
  err: (line) => console.error(line),
511
512
  });
package/dist/connect.js CHANGED
@@ -2,7 +2,8 @@ import { addToken } from "./accounts.js";
2
2
  import { registerWithIdentity, resetAgentKey, validateName } from "./agents.js";
3
3
  import { shellQuote } from "./wait.js";
4
4
  // `message-cp connect`: what a client needs to act as one agent of an account over HTTP, made in one step on the
5
- // server. The token and key are returned for the CLI to print once, to stdout; only their hashes are stored.
5
+ // server. The token is returned for the CLI to print once, to stdout; only its hash is stored, and the agent's key,
6
+ // also returned, is not printed, since the token is bound to the agent.
6
7
  export class ConnectError extends Error {
7
8
  }
8
9
  /**
@@ -68,13 +69,12 @@ export const connectAgent = (db, account, name, newKey) => db.transaction(() =>
68
69
  const token = addToken(db, acct.name, false, true, agent.id);
69
70
  return { name: agent.name, account: acct.name, reset: live !== undefined, key: agent.key, tokenId: token.id, token: token.token };
70
71
  }).immediate();
71
- /** The two things `connect` prints: a `claude mcp add` command, then the same server as a JSON `mcpServers` entry. */
72
+ /**
73
+ * The two things `connect` prints: a `claude mcp add` command, then the same server as a JSON `mcpServers` entry. Both
74
+ * send only the token: it is bound to the agent, so it alone acts as it, and the agent's key is never printed.
75
+ */
72
76
  export function clientSetup(endpoint, c) {
73
- const headers = {
74
- Authorization: `Bearer ${c.token}`,
75
- "X-Message-CP-Agent": c.name,
76
- "X-Message-CP-Agent-Key": c.key,
77
- };
77
+ const headers = { Authorization: `Bearer ${c.token}` };
78
78
  const command = [
79
79
  "claude mcp add --transport http message-cp",
80
80
  shellQuote(endpoint.href),
package/dist/dashboard.js CHANGED
@@ -672,7 +672,7 @@ const NO_CHANNELS_FULL = ' You can also start one from "New channel" above.';
672
672
  const NO_MESSAGES = "No direct messages yet: they appear here when an agent sends one with the send_message tool.";
673
673
  const NO_MESSAGES_FULL = ' You can also start one from "New message" above.';
674
674
  const NO_DELIVERIES =
675
- "No webhook deliveries yet: add a webhook on the server with message-cp webhook add --url <url> --on <#channel | mention:<agent> | agent:<agent>>, and its deliveries show here once it fires.";
675
+ "No webhook deliveries yet: add a webhook on the server with message-cp webhook add --url <url> --on <#channel | mention:<agent> | agent:<agent> | agent:*>, and its deliveries show here once it fires.";
676
676
  const NO_APPROVALS =
677
677
  "No approved clients yet: a client approved as one named agent, or as one that registers its own agents, appears here " +
678
678
  "once it connects through the server's authorization flow (serve --public-url), and its token is revoked on the server " +
@@ -4854,7 +4854,7 @@ export const DASHBOARD_HTML = `<!doctype html>
4854
4854
  <input id="hook-url" type="text" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://example.com/hook"></div>
4855
4855
  <div class="field"><label for="hook-on">Fires on</label>
4856
4856
  <input id="hook-on" type="text" autocomplete="off" spellcheck="false" placeholder="#general" aria-describedby="hook-on-hint">
4857
- <span id="hook-on-hint" class="note">#&lt;channel&gt;, mention:&lt;agent&gt; or agent:&lt;agent&gt;</span></div>
4857
+ <span id="hook-on-hint" class="note">#&lt;channel&gt;, mention:&lt;agent&gt;, agent:&lt;agent&gt; or agent:* (every agent, not people)</span></div>
4858
4858
  <label class="check"><input id="hook-text" type="checkbox" checked> Send the message text</label>
4859
4859
  <div class="field"><label for="hook-header-name">Header name (optional)</label>
4860
4860
  <input id="hook-header-name" type="text" autocomplete="off" spellcheck="false" placeholder="Authorization"></div>
@@ -4864,7 +4864,7 @@ export const DASHBOARD_HTML = `<!doctype html>
4864
4864
  <input id="hook-max" type="number" min="1" step="1" inputmode="numeric" autocomplete="off"></div>
4865
4865
  <div class="field wide"><label for="hook-body">Fixed body (optional)</label>
4866
4866
  <textarea id="hook-body" rows="2" autocomplete="off" spellcheck="false" placeholder='{"requestId":"{{delivery}}"}' aria-describedby="hook-body-hint"></textarea>
4867
- <span id="hook-body-hint" class="note">A JSON object sent in place of the event, with {{delivery}} once in a string value; it never carries the text.</span></div>
4867
+ <span id="hook-body-hint" class="note">A JSON object sent in place of the event, with {{delivery}} once in a string value, and for agent: any {{agent}} in one; it never carries the text.</span></div>
4868
4868
  <div class="actions"><button id="hook-add" type="submit">Add webhook</button></div>
4869
4869
  </form>
4870
4870
  <div id="webhook-made" hidden>
package/dist/db.js CHANGED
@@ -62,7 +62,7 @@ CREATE INDEX IF NOT EXISTS messages_by_reply ON messages (in_reply_to);
62
62
  CREATE INDEX IF NOT EXISTS messages_by_question ON messages (in_reply_to, recipient_id);`;
63
63
  // Outgoing webhooks, added only with `message-cp webhook`; an admin person token also lists and removes them over
64
64
  // /api/webhooks. `event` is 'channel', 'mention' or 'agent' and `name` the channel or agent in the same account,
65
- // which need not exist yet. The secret is stored as is: deliveries sign with it.
65
+ // which need not exist yet, or '*' with 'agent' for every agent of the account (`agent:*`), a name no agent can have. The secret is stored as is: deliveries sign with it.
66
66
  // So is the value of the one header a webhook may carry (`header_name`, `header_value`), since deliveries send it.
67
67
  // `max_per_hour` (null for none) caps its delivery requests in a fixed window: `window_started_at` is when the first
68
68
  // request after the last window ended was claimed, and `window_count` how many requests have been claimed since; the
package/dist/init.js CHANGED
@@ -18,8 +18,8 @@ import { shellQuote } from "./wait.js";
18
18
  // It never prints a token or key: the test agent's key is made here, stays in memory, and the agent is purged after.
19
19
  // With `--url` and `--agent` it adds a team server over HTTP instead, carrying the token from MESSAGE_CP_HTTP_TOKEN in
20
20
  // its headers, with the agent's name and its key from MESSAGE_CP_AGENT_KEY unless the token is bound to the agent, and
21
- // nothing it prints holds the token or key. With `--from` it reads the URL, agent, token and key from the file
22
- // `message-cp connect` wrote instead, and once the check passes also writes the token to ~/.message-cp/http-token for
21
+ // nothing it prints holds the token or key. With `--from` it reads the URL and token from the file `message-cp connect`
22
+ // wrote instead (and the agent's name and key, from a file an earlier `connect` wrote with them), and once the check passes also writes the token to ~/.message-cp/http-token for
23
23
  // the wake-up command.
24
24
  export class InitError extends Error {
25
25
  }
@@ -176,10 +176,12 @@ export const cliCommands = {
176
176
  const MAX_CONNECT_FILE = 64 * 1024;
177
177
  /**
178
178
  * The team server in the file `message-cp connect` wrote: the `mcpServers` JSON after its `claude mcp add` line, which
179
- * must name exactly one server with a `url` and the three headers `clientSetup` in `src/connect.ts` writes. The URL goes
180
- * through `mcpEndpoint`, the check `init --url` uses. Refuses a file it cannot read, one that is not a regular file or
181
- * is over 64 KiB, one whose mode is wider than 0600 (except on Windows, which has no such modes), and one without
182
- * exactly one usable URL, agent, token and key. No message holds the file's contents.
179
+ * must name exactly one server with a `url` and the `Authorization` header `clientSetup` in `src/connect.ts` writes. A
180
+ * file an earlier `connect` wrote also has the agent's name and key headers, and then both must be usable; without
181
+ * them the agent is left out, for `checkRemote` to find from the token. The URL goes through `mcpEndpoint`, the check
182
+ * `init --url` uses. Refuses a file it cannot read, one that is not a regular file or is over 64 KiB, one whose mode is
183
+ * wider than 0600 (except on Windows, which has no such modes), and one without exactly one usable URL and token. No
184
+ * message holds the file's contents.
183
185
  */
184
186
  export function readConnectFile(file, platform = process.platform) {
185
187
  const refuse = (why) => new InitError(`${file} ${why}. Nothing was changed.`);
@@ -233,9 +235,11 @@ export function readConnectFile(file, platform = process.platform) {
233
235
  const word = (v) => typeof v === "string" && /^[\x21-\x7e]+$/.test(v);
234
236
  if (!word(token))
235
237
  throw notConnect("has no token");
236
- if (typeof agent !== "string" || !NAME_PATTERN.test(agent) || agent.toLowerCase() === "all")
238
+ // A file a later `connect` wrote has neither: its token is bound to the agent.
239
+ const named = agent !== undefined || key !== undefined;
240
+ if (named && (typeof agent !== "string" || !NAME_PATTERN.test(agent) || agent.toLowerCase() === "all"))
237
241
  throw notConnect("has no usable agent name");
238
- if (!word(key))
242
+ if (named && !word(key))
239
243
  throw notConnect("has no agent key");
240
244
  let endpoint;
241
245
  try {
@@ -246,7 +250,7 @@ export function readConnectFile(file, platform = process.platform) {
246
250
  throw err;
247
251
  throw refuse(`has a server URL init refuses: ${err.message.replace(/^--url /, "it ")}`);
248
252
  }
249
- return { endpoint, agent, token, key, from: file };
253
+ return named ? { endpoint, agent: agent, token, key: key, from: file } : { endpoint, token, from: file };
250
254
  }
251
255
  /**
252
256
  * Writes `token` to `~/.message-cp/http-token` under `home`, where the wake-up command reads it, as a new 0600 file in a
@@ -423,7 +427,7 @@ export function remoteRefusal(client, m, endpoint) {
423
427
  return `Codex joins a team server through the server's OAuth flow (\`serve --public-url\`), which init does not run: run \`codex mcp add message-cp --url ${shellQuote(endpoint.href)}\`, then \`codex mcp login message-cp\` if add did not start the login, and have a person with a full or person token of the account approve it at the address Codex prints.`;
424
428
  }
425
429
  if (client === "claude-desktop") {
426
- return `Claude Desktop takes a team server as a custom connector in its app: in Customize, Connectors, choose Add, then Add custom connector, give it a name and the URL ${endpoint.href}, choose No sign in, and add the three request headers.`;
430
+ return `Claude Desktop takes a team server as a custom connector in its app: in Customize, Connectors, choose Add, then Add custom connector, give it a name and the URL ${endpoint.href}, choose No sign in, and add the header Authorization: Bearer <token> (for a token not bound to the agent, also X-Message-CP-Agent and X-Message-CP-Agent-Key).`;
427
431
  }
428
432
  if (client === "claude-code" && m.platform === "win32") {
429
433
  return "on Windows, message-cp init cannot run `claude mcp add` without passing the token through a shell; run the `claude mcp add` command `message-cp connect` printed instead, or use --client cursor.";
@@ -643,6 +647,10 @@ async function callTool(client, tool, args) {
643
647
  * is ended. Throws an InitError that says which was refused: the token (401, or 403 for a read-only one), the agent's
644
648
  * name (not on the server, or not the bound token's agent) or its key (403, or none for an unbound token), or that the
645
649
  * server could not be reached. No message holds the token or key.
650
+ *
651
+ * Without `r.agent`, as from the file a later `connect` wrote, `list_channels` (which marks nothing read) on that
652
+ * session fails as not registered for an unbound token, which is refused, since nothing names its agent; for a bound
653
+ * one, the refused `register` names the agent, and `checkRemote` sets `r.agent` to it.
646
654
  */
647
655
  export async function checkRemote(r) {
648
656
  const tokenIn = r.from === undefined ? "the token in MESSAGE_CP_HTTP_TOKEN" : `the token in ${r.from}`;
@@ -661,6 +669,23 @@ export async function checkRemote(r) {
661
669
  };
662
670
  const said = (err) => redact(err instanceof Error ? err.message : String(err), r);
663
671
  const plain = remoteClient(r, { Authorization: `Bearer ${r.token}` });
672
+ // The agent a bound token is: `register` with a random name is refused before it does anything, so nothing is
673
+ // registered, and its message names the token's agent.
674
+ const tokenAgent = async () => {
675
+ const name = `init-check-${randomBytes(10).toString("hex")}`;
676
+ let refused = null;
677
+ try {
678
+ await callTool(plain.client, "register", { name, bio: "message-cp init check", key: randomBytes(24).toString("hex") });
679
+ }
680
+ catch (err) {
681
+ refused = /This token is already agent '([^']*)'/.exec(err instanceof Error ? err.message : "");
682
+ if (!refused)
683
+ throw new InitError(`register: ${said(err).slice(0, 300)}`);
684
+ }
685
+ if (!refused)
686
+ throw new InitError(`register: the token could read as an agent but could also register '${name}'; remove it on the server with: message-cp agent remove ${name}`);
687
+ return refused[1];
688
+ };
664
689
  let bound = false;
665
690
  try {
666
691
  try {
@@ -669,6 +694,19 @@ export async function checkRemote(r) {
669
694
  catch (err) {
670
695
  throw refusal(err, false);
671
696
  }
697
+ if (r.agent === undefined) {
698
+ try {
699
+ await callTool(plain.client, "list_channels", { limit: 1 });
700
+ }
701
+ catch (err) {
702
+ const message = said(err);
703
+ if (message.includes(NOT_REGISTERED))
704
+ throw new InitError(`${tokenIn} is not bound to an agent, and the file names none; give the file message-cp connect wrote, unchanged`);
705
+ throw new InitError(`list_channels: ${message.slice(0, 300)}`);
706
+ }
707
+ r.agent = await tokenAgent();
708
+ return true;
709
+ }
672
710
  let agent;
673
711
  try {
674
712
  agent = await callTool(plain.client, "get_agent", { name: r.agent });
@@ -692,22 +730,9 @@ export async function checkRemote(r) {
692
730
  throw new InitError(`read_conversation: ${message.slice(0, 300)}`);
693
731
  }
694
732
  if (bound) {
695
- // Refused before it does anything, so nothing is registered: its message names the token's agent.
696
- const name = `init-check-${randomBytes(10).toString("hex")}`;
697
- let refused = null;
698
- try {
699
- await callTool(plain.client, "register", { name, bio: "message-cp init check", key: randomBytes(24).toString("hex") });
700
- }
701
- catch (err) {
702
- refused = /This token is already agent '([^']*)'/.exec(err instanceof Error ? err.message : "");
703
- if (!refused)
704
- throw new InitError(`register: ${said(err).slice(0, 300)}`);
705
- }
706
- if (!refused)
707
- throw new InitError(`register: the token could read as an agent but could also register '${name}'; remove it on the server with: message-cp agent remove ${name}`);
708
- if (refused[1].toLowerCase() !== r.agent.toLowerCase()) {
709
- throw new InitError(`${tokenIn} is bound to agent '${refused[1]}', not '${r.agent}'`);
710
- }
733
+ const actual = await tokenAgent();
734
+ if (actual.toLowerCase() !== r.agent.toLowerCase())
735
+ throw new InitError(`${tokenIn} is bound to agent '${actual}', not '${r.agent}'`);
711
736
  }
712
737
  }
713
738
  finally {
@@ -861,22 +886,27 @@ export async function runInit(o) {
861
886
  }
862
887
  client = found[n - 1];
863
888
  }
864
- const remote = o.remote === undefined ? undefined : { ...o.remote };
865
- if (remote !== undefined) {
866
- const refused = remoteRefusal(client, o.machine, remote.endpoint);
889
+ let remote;
890
+ if (o.remote !== undefined) {
891
+ const refused = remoteRefusal(client, o.machine, o.remote.endpoint);
867
892
  if (refused !== undefined) {
868
893
  o.err(`message-cp init: ${refused} Nothing was changed.`);
869
894
  return 1;
870
895
  }
896
+ const unchecked = { ...o.remote };
897
+ let bound;
871
898
  try {
872
- remote.bound = (await (o.check ?? checkRemote)(remote)) === true;
899
+ bound = (await (o.check ?? checkRemote)(unchecked)) === true;
900
+ if (unchecked.agent === undefined)
901
+ throw new InitError("the check did not find which agent the token is");
873
902
  }
874
903
  catch (err) {
875
904
  if (!(err instanceof InitError))
876
905
  throw err;
877
- o.err(`message-cp init: ${redact(err.message, remote)}. Nothing was changed.`);
906
+ o.err(`message-cp init: ${redact(err.message, unchecked)}. Nothing was changed.`);
878
907
  return 1;
879
908
  }
909
+ remote = { ...unchecked, agent: unchecked.agent, bound };
880
910
  o.out(remote.key === undefined
881
911
  ? `Server: ${remote.endpoint.href} accepted the token, which is agent '${remote.agent}'.`
882
912
  : remote.bound
@@ -899,7 +929,9 @@ export async function runInit(o) {
899
929
  ? `message-cp init: ${label} already has a server named message-cp that does not run \`npx -y message-cp\`; it was left as it is.`
900
930
  : `message-cp init: ${label} already has a server named message-cp that is not ${remote.endpoint.href} as agent '${remote.agent}' with this ${remote.key === undefined ? "token" : "token and key"}; it was left as it is.`);
901
931
  if (remote?.bound && remote.key === undefined) {
902
- o.err("If it is an earlier init's entry with the agent's name and key, set MESSAGE_CP_AGENT_KEY so init can check that key, or remove it so init writes the token alone.");
932
+ o.err(remote.from === undefined
933
+ ? "If it is an earlier init's entry with the agent's name and key, set MESSAGE_CP_AGENT_KEY so init can check that key, or remove it so init writes the token alone."
934
+ : "If it is an earlier init's entry, for this token with the agent's name and key or for another token, remove it so init writes this token alone.");
903
935
  }
904
936
  o.err(`To remove it, then run message-cp init again: ${setUp.undo}`);
905
937
  return 1;
@@ -907,7 +939,7 @@ export async function runInit(o) {
907
939
  o.out(setUp.state === "added" ? `${label}: added message-cp ${setUp.changed}.` : `${label}: already set up; nothing was changed.`);
908
940
  let proved;
909
941
  try {
910
- proved = await o.prove();
942
+ proved = await o.prove(remote);
911
943
  }
912
944
  catch (err) {
913
945
  o.err(`Check failed: ${redact(err instanceof Error ? err.message : String(err), remote)}`);
package/dist/messages.js CHANGED
@@ -18,6 +18,16 @@ export function validateBody(body, kind = "Message") {
18
18
  throw new ToolError(`${kind} is too long: at most ${MAX_BODY_LENGTH} characters.`);
19
19
  }
20
20
  }
21
+ /**
22
+ * Queues a stored message for every `agent:<recipient>` webhook of the account and, unless the recipient is a person,
23
+ * every `agent:*` one (stored with the name `*`), held for the recipient while it is waiting for mail (`markWaiting`),
24
+ * so `sendPending` leaves it to the waiting agent. Both are found through `webhooks_by_name`. Parameters: the message id,
25
+ * the time now, the recipient's id, the time now again, the account, the recipient's name and its `person` flag.
26
+ */
27
+ export const QUEUE_MESSAGE_SQL = `INSERT INTO webhook_deliveries (webhook_id, message_id, created_at, held_for)
28
+ SELECT id, ?, ?, (SELECT a.id FROM agents a WHERE a.id = ? AND a.waiting_until > ?)
29
+ FROM webhooks WHERE account_id = ? AND event = 'agent' AND name COLLATE NOCASE IN (?, CASE WHEN ? = 0 THEN '*' END)
30
+ ORDER BY id`;
21
31
  /**
22
32
  * Stores one delivery per recipient: the named agent, or for `all` every agent of the account registered right now
23
33
  * except the sender. Read state therefore belongs to each recipient. A message belongs to its recipient's account,
@@ -45,7 +55,7 @@ export function sendMessage(db, account, from, to, body, own, inReplyTo) {
45
55
  let recipients;
46
56
  if (broadcast) {
47
57
  recipients = db
48
- .prepare("SELECT id, name FROM agents WHERE account_id = ? AND name <> ? AND removed_at IS NULL ORDER BY name")
58
+ .prepare("SELECT id, name, person FROM agents WHERE account_id = ? AND name <> ? AND removed_at IS NULL ORDER BY name")
49
59
  .all(account, from);
50
60
  }
51
61
  else {
@@ -53,20 +63,16 @@ export function sendMessage(db, account, from, to, body, own, inReplyTo) {
53
63
  if (!agent)
54
64
  throw new ToolError(`No agent named '${to}'.`);
55
65
  recipients = db
56
- .prepare("SELECT id, name FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
66
+ .prepare("SELECT id, name, person FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
57
67
  .all(account, agent.name);
58
68
  }
59
69
  const insert = db.prepare("INSERT INTO messages (recipient_id, sender, body, broadcast, created_at, in_reply_to) VALUES (?, ?, ?, ?, ?, ?)");
60
- // Each recipient's row is owed to every `agent:<recipient>` webhook of the account, held for the recipient while
61
- // it is waiting for mail (`markWaiting`), so `sendPending` leaves it to the waiting agent.
62
- const queue = db.prepare(`INSERT INTO webhook_deliveries (webhook_id, message_id, created_at, held_for)
63
- SELECT id, ?, ?, (SELECT a.id FROM agents a WHERE a.id = ? AND a.waiting_until > ?)
64
- FROM webhooks WHERE account_id = ? AND event = 'agent' AND name = ? COLLATE NOCASE ORDER BY id`);
70
+ const queue = db.prepare(QUEUE_MESSAGE_SQL);
65
71
  const now = Date.now();
66
72
  const stored = [];
67
73
  for (const r of recipients) {
68
74
  const id = insert.run(r.id, from, body, broadcast ? 1 : 0, now, inReplyTo ?? null).lastInsertRowid;
69
- queue.run(id, now, r.id, now, account, r.name);
75
+ queue.run(id, now, r.id, now, account, r.name, r.person);
70
76
  stored.push({ id: Number(id), to: r.name });
71
77
  }
72
78
  // Sending clears the sender's status line, on its own registration only, so a send from a session whose agent was
package/dist/webhooks.js CHANGED
@@ -11,7 +11,9 @@ import { MAX_INBOX_LIMIT } from "./messages.js";
11
11
  // of its deliveries; its value is stored as is too, and never printed, logged or returned. And it may carry a limit on
12
12
  // how many delivery requests it gets per window of 60 minutes; deliveries past it wait in the queue for a later window.
13
13
  // And it may carry a fixed JSON body the owner sets, sent in place of the event body, signed the same way, with its one
14
- // `{{delivery}}` replaced by an id of that delivery that every retry repeats; the body is never printed or logged.
14
+ // `{{delivery}}` replaced by an id of that delivery that every retry repeats, and, for direct messages, each `{{agent}}`
15
+ // replaced by the name of the agent the message went to; the body is never printed or logged. `agent:*` is for direct
16
+ // messages to every agent of the account, stored with the name `*`, which no agent can have.
15
17
  export class WebhookError extends Error {
16
18
  }
17
19
  /**
@@ -38,14 +40,18 @@ export function webhookUrl(raw) {
38
40
  url.hash = "";
39
41
  return url.href;
40
42
  }
43
+ /** The name an `agent:*` webhook is stored with: it fires for a direct message to any agent of its account. */
44
+ export const EVERY_AGENT = "*";
41
45
  /**
42
- * Parses `--on`: `#<channel>`, `mention:<agent>` or `agent:<agent>`, each name following the agent-name rules. An agent
43
- * cannot be named `all`, so `mention:all` and `agent:all` are refused; `#all` is an ordinary channel.
46
+ * Parses `--on`: `#<channel>`, `mention:<agent>`, `agent:<agent>` or `agent:*`, each name following the agent-name
47
+ * rules. An agent cannot be named `all`, so `mention:all` and `agent:all` are refused; `#all` is an ordinary channel.
44
48
  */
45
49
  export function webhookOn(on) {
50
+ if (on === `agent:${EVERY_AGENT}`)
51
+ return { event: "agent", name: EVERY_AGENT };
46
52
  const [, prefix, name] = /^(#|mention:|agent:)(.*)$/s.exec(on) ?? [];
47
53
  if (prefix === undefined || !NAME_PATTERN.test(name)) {
48
- throw new WebhookError("Invalid --on: use #<channel>, mention:<agent> or agent:<agent>, names of 1-32 letters, digits, '-' and '_'.");
54
+ throw new WebhookError("Invalid --on: use #<channel>, mention:<agent>, agent:<agent> or agent:*, names of 1-32 letters, digits, '-' and '_'.");
49
55
  }
50
56
  if (prefix !== "#" && name.toLowerCase() === "all") {
51
57
  throw new WebhookError("Invalid --on: 'all' is reserved and names no agent.");
@@ -106,17 +112,21 @@ export function webhookMaxPerHour(raw) {
106
112
  }
107
113
  /** What a fixed body's placeholder is replaced with: the delivery's id, the same on every attempt of it. */
108
114
  export const WEBHOOK_PLACEHOLDER = "{{delivery}}";
115
+ /** What a fixed body's optional agent placeholder is replaced with, each time: the name of the message's recipient. */
116
+ export const WEBHOOK_AGENT_PLACEHOLDER = "{{agent}}";
109
117
  /** The longest `--body`, in UTF-8 bytes. */
110
118
  export const WEBHOOK_MAX_BODY_BYTES = 4096;
111
- /** How many times `text` contains the placeholder. */
112
- const placeholders = (text) => text.split(WEBHOOK_PLACEHOLDER).length - 1;
119
+ /** How many times `text` contains `placeholder`. */
120
+ const count = (text, placeholder) => text.split(placeholder).length - 1;
113
121
  /**
114
122
  * Checks `--body`: a JSON object of at most `WEBHOOK_MAX_BODY_BYTES`, with `WEBHOOK_PLACEHOLDER` written as is exactly
115
123
  * once in the text, inside a string value that parsing keeps (not in a key, and not dropped by a later duplicate key),
116
- * and nowhere else in any parsed string or key, `\u` escapes included. Returns it unchanged, so a delivery sends the
117
- * owner's own bytes with only the placeholder replaced. Errors never repeat the body.
124
+ * and nowhere else in any parsed string or key, `\u` escapes included. With `agent`, for an `agent:` webhook,
125
+ * `WEBHOOK_AGENT_PLACEHOLDER` may also be written as is, any number of times, each inside a string value parsing keeps;
126
+ * without it, it may not appear at all. Returns it unchanged, so a delivery sends the owner's own bytes with only the
127
+ * placeholders replaced. Errors never repeat the body.
118
128
  */
119
- export function webhookBody(raw) {
129
+ export function webhookBody(raw, agent = false) {
120
130
  if (Buffer.byteLength(raw, "utf8") > WEBHOOK_MAX_BODY_BYTES) {
121
131
  throw new WebhookError(`Invalid --body: it is over ${WEBHOOK_MAX_BODY_BYTES} bytes.`);
122
132
  }
@@ -131,32 +141,57 @@ export function webhookBody(raw) {
131
141
  throw new WebhookError("Invalid --body: give a JSON object.");
132
142
  }
133
143
  const misplaced = new WebhookError(`Invalid --body: put ${WEBHOOK_PLACEHOLDER} exactly once, inside a JSON string value.`);
134
- if (placeholders(raw) !== 1)
144
+ if (count(raw, WEBHOOK_PLACEHOLDER) !== 1)
135
145
  throw misplaced;
136
- // Parsed again with the literal placeholder swapped for a value found nowhere else, as a delivery swaps it, so the
137
- // count is of what a receiver parses: one sentinel in a string value, and no placeholder left in any string or key.
146
+ const agents = count(raw, WEBHOOK_AGENT_PLACEHOLDER);
147
+ const agentMisplaced = agent
148
+ ? new WebhookError(`Invalid --body: put each ${WEBHOOK_AGENT_PLACEHOLDER} inside a JSON string value.`)
149
+ : new WebhookError(`Invalid --body: ${WEBHOOK_AGENT_PLACEHOLDER} is only for --on agent:<agent> or agent:*.`);
150
+ if (agents > 0 && !agent)
151
+ throw agentMisplaced;
152
+ // Parsed again with each literal placeholder swapped for a value found nowhere else, as a delivery swaps it, so the
153
+ // count is of what a receiver parses: one sentinel in a string value, each agent sentinel in a string value, and no
154
+ // placeholder left in any string or key.
138
155
  const sentinel = randomUUID();
156
+ const agentSentinel = randomUUID();
139
157
  let sentinels = 0;
158
+ let agentSentinels = 0;
140
159
  let others = 0;
160
+ let agentOthers = 0;
141
161
  const walk = (v) => {
142
162
  if (typeof v === "string") {
143
- sentinels += v.split(sentinel).length - 1;
144
- others += placeholders(v);
163
+ sentinels += count(v, sentinel);
164
+ agentSentinels += count(v, agentSentinel);
165
+ others += count(v, WEBHOOK_PLACEHOLDER);
166
+ agentOthers += count(v, WEBHOOK_AGENT_PLACEHOLDER);
145
167
  }
146
168
  else if (Array.isArray(v))
147
169
  v.forEach(walk);
148
170
  else if (typeof v === "object" && v !== null) {
149
171
  for (const [k, x] of Object.entries(v)) {
150
- others += placeholders(k) + k.split(sentinel).length - 1;
172
+ others += count(k, WEBHOOK_PLACEHOLDER) + count(k, sentinel);
173
+ agentOthers += count(k, WEBHOOK_AGENT_PLACEHOLDER) + count(k, agentSentinel);
151
174
  walk(x);
152
175
  }
153
176
  }
154
177
  };
155
- walk(JSON.parse(raw.replace(WEBHOOK_PLACEHOLDER, () => sentinel)));
178
+ walk(JSON.parse(raw.replace(WEBHOOK_PLACEHOLDER, () => sentinel).replaceAll(WEBHOOK_AGENT_PLACEHOLDER, () => agentSentinel)));
156
179
  if (sentinels !== 1 || others !== 0)
157
180
  throw misplaced;
181
+ if (agentOthers !== 0 || agentSentinels !== agents)
182
+ throw agentMisplaced;
158
183
  return raw;
159
184
  }
185
+ /** True when `webhookBody` would take `raw` for an `agent:` webhook, so each `{{agent}}` in it is in a string value. */
186
+ function agentPlaceable(raw) {
187
+ try {
188
+ webhookBody(raw, true);
189
+ return true;
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ }
160
195
  /** How `--on` names a webhook's event, for printing. */
161
196
  export const formatOn = (w) => (w.event === "channel" ? `#${w.name}` : `${w.event}:${w.name}`);
162
197
  /**
@@ -173,7 +208,7 @@ export const addWebhook = (db, account, url, on, includeText, header, maxPerHour
173
208
  // A fixed body never carries the text, so --no-text with it would mean nothing.
174
209
  if (body !== undefined && !includeText)
175
210
  throw new WebhookError("--body and --no-text cannot go together: a fixed body never carries the text.");
176
- const fixed = body === undefined ? null : webhookBody(body);
211
+ const fixed = body === undefined ? null : webhookBody(body, event === "agent");
177
212
  const row = db.prepare("SELECT id, name FROM accounts WHERE name = ?").get(account);
178
213
  if (!row)
179
214
  throw new WebhookError(`No account named '${account}'.`);
@@ -419,6 +454,13 @@ export async function sendPending(db, options = {}) {
419
454
  return;
420
455
  // `webhookBody` checked that the placeholder is there once, inside a string, and a UUID needs no escaping.
421
456
  text = row.fixed_body.replace(WEBHOOK_PLACEHOLDER, () => id.delivery_uuid);
457
+ // Each agent placeholder of an `agent:` webhook's body is inside a string too, and gets the name escaped as one.
458
+ // Only there, and only when the stored body passes today's check: a body stored before `{{agent}}` existed, for a
459
+ // `#channel` or `mention:` webhook or with the text in a key, keeps any such text as it is.
460
+ if (body.event === "agent" && agentPlaceable(row.fixed_body)) {
461
+ const agent = JSON.stringify(body.recipient).slice(1, -1);
462
+ text = text.replaceAll(WEBHOOK_AGENT_PLACEHOLDER, () => agent);
463
+ }
422
464
  }
423
465
  let ok = false;
424
466
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "message-cp",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "A messaging service for AI agents: register a name, find each other, send messages and be woken when mail arrives",
5
5
  "license": "MIT",
6
6
  "mcpName": "io.github.colinhallett/message-cp",