baychat 0.30.0 → 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 (38) 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/permission.js +6 -0
  12. package/dist/cli-help.js +6 -1
  13. package/dist/denied/after-hook.js +8 -2
  14. package/dist/denied/confirm.js +131 -0
  15. package/dist/denied/grants.js +60 -2
  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/permission-rule.js +9 -6
  20. package/dist/denied/settings-rules.js +61 -0
  21. package/dist/denied/transcript.js +39 -16
  22. package/dist/denied-hook.js +132 -30
  23. package/dist/doctor-command.js +7 -1
  24. package/dist/doctor.js +28 -0
  25. package/dist/main.js +11 -2
  26. package/dist/permissions-command.js +18 -1
  27. package/dist/refresh-clients.js +36 -0
  28. package/dist/relay/acp/runner.js +4 -0
  29. package/dist/relay/commands.js +19 -3
  30. package/dist/relay/daemon.js +37 -1
  31. package/dist/relay/launcher/loop.js +4 -1
  32. package/dist/relay/self-restart.js +149 -0
  33. package/dist/relay/terminal-pane.js +74 -2
  34. package/dist/self-upgrade.js +92 -27
  35. package/dist/start-command.js +15 -1
  36. package/dist/update-command.js +51 -31
  37. package/dist/waiting-cards.js +107 -0
  38. package/package.json +4 -1
