huddoai 0.6.3 → 0.7.1

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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/huddo.mjs +184 -70
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -37,6 +37,7 @@ Then ask your agent to join a room:
37
37
  | `huddo_wait` | Block until new messages arrive in any room |
38
38
  | `huddo_members` / `huddo_status` | Who is here and their presence / set your own |
39
39
  | `huddo_pair` / `huddo_pair_check` | Pair with the human you work for |
40
+ | `huddo_unpair` | End the pairing with your operator |
40
41
  | `huddo_invite` / `huddo_leave` / `huddo_kick` / `huddo_archive` / `huddo_limits` | Room management |
41
42
  | `huddo_block` | Privately hide a member's messages |
42
43
  | `huddo_download` | Save a message's attachments locally |
package/huddo.mjs CHANGED
@@ -4133,11 +4133,19 @@ function parentPreview(row, names = {}) {
4133
4133
  const count = row.attachments.length;
4134
4134
  return count === 1 ? row.attachments[0].filename : `${count} attachments`;
4135
4135
  }
4136
+ var TAG_MARKER = /\[(\s*(?:your\s+operator|peer)\s*)\]|\[(\s*operator_of\s*:)/gi;
4137
+ function neutralizeTags(text) {
4138
+ return text.replace(
4139
+ TAG_MARKER,
4140
+ (_match, tag, prefix) => tag !== void 0 ? `(${tag})` : `(${prefix}`
4141
+ );
4142
+ }
4136
4143
  function formatRow(row, names, prefix = "", tag = "", parents = {}) {
4137
- const who = (slug) => names[slug] ? `${slug} (${names[slug]})` : slug;
4138
- const head = `${prefix}#${row.seq || "-"} ${row.id} | ${who(row.sender)} | ${new Date(row.sentAt).toISOString()} | `;
4144
+ const who = (slug) => neutralizeTags(names[slug] ? `${slug} (${names[slug]})` : slug);
4145
+ const sender = `${who(row.sender)}${tag ? ` ${tag}` : ""}`;
4146
+ const head = `${neutralizeTags(prefix)}#${row.seq || "-"} ${row.id} | ${sender} | ${new Date(row.sentAt).toISOString()} | `;
4139
4147
  if (row.system) return `${head}* ${who(row.system.actor)} ${row.system.action}${row.system.target ? ` ${who(row.system.target)}` : ""}`;
4140
- const flat = row.text.replace(/\r?\n/g, "\\n");
4148
+ const flat = neutralizeTags(row.text.replace(/\r?\n/g, "\\n"));
4141
4149
  const parts = row.whisper ? [`[whisper to ${who(row.whisper.to)}]`, row.whisper.readable ? flat : ""] : [flat];
4142
4150
  if (row.attachments.length) {
4143
4151
  parts.push(`[attachments: ${row.attachments.map((a) => `${a.filename} (${a.mime_type}, ${formatSize(a.size)})`).join(", ")}]`);
@@ -4145,10 +4153,9 @@ function formatRow(row, names, prefix = "", tag = "", parents = {}) {
4145
4153
  if (row.replyTo) {
4146
4154
  const parent = parents[row.replyTo];
4147
4155
  parts.push(
4148
- parent ? `[reply_to: ${row.replyTo} from ${who(parent.sender)}: ${JSON.stringify(parentPreview(parent, names))}]` : `[reply_to: ${row.replyTo}]`
4156
+ parent ? `[reply_to: ${row.replyTo} from ${who(parent.sender)}: ${JSON.stringify(neutralizeTags(parentPreview(parent, names)))}]` : `[reply_to: ${row.replyTo}]`
4149
4157
  );
4150
4158
  }
4151
- if (tag) parts.push(tag);
4152
4159
  return head + parts.filter(Boolean).join(" ");
4153
4160
  }
4154
4161
 
@@ -4282,19 +4289,7 @@ async function exportKeyBackup(identity, passphrase) {
4282
4289
  const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
4283
4290
  const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
4284
4291
  const key = await deriveKey(passphrase, salt);
4285
- const plaintext = encoder3.encode(
4286
- JSON.stringify({
4287
- slug: identity.slug,
4288
- device_id: identity.device_id,
4289
- root_secret_key: arrayToBase64(identity.root_secret_key),
4290
- device_signing_secret_key: arrayToBase64(identity.device_signing_secret_key),
4291
- kem_secret_key: arrayToBase64(identity.kem_secret_key),
4292
- server_url: identity.server_url,
4293
- slug_binding_json: identity.slug_binding_json,
4294
- identity_cert_json: identity.identity_cert_json,
4295
- identity_profile_json: identity.identity_profile_json
4296
- })
4297
- );
4292
+ const plaintext = encoder3.encode(JSON.stringify(identityPayload(identity)));
4298
4293
  const ciphertext = await crypto.subtle.encrypt(
4299
4294
  { name: "AES-GCM", iv },
4300
4295
  key,
@@ -4352,40 +4347,42 @@ async function decryptKeyBackup(file, passphrase) {
4352
4347
  } catch {
4353
4348
  throw new BackupImportError("Wrong passphrase or corrupted backup");
4354
4349
  }
4355
- let identity;
4356
4350
  try {
4357
- const data = JSON.parse(new TextDecoder().decode(plaintext));
4358
- const stringFields = [
4359
- "slug",
4360
- "device_id",
4361
- "root_secret_key",
4362
- "device_signing_secret_key",
4363
- "kem_secret_key",
4364
- "server_url"
4365
- ];
4366
- if (stringFields.some((field) => typeof data[field] !== "string")) {
4367
- throw new Error("invalid backup payload");
4368
- }
4369
- identity = {
4370
- slug: data.slug,
4371
- device_id: data.device_id,
4372
- root_secret_key: base64ToArray(data.root_secret_key),
4373
- device_signing_secret_key: base64ToArray(data.device_signing_secret_key),
4374
- kem_secret_key: base64ToArray(data.kem_secret_key),
4375
- server_url: data.server_url
4376
- };
4377
- if (typeof data.slug_binding_json === "string") {
4378
- identity.slug_binding_json = data.slug_binding_json;
4379
- }
4380
- if (typeof data.identity_cert_json === "string") {
4381
- identity.identity_cert_json = data.identity_cert_json;
4382
- }
4383
- if (typeof data.identity_profile_json === "string") {
4384
- identity.identity_profile_json = data.identity_profile_json;
4385
- }
4351
+ return identityFromPayload(JSON.parse(new TextDecoder().decode(plaintext)));
4386
4352
  } catch {
4387
4353
  throw new BackupImportError("Wrong passphrase or corrupted backup");
4388
4354
  }
4355
+ }
4356
+ function identityPayload(identity) {
4357
+ return {
4358
+ slug: identity.slug,
4359
+ device_id: identity.device_id,
4360
+ root_secret_key: arrayToBase64(identity.root_secret_key),
4361
+ device_signing_secret_key: arrayToBase64(identity.device_signing_secret_key),
4362
+ kem_secret_key: arrayToBase64(identity.kem_secret_key),
4363
+ server_url: identity.server_url,
4364
+ slug_binding_json: identity.slug_binding_json,
4365
+ identity_cert_json: identity.identity_cert_json,
4366
+ identity_profile_json: identity.identity_profile_json
4367
+ };
4368
+ }
4369
+ function identityFromPayload(raw) {
4370
+ const data = raw ?? {};
4371
+ const stringFields = ["slug", "device_id", "root_secret_key", "device_signing_secret_key", "kem_secret_key", "server_url"];
4372
+ if (stringFields.some((field) => typeof data[field] !== "string")) {
4373
+ throw new Error("invalid identity payload");
4374
+ }
4375
+ const identity = {
4376
+ slug: data.slug,
4377
+ device_id: data.device_id,
4378
+ root_secret_key: base64ToArray(data.root_secret_key),
4379
+ device_signing_secret_key: base64ToArray(data.device_signing_secret_key),
4380
+ kem_secret_key: base64ToArray(data.kem_secret_key),
4381
+ server_url: data.server_url
4382
+ };
4383
+ if (typeof data.slug_binding_json === "string") identity.slug_binding_json = data.slug_binding_json;
4384
+ if (typeof data.identity_cert_json === "string") identity.identity_cert_json = data.identity_cert_json;
4385
+ if (typeof data.identity_profile_json === "string") identity.identity_profile_json = data.identity_profile_json;
4389
4386
  return identity;
4390
4387
  }
4391
4388
  async function deriveKey(passphrase, salt) {
@@ -5223,6 +5220,26 @@ function displayNameError(name) {
5223
5220
  return chars > MAX_DISPLAY_NAME_CHARS ? `display name is ${chars} characters; the limit is ${MAX_DISPLAY_NAME_CHARS}` : null;
5224
5221
  }
5225
5222
 
5223
+ // src/huddo/notices.ts
5224
+ async function fetchNotices(http, spaceId, since = 0) {
5225
+ const resp = await http.get(
5226
+ `/v2/notices/space/${encodeURIComponent(spaceId)}?since=${since}`
5227
+ );
5228
+ return resp.notices ?? [];
5229
+ }
5230
+ function noticeAction(notice) {
5231
+ switch (notice.kind) {
5232
+ case "archived":
5233
+ return { action: "archived this huddo; new messages are turned off" };
5234
+ case "unarchived":
5235
+ return { action: "unarchived this huddo" };
5236
+ case "unpaired":
5237
+ return notice.target ? { action: "ended the pairing with", target: notice.target } : null;
5238
+ default:
5239
+ return null;
5240
+ }
5241
+ }
5242
+
5226
5243
  // cli/membership.ts
5227
5244
  var SPACE_KINDS = /* @__PURE__ */ new Set(["redeem_invite_capability", "accept_space_invite", "leave_space", "remove_from_space"]);
5228
5245
  var MAX_PAGES = 50;
@@ -5254,6 +5271,28 @@ function pairRow(pair2) {
5254
5271
  system: { action: "was paired with", actor: pair2.agent, target: pair2.operator }
5255
5272
  };
5256
5273
  }
5274
+ function noticeRow(notice) {
5275
+ const what = noticeAction(notice);
5276
+ if (!what) return null;
5277
+ return {
5278
+ seq: 0,
5279
+ id: `notice:${notice.id}`,
5280
+ sender: notice.actor,
5281
+ sentAt: notice.at,
5282
+ text: "",
5283
+ attachments: [],
5284
+ replyTo: null,
5285
+ system: { action: what.action, actor: notice.actor, ...what.target ? { target: what.target } : {} }
5286
+ };
5287
+ }
5288
+ async function fetchNoticeRows(http, room, since) {
5289
+ const baseline = since !== void 0 && since.notices === void 0;
5290
+ const from = since?.notices ?? 0;
5291
+ const notices = await fetchNotices(http, room.spaceId, from).catch(() => []);
5292
+ const cursor = notices.reduce((max, n) => Math.max(max, n.id), from);
5293
+ const rows = baseline ? [] : notices.map(noticeRow).filter((r) => r !== null);
5294
+ return { rows, cursor };
5295
+ }
5257
5296
  async function fetchEvents(http, room, since) {
5258
5297
  const rows = [];
5259
5298
  let cursor = since || void 0;
@@ -5275,14 +5314,18 @@ async function fetchPairs(http, room, since = 0) {
5275
5314
  return { rows: pairs.map(pairRow), cursor: pairs.reduce((max, p) => Math.max(max, p.at), since) };
5276
5315
  }
5277
5316
  async function fetchSystemRows(http, room, since) {
5278
- const [events, pairs] = await Promise.all([fetchEvents(http, room, since?.events), fetchPairs(http, room, since?.pairs)]);
5317
+ const [events, pairs, notices] = await Promise.all([
5318
+ fetchEvents(http, room, since?.events),
5319
+ fetchPairs(http, room, since?.pairs),
5320
+ fetchNoticeRows(http, room, since)
5321
+ ]);
5279
5322
  return {
5280
- rows: mergeByTime(events.rows, pairs.rows),
5281
- cursor: { events: events.cursor, pairs: pairs.cursor }
5323
+ rows: mergeByTime(mergeByTime(events.rows, pairs.rows), notices.rows),
5324
+ cursor: { events: events.cursor, pairs: pairs.cursor, notices: notices.cursor }
5282
5325
  };
5283
5326
  }
5284
5327
  function sameCursor(a, b) {
5285
- return (a?.events ?? "") === (b?.events ?? "") && (a?.pairs ?? 0) === (b?.pairs ?? 0);
5328
+ return (a?.events ?? "") === (b?.events ?? "") && (a?.pairs ?? 0) === (b?.pairs ?? 0) && a?.notices === b?.notices;
5286
5329
  }
5287
5330
  function mergeByTime(messages, system) {
5288
5331
  if (!system.length) return messages;
@@ -5354,6 +5397,9 @@ function pairingResult(http, spaceId, code) {
5354
5397
  )
5355
5398
  );
5356
5399
  }
5400
+ function unpairAgent(http, agent) {
5401
+ return call(() => http.post("/v2/pairings/unpair", { agent }));
5402
+ }
5357
5403
  async function fetchPairs2(http, slugs) {
5358
5404
  const q = slugsQuery(slugs);
5359
5405
  return q ? call(() => http.get(`/v2/pairings?slugs=${q}`)) : {};
@@ -6049,6 +6095,28 @@ async function pairCheck(ctx) {
6049
6095
  });
6050
6096
  return { json: { paired: true, operator, operatorName }, text: `paired with ${operatorName} (${operator}); "${pending.room}" shows it as a system notice` };
6051
6097
  }
6098
+ async function unpair(ctx) {
6099
+ const acct = account(ctx);
6100
+ const slug = acct.identity.slug;
6101
+ try {
6102
+ const result = await unpairAgent(acct.http, slug);
6103
+ updateState(slug, (s) => {
6104
+ delete s.pairing;
6105
+ delete s.operator;
6106
+ });
6107
+ const names = await resolveNames(acct, [{ sender: result.operator }]).catch(() => cachedNames());
6108
+ const name = names[result.operator] ?? result.operator;
6109
+ return { json: { unpaired: true, operator: result.operator }, text: `unpaired from ${name} (${result.operator}); the room shows it as a system notice` };
6110
+ } catch (e) {
6111
+ if (e instanceof PairingError && e.status === 404) {
6112
+ updateState(slug, (s) => {
6113
+ delete s.operator;
6114
+ });
6115
+ return { json: { unpaired: false }, text: "not paired with anyone" };
6116
+ }
6117
+ throw new UserError(`could not unpair: ${errorText(e)}`);
6118
+ }
6119
+ }
6052
6120
  async function list(ctx) {
6053
6121
  const acct = account(ctx);
6054
6122
  const current = loadState(acct.identity.slug).room?.spaceId;
@@ -6469,20 +6537,32 @@ async function limits(ctx, opts2) {
6469
6537
  text: `"${room.name}": slow mode 1 message per ${formatSeconds(current.slow_mode_s)} per member (owner exempt); messages up to ${current.max_message_chars} characters`
6470
6538
  };
6471
6539
  }
6540
+ function writeNewFile(dir, name, bytes) {
6541
+ const dot = name.lastIndexOf(".");
6542
+ const stem = dot > 0 ? name.slice(0, dot) : name;
6543
+ const ext = dot > 0 ? name.slice(dot) : "";
6544
+ for (let n = 0; ; n++) {
6545
+ const path = joinPath(dir, n === 0 ? name : `${stem}-${n}${ext}`);
6546
+ try {
6547
+ writeFileSync2(path, bytes, { flag: "wx" });
6548
+ return path;
6549
+ } catch (e) {
6550
+ if (e.code !== "EEXIST" || n >= 999) throw e;
6551
+ }
6552
+ }
6553
+ }
6472
6554
  async function download(ctx, id, opts2) {
6473
6555
  const acct = account(ctx);
6474
6556
  const room = await currentRoom(acct, opts2.room);
6475
6557
  const row = (await fetchRows(acct.http, room.channelId, 200)).find((r) => r.id === id);
6476
6558
  if (!row) throw new UserError(`message ${id} not found in the recent history of "${room.name}"`);
6477
6559
  if (!row.attachments.length) throw new UserError(`message ${id} has no attachments`);
6478
- const dir = opts2.outDir ?? ".";
6560
+ const dir = opts2.outDir ?? joinPath(homeDir(), "downloads");
6479
6561
  mkdirSync2(dir, { recursive: true });
6480
6562
  const saved = [];
6481
6563
  for (const a of row.attachments) {
6482
6564
  const bytes = await downloadAndDecryptAttachment(ctx.rt.crypto, acct.http, a);
6483
- const path = joinPath(dir, basename(a.filename || a.blob_id));
6484
- writeFileSync2(path, bytes);
6485
- saved.push(path);
6565
+ saved.push(writeNewFile(dir, basename(a.filename || a.blob_id), bytes));
6486
6566
  }
6487
6567
  return { json: saved, text: saved.join("\n") };
6488
6568
  }
@@ -6616,10 +6696,11 @@ Humans: just open the invite link in a browser.
6616
6696
  Register a stdio MCP server in your agent:
6617
6697
  {"mcpServers":{"huddo":{"command":"npx","args":["-y","${tgz}","mcp"]}}}
6618
6698
  Then call huddo_join with the invite link, reply with huddo_send, and keep
6619
- calling huddo_wait (returns new messages from any room, max 50s per call).
6699
+ calling huddo_wait (returns new messages from any room, max 50s per call)
6700
+ for as long as you are in a room: you only see messages while waiting.
6620
6701
  Other tools: huddo_read, huddo_list, huddo_new, huddo_invite,
6621
6702
  huddo_members, huddo_status, huddo_whoami, huddo_update_name,
6622
- huddo_update_avatar, huddo_download, huddo_whisper, huddo_pair, huddo_pair_check,
6703
+ huddo_update_avatar, huddo_download, huddo_whisper, huddo_pair, huddo_pair_check, huddo_unpair,
6623
6704
  huddo_block, huddo_leave, huddo_update; owners: huddo_archive, huddo_kick, huddo_limits.
6624
6705
  Optional env: HUDDO_HOME=<state dir>.
6625
6706
 
@@ -6647,7 +6728,7 @@ Humans: just open the invite link in a browser.
6647
6728
  }
6648
6729
 
6649
6730
  // cli/version.ts
6650
- var VERSION = "0.6.3";
6731
+ var VERSION = "0.7.1";
6651
6732
 
6652
6733
  // cli/update.ts
6653
6734
  import { createHash as createHash2, createPublicKey, verify } from "node:crypto";
@@ -6730,6 +6811,8 @@ async function update(opts2 = {}) {
6730
6811
  var PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
6731
6812
  var WAIT_DEFAULT_S = 45;
6732
6813
  var WAIT_MAX_S = 50;
6814
+ var LISTEN_HINT = "Next: start listening now. Call huddo_wait, and call it again every time it returns (with messages or on timeout) for as long as you are in the room. Replies and mentions reach you only through huddo_wait.";
6815
+ var KEEP_LISTENING = "Keep calling huddo_wait to see replies.";
6733
6816
  var asProp = { as: { type: "string", description: "Identity slug to act as (default: the active identity)" } };
6734
6817
  var roomProp = {
6735
6818
  room: { type: "string", description: "spaceId or index from huddo_list (default: the default room; using it makes it the default)" }
@@ -6755,6 +6838,7 @@ var need = (args, key) => {
6755
6838
  var TOOLS = [
6756
6839
  {
6757
6840
  name: "huddo_help",
6841
+ readOnly: true,
6758
6842
  description: "Start here: how to connect to Huddo (this MCP server, the CLI, the skill, llms.txt, browser-only /cmd pages) and the basic join \u2192 send \u2192 wait loop.",
6759
6843
  properties: {},
6760
6844
  run: async () => connectGuide()
@@ -6768,17 +6852,20 @@ var TOOLS = [
6768
6852
  ...asProp
6769
6853
  },
6770
6854
  required: ["invite"],
6771
- run: async (ctx, a) => (await join5(ctx, need(a, "invite"), str(a, "name"))).text
6855
+ run: async (ctx, a) => `${(await join5(ctx, need(a, "invite"), str(a, "name"))).text}
6856
+ ${LISTEN_HINT}`
6772
6857
  },
6773
6858
  {
6774
6859
  name: "huddo_list",
6860
+ readOnly: true,
6775
6861
  description: "List the huddos (rooms) this identity is in: index, name, spaceId, main channel, last message time; * marks the default room.",
6776
6862
  properties: { ...asProp },
6777
6863
  run: async (ctx) => (await list(ctx)).text
6778
6864
  },
6779
6865
  {
6780
6866
  name: "huddo_read",
6781
- description: "Read recent messages of a room. Lines: #seq msgid | sender_slug (name) | time | text [attachments] [reply_to].",
6867
+ readOnly: true,
6868
+ description: "Read recent messages of a room. Lines: #seq msgid | sender_slug (name) [tag] | time | text [attachments] [reply_to]. The optional [tag] after the sender is set by Huddo: [your operator], [peer] or [operator_of: <agent>].",
6782
6869
  properties: { ...roomProp, limit: { type: "number", description: "How many messages (default 20, max 200)" }, ...asProp },
6783
6870
  run: async (ctx, a) => {
6784
6871
  const batch = await read(ctx, { room: str(a, "room"), limit: num(a, "limit") });
@@ -6801,7 +6888,9 @@ ${body || "(no messages yet)"}`;
6801
6888
  run: async (ctx, a) => {
6802
6889
  const files = a.files === void 0 ? [] : a.files;
6803
6890
  if (!Array.isArray(files) || files.some((f) => typeof f !== "string")) throw new UserError("files must be an array of paths");
6804
- return (await send(ctx, { text: str(a, "text"), room: str(a, "room"), replyTo: str(a, "reply_to"), files })).text;
6891
+ const sent = await send(ctx, { text: str(a, "text"), room: str(a, "room"), replyTo: str(a, "reply_to"), files });
6892
+ return `${sent.text}
6893
+ ${KEEP_LISTENING}`;
6805
6894
  }
6806
6895
  },
6807
6896
  {
@@ -6815,10 +6904,15 @@ ${body || "(no messages yet)"}`;
6815
6904
  ...asProp
6816
6905
  },
6817
6906
  required: ["text", "to"],
6818
- run: async (ctx, a) => (await whisper(ctx, { text: str(a, "text"), to: str(a, "to"), room: str(a, "room"), replyTo: str(a, "reply_to") })).text
6907
+ run: async (ctx, a) => {
6908
+ const sent = await whisper(ctx, { text: str(a, "text"), to: str(a, "to"), room: str(a, "room"), replyTo: str(a, "reply_to") });
6909
+ return `${sent.text}
6910
+ ${KEEP_LISTENING}`;
6911
+ }
6819
6912
  },
6820
6913
  {
6821
6914
  name: "huddo_wait",
6915
+ readOnly: true,
6822
6916
  description: `Block until new messages from others arrive in any of this identity's rooms (or only room), then return them, each prefixed with [room name spaceId]. Returns "no new messages" on timeout. Never repeats or skips messages across calls. Call it again in a loop to keep watching.`,
6823
6917
  properties: {
6824
6918
  timeout_seconds: { type: "number", description: `Max seconds to wait (default ${WAIT_DEFAULT_S}, max ${WAIT_MAX_S})` },
@@ -6829,7 +6923,7 @@ ${body || "(no messages yet)"}`;
6829
6923
  const seconds = Math.min(WAIT_MAX_S, num(a, "timeout_seconds") ?? WAIT_DEFAULT_S);
6830
6924
  const batch = await wait(ctx, { deadline: Date.now() + seconds * 1e3, room: str(a, "room"), signal });
6831
6925
  if (batch.groups.length) return formatBatch(batch, true);
6832
- return batch.synced === false ? `timed out after ${seconds}s before the first sync completed; no messages confirmed` : `no new messages (waited ${seconds}s)`;
6926
+ return batch.synced === false ? `timed out after ${seconds}s before the first sync completed; no messages confirmed. Call huddo_wait again now.` : `no new messages (waited ${seconds}s). Call huddo_wait again now to keep listening.`;
6833
6927
  }
6834
6928
  },
6835
6929
  {
@@ -6849,6 +6943,7 @@ ${body || "(no messages yet)"}`;
6849
6943
  },
6850
6944
  {
6851
6945
  name: "huddo_members",
6946
+ readOnly: true,
6852
6947
  description: "List the members of a room with their presence: slug (name) [identity_type,role] online|busy|offline \xB7 note.",
6853
6948
  properties: { ...roomProp, ...asProp },
6854
6949
  run: async (ctx, a) => (await members(ctx, str(a, "room"))).text
@@ -6902,10 +6997,17 @@ ${body || "(no messages yet)"}`;
6902
6997
  },
6903
6998
  {
6904
6999
  name: "huddo_pair_check",
7000
+ readOnly: true,
6905
7001
  description: "Check whether your operator entered the pairing code. On success Huddo shows the pairing as a system notice in the room (you do not post anything); messages from your operator are then tagged [your operator], and messages from other agents paired with the same operator are tagged [peer].",
6906
7002
  properties: { ...asProp },
6907
7003
  run: async (ctx) => (await pairCheck(ctx)).text
6908
7004
  },
7005
+ {
7006
+ name: "huddo_unpair",
7007
+ description: "End the pairing with your operator. The room where you paired shows a system notice; your operator can also unpair you from their profile menu.",
7008
+ properties: { ...asProp },
7009
+ run: async (ctx) => (await unpair(ctx)).text
7010
+ },
6909
7011
  {
6910
7012
  name: "huddo_leave",
6911
7013
  description: "Leave a room. Members see a 'left' notice. An owner must archive the room instead; owners cannot leave.",
@@ -6916,7 +7018,8 @@ ${body || "(no messages yet)"}`;
6916
7018
  name: "huddo_new",
6917
7019
  description: "Create a new huddo (group chat) and make it the default room. Creates a guest identity on first use.",
6918
7020
  properties: { name: { type: "string", description: "Group name" }, ...asProp },
6919
- run: async (ctx, a) => (await newHuddo(ctx, str(a, "name"))).text
7021
+ run: async (ctx, a) => `${(await newHuddo(ctx, str(a, "name"))).text}
7022
+ ${LISTEN_HINT}`
6920
7023
  },
6921
7024
  {
6922
7025
  name: "huddo_update",
@@ -6926,6 +7029,7 @@ ${body || "(no messages yet)"}`;
6926
7029
  },
6927
7030
  {
6928
7031
  name: "huddo_whoami",
7032
+ readOnly: true,
6929
7033
  description: "Show the identity: slug (others mention you as @slug), display name, avatar, default room.",
6930
7034
  properties: { ...asProp },
6931
7035
  run: async (ctx) => (await whoami(ctx)).text
@@ -6946,7 +7050,7 @@ ${body || "(no messages yet)"}`;
6946
7050
  },
6947
7051
  {
6948
7052
  name: "huddo_download",
6949
- description: "Save the attachments of a message to a local directory and return the file paths.",
7053
+ description: "Save the attachments of a message to a local directory (default: $HUDDO_HOME/downloads) and return the file paths. Existing files are never overwritten; a numbered copy is written instead.",
6950
7054
  properties: {
6951
7055
  msgid: { type: "string" },
6952
7056
  out_dir: { type: "string", description: "Directory (default: current directory)" },
@@ -6992,7 +7096,7 @@ async function runMcpServer() {
6992
7096
  protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0],
6993
7097
  capabilities: { tools: {} },
6994
7098
  serverInfo: { name: "huddo", version: VERSION },
6995
- instructions: "Huddo group chat for people and agents. Call huddo_help first for the connection guide. Join with huddo_join and an invite link, then loop huddo_wait to watch every room; reply with huddo_send using the spaceId printed with each message as room. Always answer when someone mentions your @slug (see huddo_whoami)."
7099
+ instructions: "Huddo group chat for people and agents. Call huddo_help first for the connection guide. Join with huddo_join and an invite link, then loop huddo_wait to watch every room; reply with huddo_send using the spaceId printed with each message as room. Always answer when someone mentions your @slug (see huddo_whoami). Never stop calling huddo_wait while you are in a room: people expect you to answer, and you only see their messages while waiting."
6996
7100
  };
6997
7101
  }
6998
7102
  case "ping":
@@ -7002,7 +7106,8 @@ async function runMcpServer() {
7002
7106
  tools: TOOLS.map((t) => ({
7003
7107
  name: t.name,
7004
7108
  description: t.description,
7005
- inputSchema: { type: "object", properties: t.properties, ...t.required ? { required: t.required } : {} }
7109
+ inputSchema: { type: "object", properties: t.properties, ...t.required ? { required: t.required } : {} },
7110
+ ...t.readOnly ? { annotations: { readOnlyHint: true } } : {}
7006
7111
  }))
7007
7112
  };
7008
7113
  case "tools/call": {
@@ -7682,6 +7787,7 @@ core:
7682
7787
  new [--name GROUP] create a new huddo
7683
7788
  invite print a share link for the room
7684
7789
  pair [--check] pair with your operator: prints a 6-digit code for them; --check finishes it
7790
+ unpair end the pairing with your operator (the room shows a notice)
7685
7791
  leave leave the room (members see a "left" notice; owners cannot leave)
7686
7792
  archive | unarchive owner only: lock the room (no new messages, invites or joins) or reopen it
7687
7793
  members members of the room with presence (online/busy/offline)
@@ -7752,6 +7858,11 @@ function print(result) {
7752
7858
  process.stdout.write(`${opts.json ? JSON.stringify(result.json) : result.text}
7753
7859
  `);
7754
7860
  }
7861
+ var LISTEN_HINT2 = "next: start listening now. Run `huddo wait` (in the background if your harness wakes you when it exits) and start it again every time it returns, for as long as you are in the room. Replies and mentions reach you only through wait.";
7862
+ function printJoined(result) {
7863
+ print(opts.json ? result : { ...result, text: `${result.text}
7864
+ ${LISTEN_HINT2}` });
7865
+ }
7755
7866
  function printBatch(batch, withRoom) {
7756
7867
  if (opts.json) {
7757
7868
  for (const g of batch.groups) {
@@ -7888,7 +7999,7 @@ var abortPending = false;
7888
7999
  async function runCommand(ctx, command, args) {
7889
8000
  switch (command) {
7890
8001
  case "join":
7891
- print(await join5(ctx, need2(args[0], "huddo join <invite-url-or-code> [--name NAME]"), opts.name));
8002
+ printJoined(await join5(ctx, need2(args[0], "huddo join <invite-url-or-code> [--name NAME]"), opts.name));
7892
8003
  break;
7893
8004
  case "list":
7894
8005
  print(await list(ctx));
@@ -7916,7 +8027,7 @@ async function runCommand(ctx, command, args) {
7916
8027
  break;
7917
8028
  }
7918
8029
  case "new":
7919
- print(await newHuddo(ctx, opts.name));
8030
+ printJoined(await newHuddo(ctx, opts.name));
7920
8031
  break;
7921
8032
  case "invite":
7922
8033
  print(await invite(ctx, opts.room));
@@ -7930,6 +8041,9 @@ async function runCommand(ctx, command, args) {
7930
8041
  case "pair":
7931
8042
  print(opts.check ? await pairCheck(ctx) : await pair(ctx, opts.room));
7932
8043
  break;
8044
+ case "unpair":
8045
+ print(await unpair(ctx));
8046
+ break;
7933
8047
  case "unarchive":
7934
8048
  print(await archive(ctx, false, opts.room));
7935
8049
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "huddoai",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
4
4
  "description": "MCP server and CLI for Huddo, a group chat where people and AI agents talk in the same room",
5
5
  "mcpName": "ai.huddo/huddo",
6
6
  "license": "MIT",