baychat 0.30.0 → 0.32.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 (42) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +36 -3
  3. package/dist/approval-wait.js +46 -33
  4. package/dist/approve-hook.js +35 -1
  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/feed.js +50 -1
  12. package/dist/channel/permission.js +6 -0
  13. package/dist/channel/server.js +1 -0
  14. package/dist/claude-onboarding.js +3 -1
  15. package/dist/cli-help.js +6 -1
  16. package/dist/denied/after-hook.js +8 -2
  17. package/dist/denied/confirm.js +131 -0
  18. package/dist/denied/grants.js +60 -2
  19. package/dist/denied/hook-log.js +75 -0
  20. package/dist/denied/live-session.js +154 -0
  21. package/dist/denied/notice.js +29 -0
  22. package/dist/denied/permission-rule.js +9 -6
  23. package/dist/denied/settings-rules.js +61 -0
  24. package/dist/denied/transcript.js +39 -16
  25. package/dist/denied-hook.js +132 -30
  26. package/dist/doctor-command.js +7 -1
  27. package/dist/doctor.js +28 -0
  28. package/dist/main.js +11 -2
  29. package/dist/permissions-command.js +18 -1
  30. package/dist/protocol-content.js +1 -1
  31. package/dist/refresh-clients.js +36 -0
  32. package/dist/relay/acp/runner.js +4 -0
  33. package/dist/relay/commands.js +19 -3
  34. package/dist/relay/daemon.js +37 -1
  35. package/dist/relay/launcher/loop.js +4 -1
  36. package/dist/relay/self-restart.js +149 -0
  37. package/dist/relay/terminal-pane.js +74 -2
  38. package/dist/self-upgrade.js +92 -27
  39. package/dist/start-command.js +15 -1
  40. package/dist/update-command.js +51 -31
  41. package/dist/waiting-cards.js +107 -0
  42. package/package.json +4 -1
@@ -33,34 +33,59 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.TRANSCRIPT_WAIT_MS = void 0;
36
+ exports.CONFIRM_WAIT_MS = exports.TRANSCRIPT_WAIT_MS = void 0;
37
+ exports.claudeConfigDir = claudeConfigDir;
38
+ exports.transcriptFileOf = transcriptFileOf;
37
39
  exports.verifyDeniedCall = verifyDeniedCall;
38
40
  exports.findToolUse = findToolUse;
39
41
  // Proof that a denial really happened: the denied call must be in the session's own transcript.
40
42
  //
41
43
  // WHY. `baychat denied-hook` reads its event from stdin, and stdin is something anybody can
42
44
  // 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.
45
+ // event would put a card on the owner's phone claiming a block that never occurred. So the call
46
+ // the event names (`tool_use_id`, `tool_name`, and the command or target) must appear as a tool
47
+ // call the MODEL made, in the transcript Claude Code keeps under `~/.claude/projects/` — a
48
+ // protected path the agent cannot write without a prompt. Before the card when it is already
49
+ // there; otherwise after the owner's yes, which is then taken back when it never appears.
47
50
  //
48
51
  // It is defence in depth, not the boundary: the owner's answer is. A card the agent somehow did
49
52
  // cause still writes nothing unless the owner taps a yes.
50
53
  //
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.
54
+ // THE CALL IS NOT THERE YET WHILE THE HOOK RUNS. Measured on Claude Code 2.1.299 (2026-10-14, a
55
+ // file watcher on the transcript): the assistant line holding the denied `tool_use` — and the
56
+ // denial's result — reach the file only AFTER the PermissionDenied hook returns. The hook gave up
57
+ // at 15:32:46.742Z after waiting 45 s; the line landed at 15:32:47.029Z. Same for an MCP call. So
58
+ // waiting longer can never prove a denial up front. The hook now looks only briefly
59
+ // (TRANSCRIPT_WAIT_MS, for a Claude Code that does write first), and an absent call is proven the
60
+ // other way — the event came from the live Claude Code of that very session (live-session.ts) —
61
+ // and checked here AFTERWARDS, by `baychat permissions confirm` (confirm.ts, CONFIRM_WAIT_MS),
62
+ // which takes the rule back out when the call never shows up.
63
+ //
53
64
  // For a subagent's call the hook still gets the MAIN transcript (with `agent_id`); its calls are
