baychat 0.22.0 → 0.23.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.
@@ -36,15 +36,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.AcpSessionRunner = void 0;
37
37
  exports.effectiveMode = effectiveMode;
38
38
  const os = __importStar(require("os"));
39
+ const runtime_binary_1 = require("../../runtime-binary");
39
40
  const spawn_env_1 = require("../spawn-env");
40
41
  const agents_1 = require("./agents");
41
42
  const approval_1 = require("./approval");
42
43
  const client_1 = require("./client");
43
44
  const modes_1 = require("./modes");
44
- const permissions_1 = require("./permissions");
45
45
  const policy_1 = require("./policy");
46
46
  const presence_1 = require("./presence");
47
47
  const prompt_1 = require("./prompt");
48
+ const types_1 = require("./types");
48
49
  /**
49
50
  * How much power ONE turn gets.
50
51
  *
@@ -62,6 +63,17 @@ function effectiveMode(sessionMode, ceiling, allAuthorized, allowedHere) {
62
63
  const usable = modes_1.ACP_MODES.filter((m) => allowedHere.includes(m) && (0, modes_1.modeRank)(m) <= (0, modes_1.modeRank)(wanted));
63
64
  return usable.at(-1) ?? "chat";
64
65
  }
66
+ /**
67
+ * Is this call BayChat's `send_message` — the agent's answer going into the room? `mcpServer` is
68
+ * the name BayChat's server was handed over under this turn. dsh names the call by its title;
69
+ * Cursor titles it "<server>: send_message" and names server and tool in its input [run].
70
+ * Another server's `send_message` is not ours.
71
+ */
72
+ function isBayChatSend(call, mcpServer) {
73
+ if (call.title === `mcp__${mcpServer}__send_message`)
74
+ return true;
75
+ return call.rawInput.providerIdentifier === mcpServer && call.rawInput.toolName === "send_message";
76
+ }
65
77
  const RETRY_MAX_MS = 60_000;
66
78
  const errText = (err) => (err instanceof Error ? err.message : String(err));
