baychat 0.29.9 → 0.31.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.
Files changed (45) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +43 -10
  3. package/dist/approval-wait.js +143 -0
  4. package/dist/approve-hook.js +68 -133
  5. package/dist/auto-update/describe.js +66 -0
  6. package/dist/auto-update/notice.js +40 -0
  7. package/dist/auto-update/policy.js +110 -0
  8. package/dist/auto-update/relay-glue.js +143 -0
  9. package/dist/auto-update/store.js +140 -0
  10. package/dist/auto-update/updater.js +324 -0
  11. package/dist/channel/permission.js +6 -0
  12. package/dist/cli-help.js +30 -3
  13. package/dist/denied/after-hook.js +46 -0
  14. package/dist/denied/confirm.js +131 -0
  15. package/dist/denied/grants.js +292 -0
  16. package/dist/denied/hook-log.js +75 -0
  17. package/dist/denied/live-session.js +154 -0
  18. package/dist/denied/notice.js +29 -0
  19. package/dist/denied/opt-in.js +68 -0
  20. package/dist/denied/permission-rule.js +186 -0
  21. package/dist/denied/settings-rules.js +221 -0
  22. package/dist/denied/transcript.js +173 -0
  23. package/dist/denied-hook.js +431 -0
  24. package/dist/doctor-command.js +7 -1
  25. package/dist/doctor.js +28 -0
  26. package/dist/help-topics.js +12 -4
  27. package/dist/hook-stdin.js +35 -0
  28. package/dist/index.js +13 -1
  29. package/dist/main.js +20 -2
  30. package/dist/permissions-command.js +104 -0
  31. package/dist/protocol-content.js +1 -1
  32. package/dist/refresh-clients.js +36 -0
  33. package/dist/relay/acp/runner.js +4 -0
  34. package/dist/relay/commands.js +19 -3
  35. package/dist/relay/daemon.js +37 -1
  36. package/dist/relay/launcher/approval-settings.js +56 -2
  37. package/dist/relay/launcher/loop.js +4 -1
  38. package/dist/relay/self-restart.js +149 -0
  39. package/dist/relay/terminal-pane.js +74 -2
  40. package/dist/runtimes.js +2 -2
  41. package/dist/self-upgrade.js +92 -27
  42. package/dist/start-command.js +15 -1
  43. package/dist/update-command.js +51 -31
  44. package/dist/waiting-cards.js +107 -0
  45. package/package.json +4 -1
