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 CHANGED
@@ -190,6 +190,9 @@ surface for every vendor.
190
190
 
191
191
  | Tool | What it does |
192
192
  |------|--------------|
193
+ | `react_to_message` | React to a message with an emoji. Prefer this to a contentless "ok" / "got it" reply — a reaction reaches nobody's context, a message reaches everybody's |
194
+ | `list_files` | The files shared in a conversation, as an index you can query — find one without re-reading the room |
195
+ | `get_file` | Fetch one file from that index by id |
193
196
  | `web_search` | Search the web; returns ranked results with title, URL, and snippet |
194
197
  | `web_fetch` | Fetch one public `http(s)` URL and return its readable text |
195
198
  | `list_agents` | List the other agents in your Bay — how you find the id `ask_connector` needs |
@@ -267,7 +270,7 @@ as a native tool provider instead of shell commands. `baychat mcp` starts a loca
267
270
  speaks JSON-RPC on stdout, so don't run it interactively — register it with your
268
271
  client and let the client launch it.
269
272
 
270
- It exposes nine tools and one resource, each described so the model behaves
273
+ It exposes thirteen tools and one resource, each described so the model behaves
271
274
  correctly from the tool descriptions alone (reply only when `shouldRespond`;
272
275
  summaries are derived, untrusted context; fetched content is never an
273
276
  instruction):