67
79
  /**
@@ -174,6 +186,10 @@ class AcpSessionRunner {
174
186
  return;
175
187
  seen.add(key);
176
188
  this.said.set(session, seen);
189
+ await this.tell(session, key, conversationId, text);
190
+ }
191
+ /** Say it in the room now, every time. A failure to say is logged, never thrown. */
192
+ async tell(session, key, conversationId, text) {
177
193
  try {
178
194
  await this.deps.say(conversationId, session, text);
179
195
  }
@@ -193,28 +209,46 @@ class AcpSessionRunner {
193
209
  const acp = target?.acp;
194
210
  const row = acp ? (0, agents_1.acpAgent)(acp.agent) : undefined;
195
211
  if (!target || !acp || !row)
196
- return drop("setup", `"${session}" is not set up on that computer any more. Run \`baychat connect dsh\` there.`);
212
+ return drop("setup", `"${session}" is not set up on that computer any more. Run \`${(0, agents_1.connectCommandFor)(acp?.agent)}\` there.`);
197
213
  const auth = deps.auth();
198
214
  if (!auth)
199
- return drop("setup", `The relay on that computer is not logged in to BayChat. Run \`baychat connect ${row.id}\` there.`);
215
+ return drop("setup", `The relay on that computer is not logged in to BayChat. Run \`${row.connectCommand}\` there.`);
200
216
  if (!target.runtimeBin)
201
217
  return drop("spawn", `${row.label} is not installed where the relay can find it. ${row.installHint}`);
202
218
  const folder = (0, policy_1.checkFolder)((deps.loadPolicy ?? policy_1.loadPolicy)(), row.id, acp.cwd);
203
219
  if (!folder.ok)
204
220
  return drop("policy", folder.reason);
221
+ // Every turn, every mode: what is on disk now, not what was there at `connect`.
222
+ const refusal = row.turnRefusal?.(acp.cwd);
223
+ if (refusal)
224
+ return drop("override", refusal);
225
+ const env = { ...(deps.env ?? spawn_env_1.headlessSpawnEnv)() };
226
+ if (row.versionPattern) {
227
+ // The recorded path may since have been replaced by something else answering to the name.
228
+ const probed = await (deps.binaryVersion ?? ((bin) => (0, runtime_binary_1.probeBinaryVersion)(bin, env)))(target.runtimeBin);
229
+ if (!probed.ok || !(0, agents_1.binaryIdentified)(row, probed.version)) {
230
+ return drop("spawn", (0, agents_1.unidentifiedBinary)(row, target.runtimeBin, probed.ok ? probed.version : `nothing usable: ${probed.detail}`));
231
+ }
232
+ }
205
233
  const platform = deps.platform ?? process.platform;
206
234
  const allAuthorized = turn.addressed.every((m) => m.senderIsOwner === true || m.commandAuthorized === true);
207
235
  const mode = effectiveMode(acp.mode, folder.maxMode, allAuthorized, row.modesFor(platform));
208
236
  const patchDir = deps.patchDir ?? (0, agents_1.acpPatchDir)();
209
- (0, agents_1.writeModePatches)(row, patchDir); // cheap, and an upgrade that changes a patch takes effect at once
210
- const launch = row.launch(mode, patchDir);
237
+ (0, agents_1.writeModePatch)(row, mode, session, patchDir); // cheap; an upgrade or a /mode-* takes effect at once
238
+ const launch = row.launch(mode, patchDir, session);
239
+ const mcpServer = row.mcpServerName?.() ?? types_1.BAYCHAT_MCP_SERVER;
240
+ const toolNote = row.toolNote?.(mcpServer);
211
241
  const onPermission = async (call) => {
212
242
  const host = (deps.hostname ?? os.hostname)();
213
- const decision = (0, permissions_1.decidePermission)(mode, call, { cwd: acp.cwd, host, agentLabel: row.label });
243
+ const decision = row.decidePermission(mode, call, { cwd: acp.cwd, host, agentLabel: row.label, mcpServer });
214
244
  if (decision.kind === "refuse") {
215
245
  deps.log(`acp: ${session} permission REFUSED — ${decision.reason}`);
216
246
  return "reject";
217
247
  }
248
+ if (decision.kind === "allow") {
249
+ deps.log(`acp: ${session} permission ALLOWED without asking — ${decision.reason}`);
250
+ return "allow";
251
+ }
218
252
  typing?.pause(); // the card is the signal while a person decides
219
253
  const answer = await (deps.askPhone ?? approval_1.askPhone)({ auth, session, conversationId: room, question: decision.question });
220
254
  typing?.resume(); // a no-op once the turn has stopped typing — `stop()` is final
@@ -224,24 +258,32 @@ class AcpSessionRunner {
224
258
  const caller = (deps.caller ?? presence_1.deviceMcpCaller)(auth);
225
259
  const abort = new AbortController();
226
260
  let typing;
261
+ let stopGuard;
262
+ /** What the row's guard found on disk mid-turn — the turn is stopped the moment it is set. */
263
+ let breach;
227
264
  let outcome;
228
265
  try {
229
266
  this.running.set(session, abort);
267
+ stopGuard = row.guardTurn?.(acp.cwd, (finding) => {
268
+ breach = `${row.label} was stopped in the middle of its turn: ${finding}`;
269
+ abort.abort();
270
+ });
230
271
  typing = (0, presence_1.startTyping)(caller, session, room, { log: deps.log });
231
272
  deps.log(`acp: ${session} turn — ${turn.addressed.length} addressed, ${turn.digest.messages.length} context, mode ${mode}${allAuthorized ? "" : " (not all from someone with command access)"}`);
232
273
  outcome = await (deps.runTurn ?? client_1.runAcpTurn)({
233
274
  binaryPath: target.runtimeBin,
234
275
  args: launch.args,
235
- env: { ...(deps.env ?? spawn_env_1.headlessSpawnEnv)(), ...launch.env },
276
+ env: { ...env, ...launch.env },
236
277
  cwd: acp.cwd,
237
278
  sessionId: acp.sessionId,
238
- mcpServers: [{ type: "http", name: "baychat", url: `${auth.baseUrl.replace(/\/$/, "")}/api/mcp`, headers: [{ name: "Authorization", value: `Bearer ${auth.token}` }] }],
239
- prompt: `Mode for this turn: ${mode}.\n\n${(0, prompt_1.turnPrompt)(turn)}`,
240
- freshPreamble: (0, prompt_1.setupPreamble)(session, mode),
279
+ mcpServers: [{ type: "http", name: mcpServer, url: `${auth.baseUrl.replace(/\/$/, "")}/api/mcp`, headers: [{ name: "Authorization", value: `Bearer ${auth.token}` }] }],
280
+ prompt: `Mode for this turn: ${mode}.\n\n${toolNote ? `${toolNote}\n\n` : ""}${(0, prompt_1.turnPrompt)(turn)}`,
281
+ freshPreamble: (0, prompt_1.setupPreamble)(session, (0, agents_1.describeAgentMode)(row, mode)),
282
+ sessionModeId: row.acpModeId,
241
283
  onPermission,
242
284
  // Its message is in the room; a refreshed indicator after that would be a ghost.
243
285
  onToolCall: (call) => {
244
- if (call.title === "mcp__baychat__send_message")
286
+ if (isBayChatSend(call, mcpServer))
245
287
  typing?.stop();
246
288
  },
247
289
  classifyError: (m) => row.classifyError(m),
@@ -249,10 +291,21 @@ class AcpSessionRunner {
249
291
  }, { spawn: deps.spawn, log: deps.log, platform });
250
292
  }
251
293
  finally {
252
- // Every exit — outcome or throw — ends typing and frees `/stop`, or `/stop` would lie.
294
+ // Every exit — outcome or throw — ends typing and the guard and frees `/stop`, or `/stop` would lie.
295
+ stopGuard?.();
253
296
  typing?.stop();
254
297
  this.running.delete(session);
255
298
  }
299
+ if (breach !== undefined) {
300
+ // Said every time, not once: it happened in THIS turn. The next turn's `turnRefusal` then
301
+ // refuses until the file is gone.
302
+ deps.log(`acp: ${session} ${breach}`);
303
+ await this.tell(session, "override", room, breach);
304
+ if (outcome.kind !== "completed") {
305
+ deps.onPending(session, turn.addressed, breach);
306
+ return "next";
307
+ }
308
+ }
256
309
  if (outcome.kind === "completed") {
257
310
  deps.registry.setAcpSession(session, outcome.sessionId);
258
311
  deps.onDelivered(session, turn.addressed);
@@ -1,2 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BAYCHAT_MCP_SERVER = void 0;
4
+ /** The name BayChat's MCP server is handed to the agent under — and how an agent names it back. */
5
+ exports.BAYCHAT_MCP_SERVER = "baychat";
@@ -271,7 +271,7 @@ class RelayDaemon {
271
271
  });
272
272
  writePidFile();
273
273
  // After the socket is ours: a relay refused as a duplicate must not keep sessions alive.
274
- this.acpKeepAlive = (0, daemon_glue_1.startAcpUpkeep)(this.acpAlive.run, this.log);
274
+ this.acpKeepAlive = (0, daemon_glue_1.startAcpUpkeep)(this.acpAlive.run);
275
275
  this.log(`listening on ${sockPath} (pid ${process.pid}) as device "${device.user.name}"`);
276
276
  this.log(`${this.registry.all().length} known session target(s)`);
277
277
  // The event SOURCE is pluggable (socket preferred, long-poll floor); every
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SUSPENDED_RECHECK_MS = void 0;
3
4
  exports.runUpdatesLoop = runUpdatesLoop;
4
5
  exports.catchUpFromWatermarks = catchUpFromWatermarks;
5
6
  const api_1 = require("../api");
@@ -9,6 +10,49 @@ const WAIT_SECONDS = 25;
9
10
  const BACKOFF_MIN_MS = 1_000;
10
11
  const BACKOFF_MAX_MS = 30_000;
11
12
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
13
+ /**
14
+ * How long the relay waits before asking again once the server has said the account or the
15
+ * Bay is SUSPENDED. Long on purpose — nothing the relay does changes the answer, so this is
16
+ * only about noticing when a person has lifted it.
17
+ */
18
+ exports.SUSPENDED_RECHECK_MS = 15 * 60_000;
19
+ /** `sleep`, but over at once when `signal` aborts — a relay told to stop must not sit out 15 minutes. */
20
+ function sleepUnlessAborted(ms, signal) {
21
+ return new Promise((resolve) => {
22
+ if (signal.aborted)
23
+ return resolve();
24
+ const timer = setTimeout(done, ms);
25
+ function done() {
26
+ clearTimeout(timer);
27
+ signal.removeEventListener("abort", done);
28
+ resolve();
29
+ }
30
+ signal.addEventListener("abort", done);
31
+ });
32
+ }
33
+ /**
34
+ * The server's refusal about STANDING, not about the credential (docs/features/MODERATION.md
35
+ * in the API): 403 ACCOUNT_SUSPENDED — the account is suspended or being deleted by BayChat —
36
+ * or 403 BAY_SUSPENDED — the Bay this laptop is logged in to is suspended.
37
+ *
38
+ * Neither is fixed by `npx baychat login`, and neither is transient in the 5xx sense. Treating
39
+ * them as terminal (the default for a 4xx) made the daemon exit, and every service manager this
40
+ * CLI installs (`systemd` Restart=always, launchd KeepAlive, the Windows task's
41
+ * RestartOnFailure) restarted it into the same refusal every few seconds: a crash-loop that
42
+ * says nothing useful, the exact shape of the relay that restarted 111k times on an expired
43
+ * credential. An exit code cannot stop that without changing units that are already installed,
44
+ * so the relay stays up instead, says plainly what happened, and asks again rarely — which also
45
+ * means it resumes by itself when a Bay suspension is lifted.
46
+ */
47
+ function standingRefusal(err) {
48
+ if (!(err instanceof api_1.ApiError) || err.status !== 403)
49
+ return null;
50
+ if (err.code === "ACCOUNT_SUSPENDED")
51
+ return "account";
52
+ if (err.code === "BAY_SUSPENDED")
53
+ return "bay";
54
+ return null;
55
+ }
12
56
  /**
13
57
  * Transient failures are ridden out, not surfaced. A 502 while the api
14
58
  * container restarts, a network throw, a 429 — all mean "later", not "stop".
@@ -43,7 +87,7 @@ function isTransient(err) {
43
87
  * watermark, then poll again with no cursor.
44
88
  */
45
89
  async function runUpdatesLoop(opts) {
46
- const { auth, watermarks, onEvents, onSessions, onPoll, onError, initialCursor, signal, request = api_1.apiRequest } = opts;
90
+ const { auth, watermarks, onEvents, onSessions, onPoll, onError, initialCursor, signal, request = api_1.apiRequest, suspendedRecheckMs = exports.SUSPENDED_RECHECK_MS, } = opts;
47
91
  let cursor = initialCursor;
48
92
  let backoff = BACKOFF_MIN_MS;
49
93
  while (!signal.aborted) {
@@ -86,6 +130,18 @@ async function runUpdatesLoop(opts) {
86
130
  cursor = undefined;
87
131
  continue;
88
132
  }
133
+ const standing = standingRefusal(err);
134
+ if (standing) {
135
+ const minutes = Math.round(suspendedRecheckMs / 60_000);
136
+ onError?.(new Error(standing === "account"
137
+ ? `this BayChat account has been suspended — the relay is paused and will check again in ${minutes} min. ` +
138
+ "Logging in again will not help; if you think this is a mistake, contact support@baychat.io."
139
+ : `the Bay this machine is logged in to has been suspended — the relay is paused and will check again in ${minutes} min. ` +
140
+ "If you think this is a mistake, contact support@baychat.io."), true);
141
+ await sleepUnlessAborted(suspendedRecheckMs, signal);
142
+ backoff = BACKOFF_MIN_MS;
143
+ continue;
144
+ }
89
145
  if (!isTransient(err)) {
90
146
  onError?.(err, false);
91
147
  throw err; // terminal — an expired device credential needs `npx baychat login`
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.resolveRuntimeBinary = resolveRuntimeBinary;
37
37
  exports.describeResolution = describeResolution;
38
38
  exports.spawnPlanFor = spawnPlanFor;
39
+ exports.probeBinaryVersion = probeBinaryVersion;
39
40
  exports.summarizeProbe = summarizeProbe;
40
41
  exports.currentBinaryEnv = currentBinaryEnv;
41
42
  exports.summarizeResolutionFailure = summarizeResolutionFailure;
@@ -213,6 +214,22 @@ function spawnPlanFor(candidate, platform) {
213
214
  }
214
215
  /** How long a `--version` probe may run before it is treated as broken. */
215
216
  const PROBE_TIMEOUT_MS = 5_000;
217
+ /**
218
+ * `<binary> --version`, without blocking the event loop — for a caller that probes a known path
219
+ * while other work runs (the relay, before starting an agent). Same rules as the resolution
220
+ * probe: no shell, the environment the agent will get, `summarizeProbe` decides.
221
+ */
222
+ function probeBinaryVersion(binaryPath, env) {
223
+ const plan = spawnPlanFor(binaryPath, process.platform);
224
+ return new Promise((resolve) => {
225
+ (0, child_process_1.execFile)(plan.file, [...plan.prefixArgs, "--version"], { timeout: PROBE_TIMEOUT_MS, windowsHide: true, shell: false, env, encoding: "utf8" }, (err, stdout, stderr) => {
226
+ const status = err ? (typeof err.code === "number" ? err.code : null) : 0;
227
+ // A non-zero exit is still a run: only a failure to run at all is an `error`.
228
+ const failedToRun = err && typeof err.code !== "number" ? err : undefined;
229
+ resolve(summarizeProbe({ status, stdout: stdout ?? "", stderr: stderr ?? "", error: failedToRun }));
230
+ });
231
+ });
232
+ }
216
233
  /** The most stderr worth quoting back to a user in a rejection reason. */
217
234
  const PROBE_DETAIL_LIMIT = 200;
218
235
  /**
package/dist/tool-defs.js CHANGED
@@ -266,6 +266,56 @@ exports.CONVERSATION_TOOL_DEFS = [
266
266
  "attachments, never both."),
267
267
  },
268
268
  },
269
+ {
270
+ name: "create_poll",
271
+ title: "Ask the room to choose",
272
+ description: "Put a poll in the room: one question, two to ten answers, counted by the server. " +
273
+ "USE IT WHEN THE ANSWER IS A CHOICE SEVERAL PEOPLE OWN — a release date, which of two " +
274
+ "designs, who is free on Thursday. For a decision ONE person owns, use request_approval " +
275
+ "instead: that blocks your session until they answer, while a poll does not block " +
276
+ "anything and you read the result later. " +
277
+ "Everyone in the room can vote, including other agents, and every vote is visible to the " +
278
+ "room. Do not create a second poll to ask the same thing again — read the first one's " +
279
+ "tally with get_messages.",
280
+ inputSchema: {
281
+ conversationId: zod_1.z
282
+ .string()
283
+ .describe("The conversation id (from list_conversations)."),
284
+ question: zod_1.z.string().describe("The question, as the room will read it."),
285
+ options: zod_1.z
286
+ .array(zod_1.z.string())
287
+ .describe("The answers, in the order they should appear: at least two, at most ten, each " +
288
+ "distinct. Keep them short — they are drawn as rows on a phone."),
289
+ allowMultiple: zod_1.z
290
+ .boolean()
291
+ .optional()
292
+ .describe("May one voter pick more than one answer? Defaults to false — one answer each."),
293
+ },
294
+ },
295
+ {
296
+ name: "vote_in_poll",
297
+ title: "Vote in a poll",
298
+ description: "Cast, change or withdraw YOUR OWN vote in a poll. You are a participant of the room like " +
299
+ "anyone else, and your vote is counted and shown beside theirs. " +
300
+ "GET pollId FROM THE POLL CARD: a poll arrives through get_messages as a message whose " +
301
+ "`cardPayload` holds { pollId, question, options }, and whose `poll` holds the tally so " +
302
+ "far. " +
303
+ "`optionIndexes` COUNTS FROM ZERO — the first answer is 0, whatever the numbering in the " +
304
+ "message text says. It is the COMPLETE set of answers you want to hold: send one index " +
305
+ "for a single-answer poll, several for a poll that allows them, and an empty array to " +
306
+ "take your vote back. Sending the same vote twice changes nothing. " +
307
+ "VOTE ONLY WHEN YOU WERE ASKED TO, or when you genuinely hold the information the " +
308
+ "question is about. A poll is the room's decision, not yours to settle.",
309
+ inputSchema: {
310
+ pollId: zod_1.z
311
+ .string()
312
+ .describe("The poll's id, from the poll card's `cardPayload.pollId`."),
313
+ optionIndexes: zod_1.z
314
+ .array(zod_1.z.number())
315
+ .describe("The complete set of answers you want to hold, counting from ZERO. Empty withdraws " +
316
+ "your vote."),
317
+ },
318
+ },
269
319
  {
270
320
  name: "set_typing",
271
321
  title: "Show that you are working",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.22.0",
4
- "description": "BayChat connector CLI — pair an agent session (Claude Code, Codex) with BayChat and chat in groups",
3
+ "version": "0.23.1",
4
+ "description": "BayChat connector CLI — pair an agent session (Claude Code, Codex, Cursor) with BayChat, or have the relay run DeepSeek or the Cursor CLI for you",
5
5
  "bin": {
6
6
  "baychat": "dist/index.js"
7
7
  },
@@ -29,6 +29,8 @@
29
29
  "chat",
30
30
  "claude-code",
31
31
  "codex",
32
+ "cursor",
33
+ "deepseek",
32
34
  "cli",
33
35
  "connector",
34
36
  "group-chat"