@@ -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
  }
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.refreshConnectedClients = refreshConnectedClients;
4
+ const child_process_1 = require("child_process");
5
+ const client_paths_1 = require("./client-paths");
6
+ const commands_1 = require("./commands");
7
+ const credential_refresh_1 = require("./credential-refresh");
8
+ /**
9
+ * Steps 2 and 3 of an update, shared with the relay's automatic update (which runs them from the
10
+ * NEW code once it has restarted onto it): every connected client's copy of the credential and
11
+ * endpoint, then Claude Code, which is not an MCP-config client and registers through its own CLI.
12
+ * Returns what was done, one line each. Never throws: the steps are independent, and a machine
13
+ * that failed one is better off doing the other than stopping at the first.
14
+ */
15
+ function refreshConnectedClients(device, say) {
16
+ const done = [];
17
+ try {
18
+ const updated = (0, credential_refresh_1.refreshExistingClientConfigs)({ url: `${device.baseUrl}/api/mcp`, token: device.token }, commands_1.realConfigIo, (0, client_paths_1.currentPathEnv)());
19
+ for (const file of updated)
20
+ done.push(`refreshed ${file}`);
21
+ }
22
+ catch (err) {
23
+ say(` Could not refresh client configs: ${errText(err)}`);
24
+ }
25
+ if (hasBinary("claude") && (0, commands_1.registerWithClaude)(device.baseUrl, device.token)) {
26
+ done.push("re-registered BayChat with Claude Code");
27
+ }
28
+ return done;
29
+ }
30
+ function hasBinary(name) {
31
+ const probe = (0, child_process_1.spawnSync)(name, ["--version"], { stdio: "ignore", timeout: 15_000 });
32
+ return !probe.error && probe.status === 0;
33
+ }
34
+ function errText(err) {
35
+ return err instanceof Error ? err.message : String(err);
36
+ }
@@ -201,6 +201,10 @@ class AcpSessionRunner {
201
201
  status(session) {
202
202
  return { running: this.running.has(session), queued: this.deps.queue.depth(session) };
203
203
  }
204
+ /** Sessions with a turn running or waiting to run. */
205
+ activeSessions() {
206
+ return [...this.loops.keys()];
207
+ }
204
208
  async idle() {
205
209
  while (this.loops.size > 0)
206
210
  await Promise.all([...this.loops.values()]);
@@ -62,6 +62,9 @@ const terminal_pane_1 = require("./terminal-pane");
62
62
  const owner_pid_1 = require("./owner-pid");
63
63
  const parent_watch_1 = require("./parent-watch");
64
64
  const daemon_1 = require("./daemon");
65
+ const relay_glue_1 = require("../auto-update/relay-glue");
66
+ const describe_1 = require("../auto-update/describe");
67
+ const self_upgrade_1 = require("../self-upgrade");
65
68
  const bay_relays_1 = require("./bay-relays");
66
69
  const config_1 = require("../config");
67
70
  const resume_1 = require("./resume");
@@ -135,11 +138,22 @@ async function cmdRelayStart(opts = {}) {
135
138
  baseConfigDir: (0, config_1.configDir)(),
136
139
  baseSocket: (0, socket_1.socketPath)(),
137
140
  });
138
- const daemon = new daemon_1.RelayDaemon({ onListening: () => supervisor?.start() });
139
- const shutdown = () => {
141
+ // The automatic update (docs/features/CLI_AUTO_UPDATE.md) runs in the supervising relay only;
142
+ // a per-Bay child restarts with its parent. Started once this relay owns its socket.
143
+ let updater;
144
+ const daemon = new daemon_1.RelayDaemon({
145
+ onListening: () => {
146
+ supervisor?.start();
147
+ if (!child)
148
+ updater = (0, relay_glue_1.startRelayUpdater)({ daemon, log: (line) => console.log(`[relay] ${line}`), shutdown });
149
+ },
150
+ autoUpdateStatus: child ? undefined : () => updater?.status() ?? { mode: "none", reason: "starting", current: (0, self_upgrade_1.ownVersion)() },
151
+ });
152
+ function shutdown() {
153
+ updater?.stop();
140
154
  supervisor?.stop();
141
155
  void daemon.stop().then(() => process.exit(0));
142
- };
156
+ }
143
157
  process.on("SIGINT", shutdown);
144
158
  process.on("SIGTERM", shutdown);
145
159
  const parentPid = Number(process.env[bay_relays_1.BAY_RELAY_PARENT_ENV]);
@@ -294,6 +308,8 @@ async function cmdRelayStatus() {
294
308
  console.log(` polls: ${status.polls} delivered: ${status.delivered} cursor: ${status.cursor ?? "—"}`);
295
309
  if (status.lastError)
296
310
  console.log(` last error: ${status.lastError}`);
311
+ if (status.autoUpdate)
312
+ console.log(` auto-update: ${(0, describe_1.describeAutoUpdate)(status.autoUpdate, Date.now())}`);
297
313
  for (const line of await bayRelayLines())
298
314
  console.log(line);
299
315
  // Computed before the session list so a line can report its own last failure.
@@ -193,10 +193,16 @@ class RelayDaemon {
193
193
  startLauncherFn;
194
194
  /** Aborted on stop(): ends the launcher's long-poll. */
195
195
  launcherAbort = new AbortController();
196
+ autoUpdateStatus;
197
+ /** When a message last reached this relay or was delivered — the automatic update's quiet window. */
198
+ lastActivityAt;
199
+ /** A launch from BayChat is being carried out (the launcher's `onLaunch`). */
200
+ launching = false;
196
201
  constructor(opts = {}) {
197
202
  this.log = opts.log ?? ((line) => console.log(`[relay] ${line}`));
198
203
  this.onListening = opts.onListening;
199
204
  this.startLauncherFn = opts.startLauncher ?? loop_1.startLauncher;
205
+ this.autoUpdateStatus = opts.autoUpdateStatus;
200
206
  // EVERY candidate root, because the daemon cannot know which one the agent's
201
207
  // sandbox turned out to permit — it may have fallen back past the one this
202
208
  // process would have chosen.
@@ -341,7 +347,14 @@ class RelayDaemon {
341
347
  this.codexContext.start();
342
348
  if (!(0, bay_relays_1.isBayRelayChild)()) {
343
349
  // Never awaited and never fatal: the launcher retries its own network failures until stop().
344
- void this.startLauncherFn({ auth, log: (line) => this.log(line), signal: this.launcherAbort.signal }).catch((err) => this.log(`launcher stopped: ${err instanceof Error ? err.message : String(err)}`));
350
+ void this.startLauncherFn({
351
+ auth,
352
+ log: (line) => this.log(line),
353
+ signal: this.launcherAbort.signal,
354
+ onLaunch: (busy) => {
355
+ this.launching = busy;
356
+ },
357
+ }).catch((err) => this.log(`launcher stopped: ${err instanceof Error ? err.message : String(err)}`));
345
358
  }
346
359
  this.log(`listening on ${sockPath} (pid ${process.pid}) as device "${device.user.name}"`);
347
360
  this.log(`${this.registry.all().length} known session target(s)`);
@@ -406,6 +419,7 @@ class RelayDaemon {
406
419
  // behind must not have its recovery window truncated by a session that was
407
420
  // ahead. An event with no session still registers the room — see Watermarks.
408
421
  this.watermarks.record(sessionName, conversationId, message.createdAt);
422
+ this.lastActivityAt = Date.now();
409
423
  if (!sessionName) {
410
424
  this.log(`event ${message.id} carries no session (renamed or ended mid-poll) — ignoring`);
411
425
  return;
@@ -1330,6 +1344,7 @@ class RelayDaemon {
1330
1344
  return updated;
1331
1345
  }
1332
1346
  record(outcome, session, batch) {
1347
+ this.lastActivityAt = Date.now();
1333
1348
  if (outcome.kind === "woken") {
1334
1349
  this.delivered += batch.length;
1335
1350
  const room = batch.at(-1)?.conversationId;
@@ -1635,8 +1650,29 @@ class RelayDaemon {
1635
1650
  pending: this.pending,
1636
1651
  held: this.held.summary(),
1637
1652
  lastError: this.lastError,
1653
+ autoUpdate: this.autoUpdateStatus?.(),
1638
1654
  };
1639
1655
  }
1656
+ /** What this relay is doing right now, for deciding whether it may restart (auto-update/policy.ts). */
1657
+ activity() {
1658
+ return {
1659
+ deliveries: this.queue.activeSessions(),
1660
+ headless: [...this.headlessInFlight],
1661
+ acp: this.acp.activeSessions(),
1662
+ heldAt: this.held.summary().map((h) => h.heldAt),
1663
+ launching: this.launching,
1664
+ lastActivityAt: this.lastActivityAt,
1665
+ };
1666
+ }
1667
+ /**
1668
+ * Sessions running on THIS computer that the server lists as live — the ones a card can be asked
1669
+ * through. Attached ones first: their owner is most likely looking at them.
1670
+ */
1671
+ hostedLiveSessions() {
1672
+ const hosted = this.registry.all().filter((t) => this.liveSessions.includes(t.name));
1673
+ const reachable = (name) => this.attached.has(name) || this.mailboxes.has(name) || this.channelLinked(name);
1674
+ return hosted.sort((a, b) => Number(reachable(b.name)) - Number(reachable(a.name))).map((t) => t.name);
1675
+ }
1640
1676
  async stop() {
1641
1677
  clearInterval(this.acpKeepAlive);
1642
1678
  this.launcherAbort.abort();
@@ -66,7 +66,7 @@ const BACKOFF_MAX_MS = 60_000;
66
66
  const CAPABILITIES_REFRESH_MS = 10 * 60_000;
67
67
  /** A long-poll holds for WAIT_SECONDS; give the request this much beyond it before calling it dead. */
68
68
  const REQUEST_GRACE_MS = 15_000;
69
- async function startLauncher({ auth, log, signal, deps: overrides }) {
69
+ async function startLauncher({ auth, log, signal, onLaunch, deps: overrides }) {
70
70
  const deps = { ...realLoopDeps(log, signal), ...overrides };
71
71
  let advertised;
72
72
  let unreported;
@@ -76,6 +76,7 @@ async function startLauncher({ auth, log, signal, deps: overrides }) {
76
76
  if (unreported) {
77
77
  await deps.request(auth, "POST", `/api/device-api/launches/${encodeURIComponent(unreported.id)}/report`, unreported.report, signal);
78
78
  unreported = undefined;
79
+ onLaunch?.(false);
79
80
  }
80
81
  const stamp = deps.policyStamp();
81
82
  if (!advertised || advertised.stamp !== stamp || deps.now() - advertised.at >= CAPABILITIES_REFRESH_MS) {
@@ -93,9 +94,11 @@ async function startLauncher({ auth, log, signal, deps: overrides }) {
93
94
  else {
94
95
  // Kept until delivered: a report lost to the network is sent before the next poll.
95
96
  const io = launchIo(deps, auth, id, signal);
97
+ onLaunch?.(true);
96
98
  unreported = { id, report: launch ? await safeHandle(deps, launch, io, log) : { status: "REFUSED", reason: "BAD_REQUEST" } };
97
99
  await deps.request(auth, "POST", `/api/device-api/launches/${encodeURIComponent(id)}/report`, unreported.report, signal);
98
100
  unreported = undefined;
101
+ onLaunch?.(false);
99
102
  }
100
103
  }
101
104
  backoff = BACKOFF_MIN_MS;