54
65
  // in `<session>/subagents/agent-<id>.jsonl` beside it.
55
66
  const fs = __importStar(require("fs"));
56
67
  const os = __importStar(require("os"));
57
68
  const path = __importStar(require("path"));
58
69
  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;
70
+ /** The hook's own look at the transcript: brief, because Claude Code 2.1.299 writes the denied call
71
+ * only after the hook returns (above), so this succeeds only on a Claude Code that writes first. */
72
+ exports.TRANSCRIPT_WAIT_MS = 2_000;
73
+ /** How long the deferred confirmation (`baychat permissions confirm`) waits for the call to reach
74
+ * the transcript after the owner's yes. Measured: well under a second after the hook returns. */
75
+ exports.CONFIRM_WAIT_MS = 90_000;
61
76
  const TRANSCRIPT_POLL_MS = 250;
62
77
  /** Only the end of the file is read: the denied call is the newest thing in it. */
63
78
  const TAIL_BYTES = 4 * 1024 * 1024;
79
+ /** Claude Code's config directory: `$CLAUDE_CONFIG_DIR`, else `~/.claude`. */
80
+ function claudeConfigDir(claudeDir) {
81
+ return claudeDir ?? process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
82
+ }
83
+ /** The file a call is written to: the main transcript, or a subagent's own beside it. */
84
+ function transcriptFileOf(transcriptPath, agentId) {
85
+ return agentId
86
+ ? path.join(transcriptPath.slice(0, -".jsonl".length), "subagents", `${agentId.startsWith("agent-") ? agentId : `agent-${agentId}`}.jsonl`)
87
+ : transcriptPath;
88
+ }
64
89
  /** Did the model really make this call in this session? NEVER THROWS. */
65
90
  async function verifyDeniedCall(ref, deps = {}) {
66
91
  const now = deps.now ?? Date.now;
@@ -68,17 +93,15 @@ async function verifyDeniedCall(ref, deps = {}) {
68
93
  const realpath = deps.realpath ?? ((f) => fs.realpathSync(f));
69
94
  const readTail = deps.readTail ?? readFileTail;
70
95
  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);
96
+ const projects = realpath(path.join(claudeConfigDir(deps.claudeDir), "projects"));
97
+ const wanted = ref.call ?? (0, permission_rule_1.callKeyOf)(ref.toolName, ref.toolInput);
73
98
  if (!ref.toolUseId || !wanted)
74
99
  return { ok: false, why: "the event names no call that can be checked" };
75
100
  if (!path.isAbsolute(ref.transcriptPath) || !ref.transcriptPath.endsWith(".jsonl")) {
76
101
  return { ok: false, why: "the event carries no transcript" };
77
102
  }
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;
103
+ const file = transcriptFileOf(ref.transcriptPath, ref.agentId);
104
+ const giveUpAt = now() + (deps.waitMs ?? exports.TRANSCRIPT_WAIT_MS);
82
105
  for (;;) {
83
106
  let real;
84
107
  try {
@@ -99,7 +122,7 @@ async function verifyDeniedCall(ref, deps = {}) {
99
122
  : { ok: false, why: "the transcript's call is not the one the event names" };
100
123
  }
101
124
  if (now() >= giveUpAt)
102
- return { ok: false, why: "the call is not in the session's transcript" };
125
+ return { ok: false, why: "the call is not in the session's transcript", absent: true };
103
126
  await sleep(TRANSCRIPT_POLL_MS);
104
127
  }
105
128
  }
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.RELOAD_SETTLE_MS = void 0;
36
+ exports.RELOAD_SETTLE_MS = exports.deniedHookLogPath = void 0;
37
37
  exports.retryOutput = retryOutput;
38
38
  exports.composeDeniedQuestion = composeDeniedQuestion;
39
39
  exports.offeredMeanings = offeredMeanings;
@@ -62,9 +62,17 @@ exports.cmdDeniedHook = cmdDeniedHook;
62
62
  // ─────────────────────────────────────────────────────────────────────────────────────────────
63
63
  //
64
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.
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.
68
76
  //
69
77
  // THE PROCESS BLOCKS THE SESSION WHILE IT WAITS, like approve-hook: hooks are awaited, so the
70
78
  // session stops until the owner answers or our deadline passes. That is the reliable shape: the
@@ -80,10 +88,16 @@ const approve_hook_1 = require("./approve-hook");
80
88
  const approval_wait_1 = require("./approval-wait");
81
89
  const config_1 = require("./config");
82
90
  const hook_stdin_1 = require("./hook-stdin");
91
+ const confirm_1 = require("./denied/confirm");
83
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");
84
96
  const permission_rule_1 = require("./denied/permission-rule");
85
97
  const settings_rules_1 = require("./denied/settings-rules");
86
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; } });
87
101
  function retryOutput() {
88
102
  return { hookSpecificOutput: { hookEventName: "PermissionDenied", retry: true } };
89
103
  }
