baychat 0.12.0 → 0.13.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.
- package/README.md +47 -1
- package/dist/api.js +58 -0
- package/dist/approve-hook.js +425 -0
- package/dist/attachments.js +273 -0
- package/dist/commands.js +41 -3
- package/dist/help-topics.js +197 -0
- package/dist/index.js +91 -5
- package/dist/mcp-files.js +442 -0
- package/dist/mcp.js +187 -21
- package/dist/protocol-content.js +1 -1
- package/dist/relay/adapters.js +48 -5
- package/dist/relay/autostart.js +324 -0
- package/dist/relay/commands.js +80 -49
- package/dist/relay/daemon.js +1 -0
- package/dist/relay/socket.js +55 -6
- package/dist/runtimes.js +57 -10
- package/dist/tool-defs.js +205 -60
- package/package.json +1 -1
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Pure attachment helpers for the CLI — no network, no disk, no MCP.
|
|
3
|
+
//
|
|
4
|
+
// Everything here is a DECISION that the tool handlers around it depend on, kept
|
|
5
|
+
// separate so each one can be tested on its own and reused by the pieces that
|
|
6
|
+
// have nothing else in common:
|
|
7
|
+
//
|
|
8
|
+
// • `mimeForFile` — the stdio upload path, before it opens a socket.
|
|
9
|
+
// • `attachmentsFromMetadata` — the MCP message renderer AND the relay's wake
|
|
10
|
+
// prompt: two very different outputs, one reading of the wire format.
|
|
11
|
+
// • `filenameFromContentDisposition` — the download tool, which turns a header
|
|
12
|
+
// written by a server into a path on the caller's own disk.
|
|
13
|
+
//
|
|
14
|
+
// Zero dependencies on purpose (the package ships no MIME library and adds none).
|
|
15
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
18
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
19
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
20
|
+
}
|
|
21
|
+
Object.defineProperty(o, k2, desc);
|
|
22
|
+
}) : (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
o[k2] = m[k];
|
|
25
|
+
}));
|
|
26
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
27
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
28
|
+
}) : function(o, v) {
|
|
29
|
+
o["default"] = v;
|
|
30
|
+
});
|
|
31
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
32
|
+
var ownKeys = function(o) {
|
|
33
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
34
|
+
var ar = [];
|
|
35
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
36
|
+
return ar;
|
|
37
|
+
};
|
|
38
|
+
return ownKeys(o);
|
|
39
|
+
};
|
|
40
|
+
return function (mod) {
|
|
41
|
+
if (mod && mod.__esModule) return mod;
|
|
42
|
+
var result = {};
|
|
43
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
44
|
+
__setModuleDefault(result, mod);
|
|
45
|
+
return result;
|
|
46
|
+
};
|
|
47
|
+
})();
|
|
48
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
|
+
exports.ALLOWED_ATTACHMENT_EXTENSIONS = void 0;
|
|
50
|
+
exports.mimeForFile = mimeForFile;
|
|
51
|
+
exports.attachmentsFromMetadata = attachmentsFromMetadata;
|
|
52
|
+
exports.filenameFromContentDisposition = filenameFromContentDisposition;
|
|
53
|
+
exports.formatBytes = formatBytes;
|
|
54
|
+
const path = __importStar(require("path"));
|
|
55
|
+
// ─── Extension → MIME ───────────────────────────────────────────────────────
|
|
56
|
+
// The extensions covering the server's SECURE_MIME_ALLOWLIST
|
|
57
|
+
// (apps/api/src/lib/attachments.ts). This map is a fast local NO, never a yes:
|
|
58
|
+
// the server re-checks every upload, so an extension missing here costs a clear
|
|
59
|
+
// error instead of a wasted round trip, and one wrongly present costs a 400.
|
|
60
|
+
//
|
|
61
|
+
// The plain-text family is spelled out rather than left to `.txt` alone: the
|
|
62
|
+
// server accepts `text/plain` bytes whatever the file is called, and an agent's
|
|
63
|
+
// most ordinary attachment is a `.md` note or a `.log` excerpt it just wrote.
|
|
64
|
+
// Refusing those locally would be this map inventing a limit the server has not.
|
|
65
|
+
const EXTENSION_MIME = {
|
|
66
|
+
jpg: "image/jpeg",
|
|
67
|
+
jpeg: "image/jpeg",
|
|
68
|
+
png: "image/png",
|
|
69
|
+
gif: "image/gif",
|
|
70
|
+
webp: "image/webp",
|
|
71
|
+
pdf: "application/pdf",
|
|
72
|
+
doc: "application/msword",
|
|
73
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
74
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
75
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
76
|
+
txt: "text/plain",
|
|
77
|
+
md: "text/plain",
|
|
78
|
+
log: "text/plain",
|
|
79
|
+
json: "text/plain",
|
|
80
|
+
yaml: "text/plain",
|
|
81
|
+
yml: "text/plain",
|
|
82
|
+
csv: "text/csv",
|
|
83
|
+
zip: "application/zip",
|
|
84
|
+
};
|
|
85
|
+
/** Every extension an upload may carry, for the error message that names them. */
|
|
86
|
+
exports.ALLOWED_ATTACHMENT_EXTENSIONS = Object.keys(EXTENSION_MIME);
|
|
87
|
+
/**
|
|
88
|
+
* The MIME type to upload a local file as, from its extension alone — or
|
|
89
|
+
* `undefined` when the server would refuse it, so the caller errors before
|
|
90
|
+
* reading a byte. Never sniffs content: the server decides, this only spares an
|
|
91
|
+
* obviously-doomed request.
|
|
92
|
+
*/
|
|
93
|
+
function mimeForFile(filePath) {
|
|
94
|
+
// `path.extname` returns "" for a dotfile with no second dot (".zip"), which is
|
|
95
|
+
// exactly right — that file is named `.zip`, it is not a zip archive.
|
|
96
|
+
const ext = path.extname(filePath).replace(/^\./, "").toLowerCase();
|
|
97
|
+
return ext ? EXTENSION_MIME[ext] : undefined;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A URL we are willing to put in front of a model as "fetch this".
|
|
101
|
+
*
|
|
102
|
+
* Everything this module reads came off a message, and a message body is written
|
|
103
|
+
* by whoever sent it. The server is the layer that guarantees an `attachmentUrl`
|
|
104
|
+
* is one it signed — this is the second one, because the output of
|
|
105
|
+
* `attachmentsFromMetadata` becomes a `curl` line in the relay's wake prompt, and
|
|
106
|
+
* a prompt is a place where a newline or a quote is not a cosmetic problem.
|
|
107
|
+
*
|
|
108
|
+
* Refused: anything unparseable, any scheme but http(s), and any string carrying
|
|
109
|
+
* a control character (a newline would break out of the untrusted-messages block
|
|
110
|
+
* the prompt fences the message in; a NUL or an escape would not survive being
|
|
111
|
+
* printed intact anyway).
|
|
112
|
+
*/
|
|
113
|
+
function fetchableUrl(value) {
|
|
114
|
+
if (typeof value !== "string" || value.length === 0)
|
|
115
|
+
return null;
|
|
116
|
+
// eslint-disable-next-line no-control-regex
|
|
117
|
+
if (/[\u0000-\u001f\u007f]/.test(value))
|
|
118
|
+
return null;
|
|
119
|
+
let parsed;
|
|
120
|
+
try {
|
|
121
|
+
parsed = new URL(value);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
127
|
+
return null;
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
function itemFrom(value, fallbackType) {
|
|
131
|
+
if (!value || typeof value !== "object")
|
|
132
|
+
return null;
|
|
133
|
+
const o = value;
|
|
134
|
+
// The id is what makes the URL believable. The server mints an `attachmentUrl`
|
|
135
|
+
// only from an `attachmentId` it resolved to a row, so an entry that carries a
|
|
136
|
+
// URL and no id was not written by the server — it was written by a sender, and
|
|
137
|
+
// honouring it would hand the model an errand chosen by whoever sent the message.
|
|
138
|
+
const id = o.attachmentId;
|
|
139
|
+
if (typeof id !== "string" || id.length === 0)
|
|
140
|
+
return null;
|
|
141
|
+
const url = fetchableUrl(o.attachmentUrl);
|
|
142
|
+
// No signed URL means nothing to hand the model: an unsigned read path (the
|
|
143
|
+
// human-facing one) never adds it, and a line pointing nowhere is worse than
|
|
144
|
+
// no line at all.
|
|
145
|
+
if (url === null)
|
|
146
|
+
return null;
|
|
147
|
+
const type = typeof o.type === "string" && o.type ? o.type : undefined;
|
|
148
|
+
const out = {
|
|
149
|
+
attachmentUrl: url,
|
|
150
|
+
type: type ?? (typeof fallbackType === "string" && fallbackType ? fallbackType : "file"),
|
|
151
|
+
};
|
|
152
|
+
if (typeof o.mimeType === "string" && o.mimeType)
|
|
153
|
+
out.mimeType = o.mimeType;
|
|
154
|
+
if (typeof o.sizeBytes === "number" && Number.isFinite(o.sizeBytes)) {
|
|
155
|
+
out.sizeBytes = o.sizeBytes;
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The attachments a message carries, in render order — from EITHER wire shape.
|
|
161
|
+
*
|
|
162
|
+
* `metadata.attachments[]` is the current one and wins; the legacy single
|
|
163
|
+
* (`attachmentId`/`attachmentUrl`, or an older `fileUrl` upload) is the fallback
|
|
164
|
+
* for a message written before the array existed, or by a client that still only
|
|
165
|
+
* writes the mirror. Both are read because the server dual-publishes: reading
|
|
166
|
+
* only the array would silently lose old messages, reading only the mirror would
|
|
167
|
+
* lose every file but the first.
|
|
168
|
+
*
|
|
169
|
+
* Pure and total — junk metadata yields `[]`, never a throw. Shared with the
|
|
170
|
+
* relay's wake prompt, so a change here changes what BOTH surfaces show.
|
|
171
|
+
*/
|
|
172
|
+
function attachmentsFromMetadata(metadata) {
|
|
173
|
+
if (!metadata || typeof metadata !== "object")
|
|
174
|
+
return [];
|
|
175
|
+
const m = metadata;
|
|
176
|
+
if (Array.isArray(m.attachments)) {
|
|
177
|
+
const items = m.attachments
|
|
178
|
+
.map((item) => itemFrom(item))
|
|
179
|
+
.filter((item) => item !== null);
|
|
180
|
+
// An array that yielded nothing fetchable falls through to the mirror rather
|
|
181
|
+
// than reporting "no attachments" for a message that plainly has one.
|
|
182
|
+
if (items.length > 0)
|
|
183
|
+
return items;
|
|
184
|
+
}
|
|
185
|
+
const legacy = itemFrom(m, m.type);
|
|
186
|
+
if (legacy)
|
|
187
|
+
return [legacy];
|
|
188
|
+
// Older uploads carried the file as a bare `fileUrl` with no attachment row —
|
|
189
|
+
// so there is no id to hold it to, and the URL check is all there is.
|
|
190
|
+
const fileUrl = fetchableUrl(m.fileUrl);
|
|
191
|
+
if (fileUrl !== null) {
|
|
192
|
+
const type = typeof m.type === "string" && m.type ? m.type : "file";
|
|
193
|
+
return [{ attachmentUrl: fileUrl, type }];
|
|
194
|
+
}
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
// ─── Content-Disposition → a filename we are willing to write ───────────────
|
|
198
|
+
/** Long enough for any real name, short enough to stay under every filesystem's
|
|
199
|
+
* limit once a de-duplication suffix is appended. */
|
|
200
|
+
const MAX_FILENAME_LENGTH = 120;
|
|
201
|
+
/**
|
|
202
|
+
* Reduce an arbitrary name to a BASENAME safe to create inside a chosen
|
|
203
|
+
* directory. The header is written by whatever answered the request, and the
|
|
204
|
+
* result becomes a real path on the caller's disk, so this is a security
|
|
205
|
+
* boundary, not tidying: path separators, `..`, control characters and quotes
|
|
206
|
+
* are removed, and anything left empty becomes `download`.
|
|
207
|
+
*/
|
|
208
|
+
function sanitizeDownloadName(raw) {
|
|
209
|
+
// Basename first: `../../etc/passwd` must become `passwd`, never a traversal.
|
|
210
|
+
const base = raw.split(/[\\/]/).pop() ?? "";
|
|
211
|
+
const cleaned = base
|
|
212
|
+
// Control characters and quotes: a name must not carry an unprintable
|
|
213
|
+
// byte onto disk or break out of the line it will be printed in.
|
|
214
|
+
.replace(/[\u0000-\u001f\u007f"]/g, "")
|
|
215
|
+
// Trimmed BEFORE the leading dots are stripped, or a padded name climbs: the
|
|
216
|
+
// RFC 5987 form `%20%2e%2e` decodes to " ..", whose first character is a
|
|
217
|
+
// space, so a strip-then-trim would leave `..` standing.
|
|
218
|
+
.trim()
|
|
219
|
+
.replace(/^\.+/, "") // ".." / ".hidden" — never a name that hides or climbs
|
|
220
|
+
.trim();
|
|
221
|
+
if (!cleaned)
|
|
222
|
+
return "download";
|
|
223
|
+
return cleaned.slice(0, MAX_FILENAME_LENGTH);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The filename a download should be written under, from a `Content-Disposition`
|
|
227
|
+
* header — sanitized. Prefers the RFC 5987 `filename*=UTF-8''…` form (the only
|
|
228
|
+
* one that can carry non-ASCII), then a quoted or bare `filename=`.
|
|
229
|
+
*
|
|
230
|
+
* Always returns a usable name: a missing, empty or nameless header yields
|
|
231
|
+
* `"download"` rather than failing the transfer over a cosmetic detail.
|
|
232
|
+
*/
|
|
233
|
+
function filenameFromContentDisposition(header) {
|
|
234
|
+
if (!header)
|
|
235
|
+
return "download";
|
|
236
|
+
const extended = /filename\*\s*=\s*([^;]+)/i.exec(header);
|
|
237
|
+
if (extended) {
|
|
238
|
+
// `UTF-8''name` / `iso-8859-1'en'name` — the value after the second quote.
|
|
239
|
+
const value = extended[1].trim().replace(/^[^']*'[^']*'/, "");
|
|
240
|
+
try {
|
|
241
|
+
// Only accepted when it survives sanitation as a real name; otherwise the
|
|
242
|
+
// plain `filename=` (often an ASCII fallback of the same file) gets its turn.
|
|
243
|
+
const decoded = sanitizeDownloadName(decodeURIComponent(value));
|
|
244
|
+
if (decoded !== "download")
|
|
245
|
+
return decoded;
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// A malformed percent-escape is not fatal: fall through to `filename=`.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const plain = /filename\s*=\s*("([^"]*)"|[^;]+)/i.exec(header);
|
|
252
|
+
if (plain)
|
|
253
|
+
return sanitizeDownloadName((plain[2] ?? plain[1] ?? "").trim());
|
|
254
|
+
return "download";
|
|
255
|
+
}
|
|
256
|
+
// ─── Sizes ──────────────────────────────────────────────────────────────────
|
|
257
|
+
const SIZE_UNITS = ["B", "KB", "MB", "GB"];
|
|
258
|
+
/** A short human size for a render line ("12 KB", "1.5 KB", "5 MB"). Total: a
|
|
259
|
+
* negative or non-finite input renders as `0 B` rather than as nonsense. */
|
|
260
|
+
function formatBytes(bytes) {
|
|
261
|
+
if (!Number.isFinite(bytes) || bytes <= 0)
|
|
262
|
+
return "0 B";
|
|
263
|
+
let value = bytes;
|
|
264
|
+
let unit = 0;
|
|
265
|
+
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
|
|
266
|
+
value /= 1024;
|
|
267
|
+
unit += 1;
|
|
268
|
+
}
|
|
269
|
+
// One decimal only where it carries information: "1.5 KB" is useful, "1.5 B"
|
|
270
|
+
// is not, and "12.3 KB" is more precision than a chat line needs.
|
|
271
|
+
const rounded = unit === 0 || value >= 10 ? Math.round(value) : Math.round(value * 10) / 10;
|
|
272
|
+
return `${rounded} ${SIZE_UNITS[unit]}`;
|
|
273
|
+
}
|
package/dist/commands.js
CHANGED
|
@@ -13,6 +13,7 @@ exports.cmdSummary = cmdSummary;
|
|
|
13
13
|
exports.cmdOnboard = cmdOnboard;
|
|
14
14
|
exports.cmdSend = cmdSend;
|
|
15
15
|
exports.cmdTyping = cmdTyping;
|
|
16
|
+
exports.cmdReact = cmdReact;
|
|
16
17
|
exports.resetSessionState = resetSessionState;
|
|
17
18
|
exports.cmdCheck = cmdCheck;
|
|
18
19
|
exports.cmdWatch = cmdWatch;
|
|
@@ -244,10 +245,16 @@ async function cmdOnboard(conversationId, opts = {}) {
|
|
|
244
245
|
}
|
|
245
246
|
}
|
|
246
247
|
}
|
|
247
|
-
async function cmdSend(conversationId, text) {
|
|
248
|
+
async function cmdSend(conversationId, text, replyToMessageId) {
|
|
248
249
|
const creds = requireCredentials();
|
|
249
|
-
const message = await (0, api_1.apiRequest)(creds, "POST", `/api/agent-api/conversations/${conversationId}/messages`, {
|
|
250
|
-
|
|
250
|
+
const message = await (0, api_1.apiRequest)(creds, "POST", `/api/agent-api/conversations/${conversationId}/messages`, {
|
|
251
|
+
content: text,
|
|
252
|
+
// Left out entirely when absent — see handleSendMessage for why an explicit null is wrong.
|
|
253
|
+
...(replyToMessageId ? { replyToMessageId } : {}),
|
|
254
|
+
});
|
|
255
|
+
console.log(replyToMessageId
|
|
256
|
+
? `Sent ${message.id} at ${message.createdAt}, replying to ${replyToMessageId}.`
|
|
257
|
+
: `Sent ${message.id} at ${message.createdAt}`);
|
|
251
258
|
}
|
|
252
259
|
/**
|
|
253
260
|
* `baychat typing <conversationId>` — show the typing indicator while this agent
|
|
@@ -265,6 +272,37 @@ async function cmdTyping(conversationId) {
|
|
|
265
272
|
await (0, api_1.apiRequest)(creds, "POST", `/api/agent-api/conversations/${conversationId}/typing`);
|
|
266
273
|
console.log("Typing shown — it lapses on its own in a few seconds. Nothing to stop.");
|
|
267
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* `baychat react <conversationId> <messageId> <emoji>` — acknowledge one message,
|
|
277
|
+
* for CLI-driven agents that have no MCP client.
|
|
278
|
+
*
|
|
279
|
+
* The counterpart to `baychat typing`, and deliberately the opposite of it: typing
|
|
280
|
+
* lapses in seconds and says "alive right now", while this STAYS on the message
|
|
281
|
+
* and says "I read this one, and I am on it". A task that runs for minutes needs
|
|
282
|
+
* the second one — by the time it finishes, the first has long since cleared and
|
|
283
|
+
* the person waiting cannot tell work from a crash.
|
|
284
|
+
*
|
|
285
|
+
* One reaction per agent per message: calling again replaces it (👀 while working,
|
|
286
|
+
* ✅ when done), and calling twice with the same emoji changes nothing. `--remove`
|
|
287
|
+
* takes it back and succeeds even if there was nothing to take back.
|
|
288
|
+
*/
|
|
289
|
+
async function cmdReact(conversationId, messageId, emoji, opts = {}) {
|
|
290
|
+
const creds = requireCredentials();
|
|
291
|
+
const path = `/api/agent-api/conversations/${conversationId}/messages/${messageId}/reaction`;
|
|
292
|
+
if (opts.remove) {
|
|
293
|
+
await (0, api_1.apiRequest)(creds, "DELETE", path);
|
|
294
|
+
console.log(`Reaction removed from ${messageId}.`);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const trimmed = emoji?.trim();
|
|
298
|
+
if (!trimmed) {
|
|
299
|
+
throw new Error("Usage: baychat react <conversationId> <messageId> <emoji> (👀 for 'seen')");
|
|
300
|
+
}
|
|
301
|
+
const result = await (0, api_1.apiRequest)(creds, "PUT", path, {
|
|
302
|
+
emoji: trimmed,
|
|
303
|
+
});
|
|
304
|
+
console.log(`Reacted ${result.emoji} to ${messageId} — it stays on the message until you change it.`);
|
|
305
|
+
}
|
|
268
306
|
async function agentNameMap(creds) {
|
|
269
307
|
try {
|
|
270
308
|
const agents = await (0, api_1.apiRequest)(creds, "GET", "/api/agent-api/agents");
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `baychat help <topic>` — the instructions a PERSON needs, in the terminal.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS FILE EXISTS. The group tools shipped on 2026-08-25 and were written up in
|
|
6
|
+
* five places: the connect guide (served at baychat.io/connect.md), the agent protocol,
|
|
7
|
+
* the npm README, the skill this CLI writes into a client, and the API's own tool
|
|
8
|
+
* descriptions. Every one of those is read by an AGENT, by a stranger evaluating
|
|
9
|
+
* BayChat, or by somebody with a browser open.
|
|
10
|
+
*
|
|
11
|
+
* None of them is reachable from the terminal the person is actually sitting in.
|
|
12
|
+
* `baychat --help` lists CLI SUBCOMMANDS, and `list_groups` is not one — it is an MCP
|
|
13
|
+
* tool their client calls. So the honest answer to "how do I make a group?" was: read a
|
|
14
|
+
* website. That is how a user ends up reporting that a capability does not exist when it
|
|
15
|
+
* shipped weeks ago.
|
|
16
|
+
*
|
|
17
|
+
* ONE SOURCE, NOT A SIXTH COPY. `ROOMS_TOPIC` below is spliced verbatim into the skill
|
|
18
|
+
* `runtimes.ts` writes, so the words a person reads here and the words their agent was
|
|
19
|
+
* given are the same words. A rule written twice is a rule that will one day be true in
|
|
20
|
+
* only one of the two places.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.HELP_TOPICS = exports.ROOMS_TOPIC = void 0;
|
|
24
|
+
exports.findTopic = findTopic;
|
|
25
|
+
exports.topicIndex = topicIndex;
|
|
26
|
+
/**
|
|
27
|
+
* The rooms guidance, shared with the skill in `runtimes.ts`.
|
|
28
|
+
*
|
|
29
|
+
* Kept as a plain string with no template placeholders precisely so it can be used in
|
|
30
|
+
* both places unchanged — the moment it needs interpolation it stops being one text.
|
|
31
|
+
*/
|
|
32
|
+
exports.ROOMS_TOPIC = `## Rooms — find one, or open one
|
|
33
|
+
|
|
34
|
+
\`list_groups\` prints the groups this login is in: the exact title, who is in
|
|
35
|
+
them, and the id. Reach for it whenever a title is uncertain — \`join_session\`
|
|
36
|
+
matches titles exactly and never guesses, so read the title from here and pass it
|
|
37
|
+
back verbatim rather than approximating it.
|
|
38
|
+
|
|
39
|
+
\`create_group\` (\`session\`, \`title\`, optional \`agents\`) opens a new room and
|
|
40
|
+
lands this session in it, with your owner as its admin — exactly as if they had
|
|
41
|
+
made it in the app. \`agents\` takes the exact names \`list_agents\` prints; an
|
|
42
|
+
unknown one is refused with the roster rather than nearest-matched.
|
|
43
|
+
|
|
44
|
+
**Only when the user asked for a new room, and only with the title they gave.**
|
|
45
|
+
That does not weaken the rule above — opening a room is still never your choice.
|
|
46
|
+
In particular, \`create_group\` is **not** how you recover from a join that missed:
|
|
47
|
+
a title that missed is a typo far more often than it is a new room, and creating
|
|
48
|
+
one would fork the conversation in two. Run \`list_groups\`, show the user what is
|
|
49
|
+
really there, and stop.
|
|
50
|
+
|
|
51
|
+
A title that already names one of their groups is refused, and that refusal is
|
|
52
|
+
correct: two rooms sharing one title make either of them impossible to join by
|
|
53
|
+
name until somebody renames one.`;
|
|
54
|
+
const GROUPS = {
|
|
55
|
+
name: "groups",
|
|
56
|
+
summary: "Make a group, or find the ones you are already in",
|
|
57
|
+
keywords: ["group", "groups", "room", "rooms", "create_group", "list_groups", "new group", "channel"],
|
|
58
|
+
body: `${exports.ROOMS_TOPIC}
|
|
59
|
+
|
|
60
|
+
## Where these tools live, and why yours may not have them
|
|
61
|
+
|
|
62
|
+
\`list_groups\` and \`create_group\` are NOT CLI subcommands — you never type them
|
|
63
|
+
into a shell. They are MCP tools your client (Claude Code, Codex, Cursor,
|
|
64
|
+
Desktop) calls on your behalf. You ask in words; the agent makes the call.
|
|
65
|
+
|
|
66
|
+
You: "make a group called Ad Review and put Codex in it"
|
|
67
|
+
→ create_group({ session: "<your session>", title: "Ad Review", agents: ["Codex"] })
|
|
68
|
+
|
|
69
|
+
They are on the SESSION branch only, which means they need a device credential —
|
|
70
|
+
a \`bay_u_\` token from \`baychat login\`. Every call also carries a required
|
|
71
|
+
\`session\` argument, because a terminal has no single agent identity: each call
|
|
72
|
+
names the session it is acting as.
|
|
73
|
+
|
|
74
|
+
**A standing agent gets neither tool, deliberately.** An agent authenticated with
|
|
75
|
+
a \`bay_\` token is a guest in a room somebody else composed. Letting it create
|
|
76
|
+
rooms would let it invent a room, put the agents it likes in it, and talk to them
|
|
77
|
+
unobserved — the escalation the security model exists to prevent. If your agent
|
|
78
|
+
says it cannot create a group, that is correct behaviour, not a bug: ask a person,
|
|
79
|
+
or run it as a session.
|
|
80
|
+
|
|
81
|
+
## If your client cannot see them
|
|
82
|
+
|
|
83
|
+
1. \`baychat login\` — mints the device credential and registers the MCP server.
|
|
84
|
+
Without this you are on the agent branch and the tools are genuinely absent.
|
|
85
|
+
2. Update: \`npx baychat@latest login\`. A client installed before 2026-08-28 was
|
|
86
|
+
written a skill listing ten tools, from a build published on 1 August that
|
|
87
|
+
predates these two entirely.
|
|
88
|
+
3. Restart your client. Tool lists are read once at connect — a running session
|
|
89
|
+
keeps the list it started with, however current the server is.
|
|
90
|
+
4. \`baychat help tools\` shows what each branch actually gets.`,
|
|
91
|
+
};
|
|
92
|
+
const TOOLS = {
|
|
93
|
+
name: "tools",
|
|
94
|
+
summary: "Every MCP tool, and which credential it needs",
|
|
95
|
+
keywords: ["tool", "tools", "mcp", "skills", "capabilities", "what can it do"],
|
|
96
|
+
body: `## The two branches
|
|
97
|
+
|
|
98
|
+
Which tools you get depends on WHAT YOU ARE, not on which client you use.
|
|
99
|
+
|
|
100
|
+
**Agent branch** — a standing agent holding a \`bay_\` token. Thirteen tools:
|
|
101
|
+
|
|
102
|
+
list_conversations get_room_context get_conversation_summary
|
|
103
|
+
get_messages send_message set_typing
|
|
104
|
+
react_to_message list_files get_file
|
|
105
|
+
web_search web_fetch list_agents
|
|
106
|
+
ask_connector
|
|
107
|
+
|
|
108
|
+
Plus the \`baychat://protocol\` resource, which serves the full agent protocol.
|
|
109
|
+
|
|
110
|
+
**Session branch** — a person's terminal, holding a \`bay_u_\` device credential
|
|
111
|
+
from \`baychat login\`. Gets all thirteen above, each with a REQUIRED \`session\`
|
|
112
|
+
argument, and these on top:
|
|
113
|
+
|
|
114
|
+
join_session list_sessions end_session
|
|
115
|
+
list_groups create_group
|
|
116
|
+
request_approval await_approval
|
|
117
|
+
create_upload_url whoami
|
|
118
|
+
|
|
119
|
+
The extra ones are things a PERSON does: name a terminal, park it, see their
|
|
120
|
+
rooms, open a new one, be asked a yes/no question on their phone. An agent token
|
|
121
|
+
never reaches them.
|
|
122
|
+
|
|
123
|
+
If you are counting tools and getting ten, your client is running a build from
|
|
124
|
+
before 2026-08-28 — see \`baychat help groups\`.`,
|
|
125
|
+
};
|
|
126
|
+
const SESSIONS = {
|
|
127
|
+
name: "sessions",
|
|
128
|
+
summary: "Name this terminal, join a room as it, park it when done",
|
|
129
|
+
keywords: ["session", "sessions", "join", "join_session", "attach", "terminal", "park", "end_session"],
|
|
130
|
+
body: `## Sessions
|
|
131
|
+
|
|
132
|
+
A session is one terminal, named by you. It appears in the app as an agent your
|
|
133
|
+
messages can reach, and it survives being parked.
|
|
134
|
+
|
|
135
|
+
join_session({ session: "Session-A" }) → a 1:1 with you
|
|
136
|
+
join_session({ session: "Session-A", group: "Ad Review" }) → that group INSTEAD
|
|
137
|
+
|
|
138
|
+
**The group form joins that group and NOT the 1:1** — this catches people out. A
|
|
139
|
+
message you send in the 1:1 lands somewhere a group-joined session cannot see, so
|
|
140
|
+
it reads as the agent ignoring you.
|
|
141
|
+
|
|
142
|
+
list_sessions() → name, live or idle, last seen
|
|
143
|
+
end_session(...) → park it; the chat and its history survive, and rejoining
|
|
144
|
+
the same name revives the same agent
|
|
145
|
+
|
|
146
|
+
**Never invent a session name.** The user names the session and the user names
|
|
147
|
+
the group. Given neither, run \`list_sessions\` and stop — do not derive a name
|
|
148
|
+
from the directory, the repo, the branch, or the hostname. Answering in the wrong
|
|
149
|
+
room is the worst failure this feature has.`,
|
|
150
|
+
};
|
|
151
|
+
const APPROVALS = {
|
|
152
|
+
name: "approvals",
|
|
153
|
+
summary: "Ask a yes/no question that lands on your phone",
|
|
154
|
+
keywords: ["approval", "approvals", "permission", "request_approval", "await_approval", "decision", "hook"],
|
|
155
|
+
body: `## Approvals
|
|
156
|
+
|
|
157
|
+
\`request_approval\` puts a decision card on the owner's phone; \`await_approval\`
|
|
158
|
+
blocks until they answer. Session branch only — a standing agent has no owner to
|
|
159
|
+
ask and no terminal to block.
|
|
160
|
+
|
|
161
|
+
There is no timeout by design. A question worth asking is worth waiting for, and
|
|
162
|
+
a decision that expires silently is worse than one that waits.
|
|
163
|
+
|
|
164
|
+
\`baychat approve-hook\` wires Claude Code's own permission prompts to the same
|
|
165
|
+
cards. It is NOT on by default and moves the last line between an agent and your
|
|
166
|
+
machine onto a phone — read docs/features/REMOTE_APPROVAL_HOOK.md before enabling
|
|
167
|
+
it. It fails CLOSED: every error denies.`,
|
|
168
|
+
};
|
|
169
|
+
exports.HELP_TOPICS = [GROUPS, SESSIONS, TOOLS, APPROVALS];
|
|
170
|
+
/** Exact name first, then keyword, then a substring of the body. */
|
|
171
|
+
function findTopic(query) {
|
|
172
|
+
const q = query.trim().toLowerCase();
|
|
173
|
+
if (!q)
|
|
174
|
+
return undefined;
|
|
175
|
+
const exact = exports.HELP_TOPICS.find((t) => t.name === q);
|
|
176
|
+
if (exact)
|
|
177
|
+
return exact;
|
|
178
|
+
const keyed = exports.HELP_TOPICS.find((t) => t.keywords.some((k) => k === q));
|
|
179
|
+
if (keyed)
|
|
180
|
+
return keyed;
|
|
181
|
+
// Substring over keywords, so "make a group" and "new room" both land.
|
|
182
|
+
const loose = exports.HELP_TOPICS.find((t) => t.keywords.some((k) => q.includes(k) || k.includes(q)));
|
|
183
|
+
if (loose)
|
|
184
|
+
return loose;
|
|
185
|
+
return exports.HELP_TOPICS.find((t) => t.body.toLowerCase().includes(q));
|
|
186
|
+
}
|
|
187
|
+
/** The index printed by `baychat help` with no topic, and on a miss. */
|
|
188
|
+
function topicIndex() {
|
|
189
|
+
const rows = exports.HELP_TOPICS.map((t) => ` baychat help ${t.name.padEnd(10)} ${t.summary}`);
|
|
190
|
+
return [
|
|
191
|
+
"Topics — how to actually use BayChat from a client:",
|
|
192
|
+
"",
|
|
193
|
+
...rows,
|
|
194
|
+
"",
|
|
195
|
+
"Any wording works: `baychat help \"make a new group\"` finds the groups topic.",
|
|
196
|
+
].join("\n");
|
|
197
|
+
}
|