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.
@@ -0,0 +1,150 @@
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.TRANSCRIPT_WAIT_MS = void 0;
37
+ exports.verifyDeniedCall = verifyDeniedCall;
38
+ exports.findToolUse = findToolUse;
39
+ // Proof that a denial really happened: the denied call must be in the session's own transcript.
40
+ //
41
+ // WHY. `baychat denied-hook` reads its event from stdin, and stdin is something anybody can
42
+ // write — including the agent, by running `echo '{…}' | baychat denied-hook` itself. A forged
43
+ // event would put a card on the owner's phone claiming a block that never occurred. So before
44
+ // anything is posted, the call the event names (`tool_use_id`, `tool_name`, and the command or
45
+ // target) must appear as a tool call the MODEL made, in the transcript Claude Code keeps under
46
+ // `~/.claude/projects/` — a protected path the agent cannot write without a prompt.
47
+ //
48
+ // It is defence in depth, not the boundary: the owner's answer is. A card the agent somehow did
49
+ // cause still writes nothing unless the owner taps a yes.
50
+ //
51
+ // THE TRANSCRIPT CAN LAG. The hooks reference says it "is written asynchronously and may lag the
52
+ // in-memory conversation", so a missing call is polled for, briefly, before it counts as absent.
53
+ // For a subagent's call the hook still gets the MAIN transcript (with `agent_id`); its calls are
54
+ // in `<session>/subagents/agent-<id>.jsonl` beside it.
55
+ const fs = __importStar(require("fs"));
56
+ const os = __importStar(require("os"));
57
+ const path = __importStar(require("path"));
58
+ const permission_rule_1 = require("./permission-rule");
59
+ /** How long a call may take to reach the transcript before it counts as never made. */
60
+ exports.TRANSCRIPT_WAIT_MS = 10_000;
61
+ const TRANSCRIPT_POLL_MS = 250;
62
+ /** Only the end of the file is read: the denied call is the newest thing in it. */
63
+ const TAIL_BYTES = 4 * 1024 * 1024;
64
+ /** Did the model really make this call in this session? NEVER THROWS. */
65
+ async function verifyDeniedCall(ref, deps = {}) {
66
+ const now = deps.now ?? Date.now;
67
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
68
+ const realpath = deps.realpath ?? ((f) => fs.realpathSync(f));
69
+ const readTail = deps.readTail ?? readFileTail;
70
+ try {
71
+ const projects = realpath(path.join(deps.claudeDir ?? process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"), "projects"));
72
+ const wanted = (0, permission_rule_1.callKeyOf)(ref.toolName, ref.toolInput);
73
+ if (!ref.toolUseId || !wanted)
74
+ return { ok: false, why: "the event names no call that can be checked" };
75
+ if (!path.isAbsolute(ref.transcriptPath) || !ref.transcriptPath.endsWith(".jsonl")) {
76
+ return { ok: false, why: "the event carries no transcript" };
77
+ }
78
+ const file = ref.agentId
79
+ ? path.join(ref.transcriptPath.slice(0, -".jsonl".length), "subagents", `${ref.agentId.startsWith("agent-") ? ref.agentId : `agent-${ref.agentId}`}.jsonl`)
80
+ : ref.transcriptPath;
81
+ const giveUpAt = now() + exports.TRANSCRIPT_WAIT_MS;
82
+ for (;;) {
83
+ let real;
84
+ try {
85
+ real = realpath(file);
86
+ }
87
+ catch {
88
+ real = undefined; // not written yet
89
+ }
90
+ if (real !== undefined && !real.startsWith(projects + path.sep)) {
91
+ return { ok: false, why: "the transcript is not in Claude Code's own folder" };
92
+ }
93
+ const tail = real === undefined ? undefined : readTail(real);
94
+ const found = tail === undefined ? undefined : findToolUse(tail, ref.toolUseId);
95
+ if (found) {
96
+ const key = (0, permission_rule_1.callKeyOf)(found.name, found.input);
97
+ return found.name === ref.toolName && key !== undefined && (0, permission_rule_1.sameCall)(key, wanted)
98
+ ? { ok: true }
99
+ : { ok: false, why: "the transcript's call is not the one the event names" };
100
+ }
101
+ if (now() >= giveUpAt)
102
+ return { ok: false, why: "the call is not in the session's transcript" };
103
+ await sleep(TRANSCRIPT_POLL_MS);
104
+ }
105
+ }
106
+ catch (err) {
107
+ return { ok: false, why: `the transcript could not be read (${err instanceof Error ? err.message : String(err)})` };
108
+ }
109
+ }
110
+ /** The model's tool call with this id, from transcript JSONL text. */
111
+ function findToolUse(text, toolUseId) {
112
+ for (const line of text.split("\n")) {
113
+ if (!line.includes(toolUseId))
114
+ continue;
115
+ let entry;
116
+ try {
117
+ entry = JSON.parse(line);
118
+ }
119
+ catch {
120
+ continue; // the cut-off first line of a tail, or a line being written
121
+ }
122
+ const e = entry;
123
+ if (e.type !== "assistant" || !Array.isArray(e.message?.content))
124
+ continue;
125
+ for (const block of e.message.content) {
126
+ if (block?.type === "tool_use" && block.id === toolUseId && typeof block.name === "string") {
127
+ return { name: block.name, input: block.input };
128
+ }
129
+ }
130
+ }
131
+ return undefined;
132
+ }
133
+ function readFileTail(file) {
134
+ let fd;
135
+ try {
136
+ fd = fs.openSync(file, "r");
137
+ const size = fs.fstatSync(fd).size;
138
+ const length = Math.min(size, TAIL_BYTES);
139
+ const buf = Buffer.alloc(length);
140
+ fs.readSync(fd, buf, 0, length, size - length);
141
+ return buf.toString("utf8");
142
+ }
143
+ catch {
144
+ return undefined;
145
+ }
146
+ finally {
147
+ if (fd !== undefined)
148
+ fs.closeSync(fd);
149
+ }
150
+ }
@@ -0,0 +1,329 @@
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.RELOAD_SETTLE_MS = void 0;
37
+ exports.retryOutput = retryOutput;
38
+ exports.composeDeniedQuestion = composeDeniedQuestion;
39
+ exports.offeredMeanings = offeredMeanings;
40
+ exports.decideDenied = decideDenied;
41
+ exports.cmdDeniedHook = cmdDeniedHook;
42
+ // `baychat denied-hook` — the Claude Code PermissionDenied hook that turns an auto-mode block into
43
+ // a card on the owner's phone, and the owner's "yes" into the one exact permission rule that lets
44
+ // the session run that call.
45
+ //
46
+ // WHY THIS EXISTS. Auto mode's classifier HARD-denies some calls — `npx eas-cli build …`, a release
47
+ // script, `npm publish`. A hard denial draws no prompt, so approve-hook (a PermissionRequest hook)
48
+ // never runs, and the only way through was to walk to the computer and type `/permissions`. Karmen:
49
+ // "We have to have this possibility as people have to use BayChat also when not near computer".
50
+ // See docs/features/DENIED_COMMAND_CARD.md.
51
+ //
52
+ // WHAT CLAUDE CODE GIVES US (hooks reference, "PermissionDenied"): the event fires only in auto
53
+ // mode, after the denial; "Exit code and stderr are ignored because the denial already occurred";
54
+ // the one output read is `hookSpecificOutput.retry: true`, which "adds a message to the
55
+ // conversation telling the model it may retry the tool call" — it does not reverse the denial. So
56
+ // the retry only succeeds because, before printing it, THIS PROCESS (never the agent) writes an
57
+ // allow rule that matches exactly that call into the project's `.claude/settings.local.json`,
58
+ // which Claude Code reloads live and checks before the classifier.
59
+ //
60
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
61
+ // FAIL-CLOSED HERE MEANS "PRINT NOTHING".
62
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
63
+ //
64
+ // Unlike approve-hook, silence is safe: no output leaves the denial standing. Every abnormal path
65
+ // — no credential, no transcript proof, BayChat unreachable, an unanswered card, a settings file
66
+ // we cannot parse, an exception — prints nothing and writes no rule. A rule is written only after
67
+ // the card's owner tapped a yes, and `retry` is printed only after the rule was written.
68
+ //
69
+ // THE PROCESS BLOCKS THE SESSION WHILE IT WAITS, like approve-hook: hooks are awaited, so the
70
+ // session stops until the owner answers or our deadline passes. That is the reliable shape: the
71
+ // rule is in place before the model hears "you may retry", and the retry happens at once, in the
72
+ // same turn. Being killed by the handler timeout is harmless here (the denial stands), but the
73
+ // same 3600 s / 3900 s pair as approve-hook keeps our own deadline first, so the card is withdrawn
74
+ // rather than left looking answerable.
75
+ const child_process_1 = require("child_process");
76
+ const fs = __importStar(require("fs"));
77
+ const os = __importStar(require("os"));
78
+ const path = __importStar(require("path"));
79
+ const approve_hook_1 = require("./approve-hook");
80
+ const approval_wait_1 = require("./approval-wait");
81
+ const config_1 = require("./config");
82
+ const hook_stdin_1 = require("./hook-stdin");
83
+ const grants_1 = require("./denied/grants");
84
+ const permission_rule_1 = require("./denied/permission-rule");
85
+ const settings_rules_1 = require("./denied/settings-rules");
86
+ const transcript_1 = require("./denied/transcript");
87
+ function retryOutput() {
88
+ return { hookSpecificOutput: { hookEventName: "PermissionDenied", retry: true } };
89
+ }
90
+ // ─── The card ────────────────────────────────────────────────────────────────
91
+ const LOG_LABEL = "baychat denied-hook";
92
+ const LABEL = {
93
+ once: "Allow once",
94
+ always: "Always allow this exact command",
95
+ no: "No",
96
+ };
97
+ /** The single button of a card that only informs: nothing can be allowed from BayChat. */
98
+ const NOTICE_OPTIONS = ["OK"];
99
+ /** The card's own limits (apps/api/src/lib/cards.ts), used only to TRIM. */
100
+ const QUESTION_MAX = 2000;
101
+ const SHOWN_MAX = 900;
102
+ /**
103
+ * Give Claude Code's settings watcher this long to load the new rule before the model is told to
104
+ * retry. Claude Code does not say how fast its watcher is; this is a margin, and a retry that
105
+ * still meets the old settings is handled (see `MAX_SILENT_RETRIES`).
106
+ */
107
+ exports.RELOAD_SETTLE_MS = 1500;
108
+ /** A call denied AGAIN while a grant already covers it is retried this many times without a new
109
+ * card (the watcher was slower than RELOAD_SETTLE_MS). After that the rule evidently does not
110
+ * match how Claude Code reads the call, and the owner is told so. */
111
+ const MAX_SILENT_RETRIES = 1;
112
+ /** The classifier gave no verdict: Claude Code ignores `retry` for these, and a rule written for a
113
+ * transient failure would outlive it. Texts from the hooks reference. */
114
+ function isNoVerdict(reason) {
115
+ return reason.startsWith("Auto mode could not evaluate") || reason === "Classifier unavailable";
116
+ }
117
+ /** The card's question. A person decides from this text alone, on a lock screen. */
118
+ function composeDeniedQuestion(q) {
119
+ const shown = q.plan.shown.length <= SHOWN_MAX ? q.plan.shown : `${q.plan.shown.slice(0, SHOWN_MAX - 1)}…`;
120
+ const offers = q.plan.onceRules.length
121
+ ? [
122
+ `Allow once: BayChat adds ${q.plan.onceRules.length === 1 ? "this exact rule" : "these exact rules"} to ` +
123
+ `${q.settingsFile} for the retry, then takes ${q.plan.onceRules.length === 1 ? "it" : "them"} out.`,
124
+ q.plan.alwaysAllowed ? "Always: BayChat keeps the rule there (`baychat permissions list` shows it)." : "",
125
+ ]
126
+ : [];
127
+ const lines = [
128
+ `${q.who} on ${q.host} was blocked by Claude Code's auto mode:`,
129
+ "",
130
+ shown,
131
+ "",
132
+ q.reason ? `Reason: ${q.reason}` : "",
133
+ q.cwd ? `Working directory: ${q.cwd}` : "",
134
+ "",
135
+ ...offers,
136
+ q.plan.limitation,
137
+ ];
138
+ const text = lines.filter((l) => l !== undefined).join("\n").replace(/\n{3,}/g, "\n\n").trim();
139
+ return text.length <= QUESTION_MAX ? text : `${text.slice(0, QUESTION_MAX - 1)}…`;
140
+ }
141
+ /** The buttons for a plan: every option it allows, and always a way to say no. */
142
+ function offeredMeanings(plan) {
143
+ if (plan.onceRules.length === 0)
144
+ return [];
145
+ return plan.alwaysAllowed ? ["once", "always", "no"] : ["once", "no"];
146
+ }
147
+ function parseArgs(argv) {
148
+ const at = (name) => {
149
+ const i = argv.indexOf(name);
150
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
151
+ };
152
+ const rawTimeout = Number(at("--timeout"));
153
+ return {
154
+ session: at("--session"),
155
+ fromClaude: argv.includes("--session-from-claude"),
156
+ waitSeconds: Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : approve_hook_1.DEFAULT_WAIT_SECONDS,
157
+ };
158
+ }
159
+ const str = (v) => (typeof v === "string" ? v : "");
160
+ /**
161
+ * Turn one PermissionDenied event into `retry` — or into nothing, which leaves the denial
162
+ * standing. NEVER THROWS. `retry` is returned only after the owner allowed the call and the rule
163
+ * that matches it is written.
164
+ */
165
+ async function decideDenied(rawStdin, argv = [], deps = {}) {
166
+ const now = deps.now ?? Date.now;
167
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
168
+ const log = deps.log ?? ((line) => process.stderr.write(`${line}\n`));
169
+ try {
170
+ const opts = parseArgs(argv);
171
+ let input;
172
+ try {
173
+ const parsed = JSON.parse(rawStdin);
174
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
175
+ throw new Error("not an object");
176
+ input = parsed;
177
+ }
178
+ catch {
179
+ log(`${LOG_LABEL}: the event could not be read from stdin; the denial stands.`);
180
+ return null;
181
+ }
182
+ if (input.hook_event_name !== "PermissionDenied") {
183
+ log(`${LOG_LABEL}: not a PermissionDenied event (${String(input.hook_event_name)}); the denial stands.`);
184
+ return null;
185
+ }
186
+ const reason = str(input.reason);
187
+ if (isNoVerdict(reason)) {
188
+ log(`${LOG_LABEL}: auto mode gave no verdict ("${reason}"), so Claude Code would ignore a retry.`);
189
+ return null;
190
+ }
191
+ const toolName = str(input.tool_name);
192
+ const claudeSessionId = str(input.session_id);
193
+ const call = (0, permission_rule_1.callKeyOf)(toolName, input.tool_input);
194
+ if (!toolName || !claudeSessionId) {
195
+ log(`${LOG_LABEL}: the event has no tool or session (keys: ${Object.keys(input).join(", ")}).`);
196
+ return null;
197
+ }
198
+ const session = opts.session ?? (opts.fromClaude ? (0, approve_hook_1.sessionFromClaude)(deps.claude) : undefined);
199
+ if (opts.fromClaude && session === undefined) {
200
+ log(`${LOG_LABEL}: this session has not joined BayChat yet; the denial stands.`);
201
+ return null;
202
+ }
203
+ const device = (deps.loadDevice ?? config_1.loadDeviceCredentials)();
204
+ if (!device) {
205
+ log(`${LOG_LABEL}: this machine is not logged in to BayChat (\`baychat login\`); the denial stands.`);
206
+ return null;
207
+ }
208
+ // Only a call the model really made may put a card on anybody's phone.
209
+ const proof = await (deps.verify ?? transcript_1.verifyDeniedCall)({
210
+ transcriptPath: str(input.transcript_path),
211
+ agentId: str(input.agent_id) || undefined,
212
+ toolUseId: str(input.tool_use_id),
213
+ toolName,
214
+ toolInput: input.tool_input,
215
+ });
216
+ if (!proof.ok) {
217
+ log(`${LOG_LABEL}: ${proof.why}; no card was posted and the denial stands.`);
218
+ return null;
219
+ }
220
+ const host = (deps.hostname ?? os.hostname)();
221
+ const who = session ? `Session "${session}"` : "A Claude Code session";
222
+ const waitDeps = { now, sleep, log };
223
+ // A card that only informs: posted, never waited on (its deadline is already now).
224
+ const notify = (question) => (0, approval_wait_1.askOwnerAndWait)(device, { session, question, options: NOTICE_OPTIONS }, now(), waitDeps, LOG_LABEL);
225
+ // ── Already allowed, and denied again: the reload race, or a rule that does not match.
226
+ const covering = call ? (0, grants_1.coveringGrant)(claudeSessionId, call, now()) : undefined;
227
+ if (covering) {
228
+ if (covering.retries < MAX_SILENT_RETRIES) {
229
+ await (0, grants_1.noteRetry)(covering.id);
230
+ await sleep(exports.RELOAD_SETTLE_MS * 2);
231
+ return retryOutput();
232
+ }
233
+ if (covering.kind === "once")
234
+ await (0, grants_1.revokeGrant)(covering.id);
235
+ await notify(`${who} on ${host} is still blocked although you allowed it:\n\n${(0, permission_rule_1.planDeniedCall)(toolName, input.tool_input).shown}\n\n` +
236
+ "Claude Code did not accept the rule BayChat wrote for it. Approve it at the computer " +
237
+ "(/permissions → Recently denied).");
238
+ return null;
239
+ }
240
+ const projectDir = deps.projectDir ?? (process.env.CLAUDE_PROJECT_DIR || str(input.cwd));
241
+ if (!projectDir || !path.isAbsolute(projectDir)) {
242
+ log(`${LOG_LABEL}: no project directory to keep a rule in; the denial stands.`);
243
+ return null;
244
+ }
245
+ const settingsFile = (deps.settingsFileFor ?? settings_rules_1.localSettingsFile)(projectDir);
246
+ const plan = (0, permission_rule_1.planDeniedCall)(toolName, input.tool_input);
247
+ const question = composeDeniedQuestion({ who, host, plan, reason, cwd: str(input.cwd), settingsFile });
248
+ const meanings = offeredMeanings(plan);
249
+ if (meanings.length === 0) {
250
+ // Nothing can be allowed from BayChat; the owner still learns why the session stopped.
251
+ await notify(question);
252
+ return null;
253
+ }
254
+ const deadline = now() + opts.waitSeconds * 1000;
255
+ const outcome = await (0, approval_wait_1.askOwnerAndWait)(device, {
256
+ session,
257
+ question,
258
+ options: meanings.map((m) => LABEL[m]),
259
+ guardedOptions: meanings.flatMap((m, i) => (m === "always" ? [i] : [])),
260
+ }, deadline, waitDeps, LOG_LABEL);
261
+ if (outcome.kind === "deadline") {
262
+ await (0, approval_wait_1.withdrawOwnerQuestion)(device, outcome.requestId, log);
263
+ log(`${LOG_LABEL}: nobody answered within ${opts.waitSeconds}s; the card was withdrawn and the denial stands.`);
264
+ return null;
265
+ }
266
+ if (outcome.kind !== "answered") {
267
+ log(`${LOG_LABEL}: ${outcome.kind === "failed" ? outcome.reason : outcome.kind}; the denial stands.`);
268
+ return null;
269
+ }
270
+ const meaning = (outcome.index !== null ? meanings[outcome.index] : undefined) ?? "no";
271
+ if (meaning === "no" || !plan.call)
272
+ return null;
273
+ let grant;
274
+ try {
275
+ grant = await (0, grants_1.grantRules)({
276
+ kind: meaning,
277
+ rules: plan.onceRules,
278
+ settingsFile,
279
+ call: plan.call,
280
+ shown: plan.shown,
281
+ claudeSessionId,
282
+ session,
283
+ }, now());
284
+ }
285
+ catch (err) {
286
+ log(`${LOG_LABEL}: the rule could not be written (${(0, approval_wait_1.reasonOf)(err)}); the denial stands.`);
287
+ await notify(`BayChat could not save the rule you allowed, so ${who.toLowerCase()} stays blocked:\n\n${(0, approval_wait_1.reasonOf)(err)}`);
288
+ return null;
289
+ }
290
+ if (grant.kind === "once" && grant.expiresAt) {
291
+ (deps.scheduleSweep ?? scheduleSweep)(Date.parse(grant.expiresAt));
292
+ }
293
+ await sleep(exports.RELOAD_SETTLE_MS);
294
+ return retryOutput();
295
+ }
296
+ catch (err) {
297
+ log(`${LOG_LABEL}: unexpected failure (${(0, approval_wait_1.reasonOf)(err)}); the denial stands.`);
298
+ return null;
299
+ }
300
+ }
301
+ /**
302
+ * Start `baychat permissions sweep --at <ms>` detached, so a once-rule is taken back at its TTL
303
+ * even when the retried call never reports back and no other hook runs. Best effort: the
304
+ * after-hook and every later hook run sweep too.
305
+ */
306
+ function scheduleSweep(atMs) {
307
+ try {
308
+ const entry = fs.realpathSync(process.argv[1] ?? "");
309
+ const child = (0, child_process_1.spawn)(process.execPath, [entry, "permissions", "sweep", "--at", String(atMs)], {
310
+ detached: true,
311
+ stdio: "ignore",
312
+ });
313
+ child.unref();
314
+ }
315
+ catch (err) {
316
+ process.stderr.write(`${LOG_LABEL}: could not schedule the once-rule's removal (${(0, approval_wait_1.reasonOf)(err)}).\n`);
317
+ }
318
+ }
319
+ /**
320
+ * `baychat denied-hook [--session <name> | --session-from-claude] [--timeout <seconds>]`
321
+ *
322
+ * Always returns 0 (Claude Code ignores this event's exit code). Prints `retry` or nothing.
323
+ */
324
+ async function cmdDeniedHook(argv = [], stdin = process.stdin) {
325
+ const output = await decideDenied(await (0, hook_stdin_1.readStdin)(stdin), argv);
326
+ if (output)
327
+ process.stdout.write(`${JSON.stringify(output)}\n`);
328
+ return 0;
329
+ }
@@ -136,10 +136,11 @@ A session is one terminal, named by you. It appears in the app as an agent your
136
136
  messages can reach, and it survives being parked.
137
137
 
138
138
  join_session({ session: "Session-A" }) → a 1:1 with you
139
- join_session({ session: "Session-A", group: "Ad Review" }) → that group + your private chat
139
+ join_session({ session: "Session-A", group: "Ad Review" }) → that group only
140
140
 
141
- **The group form also keeps a private chat with you**, under the same agent and
142
- session name. The join result includes its directConversationId.
141
+ **A group join creates no private chat.** If you already have one with this
142
+ session (same agent, same name), the result names it as directConversationId;
143
+ otherwise there is none until you start one.
143
144
 
144
145
  list_sessions() → name, live or idle, last seen
145
146
  end_session(...) → park it; the chat and its history survive, and rejoining
@@ -167,7 +168,14 @@ a decision that expires silently is worse than one that waits.
167
168
  cards. It is on only for Claude Code sessions BayChat starts (from the phone,
168
169
  \`baychat claude\` or \`baychat start\`), never for a plain \`claude\`, and it moves
169
170
  the last line between an agent and your machine onto a phone — read
170
- docs/features/REMOTE_APPROVAL_HOOK.md. It fails CLOSED: every error denies.`,
171
+ docs/features/REMOTE_APPROVAL_HOOK.md. It fails CLOSED: every error denies.
172
+
173
+ When auto mode blocks a call outright there is no prompt to answer. With
174
+ \`baychat permissions on\` (off by default), BayChat-started sessions then put a
175
+ card in BayChat — Allow once / Always allow this exact command / No — and a yes
176
+ writes that one exact rule to the project's .claude/settings.local.json and lets
177
+ the session retry. \`baychat permissions\` lists and removes what it kept. See
178
+ docs/features/DENIED_COMMAND_CARD.md.`,
171
179
  };
172
180
  exports.HELP_TOPICS = [GROUPS, SESSIONS, TOOLS, APPROVALS];
173
181
  /** Exact name first, then keyword, then a substring of the body. */
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ // A Claude Code hook's JSON input, read from stdin with a deadline. Imports nothing but Node, so
3
+ // the fast-path hooks (denied/after-hook.ts) can use it without loading the CLI.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.readStdin = readStdin;
6
+ /**
7
+ * The hook's JSON input.
8
+ *
9
+ * Bounded, because a stdin that never closes would stall us until the handler timeout kills the
10
+ * process — and for approve-hook being killed ALLOWS. On timeout we return what arrived (usually
11
+ * nothing), which fails to parse, which each hook turns into its own fail-closed answer.
12
+ */
13
+ function readStdin(stream = process.stdin, timeoutMs = 10_000) {
14
+ return new Promise((resolve) => {
15
+ let text = "";
16
+ let done = false;
17
+ const finish = () => {
18
+ if (done)
19
+ return;
20
+ done = true;
21
+ clearTimeout(timer);
22
+ resolve(text);
23
+ };
24
+ const timer = setTimeout(finish, timeoutMs);
25
+ // Do not hold the process open on the timer alone.
26
+ if (typeof timer.unref === "function")
27
+ timer.unref();
28
+ stream.setEncoding?.("utf8");
29
+ stream.on("data", (chunk) => {
30
+ text += typeof chunk === "string" ? chunk : chunk.toString("utf8");
31
+ });
32
+ stream.on("end", finish);
33
+ stream.on("error", finish);
34
+ });
35
+ }
package/dist/index.js CHANGED
@@ -12,7 +12,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  /** Kept in step with `FAST_CONTEXT_COMMANDS` in claude-context.ts (a test pins the two). */
13
13
  const FAST_COMMANDS = new Set(["context-statusline", "context-hook", "context-flush"]);
14
14
  const command = process.argv[2];
15
- if (command !== undefined && FAST_COMMANDS.has(command)) {
15
+ if (command === "denied-after") {
16
+ // The denied-command card's after-hook runs on every matching tool call (as an async hook), so
17
+ // it too must not load the CLI. It imports only its own files and Node.
18
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
19
+ const after = require("./denied/after-hook");
20
+ after.cmdDeniedAfter().then((code) => {
21
+ process.exitCode = code;
22
+ }, (err) => {
23
+ process.stderr.write(`baychat denied-after: ${err instanceof Error ? err.message : String(err)}\n`);
24
+ process.exitCode = 0;
25
+ });
26
+ }
27
+ else if (command !== undefined && FAST_COMMANDS.has(command)) {
16
28
  // eslint-disable-next-line @typescript-eslint/no-require-imports
17
29
  const fast = require("./claude-context");
18
30
  fast.runFastContextCommand(command, process.argv.slice(3)).then(
package/dist/main.js CHANGED
@@ -3,6 +3,8 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const commands_1 = require("./commands");
5
5
  const approve_hook_1 = require("./approve-hook");
6
+ const denied_hook_1 = require("./denied-hook");
7
+ const permissions_command_1 = require("./permissions-command");
6
8
  const context_hooks_command_1 = require("./context-hooks-command");
7
9
  const session_name_1 = require("./session-name");
8
10
  const start_command_1 = require("./start-command");
@@ -301,6 +303,13 @@ async function main() {
301
303
  // exit-1 hook with no JSON on stdout is a NON-BLOCKING error: the tool call proceeds. The
302
304
  // guard in the catch below covers that anyway.
303
305
  return await (0, approve_hook_1.cmdApproveHook)(args);
306
+ case "denied-hook":
307
+ // Never throws and always returns 0; prints `retry` or nothing (see denied-hook.ts). For
308
+ // this event Claude Code ignores the exit code, and printing nothing leaves the denial
309
+ // standing — so a crash here is already the safe outcome.
310
+ return await (0, denied_hook_1.cmdDeniedHook)(args);
311
+ case "permissions":
312
+ return await (0, permissions_command_1.cmdPermissions)(args);
304
313
  case "context-hooks":
305
314
  return await (0, context_hooks_command_1.cmdContextHooks)(args);
306
315
  case "doctor":