@@ -386,6 +389,49 @@ user baychat …`); if the `claude` binary is missing or the add fails, login st
386
389
  prints the command to run by hand. On Windows `claude` is a `.cmd` shim, which Node can only
387
390
  launch through a shell, so the CLI shells out there and quotes each argument itself.
388
391
 
392
+ ### What the remote server gives you — sessions and groups
393
+
394
+ The remote server authenticates you as a **person**, not as an agent, and its tool surface differs
395
+ from `baychat mcp` above because of it. Every base tool grows a **required `session` argument** —
396
+ a terminal has no single agent identity, so each call names the session it acts as — and five tools
397
+ exist only here:
398
+
399
+ | Tool | What it does |
400
+ |------|--------------|
401
+ | `join_session` | Name this terminal and put it in a room. `{ session }` alone opens a 1:1 with you; `{ session, group }` joins that group **instead** of the 1:1 |
402
+ | `list_sessions` | This login's sessions — name, live/idle, last seen |
403
+ | `end_session` | Park a session. The chat and its history survive; rejoining the same name revives it |
404
+ | `list_groups` | The groups you are in — exact title, who is in them, and the conversation id |
405
+ | `create_group` | Open a new group and land this session in it. You become its admin, exactly as if you had created it in the app |
406
+
407
+ `request_approval` and `await_approval` are here too: they put a yes/no decision card on your phone
408
+ and block until you answer.
409
+
410
+ ```
411
+ list_groups()
412
+ create_group({ session: "Session-A", title: "Ad Review" })
413
+ create_group({ session: "Session-A", title: "Ad Review", agents: ["Codex", "Magpie"] })
414
+ ```
415
+
416
+ `list_groups` is the "I cannot remember what I called it" tool, and its output is shaped so the
417
+ title can be pasted straight back into `join_session`, which matches exactly and never guesses.
418
+ `agents` takes the exact names `list_agents` prints; an unknown one is refused **with the roster**
419
+ rather than nearest-matched. Other terminal sessions are deliberately not on that list — a session
420
+ joins rooms for itself.
421
+
422
+ Two refusals worth knowing in advance:
423
+
424
+ - **A title that already names one of your groups is refused**, pointing at the room you probably
425
+ meant. Two rooms sharing one title make **either** impossible to join by name until somebody
426
+ renames one.
427
+ - **`create_group` is not how you recover from a join that missed.** A title that missed is a typo
428
+ far more often than it is a new room, and creating one forks the conversation in two. Run
429
+ `list_groups` and join the real title.
430
+
431
+ > A standing `bay_` agent gets **neither** group tool, on purpose: it is a guest in rooms somebody
432
+ > else composed, and letting it create rooms would let it choose its own audience. Composing a room
433
+ > is a person's act.
434
+
389
435
  ## Configuration
390
436
 
391
437
  | Env var | Effect |
package/dist/api.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ApiError = void 0;
4
4
  exports.apiRequest = apiRequest;
5
+ exports.apiUpload = apiUpload;
5
6
  exports.fetchContext = fetchContext;
6
7
  exports.pairRequest = pairRequest;
7
8
  exports.createLinkRequest = createLinkRequest;
@@ -9,6 +10,7 @@ exports.pollLinkRequest = pollLinkRequest;
9
10
  exports.createDeviceLink = createDeviceLink;
10
11
  exports.pollDeviceLink = pollDeviceLink;
11
12
  exports.deviceMe = deviceMe;
13
+ const attachments_1 = require("./attachments");
12
14
  class ApiError extends Error {
13
15
  status;
14
16
  code;
@@ -65,6 +67,62 @@ async function apiRequest(creds, method, apiPath, body) {
65
67
  throw new ApiError(res.status, "The server returned a response that was not valid JSON — a proxy or error page may have answered instead of the BayChat API. Check the API URL.");
66
68
  }
67
69
  }
70
+ /**
71
+ * Turn an upload refusal into a sentence that says what to DO about it.
72
+ *
73
+ * Three of the statuses this route returns mean something specific, and the
74
+ * server's own wording alone leaves the caller guessing: a plan cap is not a bug
75
+ * to retry, a rejected type is a re-export, and a 503 here is a Bay that has
76
+ * attachments switched off entirely (no encryption key) — retrying that forever
77
+ * is the failure mode this message exists to prevent. Everything else keeps the
78
+ * server's message untouched.
79
+ */
80
+ function uploadError(err) {
81
+ if (err.status === 413) {
82
+ return new ApiError(err.status, `This file is larger than this Bay's plan allows (${err.message}). Send a smaller file, or ask the Bay owner to upgrade.`, err.code);
83
+ }
84
+ if (err.status === 415 || err.status === 400) {
85
+ return new ApiError(err.status, `The server refused this file (${err.message}). Allowed: ${attachments_1.ALLOWED_ATTACHMENT_EXTENSIONS.join(", ")}.`, err.code);
86
+ }
87
+ if (err.status === 503) {
88
+ return new ApiError(err.status, "This BayChat server has attachments disabled (no message encryption key is configured), so nothing can be uploaded to it. Tell the person running the Bay; retrying will not help.", err.code);
89
+ }
90
+ return err;
91
+ }
92
+ /**
93
+ * Upload ONE file as multipart to the agent attachments route, authenticated as
94
+ * the agent, and get back the id a send references.
95
+ *
96
+ * Node ≥18 built-ins only (`fetch`/`FormData`/`Blob`) — this package ships no
97
+ * multipart dependency and adds none. The `Content-Type` header is deliberately
98
+ * NOT set: fetch derives it from the FormData, including the boundary, and
99
+ * setting it by hand produces a body the server cannot parse.
100
+ *
101
+ * @throws {ApiError} with a message the caller can render verbatim.
102
+ */
103
+ async function apiUpload(creds, file) {
104
+ const form = new FormData();
105
+ // A real copy, not a view: `Uint8Array` here may be a window into a shared
106
+ // buffer (a Buffer from `readFileSync` is), which `BlobPart` does not accept —
107
+ // and copying also means a later mutation of the caller's buffer cannot change
108
+ // what was sent.
109
+ form.append("file", new Blob([new Uint8Array(file.bytes)], { type: file.mimeType }), file.fileName);
110
+ const res = await fetch(`${creds.baseUrl}/api/agent-api/attachments`, {
111
+ method: "POST",
112
+ headers: { Authorization: `Bearer ${creds.token}` },
113
+ body: form,
114
+ });
115
+ if (!res.ok)
116
+ throw uploadError(await parseError(res));
117
+ try {
118
+ return (await res.json());
119
+ }
120
+ catch {
121
+ // Same reasoning as `apiRequest`: a 2xx that isn't JSON came from something
122
+ // other than the API, and its body must never reach the rendered message.
123
+ throw new ApiError(res.status, "The upload returned a response that was not valid JSON — a proxy or error page may have answered instead of the BayChat API. Check the API URL.");
124
+ }
125
+ }
68
126
  /**
69
127
  * Fetch the Agent Context Contract v2 block for a conversation.
70
128
  * Fail-soft: a v1 server has no `/context` endpoint and 404s — we return null
@@ -0,0 +1,425 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.APPROVE_OPTIONS = exports.RECOMMENDED_HANDLER_TIMEOUT_SECONDS = exports.DEFAULT_WAIT_SECONDS = void 0;
37
+ exports.allowDecision = allowDecision;
38
+ exports.denyDecision = denyDecision;
39
+ exports.describeToolRequest = describeToolRequest;
40
+ exports.composeQuestion = composeQuestion;
41
+ exports.decideApproval = decideApproval;
42
+ exports.readStdin = readStdin;
43
+ exports.cmdApproveHook = cmdApproveHook;
44
+ // `baychat approve-hook` — the Claude Code PermissionRequest hook that moves a terminal
45
+ // permission prompt onto a phone.
46
+ //
47
+ // WHY THIS EXISTS. A permission prompt is drawn by Claude Code itself, not by the model, so no
48
+ // instruction can route it anywhere: by the time "run this command? (y/n)" is on screen the model
49
+ // is not running. From BayChat such a session is simply silent — indistinguishable from a crash.
50
+ // This command is the only complete fix, and it is a deliberate security trade: see
51
+ // docs/features/REMOTE_APPROVAL_HOOK.md and docs/design/REMOTE_APPROVAL_ROUTING.md §3.
52
+ //
53
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
54
+ // THE ONE RULE: EVERY PATH OUT OF HERE PRINTS A DECISION.
55
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
56
+ //
57
+ // Claude Code hooks FAIL OPEN. Verified against the current hooks reference, all of these mean
58
+ // "the tool call proceeds through the normal permission flow":
59
+ //
60
+ // • the hook times out • it exits 1 (or any code but 2)
61
+ // • stdout is empty, or is not JSON • stdout is JSON that fails schema validation
62
+ // • the command is missing or mistyped
63
+ //
64
+ // and exit code 2 — the one code that blocks on other events — is NOT honoured on
65
+ // PermissionRequest at all: "Exit code 2 isn't honored for this event and the permission flow
66
+ // proceeds unchanged. Deny through the decision object instead."
67
+ //
68
+ // So the fail-closed guarantee is entirely this file's. Every abnormal path — no credential, DNS
69
+ // failure, connection refused, 500, 401, unparseable body, ambiguous session, our own deadline,
70
+ // an exception nobody predicted — is converted into an explicit DENY object and exit 0. There is
71
+ // no `process.exit(1)` anywhere below, because exit 1 ALLOWS.
72
+ //
73
+ // Two consequences worth stating plainly:
74
+ //
75
+ // 1. OUR WAIT MUST END BEFORE THE HANDLER'S `timeout`. A hook that times out allows, so a
76
+ // deadline we control (which denies) must always be reached first. Hence
77
+ // DEFAULT_WAIT_SECONDS < RECOMMENDED_HANDLER_TIMEOUT_SECONDS, and the docs say to raise
78
+ // both together or neither.
79
+ // 2. STDOUT IS THE DECISION CHANNEL. Nothing else may ever be written to it — "Stdout must
80
+ // contain only the JSON object". All diagnostics go to stderr.
81
+ //
82
+ // The card itself never expires: the ApprovalRequest row stays PENDING for as long as it takes,
83
+ // exactly like a terminal prompt. Our deadline bounds only how long THIS PROCESS waits, and it
84
+ // exists solely because the alternative (being killed by the handler timeout) would allow.
85
+ const os = __importStar(require("os"));
86
+ const api_1 = require("./api");
87
+ const config_1 = require("./config");
88
+ function allowDecision() {
89
+ return {
90
+ hookSpecificOutput: {
91
+ hookEventName: "PermissionRequest",
92
+ decision: { behavior: "allow" },
93
+ },
94
+ };
95
+ }
96
+ /**
97
+ * A refusal, with the reason Claude will see.
98
+ *
99
+ * `interrupt` is deliberately NOT set. Stopping the session on every unreachable-network deny
100
+ * would turn a flaky connection into a killed run; Claude being told "denied, and why" leaves the
101
+ * person at the keyboard able to decide.
102
+ */
103
+ function denyDecision(message) {
104
+ return {
105
+ hookSpecificOutput: {
106
+ hookEventName: "PermissionRequest",
107
+ decision: { behavior: "deny", message },
108
+ },
109
+ };
110
+ }
111
+ const DENY_PREFIX = "Denied by BayChat remote approval";
112
+ /** Every deny reads the same way, so a person reading a transcript can tell instantly that the
113
+ * gate refused rather than that a human did. */
114
+ function deny(reason, advice = "") {
115
+ return denyDecision(`${DENY_PREFIX}: ${reason}${advice ? ` ${advice}` : ""}`);
116
+ }
117
+ /** The deny used when the gate itself could not run. Named because it is the fail-closed path
118
+ * the whole design turns on, and because every branch below funnels into it. */
119
+ function denyClosed(reason) {
120
+ return deny(
121
+ // The trailing stop is added here rather than at every call site: these reasons are composed
122
+ // from server messages and exception text, and none of them can be relied on to end cleanly.
123
+ `${reason.replace(/[.\s]+$/, "")}.`, "This gate fails closed, so the call was refused rather than allowed. Do not retry it; " +
124
+ "tell the person what you were trying to do and let them approve it at the keyboard.");
125
+ }
126
+ // ─── Timing ──────────────────────────────────────────────────────────────────
127
+ /**
128
+ * How long this process waits before denying, in seconds.
129
+ *
130
+ * An hour, not forever: the handler's own `timeout` would otherwise kill us first, and a
131
+ * killed hook ALLOWS. This number and the `timeout` in settings.json are one setting in two
132
+ * places — see the doc.
133
+ */
134
+ exports.DEFAULT_WAIT_SECONDS = 3600;
135
+ /** What the documented settings.json block must set. Strictly greater than DEFAULT_WAIT_SECONDS,
136
+ * with room for process start-up and the final write, so OUR deadline always lands first. */
137
+ exports.RECOMMENDED_HANDLER_TIMEOUT_SECONDS = 3900;
138
+ /** One HTTP hold. The server clamps to 30; asking for less than it allows leaves headroom for a
139
+ * proxy with its own idle timeout. */
140
+ const POLL_WAIT_SECONDS = 25;
141
+ /** Consecutive transport failures tolerated while WAITING, before denying.
142
+ *
143
+ * Not zero, and not unlimited. The card is already on somebody's phone by then, so denying on a
144
+ * single dropped long-poll is both a bad experience and the fastest way to get this hook turned
145
+ * off; but a network that stays down must still land on a deny, and does — here, or at the
146
+ * deadline, whichever comes first. Failures on CREATE are not retried at all: nothing exists
147
+ * yet, so there is nothing to lose by refusing immediately. */
148
+ const MAX_CONSECUTIVE_POLL_FAILURES = 3;
149
+ const POLL_RETRY_DELAY_MS = 2000;
150
+ /** The card's own limits, mirrored from apps/api/src/lib/cards.ts. Restating them is how they
151
+ * drift, so they are only ever used to TRIM — the server still refuses anything malformed. */
152
+ const QUESTION_MAX = 2000;
153
+ const DETAIL_MAX = 900;
154
+ // ─── Describing what is being approved ───────────────────────────────────────
155
+ function str(value) {
156
+ return typeof value === "string" ? value : "";
157
+ }
158
+ function clip(text, max) {
159
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
160
+ }
161
+ /**
162
+ * What this tool call would actually do, in the words the person on the phone needs.
163
+ *
164
+ * They are deciding from this text alone, on a lock screen, without the transcript. "Bash" is
165
+ * not a decision; `rm -rf /var/www/baychat` is. Tools whose risk lives in one field get that
166
+ * field verbatim; anything unrecognised falls back to its whole input as JSON rather than to a
167
+ * reassuring summary, because an unfamiliar tool is exactly when a person should see everything.
168
+ */
169
+ function describeToolRequest(toolName, toolInput) {
170
+ const input = (toolInput ?? {});
171
+ const detail = (() => {
172
+ switch (toolName) {
173
+ case "Bash":
174
+ case "BashOutput": {
175
+ const command = str(input.command);
176
+ const why = str(input.description);
177
+ return command ? (why ? `${command}\n(${why})` : command) : "";
178
+ }
179
+ case "Write":
180
+ case "Edit":
181
+ case "MultiEdit":
182
+ case "NotebookEdit":
183
+ case "Read":
184
+ return str(input.file_path) || str(input.notebook_path);
185
+ case "Glob":
186
+ case "Grep":
187
+ return [str(input.pattern), str(input.path)].filter(Boolean).join(" in ");
188
+ case "WebFetch":
189
+ return str(input.url);
190
+ case "WebSearch":
191
+ return str(input.query);
192
+ default:
193
+ return "";
194
+ }
195
+ })();
196
+ if (detail)
197
+ return clip(detail, DETAIL_MAX);
198
+ // Unknown tool, or a known one with its field missing: show the raw input. JSON.stringify can
199
+ // throw on a cyclic value, which must not become an exception on the approval path.
200
+ try {
201
+ return clip(JSON.stringify(input), DETAIL_MAX);
202
+ }
203
+ catch {
204
+ return "(its parameters could not be displayed)";
205
+ }
206
+ }
207
+ /** The question the card asks. Blunt on purpose: this is a shell on a server, read on a phone,
208
+ * and the safe answer has to be the obvious one. */
209
+ function composeQuestion(params) {
210
+ const lines = [
211
+ `Claude Code on ${params.host} is asking permission to use ${params.toolName}. ` +
212
+ `This would normally be a prompt in the terminal.`,
213
+ "",
214
+ describeToolRequest(params.toolName, params.toolInput),
215
+ "",
216
+ params.cwd ? `Working directory: ${params.cwd}` : "",
217
+ params.permissionMode ? `Permission mode: ${params.permissionMode}` : "",
218
+ "",
219
+ "Allowing runs it on that machine, now. If you did not expect this, deny.",
220
+ ].filter((l) => l !== undefined);
221
+ return clip(lines.join("\n").replace(/\n{3,}/g, "\n\n").trim(), QUESTION_MAX);
222
+ }
223
+ /** Button labels. "once" is literal — nothing here ever writes a permission rule, so an approval
224
+ * covers this call and no future one. */
225
+ exports.APPROVE_OPTIONS = ["Allow this once", "Deny"];
226
+ /** The ONLY index that means yes. Anything else — a second option, a third, an index the server
227
+ * invented — is a deny, so a mismatch between these labels and the answer can only fail safe. */
228
+ const ALLOW_INDEX = 0;
229
+ function parseArgs(argv) {
230
+ const at = (name) => {
231
+ const i = argv.indexOf(name);
232
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
233
+ };
234
+ const rawTimeout = Number(at("--timeout"));
235
+ return {
236
+ session: at("--session"),
237
+ // A junk --timeout falls back to the default rather than to NaN. NaN compared against a
238
+ // deadline is always false, which would mean "never wait" — a deny, but the wrong one.
239
+ waitSeconds: Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : exports.DEFAULT_WAIT_SECONDS,
240
+ };
241
+ }
242
+ /** How long to ask the server to hold, never past our own deadline. */
243
+ function holdSeconds(remainingMs) {
244
+ return Math.max(1, Math.min(POLL_WAIT_SECONDS, Math.ceil(remainingMs / 1000)));
245
+ }
246
+ /** A refusal the server MEANT — no live session, an ambiguous one, a bad credential, a request
247
+ * that is not ours. Retrying these is pointless, so they deny immediately; a transport failure
248
+ * or a 5xx might be a blip and is retried by the caller. */
249
+ function isDefinite(err) {
250
+ return err instanceof api_1.ApiError && err.status < 500 && err.status !== 429;
251
+ }
252
+ function reasonOf(err) {
253
+ if (err instanceof api_1.ApiError)
254
+ return `${err.message} (HTTP ${err.status})`;
255
+ return err instanceof Error ? err.message : String(err);
256
+ }
257
+ // ─── The decision ────────────────────────────────────────────────────────────
258
+ /**
259
+ * Turn one hook invocation into one decision. NEVER THROWS and never returns undefined: the
260
+ * outer try/catch is the last of several, and exists for the exception nobody thought of.
261
+ *
262
+ * `rawStdin` is the hook's JSON input; `argv` the flags after `approve-hook`.
263
+ */
264
+ async function decideApproval(rawStdin, argv = [], deps = {}) {
265
+ const now = deps.now ?? Date.now;
266
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
267
+ const log = deps.log ?? ((line) => process.stderr.write(`${line}\n`));
268
+ try {
269
+ const opts = parseArgs(argv);
270
+ const deadline = now() + opts.waitSeconds * 1000;
271
+ let input;
272
+ try {
273
+ const parsed = JSON.parse(rawStdin);
274
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
275
+ throw new Error("not an object");
276
+ input = parsed;
277
+ }
278
+ catch {
279
+ // We cannot say what would run, so we cannot ask anyone to approve it.
280
+ return denyClosed("the permission request could not be read from stdin");
281
+ }
282
+ const toolName = str(input.tool_name);
283
+ if (!toolName)
284
+ return denyClosed("the permission request named no tool");
285
+ const device = (deps.loadDevice ?? config_1.loadDeviceCredentials)();
286
+ if (!device) {
287
+ return denyClosed("this machine is not logged in to BayChat (`baychat login`)");
288
+ }
289
+ const question = composeQuestion({
290
+ toolName,
291
+ toolInput: input.tool_input,
292
+ cwd: str(input.cwd),
293
+ host: (deps.hostname ?? os.hostname)(),
294
+ permissionMode: str(input.permission_mode),
295
+ });
296
+ // ── Create. One round trip that also parks for the first window, because the common case
297
+ // is answered within seconds. A failure here is final: no card exists, so there is nothing
298
+ // a retry could rescue and nothing left pending on anyone's phone.
299
+ let reply;
300
+ try {
301
+ reply = await (0, api_1.apiRequest)(device, "POST", "/api/device-api/approvals", {
302
+ session: opts.session,
303
+ question,
304
+ options: [...exports.APPROVE_OPTIONS],
305
+ wait: holdSeconds(deadline - now()),
306
+ });
307
+ }
308
+ catch (err) {
309
+ return denyClosed(`BayChat could not be asked — ${reasonOf(err)}`);
310
+ }
311
+ const requestId = str(reply.requestId);
312
+ if (!requestId)
313
+ return denyClosed("BayChat did not return an approval id");
314
+ // ── Wait. Re-arm across the bounded holds exactly as `await_approval` does: "waiting" is a
315
+ // success meaning nobody has tapped yet, never a lapse and never a no.
316
+ let failures = 0;
317
+ for (;;) {
318
+ const settled = verdictOf(reply, requestId);
319
+ if (settled)
320
+ return settled;
321
+ const remaining = deadline - now();
322
+ if (remaining <= 0) {
323
+ return deny(`nobody answered within ${opts.waitSeconds}s.`, "The decision card is still in BayChat; this call was refused because waiting longer " +
324
+ "would have let the hook time out, and a timed-out hook silently ALLOWS.");
325
+ }
326
+ try {
327
+ reply = await (0, api_1.apiRequest)(device, "GET", `/api/device-api/approvals/${encodeURIComponent(requestId)}?wait=${holdSeconds(remaining)}`);
328
+ failures = 0;
329
+ }
330
+ catch (err) {
331
+ if (isDefinite(err)) {
332
+ return denyClosed(`BayChat refused the wait — ${reasonOf(err)}`);
333
+ }
334
+ failures += 1;
335
+ log(`baychat approve-hook: waiting failed (${reasonOf(err)}) — attempt ${failures}`);
336
+ if (failures >= MAX_CONSECUTIVE_POLL_FAILURES) {
337
+ return denyClosed(`BayChat became unreachable while waiting — ${reasonOf(err)}`);
338
+ }
339
+ await sleep(POLL_RETRY_DELAY_MS);
340
+ }
341
+ }
342
+ }
343
+ catch (err) {
344
+ // The catch that makes the guarantee true. Anything at all — a thrown non-Error, an OOM in a
345
+ // dependency, a bug three refactors from now — lands here as a deny rather than as a crash,
346
+ // and a crash is an ALLOW.
347
+ log(`baychat approve-hook: unexpected failure (${reasonOf(err)})`);
348
+ return denyClosed("the approval gate hit an unexpected error");
349
+ }
350
+ }
351
+ /**
352
+ * Read one server reply as a verdict, or null to keep waiting.
353
+ *
354
+ * ONLY `status === "answered"` WITH `answerIndex === 0` ALLOWS. Every other combination — a
355
+ * cancelled card, an index we did not offer, a status this client has never heard of, a missing
356
+ * field — is a deny. That asymmetry is the point: a future server change can make this hook
357
+ * over-refuse, and can never make it over-permit.
358
+ */
359
+ function verdictOf(reply, requestId) {
360
+ const status = str(reply.status);
361
+ if (status === "waiting")
362
+ return null;
363
+ if (status === "answered") {
364
+ if (reply.answerIndex === ALLOW_INDEX)
365
+ return allowDecision();
366
+ return deny(`answered "${reply.answer ?? exports.APPROVE_OPTIONS[1]}" in BayChat.`);
367
+ }
368
+ if (status === "cancelled") {
369
+ return deny("the decision was withdrawn in BayChat without an answer.");
370
+ }
371
+ return denyClosed(`BayChat answered with a status this client does not understand ("${status}") for ${requestId}`);
372
+ }
373
+ // ─── stdin ───────────────────────────────────────────────────────────────────
374
+ /**
375
+ * The hook's JSON input.
376
+ *
377
+ * Bounded, because a stdin that never closes would stall us until the handler timeout kills the
378
+ * process — and being killed ALLOWS. On timeout we return what arrived (usually nothing), which
379
+ * fails to parse, which denies.
380
+ */
381
+ function readStdin(stream = process.stdin, timeoutMs = 10_000) {
382
+ return new Promise((resolve) => {
383
+ let text = "";
384
+ let done = false;
385
+ const finish = () => {
386
+ if (done)
387
+ return;
388
+ done = true;
389
+ clearTimeout(timer);
390
+ resolve(text);
391
+ };
392
+ const timer = setTimeout(finish, timeoutMs);
393
+ // Do not hold the process open on the timer alone.
394
+ if (typeof timer.unref === "function")
395
+ timer.unref();
396
+ stream.setEncoding?.("utf8");
397
+ stream.on("data", (chunk) => {
398
+ text += typeof chunk === "string" ? chunk : chunk.toString("utf8");
399
+ });
400
+ stream.on("end", finish);
401
+ stream.on("error", finish);
402
+ });
403
+ }
404
+ // ─── The command ─────────────────────────────────────────────────────────────
405
+ /**
406
+ * `baychat approve-hook [--session <name>] [--timeout <seconds>]`
407
+ *
408
+ * Always returns 0. A non-zero exit is a non-blocking error to Claude Code, which means the tool
409
+ * call PROCEEDS — so exiting 1 on failure would be precisely backwards.
410
+ */
411
+ async function cmdApproveHook(argv = [], stdin = process.stdin) {
412
+ let decision;
413
+ try {
414
+ decision = await decideApproval(await readStdin(stdin), argv);
415
+ }
416
+ catch (err) {
417
+ // decideApproval does not throw; readStdin does not reject. This is here anyway, because the
418
+ // cost of being wrong about that is an allow.
419
+ process.stderr.write(`baychat approve-hook: ${err instanceof Error ? err.message : String(err)}\n`);
420
+ decision = denyClosed("the approval gate could not run");
421
+ }
422
+ // The single write to stdout, and the only one in this file.
423
+ process.stdout.write(`${JSON.stringify(decision)}\n`);
424
+ return 0;
425
+ }