baychat 0.29.9 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.29.9",
3
+ "version": "0.30.0",
4
4
  "displayName": "BayChat",
5
5
  "description": "Wakes this Claude Code session the moment a BayChat message arrives: no Monitor, no 30-minute deadline. Uses the baychat CLI already on this computer (npm install -g baychat, then baychat login).",
6
6
  "author": {
package/README.md CHANGED
@@ -61,8 +61,8 @@ $baychat --update # Codex updates BayChat from the session
61
61
  Use the exact group title (or the group's id, which `list_groups` prints); a missing
62
62
  group is created when requested by title this way, never by id.
63
63
  An existing group requires membership and admin rights. The command creates or
64
- reuses the agent and session together, joins the group, and retains a private
65
- chat with you. Automatic names, such as `Codex-7c3a912b84d2`, come from a verified
64
+ reuses the agent and session together and joins the group. It creates no private
65
+ chat with you (start one from the app, or use `--private`). Automatic names, such as `Codex-7c3a912b84d2`, come from a verified
66
66
  native session identity; retries reuse the name. If that identity cannot be
67
67
  verified, supply a name. A bare command lists sessions.
68
68
 
@@ -308,8 +308,8 @@ skill as well as a missing one.
308
308
  ### Inside an agent
309
309
 
310
310
  ```
311
- Claude Code /baychat Session-A Sessions + private chat
312
- /baychat Session-A "Design Review" that room + private chat
311
+ Claude Code /baychat Session-A the Sessions group
312
+ /baychat Session-A "Design Review" that room
313
313
  /baychat list sessions, join nothing
314
314
 
315
315
  Codex $baychat Session-B no slash commands; a $ name
@@ -318,8 +318,8 @@ Codex $baychat Session-B no slash commands; a $ nam
318
318
  Cursor no command — ask it: "join BayChat as Session-C"
319
319
  ```
320
320
 
321
- Naming a group retains the private owner chat under the same agent and session.
322
- Use `--private` when only the private chat is wanted.
321
+ A group or Sessions join creates no private owner chat; one you already have stays
322
+ under the same agent and session. Use `--private` for the private chat.
323
323
 
324
324
  ### Upgrading, in order
325
325
 
@@ -992,7 +992,7 @@ session and group tools:
992
992
 
993
993
  | Tool | What it does |
994
994
  |------|--------------|
995
- | `join_session` | Name this terminal and put it in a room. `{ session }` opens a 1:1 with you; `{ session, group }` joins that group and retains the same agent's private chat |
995
+ | `join_session` | Name this terminal and put it in a room. `{ session }` opens a 1:1 with you; `{ session, group }` joins that group (no private chat is created) |
996
996
  | `list_sessions` | This login's sessions — name, live/idle, last seen |
997
997
  | `end_session` | Park a session. The chat and its history survive; rejoining the same name revives it |
998
998
  | `list_groups` | The groups you are in — exact title, who is in them, and the conversation id |
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.askOwnerAndWait = askOwnerAndWait;
4
+ exports.withdrawOwnerQuestion = withdrawOwnerQuestion;
5
+ exports.reasonOf = reasonOf;
6
+ // Asking this machine's owner a decision card, and waiting for the tap.
7
+ //
8
+ // Shared by the two Claude Code hooks that move a question off the terminal and onto a phone:
9
+ // `approve-hook` (a permission prompt) and `denied-hook` (an auto-mode denial). Both talk to
10
+ // `POST /api/device-api/approvals` with the device credential, both park on the same bounded
11
+ // long-poll, and both must read "waiting" as "nobody has tapped yet" — never a yes, never a no.
12
+ //
13
+ // WHAT THIS MODULE DOES NOT DO: decide. It reports what happened (`OwnerAnswer`) and leaves the
14
+ // meaning to the caller, because the two hooks fail in opposite directions — a PermissionRequest
15
+ // hook that prints nothing ALLOWS, a PermissionDenied hook that prints nothing leaves the denial
16
+ // standing. Each caller turns every outcome below into its own fail-closed answer.
17
+ const api_1 = require("./api");
18
+ /** One HTTP hold. The server clamps to 30; asking for less than it allows leaves headroom for a
19
+ * proxy with its own idle timeout. */
20
+ const POLL_WAIT_SECONDS = 25;
21
+ /** Consecutive transport failures tolerated while WAITING before giving up.
22
+ *
23
+ * Not zero, and not unlimited. The card is already on somebody's phone by then, so giving up on a
24
+ * single dropped long-poll is both a bad experience and the fastest way to get a hook turned off;
25
+ * but a network that stays down must still end the wait, and does — here, or at the deadline,
26
+ * whichever comes first. Failures on CREATE are not retried at all: nothing exists yet, so there
27
+ * is nothing to lose by refusing immediately. */
28
+ const MAX_CONSECUTIVE_POLL_FAILURES = 3;
29
+ const POLL_RETRY_DELAY_MS = 2000;
30
+ /**
31
+ * Put the card in the private chat between the session and its owner, and wait until somebody
32
+ * answers it, it is withdrawn, BayChat stops answering, or `deadlineMs` (epoch ms) passes.
33
+ * NEVER THROWS.
34
+ *
35
+ * `direct: true` sends the card to that private chat, never to a group: the question is about one
36
+ * person's terminal. It also stops the server from guessing a room (a session in a group AND its
37
+ * owner's private chat used to get 409 APPROVAL_ROOM_AMBIGUOUS).
38
+ *
39
+ * `label` prefixes the stderr lines, so a person reading a log can tell which hook wrote them.
40
+ */
41
+ async function askOwnerAndWait(device, ask, deadlineMs, deps, label) {
42
+ try {
43
+ // Create. One round trip that also parks for the first window, because the common case is
44
+ // answered within seconds. A failure here is final: no card exists, so a retry could rescue
45
+ // nothing and nothing is left pending on anyone's phone.
46
+ let reply;
47
+ try {
48
+ reply = await (0, api_1.apiRequest)(device, "POST", "/api/device-api/approvals", {
49
+ session: ask.session,
50
+ question: ask.question,
51
+ options: ask.options,
52
+ ...(ask.guardedOptions?.length ? { guardedOptions: ask.guardedOptions } : {}),
53
+ direct: true,
54
+ wait: holdSeconds(deadlineMs - deps.now()),
55
+ });
56
+ }
57
+ catch (err) {
58
+ return { kind: "failed", reason: `BayChat could not be asked — ${reasonOf(err)}` };
59
+ }
60
+ const requestId = typeof reply.requestId === "string" ? reply.requestId : "";
61
+ if (!requestId)
62
+ return { kind: "failed", reason: "BayChat did not return an approval id" };
63
+ // Wait. Re-arm across the bounded holds exactly as `await_approval` does.
64
+ let failures = 0;
65
+ for (;;) {
66
+ const status = typeof reply.status === "string" ? reply.status : "";
67
+ if (status === "answered") {
68
+ return {
69
+ kind: "answered",
70
+ requestId,
71
+ index: typeof reply.answerIndex === "number" ? reply.answerIndex : null,
72
+ answer: typeof reply.answer === "string" ? reply.answer : null,
73
+ };
74
+ }
75
+ if (status === "cancelled")
76
+ return { kind: "cancelled", requestId };
77
+ if (status !== "waiting")
78
+ return { kind: "unrecognised", requestId, status };
79
+ const remaining = deadlineMs - deps.now();
80
+ if (remaining <= 0)
81
+ return { kind: "deadline", requestId };
82
+ try {
83
+ reply = await (0, api_1.apiRequest)(device, "GET", `/api/device-api/approvals/${encodeURIComponent(requestId)}?wait=${holdSeconds(remaining)}`);
84
+ failures = 0;
85
+ }
86
+ catch (err) {
87
+ if (isDefinite(err))
88
+ return { kind: "failed", reason: `BayChat refused the wait — ${reasonOf(err)}` };
89
+ failures += 1;
90
+ deps.log(`${label}: waiting failed (${reasonOf(err)}) — attempt ${failures}`);
91
+ if (failures >= MAX_CONSECUTIVE_POLL_FAILURES) {
92
+ return { kind: "failed", reason: `BayChat became unreachable while waiting — ${reasonOf(err)}` };
93
+ }
94
+ await deps.sleep(POLL_RETRY_DELAY_MS);
95
+ }
96
+ }
97
+ }
98
+ catch (err) {
99
+ // The exception nobody predicted. Reported, never thrown: both callers must still print their
100
+ // fail-closed answer.
101
+ return { kind: "failed", reason: `waiting for BayChat failed unexpectedly — ${reasonOf(err)}` };
102
+ }
103
+ }
104
+ /**
105
+ * Take back a card nobody will act on any more (the waiting process is about to give up). Best
106
+ * effort and NEVER THROWS: a card left pending is harmless — the answer would reach nobody — so
107
+ * a failure here is only logged.
108
+ */
109
+ async function withdrawOwnerQuestion(device, requestId, log) {
110
+ try {
111
+ await (0, api_1.apiRequest)(device, "POST", `/api/device-api/approvals/${encodeURIComponent(requestId)}/withdraw`, {});
112
+ }
113
+ catch (err) {
114
+ log(`could not withdraw the card ${requestId} (${reasonOf(err)})`);
115
+ }
116
+ }
117
+ /** How long to ask the server to hold, never past our own deadline. */
118
+ function holdSeconds(remainingMs) {
119
+ return Math.max(1, Math.min(POLL_WAIT_SECONDS, Math.ceil(remainingMs / 1000)));
120
+ }
121
+ /** A refusal the server MEANT — no live session, an ambiguous one, a bad credential, a request
122
+ * that is not ours. Retrying these is pointless; a transport failure or a 5xx might be a blip. */
123
+ function isDefinite(err) {
124
+ return err instanceof api_1.ApiError && err.status < 500 && err.status !== 429;
125
+ }
126
+ function reasonOf(err) {
127
+ if (err instanceof api_1.ApiError)
128
+ return `${err.message} (HTTP ${err.status})`;
129
+ return err instanceof Error ? err.message : String(err);
130
+ }
@@ -33,13 +33,12 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.MAX_CLAUDE_HOPS = exports.APPROVE_OPTIONS = exports.RECOMMENDED_HANDLER_TIMEOUT_SECONDS = exports.DEFAULT_WAIT_SECONDS = void 0;
36
+ exports.MAX_CLAUDE_HOPS = exports.readStdin = exports.APPROVE_OPTIONS = exports.RECOMMENDED_HANDLER_TIMEOUT_SECONDS = exports.DEFAULT_WAIT_SECONDS = void 0;
37
37
  exports.allowDecision = allowDecision;
38
38
  exports.denyDecision = denyDecision;
39
39
  exports.describeToolRequest = describeToolRequest;
40
40
  exports.composeQuestion = composeQuestion;
41
41
  exports.decideApproval = decideApproval;
42
- exports.readStdin = readStdin;
43
42
  exports.sessionFromClaude = sessionFromClaude;
44
43
  exports.parseEtime = parseEtime;
45
44
  exports.decideApprovalOrDefer = decideApprovalOrDefer;
@@ -95,8 +94,9 @@ exports.cmdApproveHook = cmdApproveHook;
95
94
  // exists solely because the alternative (being killed by the handler timeout) would allow.
96
95
  const child_process_1 = require("child_process");
97
96
  const os = __importStar(require("os"));
98
- const api_1 = require("./api");
97
+ const approval_wait_1 = require("./approval-wait");
99
98
  const config_1 = require("./config");
99
+ const hook_stdin_1 = require("./hook-stdin");
100
100
  const claude_link_1 = require("./channel/claude-link");
101
101
  function allowDecision() {
102
102
  return {
@@ -148,18 +148,6 @@ exports.DEFAULT_WAIT_SECONDS = 3600;
148
148
  /** What the documented settings.json block must set. Strictly greater than DEFAULT_WAIT_SECONDS,
149
149
  * with room for process start-up and the final write, so OUR deadline always lands first. */
150
150
  exports.RECOMMENDED_HANDLER_TIMEOUT_SECONDS = 3900;
151
- /** One HTTP hold. The server clamps to 30; asking for less than it allows leaves headroom for a
152
- * proxy with its own idle timeout. */
153
- const POLL_WAIT_SECONDS = 25;
154
- /** Consecutive transport failures tolerated while WAITING, before denying.
155
- *
156
- * Not zero, and not unlimited. The card is already on somebody's phone by then, so denying on a
157
- * single dropped long-poll is both a bad experience and the fastest way to get this hook turned
158
- * off; but a network that stays down must still land on a deny, and does — here, or at the
159
- * deadline, whichever comes first. Failures on CREATE are not retried at all: nothing exists
160
- * yet, so there is nothing to lose by refusing immediately. */
161
- const MAX_CONSECUTIVE_POLL_FAILURES = 3;
162
- const POLL_RETRY_DELAY_MS = 2000;
163
151
  /** The card's own limits, mirrored from apps/api/src/lib/cards.ts. Restating them is how they
164
152
  * drift, so they are only ever used to TRIM — the server still refuses anything malformed. */
165
153
  const QUESTION_MAX = 2000;
@@ -255,21 +243,6 @@ function parseArgs(argv) {
255
243
  waitSeconds: Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : exports.DEFAULT_WAIT_SECONDS,
256
244
  };
257
245
  }
258
- /** How long to ask the server to hold, never past our own deadline. */
259
- function holdSeconds(remainingMs) {
260
- return Math.max(1, Math.min(POLL_WAIT_SECONDS, Math.ceil(remainingMs / 1000)));
261
- }
262
- /** A refusal the server MEANT — no live session, an ambiguous one, a bad credential, a request
263
- * that is not ours. Retrying these is pointless, so they deny immediately; a transport failure
264
- * or a 5xx might be a blip and is retried by the caller. */
265
- function isDefinite(err) {
266
- return err instanceof api_1.ApiError && err.status < 500 && err.status !== 429;
267
- }
268
- function reasonOf(err) {
269
- if (err instanceof api_1.ApiError)
270
- return `${err.message} (HTTP ${err.status})`;
271
- return err instanceof Error ? err.message : String(err);
272
- }
273
246
  // ─── The decision ────────────────────────────────────────────────────────────
274
247
  /**
275
248
  * Turn one hook invocation into one decision. NEVER THROWS and never returns undefined: the
@@ -309,121 +282,49 @@ async function decideApproval(rawStdin, argv = [], deps = {}) {
309
282
  host: (deps.hostname ?? os.hostname)(),
310
283
  permissionMode: str(input.permission_mode),
311
284
  });
312
- // ── Create. One round trip that also parks for the first window, because the common case
313
- // is answered within seconds. A failure here is final: no card exists, so there is nothing
314
- // a retry could rescue and nothing left pending on anyone's phone.
315
- //
316
- // `direct: true` sends the card to the private chat between this session and its owner,
317
- // never to a group. A prompt about one person's terminal belongs to that person. It also
318
- // stops the server from guessing a room: a session in a group AND its owner's private chat
319
- // used to get 409 APPROVAL_ROOM_AMBIGUOUS, which denied every prompt with no card shown.
320
- // The Claude Code channel's relay (channel/permission.ts) asks the same way.
321
- let reply;
322
- try {
323
- reply = await (0, api_1.apiRequest)(device, "POST", "/api/device-api/approvals", {
324
- session: opts.session,
325
- question,
326
- options: [...exports.APPROVE_OPTIONS],
327
- direct: true,
328
- wait: holdSeconds(deadline - now()),
329
- });
330
- }
331
- catch (err) {
332
- return denyClosed(`BayChat could not be asked — ${reasonOf(err)}`);
333
- }
334
- const requestId = str(reply.requestId);
335
- if (!requestId)
336
- return denyClosed("BayChat did not return an approval id");
337
- // ── Wait. Re-arm across the bounded holds exactly as `await_approval` does: "waiting" is a
338
- // success meaning nobody has tapped yet, never a lapse and never a no.
339
- let failures = 0;
340
- for (;;) {
341
- const settled = verdictOf(reply, requestId);
342
- if (settled)
343
- return settled;
344
- const remaining = deadline - now();
345
- if (remaining <= 0) {
346
- return deny(`nobody answered within ${opts.waitSeconds}s.`, "The decision card is still in BayChat; this call was refused because waiting longer " +
347
- "would have let the hook time out, and a timed-out hook silently ALLOWS.");
348
- }
349
- try {
350
- reply = await (0, api_1.apiRequest)(device, "GET", `/api/device-api/approvals/${encodeURIComponent(requestId)}?wait=${holdSeconds(remaining)}`);
351
- failures = 0;
352
- }
353
- catch (err) {
354
- if (isDefinite(err)) {
355
- return denyClosed(`BayChat refused the wait — ${reasonOf(err)}`);
356
- }
357
- failures += 1;
358
- log(`baychat approve-hook: waiting failed (${reasonOf(err)}) — attempt ${failures}`);
359
- if (failures >= MAX_CONSECUTIVE_POLL_FAILURES) {
360
- return denyClosed(`BayChat became unreachable while waiting — ${reasonOf(err)}`);
361
- }
362
- await sleep(POLL_RETRY_DELAY_MS);
363
- }
364
- }
285
+ // The card goes to the private chat between this session and its owner, and the wait
286
+ // re-arms across bounded holds until somebody taps (approval-wait.ts). Every outcome that is
287
+ // not "answered with index 0" becomes a deny below.
288
+ const outcome = await (0, approval_wait_1.askOwnerAndWait)(device, { session: opts.session, question, options: [...exports.APPROVE_OPTIONS] }, deadline, { now, sleep, log }, "baychat approve-hook");
289
+ return verdictOf(outcome, opts.waitSeconds);
365
290
  }
366
291
  catch (err) {
367
292
  // The catch that makes the guarantee true. Anything at all — a thrown non-Error, an OOM in a
368
293
  // dependency, a bug three refactors from now — lands here as a deny rather than as a crash,
369
294
  // and a crash is an ALLOW.
370
- log(`baychat approve-hook: unexpected failure (${reasonOf(err)})`);
295
+ log(`baychat approve-hook: unexpected failure (${(0, approval_wait_1.reasonOf)(err)})`);
371
296
  return denyClosed("the approval gate hit an unexpected error");
372
297
  }
373
298
  }
374
299
  /**
375
- * Read one server reply as a verdict, or null to keep waiting.
300
+ * Turn what happened into the decision.
376
301
  *
377
- * ONLY `status === "answered"` WITH `answerIndex === 0` ALLOWS. Every other combination — a
378
- * cancelled card, an index we did not offer, a status this client has never heard of, a missing
379
- * field — is a deny. That asymmetry is the point: a future server change can make this hook
380
- * over-refuse, and can never make it over-permit.
302
+ * ONLY `answered` WITH index 0 ALLOWS. Every other combination — a cancelled card, an index we
303
+ * did not offer, a status this client has never heard of, a missing field — is a deny. That
304
+ * asymmetry is the point: a future server change can make this hook over-refuse, and can never
305
+ * make it over-permit.
381
306
  */
382
- function verdictOf(reply, requestId) {
383
- const status = str(reply.status);
384
- if (status === "waiting")
385
- return null;
386
- if (status === "answered") {
387
- if (reply.answerIndex === ALLOW_INDEX)
388
- return allowDecision();
389
- return deny(`answered "${reply.answer ?? exports.APPROVE_OPTIONS[1]}" in BayChat.`);
390
- }
391
- if (status === "cancelled") {
392
- return deny("the decision was withdrawn in BayChat without an answer.");
307
+ function verdictOf(outcome, waitSeconds) {
308
+ switch (outcome.kind) {
309
+ case "answered":
310
+ if (outcome.index === ALLOW_INDEX)
311
+ return allowDecision();
312
+ return deny(`answered "${outcome.answer ?? exports.APPROVE_OPTIONS[1]}" in BayChat.`);
313
+ case "cancelled":
314
+ return deny("the decision was withdrawn in BayChat without an answer.");
315
+ case "deadline":
316
+ return deny(`nobody answered within ${waitSeconds}s.`, "The decision card is still in BayChat; this call was refused because waiting longer " +
317
+ "would have let the hook time out, and a timed-out hook silently ALLOWS.");
318
+ case "failed":
319
+ return denyClosed(outcome.reason);
320
+ case "unrecognised":
321
+ return denyClosed(`BayChat answered with a status this client does not understand ("${outcome.status}") for ${outcome.requestId}`);
393
322
  }
394
- return denyClosed(`BayChat answered with a status this client does not understand ("${status}") for ${requestId}`);
395
323
  }
396
324
  // ─── stdin ───────────────────────────────────────────────────────────────────
397
- /**
398
- * The hook's JSON input.
399
- *
400
- * Bounded, because a stdin that never closes would stall us until the handler timeout kills the
401
- * process — and being killed ALLOWS. On timeout we return what arrived (usually nothing), which
402
- * fails to parse, which denies.
403
- */
404
- function readStdin(stream = process.stdin, timeoutMs = 10_000) {
405
- return new Promise((resolve) => {
406
- let text = "";
407
- let done = false;
408
- const finish = () => {
409
- if (done)
410
- return;
411
- done = true;
412
- clearTimeout(timer);
413
- resolve(text);
414
- };
415
- const timer = setTimeout(finish, timeoutMs);
416
- // Do not hold the process open on the timer alone.
417
- if (typeof timer.unref === "function")
418
- timer.unref();
419
- stream.setEncoding?.("utf8");
420
- stream.on("data", (chunk) => {
421
- text += typeof chunk === "string" ? chunk : chunk.toString("utf8");
422
- });
423
- stream.on("end", finish);
424
- stream.on("error", finish);
425
- });
426
- }
325
+ // The bounded stdin read lives in hook-stdin.ts, shared with the denied-command hooks.
326
+ var hook_stdin_2 = require("./hook-stdin");
327
+ Object.defineProperty(exports, "readStdin", { enumerable: true, get: function () { return hook_stdin_2.readStdin; } });
427
328
  // ─── The command ─────────────────────────────────────────────────────────────
428
329
  // ─── --session-from-claude ───────────────────────────────────────────────────
429
330
  /** How many ancestors of the hook process are searched for the Claude Code that ran it. The hook
@@ -508,7 +409,7 @@ async function decideApprovalOrDefer(rawStdin, argv = [], deps = {}) {
508
409
  async function cmdApproveHook(argv = [], stdin = process.stdin) {
509
410
  let decision;
510
411
  try {
511
- decision = await decideApprovalOrDefer(await readStdin(stdin), argv);
412
+ decision = await decideApprovalOrDefer(await (0, hook_stdin_1.readStdin)(stdin), argv);
512
413
  }
513
414
  catch (err) {
514
415
  // decideApproval does not throw; readStdin does not reject. This is here anyway, because the
package/dist/cli-help.js CHANGED
@@ -159,6 +159,17 @@ Usage:
159
159
  docs/features/REMOTE_APPROVAL_HOOK.md before
160
160
  switching it on, because it moves the last line
161
161
  between an agent and this machine onto a phone
162
+ baychat denied-hook [--session <name> | --session-from-claude] [--timeout <sec>]
163
+ Claude Code PermissionDenied hook: when auto
164
+ mode blocks a call, put a card in BayChat; on
165
+ your yes, write that exact allow rule to the
166
+ project's .claude/settings.local.json and let
167
+ the session retry. Prints the retry or nothing;
168
+ every error leaves the denial standing
169
+ baychat permissions [list | remove <id> | on | off]
170
+ The denied-command card: on/off for sessions
171
+ BayChat starts (OFF by default), and the rules
172
+ it kept. See docs/features/DENIED_COMMAND_CARD.md
162
173
  baychat relay attach --session <name> [--runtime claude|codex|hermes]
163
174
  [--resume-id <id>] [--timeout <sec>]
164
175
  Register this session with the relay and block
@@ -206,10 +217,21 @@ function helpRequest(command, args) {
206
217
  * Commands whose arguments belong to someone else, so `--help` among them is data:
207
218
  * - `claude` passes every argument to Claude Code — `baychat claude --help` is Claude Code's help;
208
219
  * - `send` and `search` take free text, which may say "--help";
209
- * - `approve-hook` must write exactly one decision object to stdout, whatever it is given;
220
+ * - `approve-hook` must write exactly one decision object to stdout, whatever it is given, and
221
+ * `denied-hook` / `denied-after` must never print help where Claude Code reads a hook's output;
210
222
  * - `help` (and its spellings) already prints help, and its arguments are a question.
211
223
  */
212
- const ARGS_ARE_NOT_OURS = new Set(["claude", "send", "search", "approve-hook", "help", "--help", "-h"]);
224
+ const ARGS_ARE_NOT_OURS = new Set([
225
+ "claude",
226
+ "send",
227
+ "search",
228
+ "approve-hook",
229
+ "denied-hook",
230
+ "denied-after",
231
+ "help",
232
+ "--help",
233
+ "-h",
234
+ ]);
213
235
  /** Commands with a usage of their own, fuller than their lines in `HELP`. */
214
236
  const OWN_USAGE = { login: exports.LOGIN_HELP, bays: bays_command_1.BAYS_USAGE, launch: command_1.LAUNCH_USAGE };
215
237
  /**
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.afterCall = afterCall;
4
+ exports.cmdDeniedAfter = cmdDeniedAfter;
5
+ // `baychat denied-after` — the PostToolUse / PostToolUseFailure hook that takes an "Allow once"
6
+ // rule back out as soon as the retried call has run.
7
+ //
8
+ // It is installed beside `denied-hook` (relay/launcher/approval-settings.ts) as an ASYNC hook:
9
+ // Claude Code starts it and moves on, so it adds nothing to any tool call's latency, and it prints
10
+ // nothing. It runs on every matching tool call, so it is routed by index.ts without loading the
11
+ // CLI, and it returns at once when no once-grant exists — the common case by far.
12
+ //
13
+ // The TTL sweep (grants.ts, ONCE_TTL_MS) is the backstop for a retried call that never finishes,
14
+ // and every run here sweeps too.
15
+ const hook_stdin_1 = require("../hook-stdin");
16
+ const grants_1 = require("./grants");
17
+ const permission_rule_1 = require("./permission-rule");
18
+ /** Handle one finished call. NEVER THROWS; diagnostics go to stderr. */
19
+ async function afterCall(rawStdin, log = defaultLog) {
20
+ try {
21
+ if (!(0, grants_1.hasOnceGrants)())
22
+ return;
23
+ const input = JSON.parse(rawStdin);
24
+ const call = typeof input.tool_name === "string" ? (0, permission_rule_1.callKeyOf)(input.tool_name, input.tool_input) : undefined;
25
+ if (call && typeof input.session_id === "string")
26
+ await (0, grants_1.consumeOnceGrants)(input.session_id, call);
27
+ await (0, grants_1.sweepExpiredGrants)();
28
+ }
29
+ catch (err) {
30
+ log(`baychat denied-after: ${err instanceof Error ? err.message : String(err)}`);
31
+ }
32
+ }
33
+ function defaultLog(line) {
34
+ process.stderr.write(`${line}\n`);
35
+ }
36
+ /** The command. Always 0: nothing this hook could say would change the call that already ran. */
37
+ async function cmdDeniedAfter(stdin = process.stdin) {
38
+ await afterCall(await (0, hook_stdin_1.readStdin)(stdin, 5_000));
39
+ return 0;
40
+ }