@@ -0,0 +1,431 @@
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 = exports.deniedHookLogPath = 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 proof, BayChat unreachable, an unanswered card, a settings file we cannot
66
+ // parse, an exception — prints nothing and writes no rule. A rule is written only after the card's
67
+ // owner tapped a yes, and `retry` is printed only after the rule was written.
68
+ //
69
+ // PROOF, IN TWO HALVES. Claude Code 2.1.299 writes the denied call to the transcript only after
70
+ // this hook returns (measured; denied/transcript.ts), so the transcript is looked at only briefly.
71
+ // Found → proven. Found as a DIFFERENT call → refused. Not there yet → the event must come from
72
+ // the live Claude Code of the session it names (denied/live-session.ts), and a yes is recorded as
73
+ // UNCONFIRMED and checked against the transcript afterwards by a detached
74
+ // `baychat permissions confirm` (denied/confirm.ts), which takes the rule back out — and tells the
75
+ // owner — when the call never shows up.
76
+ //
77
+ // THE PROCESS BLOCKS THE SESSION WHILE IT WAITS, like approve-hook: hooks are awaited, so the
78
+ // session stops until the owner answers or our deadline passes. That is the reliable shape: the
79
+ // rule is in place before the model hears "you may retry", and the retry happens at once, in the
80
+ // same turn. Being killed by the handler timeout is harmless here (the denial stands), but the
81
+ // same 3600 s / 3900 s pair as approve-hook keeps our own deadline first, so the card is withdrawn
82
+ // rather than left looking answerable.
83
+ const child_process_1 = require("child_process");
84
+ const fs = __importStar(require("fs"));
85
+ const os = __importStar(require("os"));
86
+ const path = __importStar(require("path"));
87
+ const approve_hook_1 = require("./approve-hook");
88
+ const approval_wait_1 = require("./approval-wait");
89
+ const config_1 = require("./config");
90
+ const hook_stdin_1 = require("./hook-stdin");
91
+ const confirm_1 = require("./denied/confirm");
92
+ const grants_1 = require("./denied/grants");
93
+ const hook_log_1 = require("./denied/hook-log");
94
+ const live_session_1 = require("./denied/live-session");
95
+ const notice_1 = require("./denied/notice");
96
+ const permission_rule_1 = require("./denied/permission-rule");
97
+ const settings_rules_1 = require("./denied/settings-rules");
98
+ const transcript_1 = require("./denied/transcript");
99
+ var hook_log_2 = require("./denied/hook-log");
100
+ Object.defineProperty(exports, "deniedHookLogPath", { enumerable: true, get: function () { return hook_log_2.deniedHookLogPath; } });
101
+ function retryOutput() {
102
+ return { hookSpecificOutput: { hookEventName: "PermissionDenied", retry: true } };
103
+ }
104
+ // ─── The card ────────────────────────────────────────────────────────────────
105
+ const LOG_LABEL = "baychat denied-hook";
106
+ const LABEL = {
107
+ once: "Allow once",
108
+ always: "Always allow this exact command",
109
+ no: "No",
110
+ };
111
+ /** The card's own limits (apps/api/src/lib/cards.ts), used only to TRIM. */
112
+ const QUESTION_MAX = 2000;
113
+ const SHOWN_MAX = 900;
114
+ /**
115
+ * Give Claude Code's settings watcher this long to load the new rule before the model is told to
116
+ * retry. Claude Code does not say how fast its watcher is; this is a margin, and a retry that
117
+ * still meets the old settings is handled (see `MAX_SILENT_RETRIES`).
118
+ */
119
+ exports.RELOAD_SETTLE_MS = 1500;
120
+ /** A call denied AGAIN while a grant already covers it is retried this many times without a new
121
+ * card (the watcher was slower than RELOAD_SETTLE_MS). After that the rule evidently does not
122
+ * match how Claude Code reads the call, and the owner is told so. */
123
+ const MAX_SILENT_RETRIES = 1;
124
+ /** The classifier gave no verdict: Claude Code ignores `retry` for these, and a rule written for a
125
+ * transient failure would outlive it. Texts from the hooks reference. */
126
+ function isNoVerdict(reason) {
127
+ return reason.startsWith("Auto mode could not evaluate") || reason === "Classifier unavailable";
128
+ }
129
+ /** The card's question. A person decides from this text alone, on a lock screen. */
130
+ function composeDeniedQuestion(q) {
131
+ const shown = q.plan.shown.length <= SHOWN_MAX ? q.plan.shown : `${q.plan.shown.slice(0, SHOWN_MAX - 1)}…`;
132
+ const one = q.plan.onceRules.length === 1;
133
+ const user = q.target.scope === "user";
134
+ // The user scope reaches every project on this computer, so the card says where the rule goes
135
+ // and why — never only a path the person has to decode on a lock screen.
136
+ const where = user ? `your user settings (${tildePath(q.target.file)})` : q.target.file;
137
+ const offers = q.plan.onceRules.length
138
+ ? [
139
+ `Allow once: BayChat adds ${one ? "this exact rule" : "these exact rules"} to ${where} for the retry, ` +
140
+ `then takes ${one ? "it" : "them"} out${user ? " — the project folder isn't writable for you" : ""}.`,
141
+ q.plan.alwaysAllowed
142
+ ? user
143
+ ? "Always: BayChat keeps the rule in your user settings, so it applies to every project on this computer " +
144
+ "(`baychat permissions list` shows it)."
145
+ : "Always: BayChat keeps the rule there, for this project (`baychat permissions list` shows it)."
146
+ : "",
147
+ ]
148
+ : [];
149
+ const lines = [
150
+ `${q.who} on ${q.host} was blocked by Claude Code's auto mode:`,
151
+ "",
152
+ shown,
153
+ "",
154
+ q.reason ? `Reason: ${q.reason}` : "",
155
+ q.cwd ? `Working directory: ${q.cwd}` : "",
156
+ "",
157
+ ...offers,
158
+ q.plan.limitation,
159
+ ];
160
+ const text = lines.filter((l) => l !== undefined).join("\n").replace(/\n{3,}/g, "\n\n").trim();
161
+ return text.length <= QUESTION_MAX ? text : `${text.slice(0, QUESTION_MAX - 1)}…`;
162
+ }
163
+ /** `/home/k/.claude/settings.json` → `~/.claude/settings.json`. */
164
+ function tildePath(file) {
165
+ const home = os.homedir();
166
+ return file === home || file.startsWith(home + path.sep) ? `~${file.slice(home.length)}` : file;
167
+ }
168
+ /** The buttons for a plan: every option it allows, and always a way to say no. */
169
+ function offeredMeanings(plan) {
170
+ if (plan.onceRules.length === 0)
171
+ return [];
172
+ return plan.alwaysAllowed ? ["once", "always", "no"] : ["once", "no"];
173
+ }
174
+ function parseArgs(argv) {
175
+ const at = (name) => {
176
+ const i = argv.indexOf(name);
177
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
178
+ };
179
+ const rawTimeout = Number(at("--timeout"));
180
+ return {
181
+ session: at("--session"),
182
+ fromClaude: argv.includes("--session-from-claude"),
183
+ waitSeconds: Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : approve_hook_1.DEFAULT_WAIT_SECONDS,
184
+ };
185
+ }
186
+ const str = (v) => (typeof v === "string" ? v : "");
187
+ /**
188
+ * Turn one PermissionDenied event into `retry` — or into nothing, which leaves the denial
189
+ * standing. NEVER THROWS. `retry` is returned only after the owner allowed the call and the rule
190
+ * that matches it is written.
191
+ */
192
+ async function decideDenied(rawStdin, argv = [], deps = {}) {
193
+ const now = deps.now ?? Date.now;
194
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
195
+ const log = deps.log ?? ((line) => process.stderr.write(`${line}\n`));
196
+ try {
197
+ const opts = parseArgs(argv);
198
+ let input;
199
+ try {
200
+ const parsed = JSON.parse(rawStdin);
201
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
202
+ throw new Error("not an object");
203
+ input = parsed;
204
+ }
205
+ catch {
206
+ log(`${LOG_LABEL}: the event could not be read from stdin; the denial stands.`);
207
+ return null;
208
+ }
209
+ if (input.hook_event_name !== "PermissionDenied") {
210
+ log(`${LOG_LABEL}: not a PermissionDenied event (${String(input.hook_event_name)}); the denial stands.`);
211
+ return null;
212
+ }
213
+ const reason = str(input.reason);
214
+ if (isNoVerdict(reason)) {
215
+ log(`${LOG_LABEL}: auto mode gave no verdict ("${reason}"), so Claude Code would ignore a retry.`);
216
+ return null;
217
+ }
218
+ const toolName = str(input.tool_name);
219
+ const claudeSessionId = str(input.session_id);
220
+ const call = (0, permission_rule_1.callKeyOf)(toolName, input.tool_input);
221
+ if (!toolName || !claudeSessionId) {
222
+ log(`${LOG_LABEL}: the event has no tool or session (keys: ${Object.keys(input).join(", ")}).`);
223
+ return null;
224
+ }
225
+ const session = opts.session ?? (opts.fromClaude ? (0, approve_hook_1.sessionFromClaude)(deps.claude) : undefined);
226
+ if (opts.fromClaude && session === undefined) {
227
+ log(`${LOG_LABEL}: this session has not joined BayChat yet; the denial stands.`);
228
+ return null;
229
+ }
230
+ const device = (deps.loadDevice ?? config_1.loadDeviceCredentials)();
231
+ if (!device) {
232
+ log(`${LOG_LABEL}: this machine is not logged in to BayChat (\`baychat login\`); the denial stands.`);
233
+ return null;
234
+ }
235
+ // Every run takes back what is overdue first, so a stale grant never covers this call.
236
+ try {
237
+ await (deps.sweepGrants ?? defaultSweepGrants)(device);
238
+ }
239
+ catch (err) {
240
+ log(`${LOG_LABEL}: the grants sweep failed (${(0, approval_wait_1.reasonOf)(err)}).`);
241
+ }
242
+ // Only a call the model really made may put a card on anybody's phone.
243
+ const transcriptPath = str(input.transcript_path);
244
+ const agentId = str(input.agent_id) || undefined;
245
+ const toolUseId = str(input.tool_use_id);
246
+ const proofStarted = now();
247
+ const proof = await (deps.verify ?? transcript_1.verifyDeniedCall)({ transcriptPath, agentId, toolUseId, toolName, toolInput: input.tool_input });
248
+ // Set when the call is accepted before it is in the transcript: a yes is then confirmed later.
249
+ let unconfirmed;
250
+ if (proof.ok) {
251
+ log(`${LOG_LABEL}: proven — the call is in the transcript (after ${now() - proofStarted} ms).`);
252
+ }
253
+ else if (!proof.absent || !toolUseId || !call) {
254
+ log(`${LOG_LABEL}: refused — ${proof.why} (after ${now() - proofStarted} ms); no card was posted and the denial stands.`);
255
+ return null;
256
+ }
257
+ else {
258
+ const live = (deps.liveSession ?? live_session_1.proveLiveSession)({ sessionId: claudeSessionId, transcriptPath, agentId });
259
+ if (!live.ok) {
260
+ log(`${LOG_LABEL}: refused — the call is not in the transcript yet (after ${now() - proofStarted} ms) and ` +
261
+ `the event is not from this session's live Claude Code: ${live.why}; no card was posted and the denial stands.`);
262
+ return null;
263
+ }
264
+ log(`${LOG_LABEL}: accepted — the call is not in the transcript yet (after ${now() - proofStarted} ms), and the ` +
265
+ `event comes from this session's live Claude Code (pid ${live.pid}); a yes is confirmed against the transcript afterwards.`);
266
+ unconfirmed = { transcriptPath, ...(agentId ? { agentId } : {}), toolUseId, toolName };
267
+ }
268
+ const host = (deps.hostname ?? os.hostname)();
269
+ const who = session ? `Session "${session}"` : "A Claude Code session";
270
+ const waitDeps = { now, sleep, log };
271
+ // A card that only informs: posted, never waited on (its deadline is already now).
272
+ const notify = (question) => (0, approval_wait_1.askOwnerAndWait)(device, { session, question, options: notice_1.NOTICE_OPTIONS }, now(), waitDeps, LOG_LABEL);
273
+ // ── Already allowed, and denied again: the reload race, or a rule that does not match.
274
+ const covering = call ? (0, grants_1.coveringGrant)(claudeSessionId, call, now()) : undefined;
275
+ if (covering) {
276
+ if (covering.retries < MAX_SILENT_RETRIES) {
277
+ await (0, grants_1.noteRetry)(covering.id);
278
+ await sleep(exports.RELOAD_SETTLE_MS * 2);
279
+ return retryOutput();
280
+ }
281
+ if (covering.kind === "once")
282
+ await (0, grants_1.revokeGrant)(covering.id);
283
+ 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` +
284
+ "Claude Code did not accept the rule BayChat wrote for it. Approve it at the computer " +
285
+ "(/permissions → Recently denied).");
286
+ return null;
287
+ }
288
+ const projectDir = deps.projectDir ?? (process.env.CLAUDE_PROJECT_DIR || str(input.cwd));
289
+ if (!projectDir || !path.isAbsolute(projectDir)) {
290
+ log(`${LOG_LABEL}: no project directory to keep a rule in; the denial stands.`);
291
+ return null;
292
+ }
293
+ // Decided BEFORE the card: an Allow button for a file this user cannot write can only fail.
294
+ const choice = (deps.settingsChoiceFor ?? settings_rules_1.chooseSettingsFile)(projectDir);
295
+ const planned = (0, permission_rule_1.planDeniedCall)(toolName, input.tool_input);
296
+ const plan = choice.scope === "none" && planned.onceRules.length > 0
297
+ ? {
298
+ ...planned,
299
+ onceRules: [],
300
+ alwaysAllowed: false,
301
+ limitation: `BayChat can't allow it from here: ${choice.why}, so no rule can be saved. ` +
302
+ "Approve it at the computer (/permissions → Recently denied).",
303
+ }
304
+ : planned;
305
+ const target = choice.scope === "none" ? undefined : choice;
306
+ const question = composeDeniedQuestion({
307
+ who,
308
+ host,
309
+ plan,
310
+ reason,
311
+ cwd: str(input.cwd),
312
+ // Any target will do for a card that offers nothing: no write line is drawn.
313
+ target: target ?? { scope: "project", file: "" },
314
+ });
315
+ const meanings = offeredMeanings(plan);
316
+ if (meanings.length === 0) {
317
+ // Nothing can be allowed from BayChat; the owner still learns why the session stopped.
318
+ await notify(question);
319
+ return null;
320
+ }
321
+ const deadline = now() + opts.waitSeconds * 1000;
322
+ const outcome = await (0, approval_wait_1.askOwnerAndWait)(device, {
323
+ session,
324
+ question,
325
+ options: meanings.map((m) => LABEL[m]),
326
+ guardedOptions: meanings.flatMap((m, i) => (m === "always" ? [i] : [])),
327
+ }, deadline, waitDeps, LOG_LABEL);
328
+ if (outcome.kind === "deadline") {
329
+ await (0, approval_wait_1.withdrawOwnerQuestion)(device, outcome.requestId, log);
330
+ log(`${LOG_LABEL}: nobody answered within ${opts.waitSeconds}s; the card was withdrawn and the denial stands.`);
331
+ return null;
332
+ }
333
+ if (outcome.kind !== "answered") {
334
+ log(`${LOG_LABEL}: ${outcome.kind === "failed" ? outcome.reason : outcome.kind}; the denial stands.`);
335
+ return null;
336
+ }
337
+ const meaning = (outcome.index !== null ? meanings[outcome.index] : undefined) ?? "no";
338
+ if (meaning === "no" || !plan.call || !target)
339
+ return null;
340
+ let grant;
341
+ try {
342
+ grant = await (0, grants_1.grantRules)({
343
+ kind: meaning,
344
+ rules: plan.onceRules,
345
+ settingsFile: target.file,
346
+ scope: target.scope,
347
+ call: plan.call,
348
+ shown: plan.shown,
349
+ claudeSessionId,
350
+ session,
351
+ ...(unconfirmed ? { unconfirmed } : {}),
352
+ }, now());
353
+ }
354
+ catch (err) {
355
+ log(`${LOG_LABEL}: the rule could not be written (${(0, approval_wait_1.reasonOf)(err)}); the denial stands.`);
356
+ await notify(`BayChat could not save the rule you allowed, so ${who.toLowerCase()} stays blocked:\n\n${(0, approval_wait_1.reasonOf)(err)}`);
357
+ return null;
358
+ }
359
+ if (grant.kind === "once" && grant.expiresAt) {
360
+ (deps.scheduleSweep ?? scheduleSweep)(Date.parse(grant.expiresAt));
361
+ }
362
+ if (grant.unconfirmed) {
363
+ // Started before `retry`, so it holds the grant before the retried call can use it up.
364
+ (deps.scheduleConfirm ?? scheduleConfirm)(grant.id);
365
+ log(`${LOG_LABEL}: grant ${grant.id} (${grant.kind}) is unconfirmed; its confirmation was started.`);
366
+ }
367
+ await sleep(exports.RELOAD_SETTLE_MS);
368
+ return retryOutput();
369
+ }
370
+ catch (err) {
371
+ log(`${LOG_LABEL}: unexpected failure (${(0, approval_wait_1.reasonOf)(err)}); the denial stands.`);
372
+ return null;
373
+ }
374
+ }
375
+ /**
376
+ * Start `baychat permissions sweep --at <ms>` detached, so a once-rule is taken back at its TTL
377
+ * even when the retried call never reports back and no other hook runs. Best effort: the
378
+ * after-hook and every later hook run sweep too.
379
+ */
380
+ function scheduleSweep(atMs) {
381
+ spawnDetached(["permissions", "sweep", "--at", String(atMs)], "schedule the once-rule's removal");
382
+ }
383
+ /**
384
+ * Start `baychat permissions confirm --grant <id>` detached: it waits for the denied call to reach
385
+ * the transcript and takes the grant back when it does not (denied/confirm.ts). Best effort: a
386
+ * confirmation that never runs is caught as a stale unconfirmed grant by every later hook run.
387
+ */
388
+ function scheduleConfirm(grantId) {
389
+ spawnDetached(["permissions", "confirm", "--grant", grantId], "start the grant's confirmation");
390
+ }
391
+ function spawnDetached(args, what) {
392
+ try {
393
+ const entry = fs.realpathSync(process.argv[1] ?? "");
394
+ const child = (0, child_process_1.spawn)(process.execPath, [entry, ...args], { detached: true, stdio: "ignore" });
395
+ child.unref();
396
+ }
397
+ catch (err) {
398
+ process.stderr.write(`${LOG_LABEL}: could not ${what} (${(0, approval_wait_1.reasonOf)(err)}).\n`);
399
+ (0, hook_log_1.appendHookLog)(`${LOG_LABEL}: could not ${what} (${(0, approval_wait_1.reasonOf)(err)}).`);
400
+ }
401
+ }
402
+ async function defaultSweepGrants(device) {
403
+ await (0, grants_1.sweepExpiredGrants)();
404
+ await (0, confirm_1.takeBackStaleUnconfirmed)({ loadDevice: () => device, log: hook_log_1.logBoth });
405
+ }
406
+ /**
407
+ * `baychat denied-hook [--session <name> | --session-from-claude] [--timeout <seconds>]`
408
+ *
409
+ * Always returns 0 (Claude Code ignores this event's exit code). Prints `retry` or nothing.
410
+ */
411
+ async function cmdDeniedHook(argv = [], stdin = process.stdin) {
412
+ const raw = await (0, hook_stdin_1.readStdin)(stdin);
413
+ const log = hook_log_1.logBoth;
414
+ (0, hook_log_1.appendHookLog)(`${LOG_LABEL}: called (${eventSummary(raw)})`);
415
+ const output = await decideDenied(raw, argv, { log });
416
+ (0, hook_log_1.appendHookLog)(`${LOG_LABEL}: ${output ? "retry" : "no retry"}`);
417
+ if (output)
418
+ process.stdout.write(`${JSON.stringify(output)}\n`);
419
+ return 0;
420
+ }
421
+ // ─── The hook's own log (denied/hook-log.ts) ──────────────────────────────────
422
+ /** Event name, tool, session id and which fields came — never the tool input itself. */
423
+ function eventSummary(raw) {
424
+ try {
425
+ const e = JSON.parse(raw);
426
+ return `event=${String(e.hook_event_name)} tool=${String(e.tool_name)} session=${String(e.session_id)} call=${String(e.tool_use_id)} keys=${Object.keys(e).join(",")}`;
427
+ }
428
+ catch {
429
+ return `unreadable stdin, ${raw.length} bytes`;
430
+ }
431
+ }
@@ -55,6 +55,9 @@ const credential_refresh_1 = require("./credential-refresh");
55
55
  const client_paths_1 = require("./client-paths");