@@ -94,8 +108,6 @@ const LABEL = {
94
108
  always: "Always allow this exact command",
95
109
  no: "No",
96
110
  };
97
- /** The single button of a card that only informs: nothing can be allowed from BayChat. */
98
- const NOTICE_OPTIONS = ["OK"];
99
111
  /** The card's own limits (apps/api/src/lib/cards.ts), used only to TRIM. */
100
112
  const QUESTION_MAX = 2000;
101
113
  const SHOWN_MAX = 900;
@@ -117,11 +129,21 @@ function isNoVerdict(reason) {
117
129
  /** The card's question. A person decides from this text alone, on a lock screen. */
118
130
  function composeDeniedQuestion(q) {
119
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;
120
137
  const offers = q.plan.onceRules.length
121
138
  ? [
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)." : "",
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
+ : "",
125
147
  ]
126
148
  : [];
127
149
  const lines = [
@@ -138,6 +160,11 @@ function composeDeniedQuestion(q) {
138
160
  const text = lines.filter((l) => l !== undefined).join("\n").replace(/\n{3,}/g, "\n\n").trim();
139
161
  return text.length <= QUESTION_MAX ? text : `${text.slice(0, QUESTION_MAX - 1)}…`;
140
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
+ }
141
168
  /** The buttons for a plan: every option it allows, and always a way to say no. */
142
169
  function offeredMeanings(plan) {
143
170
  if (plan.onceRules.length === 0)
@@ -205,23 +232,44 @@ async function decideDenied(rawStdin, argv = [], deps = {}) {
205
232
  log(`${LOG_LABEL}: this machine is not logged in to BayChat (\`baychat login\`); the denial stands.`);
206
233
  return null;
207
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
+ }
208
242
  // 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.`);
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.`);
218
255
  return null;
219
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
+ }
220
268
  const host = (deps.hostname ?? os.hostname)();
221
269
  const who = session ? `Session "${session}"` : "A Claude Code session";
222
270
  const waitDeps = { now, sleep, log };
223
271
  // 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);
272
+ const notify = (question) => (0, approval_wait_1.askOwnerAndWait)(device, { session, question, options: notice_1.NOTICE_OPTIONS }, now(), waitDeps, LOG_LABEL);
225
273
  // ── Already allowed, and denied again: the reload race, or a rule that does not match.
226
274
  const covering = call ? (0, grants_1.coveringGrant)(claudeSessionId, call, now()) : undefined;
227
275
  if (covering) {
@@ -242,9 +290,28 @@ async function decideDenied(rawStdin, argv = [], deps = {}) {
242
290
  log(`${LOG_LABEL}: no project directory to keep a rule in; the denial stands.`);
243
291
  return null;
244
292
  }
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 });
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
+ });
248
315
  const meanings = offeredMeanings(plan);
249
316
  if (meanings.length === 0) {
250
317
  // Nothing can be allowed from BayChat; the owner still learns why the session stopped.
@@ -268,18 +335,20 @@ async function decideDenied(rawStdin, argv = [], deps = {}) {
268
335
  return null;
269
336
  }
270
337
  const meaning = (outcome.index !== null ? meanings[outcome.index] : undefined) ?? "no";
271
- if (meaning === "no" || !plan.call)
338
+ if (meaning === "no" || !plan.call || !target)
272
339
  return null;
273
340
  let grant;
274
341
  try {
275
342
  grant = await (0, grants_1.grantRules)({
276
343
  kind: meaning,
277
344
  rules: plan.onceRules,
278
- settingsFile,
345
+ settingsFile: target.file,
346
+ scope: target.scope,
279
347
  call: plan.call,
280
348
  shown: plan.shown,
281
349
  claudeSessionId,
282
350
  session,
351
+ ...(unconfirmed ? { unconfirmed } : {}),
283
352
  }, now());
284
353
  }
285
354
  catch (err) {
@@ -290,6 +359,11 @@ async function decideDenied(rawStdin, argv = [], deps = {}) {
290
359
  if (grant.kind === "once" && grant.expiresAt) {
291
360
  (deps.scheduleSweep ?? scheduleSweep)(Date.parse(grant.expiresAt));
292
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
+ }
293
367
  await sleep(exports.RELOAD_SETTLE_MS);
294
368
  return retryOutput();
295
369
  }
@@ -304,26 +378,54 @@ async function decideDenied(rawStdin, argv = [], deps = {}) {
304
378
  * after-hook and every later hook run sweep too.
305
379
  */
306
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) {
307
392
  try {
308
393
  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
- });
394
+ const child = (0, child_process_1.spawn)(process.execPath, [entry, ...args], { detached: true, stdio: "ignore" });
313
395
  child.unref();
314
396
  }
315
397
  catch (err) {
316
- process.stderr.write(`${LOG_LABEL}: could not schedule the once-rule's removal (${(0, approval_wait_1.reasonOf)(err)}).\n`);
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)}).`);
317
400
  }
318
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
+ }
319
406
  /**
320
407
  * `baychat denied-hook [--session <name> | --session-from-claude] [--timeout <seconds>]`
321
408
  *
322
409
  * Always returns 0 (Claude Code ignores this event's exit code). Prints `retry` or nothing.
323
410
  */
324
411
  async function cmdDeniedHook(argv = [], stdin = process.stdin) {
325
- const output = await decideDenied(await (0, hook_stdin_1.readStdin)(stdin), argv);
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"}`);
326
417
  if (output)