56
56
  const runtime_binary_1 = require("./runtime-binary");
57
57
  const ws_1 = require("./relay/ws");
58
+ const describe_1 = require("./auto-update/describe");
59
+ const store_1 = require("./auto-update/store");
60
+ const self_upgrade_1 = require("./self-upgrade");
58
61
  /** How long `claude mcp list` may take before we treat it as unanswerable. */
59
62
  const MCP_LIST_TIMEOUT_MS = 15_000;
60
63
  /**
@@ -88,6 +91,7 @@ async function currentDoctorEnv() {
88
91
  bayRelayAlive[bay.bayId] = await (0, socket_1.probeSocket)((0, bay_relays_1.bayRelayScope)(bay.bayId, (0, config_1.configDir)(), (0, socket_1.socketPath)()).socket);
89
92
  }
90
93
  }
94
+ const relay = await (0, commands_1.tryRelayStatus)();
91
95
  return {
92
96
  bays,
93
97
  bayRelayAlive,
@@ -95,7 +99,9 @@ async function currentDoctorEnv() {
95
99
  platform: process.platform,
96
100
  now: Date.now(),
97
101
  device: (0, config_1.loadDeviceCredentials)(),
98
- relay: await (0, commands_1.tryRelayStatus)(),
102
+ relay,
103
+ // The running relay's own account when it gives one; else what its files say.
104
+ autoUpdate: relay?.autoUpdate ?? (0, describe_1.statusFromFiles)({ state: (0, store_1.loadAutoUpdateState)(), setting: (0, store_1.loadAutoSetting)(), env: process.env, current: (0, self_upgrade_1.ownVersion)() }),
99
105
  node: { version: process.version, webSocket: (0, ws_1.hasBuiltInWebSocket)() },
100
106
  // APPDATA, LOCALAPPDATA and a directory lister: without them a Windows report cannot
101
107
  // see Claude Desktop's config at all, Store copy or classic.
package/dist/doctor.js CHANGED
@@ -61,6 +61,7 @@ exports.acpChecks = acpChecks;
61
61
  exports.exitCodeFor = exitCodeFor;
62
62
  exports.renderReport = renderReport;
63
63
  exports.bayChecks = bayChecks;
64
+ exports.autoUpdateCheck = autoUpdateCheck;
64
65
  const fs = __importStar(require("fs"));
65
66
  const os = __importStar(require("os"));
66
67
  const path = __importStar(require("path"));
@@ -76,6 +77,8 @@ const launch_1 = require("./channel/launch");
76
77
  const plugin_1 = require("./channel/plugin");
77
78
  const transport_1 = require("./relay/transport");
78
79
  const ws_1 = require("./relay/ws");
80
+ const describe_1 = require("./auto-update/describe");
81
+ const self_upgrade_1 = require("./self-upgrade");
79
82
  /**
80
83
  * The runtimes worth reporting on.
81
84
  *
@@ -122,6 +125,7 @@ function buildReport(env) {
122
125
  credentials: credentialsCheck(env),
123
126
  bays: bayChecks(env),
124
127
  relay: relayCheck(env),
128
+ autoUpdate: autoUpdateCheck(env),
125
129
  mailbox: mailboxCheck(),
126
130
  staleCredentials: staleCredentialCheck(env),
127
131
  runtimes: REPORTED.map((runtime) => runtimeReport(runtime, env)),
@@ -293,6 +297,7 @@ function renderReport(report) {
293
297
  lines.push(...renderSummaryCheck(check));
294
298
  lines.push(...renderSummaryCheck(report.staleCredentials));
295
299
  lines.push(...renderSummaryCheck(report.relay));
300
+ lines.push(...renderSummaryCheck(report.autoUpdate));
296
301
  lines.push(...renderSummaryCheck(report.mailbox));
297
302
  for (const block of report.runtimes) {
298
303
  const [first, ...rest] = block.checks;
@@ -443,6 +448,28 @@ function relayCheck(env) {
443
448
  }
444
449
  return { name: "relay", status: "pass", detail: `running (pid ${env.relay.pid})${transport}` };
445
450
  }
451
+ /**
452
+ * Is this computer keeping itself current? A warning only when something needs the person: a
453
+ * newer version this copy cannot install by itself, a card waiting on the phone, or an automatic
454
+ * install that failed. Off is a choice, not a fault, so it is reported and never nagged about.
455
+ */
456
+ function autoUpdateCheck(env) {
457
+ const name = "auto-update";
458
+ const status = env.autoUpdate;
459
+ if (!status)
460
+ return { name, status: "skip", detail: "not inspected" };
461
+ const detail = (0, describe_1.describeAutoUpdate)(status, env.now);
462
+ const behind = !!status.latest && (0, self_upgrade_1.compareVersions)(status.current, status.latest) === -1;
463
+ if (status.mode === "off" || status.mode === "none")
464
+ return { name, status: "skip", detail };
465
+ if (status.failure)
466
+ return { name, status: "warn", detail, remedy: "baychat update" };
467
+ if (status.cardVersion)
468
+ return { name, status: "warn", detail, remedy: "answer the card in BayChat, or run baychat update" };
469
+ if (status.mode === "notify" && behind)
470
+ return { name, status: "warn", detail, remedy: "baychat update" };
471
+ return { name, status: "pass", detail };
472
+ }
446
473
  function pendingCheck(env) {
447
474
  if (!env.relay) {
448
475
  return { name: "pending", status: "skip", detail: "relay not running — nothing to report" };
@@ -783,6 +810,7 @@ function allChecks(report) {
783
810
  ...report.bays,
784
811
  report.staleCredentials,
785
812
  report.relay,
813
+ report.autoUpdate,
786
814
  report.mailbox,
787
815
  ...report.runtimes.flatMap((r) => r.checks),
788
816
  ...report.agents,
@@ -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(