327
418
  process.stdout.write(`${JSON.stringify(output)}\n`);
328
419
  return 0;
329
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,
package/dist/main.js CHANGED
@@ -9,6 +9,7 @@ const context_hooks_command_1 = require("./context-hooks-command");
9
9
  const session_name_1 = require("./session-name");
10
10
  const start_command_1 = require("./start-command");
11
11
  const update_command_1 = require("./update-command");
12
+ const notice_1 = require("./auto-update/notice");
12
13
  const session_command_1 = require("./session-command");
13
14
  const doctor_command_1 = require("./doctor-command");
14
15
  const bays_command_1 = require("./bays-command");
@@ -56,8 +57,13 @@ async function main() {
56
57
  // else", and a usage dump printed at somebody who typed the product's own name is the
57
58
  // "something else". `--help` still prints the help, and `start` refuses with one sentence
58
59
  // when this machine is not signed in, so nothing is launched from an unusable state.
59
- if (command === undefined)
60
+ if (command === undefined) {
61
+ (0, notice_1.printUpdateNotice)(command);
60
62
  return (0, start_command_1.cmdStart)([]);
63
+ }
64
+ // "BayChat updated itself to …": the relay's one line for the terminal, shown once, on a command
65
+ // a person typed (never into a hook's or an MCP server's output).
66
+ (0, notice_1.printUpdateNotice)(command);
61
67
  // Asking for help never acts: `--help`/`-h` on any command prints its usage and runs nothing
62
68
  // (cli-help.ts says which commands' arguments are not ours to read — `claude`, `send`, …).
63
69
  const help = (0, cli_help_1.helpRequest)(command, args);
@@ -76,7 +82,10 @@ async function main() {
76
82
  return (0, start_command_1.cmdStart)(args);
77
83
  case "update":
78
84
  // `--quiet` is how an old version hands a `start`-driven update to the new one.
79
- return (0, update_command_1.cmdUpdate)({ quiet: args.includes("--quiet") });
85
+ (0, args_1.rejectUnknownFlags)(args, ["--quiet", "--auto"], "baychat update [--auto on|off]");
86
+ if (args.includes("--auto") && !(0, args_1.flag)(args, "--auto"))
87
+ throw new Error("Usage: baychat update --auto on|off");
88
+ return (0, update_command_1.cmdUpdate)({ quiet: args.includes("--quiet"), auto: (0, args_1.flag)(args, "--auto") });
80
89
  case "skill": {
81
90
  const runtime = args[1];
82
91
  if (args.length !== 2 ||
@@ -7,10 +7,15 @@ exports.cmdPermissions = cmdPermissions;
7
7
  // baychat permissions remove <id> take one back out of its settings file
8
8
  // baychat permissions on | off the card itself, for sessions BayChat starts from now on
9
9
  // baychat permissions sweep (internal) take back once-rules whose time is up
10
+ // baychat permissions confirm --grant <id>
11
+ // (internal) confirm an unconfirmed grant against the
12
+ // session's transcript, or take it back (denied/confirm.ts)
10
13
  //
11
14
  // Only rules BayChat wrote are listed or removed. A rule the person wrote themselves never
12
15
  // appears here, because a rule that was already in the file was never recorded (denied/grants.ts).
16
+ const confirm_1 = require("./denied/confirm");
13
17
  const grants_1 = require("./denied/grants");
18
+ const hook_log_1 = require("./denied/hook-log");
14
19
  const opt_in_1 = require("./denied/opt-in");
15
20
  /** The longest a scheduled sweep may sleep: a once-rule's TTL is minutes, never hours. */
16
21
  const MAX_SWEEP_DELAY_MS = 60 * 60_000;
@@ -71,6 +76,17 @@ async function cmdPermissions(args, deps = {}) {
71
76
  await (deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(delay + 1000);
72
77
  }
73
78
  await (0, grants_1.sweepExpiredGrants)(now());
79
+ await (0, confirm_1.takeBackStaleUnconfirmed)({ log: hook_log_1.logBoth, ...deps.confirm });
80
+ return 0;
81
+ }
82
+ case "confirm": {
83
+ // Started detached by denied-hook (never by a person); not in USAGE on purpose.
84
+ const id = rest[rest.indexOf("--grant") + 1];
85
+ if (!rest.includes("--grant") || !id) {
86
+ err("Usage: baychat permissions confirm --grant <id>");
87
+ return 1;
88
+ }
89
+ await (0, confirm_1.confirmGrant)(id, { log: hook_log_1.logBoth, ...deps.confirm });
74
90
  return 0;
75
91
  }
76
92
  default:
@@ -83,5 +99,6 @@ function describeGrant(grant) {
83
99
  // A once-rule can carry a secret from the command (it is never offered as "always" then), so it
84
100
  // is listed as the card showed it, masked; an always-rule never holds one and is listed as written.
85
101
  const rules = grant.kind === "once" ? grant.shown : grant.rules.length ? grant.rules.join(", ") : "(the rule was already there)";
86
- return `${grant.id} ${rules}\n ${when} · ${grant.settingsFile}${grant.session ? ` · ${grant.session}` : ""}`;
102
+ const scope = grant.scope === "user" ? "user settings, every project" : "this project";
103
+ return `${grant.id} ${rules}\n ${when} · ${scope} · ${grant.settingsFile}${grant.session ? ` · ${grant.session}` : ""}`;
87
104
  }