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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "displayName": "BayChat",
5
5
  "description": "Wakes this Claude Code session the moment a BayChat message arrives: no Monitor, no 30-minute deadline. Uses the baychat CLI already on this computer (npm install -g baychat, then baychat login).",
6
6
  "author": {
package/README.md CHANGED
@@ -289,7 +289,7 @@ Read these as *how often you do it*, not as steps you repeat.
289
289
  ### In a plain shell
290
290
 
291
291
  ```bash
292
- npm i -g baychat # install or upgrade — nothing updates itself
292
+ npm i -g baychat # install once — from 0.31.0 the relay keeps it current
293
293
  baychat login # once per computer: the QR
294
294
  baychat connect # list the apps it can configure
295
295
  baychat connect claude # refresh Claude Code's skill (no QR, no re-pairing)
@@ -321,9 +321,42 @@ Cursor no command — ask it: "join BayChat as Session-C"
321
321
  A group or Sessions join creates no private owner chat; one you already have stays
322
322
  under the same agent and session. Use `--private` for the private chat.
323
323
 
324
- ### Upgrading, in order
324
+ ### Staying up to date
325
325
 
326
- Publishing a new version upgrades nobody by itself.
326
+ From 0.31.0 **the relay updates BayChat by itself**, the way Claude Code does. About once a
327
+ day it asks npm for the newest `baychat`; a patch or minor release is installed with the same
328
+ command `baychat update` uses, in a quiet moment — never while an agent turn is being delivered,
329
+ a session command is running, a session is being started from BayChat, or a card on your phone
330
+ is waiting for an answer. Then the relay restarts onto the new code and your next `baychat`
331
+ command says so in one line:
332
+
333
+ ```text
334
+ BayChat updated itself to 0.31.1 (from 0.31.0).
335
+ ```
336
+
337
+ A new major version, or a release marked as needing you, is not installed by itself: your phone
338
+ gets a card, *"New BayChat for your computer (…): <reason> · Update now / Later"*. Only you can
339
+ answer it. "Later" asks again in three days.
340
+
341
+ If an install fails, the version you have keeps running and it tries again a day later.
342
+
343
+ ```bash
344
+ baychat update --auto off # switch automatic updates off for this computer
345
+ baychat update --auto on # and back on (the default)
346
+ baychat doctor # shows the state: on/off, last check, latest version
347
+ ```
348
+
349
+ `BAYCHAT_AUTO_UPDATE=0` also switches them off, for CI and managed machines — but only for
350
+ processes that see it; for the relay itself, use `baychat update --auto off`.
351
+
352
+ Only a **global npm install** whose relay runs under systemd or launchd (what
353
+ `baychat relay start` sets up on Linux and macOS) updates itself. On **Windows** the relay only
354
+ tells you for now — install with `baychat update`. An `npx` copy, a project install, a relay
355
+ started by hand with `--foreground`, or one running as root is also only told about new versions.
356
+
357
+ ### Upgrading by hand
358
+
359
+ `baychat update` still does the whole update at once, any time.
327
360
 
328
361
  ```text
329
362
  $baychat --update # in Codex
@@ -15,6 +15,7 @@ exports.reasonOf = reasonOf;
15
15
  // hook that prints nothing ALLOWS, a PermissionDenied hook that prints nothing leaves the denial
16
16
  // standing. Each caller turns every outcome below into its own fail-closed answer.
17
17
  const api_1 = require("./api");
18
+ const waiting_cards_1 = require("./waiting-cards");
18
19
  /** One HTTP hold. The server clamps to 30; asking for less than it allows leaves headroom for a
19
20
  * proxy with its own idle timeout. */
20
21
  const POLL_WAIT_SECONDS = 25;
@@ -60,39 +61,14 @@ async function askOwnerAndWait(device, ask, deadlineMs, deps, label) {
60
61
  const requestId = typeof reply.requestId === "string" ? reply.requestId : "";
61
62
  if (!requestId)
62
63
  return { kind: "failed", reason: "BayChat did not return an approval id" };
63
- // Wait. Re-arm across the bounded holds exactly as `await_approval` does.
64
- let failures = 0;
65
- for (;;) {
66
- const status = typeof reply.status === "string" ? reply.status : "";
67
- if (status === "answered") {
68
- return {
69
- kind: "answered",
70
- requestId,
71
- index: typeof reply.answerIndex === "number" ? reply.answerIndex : null,
72
- answer: typeof reply.answer === "string" ? reply.answer : null,
73
- };
74
- }
75
- if (status === "cancelled")
76
- return { kind: "cancelled", requestId };
77
- if (status !== "waiting")
78
- return { kind: "unrecognised", requestId, status };
79
- const remaining = deadlineMs - deps.now();
80
- if (remaining <= 0)
81
- return { kind: "deadline", requestId };
82
- try {
83
- reply = await (0, api_1.apiRequest)(device, "GET", `/api/device-api/approvals/${encodeURIComponent(requestId)}?wait=${holdSeconds(remaining)}`);
84
- failures = 0;
85
- }
86
- catch (err) {
87
- if (isDefinite(err))
88
- return { kind: "failed", reason: `BayChat refused the wait — ${reasonOf(err)}` };
89
- failures += 1;
90
- deps.log(`${label}: waiting failed (${reasonOf(err)}) — attempt ${failures}`);
91
- if (failures >= MAX_CONSECUTIVE_POLL_FAILURES) {
92
- return { kind: "failed", reason: `BayChat became unreachable while waiting — ${reasonOf(err)}` };
93
- }
94
- await deps.sleep(POLL_RETRY_DELAY_MS);
95
- }
64
+ // Wait. Re-arm across the bounded holds exactly as `await_approval` does. Marked while it
65
+ // waits, so the relay's automatic update never restarts BayChat under an open question.
66
+ const unmark = (0, waiting_cards_1.markWaitingCard)(requestId);
67
+ try {
68
+ return await waitForAnswer(device, requestId, reply, deadlineMs, deps, label);
69
+ }
70
+ finally {
71
+ unmark();
96
72
  }
97
73
  }
98
74
  catch (err) {
@@ -101,6 +77,43 @@ async function askOwnerAndWait(device, ask, deadlineMs, deps, label) {
101
77
  return { kind: "failed", reason: `waiting for BayChat failed unexpectedly — ${reasonOf(err)}` };
102
78
  }
103
79
  }
80
+ /** Park on an existing card until it is answered, withdrawn, unreachable or past the deadline. */
81
+ async function waitForAnswer(device, requestId, first, deadlineMs, deps, label) {
82
+ let reply = first;
83
+ let failures = 0;
84
+ for (;;) {
85
+ const status = typeof reply.status === "string" ? reply.status : "";
86
+ if (status === "answered") {
87
+ return {
88
+ kind: "answered",
89
+ requestId,
90
+ index: typeof reply.answerIndex === "number" ? reply.answerIndex : null,
91
+ answer: typeof reply.answer === "string" ? reply.answer : null,
92
+ };
93
+ }
94
+ if (status === "cancelled")
95
+ return { kind: "cancelled", requestId };
96
+ if (status !== "waiting")
97
+ return { kind: "unrecognised", requestId, status };
98
+ const remaining = deadlineMs - deps.now();
99
+ if (remaining <= 0)
100
+ return { kind: "deadline", requestId };
101
+ try {
102
+ reply = await (0, api_1.apiRequest)(device, "GET", `/api/device-api/approvals/${encodeURIComponent(requestId)}?wait=${holdSeconds(remaining)}`);
103
+ failures = 0;
104
+ }
105
+ catch (err) {
106
+ if (isDefinite(err))
107
+ return { kind: "failed", reason: `BayChat refused the wait — ${reasonOf(err)}` };
108
+ failures += 1;
109
+ deps.log(`${label}: waiting failed (${reasonOf(err)}) — attempt ${failures}`);
110
+ if (failures >= MAX_CONSECUTIVE_POLL_FAILURES) {
111
+ return { kind: "failed", reason: `BayChat became unreachable while waiting — ${reasonOf(err)}` };
112
+ }
113
+ await deps.sleep(POLL_RETRY_DELAY_MS);
114
+ }
115
+ }
116
+ }
104
117
  /**
105
118
  * Take back a card nobody will act on any more (the waiting process is about to give up). Best
106
119
  * effort and NEVER THROWS: a card left pending is harmless — the answer would reach nobody — so
@@ -40,6 +40,10 @@ exports.describeToolRequest = describeToolRequest;
40
40
  exports.composeQuestion = composeQuestion;
41
41
  exports.decideApproval = decideApproval;
42
42
  exports.sessionFromClaude = sessionFromClaude;
43
+ exports.procStatFields = procStatFields;
44
+ exports.procStartTicks = procStartTicks;
45
+ exports.psParentOf = psParentOf;
46
+ exports.psAgeMs = psAgeMs;
43
47
  exports.parseEtime = parseEtime;
44
48
  exports.decideApprovalOrDefer = decideApprovalOrDefer;
45
49
  exports.cmdApproveHook = cmdApproveHook;
@@ -93,6 +97,7 @@ exports.cmdApproveHook = cmdApproveHook;
93
97
  // exactly like a terminal prompt. Our deadline bounds only how long THIS PROCESS waits, and it
94
98
  // exists solely because the alternative (being killed by the handler timeout) would allow.
95
99
  const child_process_1 = require("child_process");
100
+ const fs = __importStar(require("fs"));
96
101
  const os = __importStar(require("os"));
97
102
  const approval_wait_1 = require("./approval-wait");
98
103
  const config_1 = require("./config");
@@ -371,10 +376,39 @@ function psField(pid, field) {
371
376
  const out = String(r.stdout ?? "").trim();
372
377
  return out || undefined;
373
378
  }
379
+ /**
380
+ * The fields of `/proc/<pid>/stat` (Linux only), numbered as proc(5) numbers them: `[1]` is the
381
+ * pid, `[4]` the parent, `[22]` the start time in clock ticks since boot. The command name
382
+ * (field 2) may itself hold spaces and parentheses, so the split starts after its LAST `)`.
383
+ * Undefined anywhere else, or when the process is gone.
384
+ */
385
+ function procStatFields(pid, read = (f) => fs.readFileSync(f, "utf8")) {
386
+ if (process.platform !== "linux" || !Number.isInteger(pid) || pid <= 0)
387
+ return undefined;
388
+ try {
389
+ const text = read(`/proc/${pid}/stat`);
390
+ const close = text.lastIndexOf(")");
391
+ if (close < 0)
392
+ return undefined;
393
+ // Index 0 unused, 1 = pid, 2 = comm, then field 3 onwards from after the parenthesis.
394
+ return ["", String(pid), text.slice(text.indexOf("(") + 1, close), ...text.slice(close + 1).trim().split(/\s+/)];
395
+ }
396
+ catch {
397
+ return undefined;
398
+ }
399
+ }
400
+ /** The process's start time as `/proc/<pid>/stat` field 22 (clock ticks since boot), Linux only. */
401
+ function procStartTicks(pid) {
402
+ const start = procStatFields(pid)?.[22];
403
+ return start && /^\d+$/.test(start) ? start : undefined;
404
+ }
405
+ /** The parent of `pid`: `/proc` on Linux (no process spawned), `ps` elsewhere. */
374
406
  function psParentOf(pid) {
375
- const n = Number(psField(pid, "ppid"));
407
+ const fromProc = Number(procStatFields(pid)?.[4]);
408
+ const n = Number.isInteger(fromProc) && fromProc > 0 ? fromProc : Number(psField(pid, "ppid"));
376
409
  return Number.isInteger(n) && n > 0 ? n : undefined;
377
410
  }
411
+ /** How long `pid` has been running, from `ps -o etime` (whole seconds). */
378
412
  function psAgeMs(pid) {
379
413
  const etime = psField(pid, "etime");
380
414
  return etime ? parseEtime(etime) : undefined;
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.describeAutoUpdate = describeAutoUpdate;
4
+ exports.statusFromFiles = statusFromFiles;
5
+ const self_upgrade_1 = require("../self-upgrade");
6
+ const policy_1 = require("./policy");
7
+ /**
8
+ * The automatic update in one line, for `relay status` and `baychat doctor`. One wording for both,
9
+ * so the two can never describe the same machine differently.
10
+ */
11
+ function describeAutoUpdate(status, now) {
12
+ const checked = status.lastCheckAt ? `, checked ${ago(now - status.lastCheckAt)}` : ", not checked yet";
13
+ const latest = status.latest && (0, self_upgrade_1.compareVersions)(status.current, status.latest) === -1 ? `${status.latest} is out` : `${status.current} is the latest`;
14
+ switch (status.mode) {
15
+ case "off":
16
+ return `off (${status.reason}) — running ${status.current}`;
17
+ case "none":
18
+ return `not used here (${status.reason ?? "not an npm install"})`;
19
+ case "notify":
20
+ // The reason reads on from "notify only": "on Windows (install with `baychat update`)".
21
+ return `notify only ${status.reason} — ${status.latest ? latest : `running ${status.current}`}${checked}`;
22
+ case "auto": {
23
+ if (status.failure)
24
+ return `on — the last automatic install failed: ${status.failure}`;
25
+ const waiting = status.cardVersion
26
+ ? ` — ${status.cardVersion} is waiting for you on your phone`
27
+ : status.waitingFor
28
+ ? ` — waiting: ${status.waitingFor}`
29
+ : "";
30
+ return `on — ${latest}${checked}${waiting}`;
31
+ }
32
+ }
33
+ }
34
+ /**
35
+ * The status as the files say, for `doctor` (which runs whether or not a relay is up). The relay
36
+ * writes its mode into the state on every look; the person's switch and the environment are read
37
+ * fresh, because they may have changed since.
38
+ */
39
+ function statusFromFiles(input) {
40
+ const { state, setting, env, current } = input;
41
+ const off = (0, policy_1.autoUpdateOffReason)(env, setting);
42
+ const base = {
43
+ current,
44
+ latest: state.latest,
45
+ lastCheckAt: state.lastCheckAt,
46
+ waitingFor: state.waitingFor,
47
+ cardVersion: state.card?.requestId ? state.card.version : undefined,
48
+ failure: state.failure?.reason,
49
+ };
50
+ if (off)
51
+ return { ...base, mode: "off", reason: off };
52
+ if (!state.mode)
53
+ return { ...base, mode: "none", reason: "the relay has not run a version with automatic updates yet" };
54
+ if (state.mode === "off")
55
+ return { ...base, mode: "none", reason: "just turned on; the relay picks it up within a minute" };
56
+ return { ...base, mode: state.mode, reason: state.modeReason };
57
+ }
58
+ function ago(ms) {
59
+ const minutes = Math.max(0, Math.round(ms / 60_000));
60
+ if (minutes < 60)
61
+ return `${minutes} min ago`;
62
+ const hours = Math.round(minutes / 60);
63
+ if (hours < 48)
64
+ return `${hours} h ago`;
65
+ return `${Math.round(hours / 24)} days ago`;
66
+ }
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.printUpdateNotice = printUpdateNotice;
4
+ const store_1 = require("./store");
5
+ /**
6
+ * Show the relay's one line about updates ("BayChat updated itself to 0.31.1 (from 0.31.0).") on
7
+ * the next `baychat` command a PERSON runs — the way Claude Code says it updated.
8
+ *
9
+ * Only to a terminal: on stderr, and only when stderr is a TTY, so a hook's verdict, an MCP
10
+ * server's JSON-RPC, a status line or anything piped never carries it. Commands run by a client
11
+ * rather than typed are skipped outright, and so is `update` (it answers the notice itself). The
12
+ * notice is taken when shown, so it is shown once.
13
+ */
14
+ const NOT_FOR_PEOPLE = new Set([
15
+ "mcp",
16
+ "channel",
17
+ "approve-hook",
18
+ "denied-hook",
19
+ "denied-after",
20
+ "context-statusline",
21
+ "context-hook",
22
+ "context-flush",
23
+ "skill",
24
+ "session-name",
25
+ "update",
26
+ ]);
27
+ function printUpdateNotice(command, stream = process.stderr, take = store_1.takeUpdateNotice) {
28
+ if ((command !== undefined && NOT_FOR_PEOPLE.has(command)) || !stream.isTTY)
29
+ return;
30
+ let text;
31
+ try {
32
+ text = take();
33
+ }
34
+ catch {
35
+ // An unreadable notice is not worth failing the command the person actually asked for.
36
+ return;
37
+ }
38
+ if (text)
39
+ stream.write(`${text}\n`);
40
+ }
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HELD_FRESH_MS = exports.QUIET_MS = exports.CARD_POLL_MS = exports.ASK_AGAIN_MS = exports.CHECK_INTERVAL_MS = exports.AUTO_UPDATE_ENV = void 0;
4
+ exports.planUpdate = planUpdate;
5
+ exports.autoUpdateOffReason = autoUpdateOffReason;
6
+ exports.nextCheckAt = nextCheckAt;
7
+ exports.installAllowedAt = installAllowedAt;
8
+ exports.busyReason = busyReason;
9
+ const self_upgrade_1 = require("../self-upgrade");
10
+ /** Longest reason a card or terminal line carries when several releases each said something. */
11
+ const REASON_MAX_CHARS = 300;
12
+ function planUpdate(current, release) {
13
+ const order = (0, self_upgrade_1.compareVersions)(current, release.version);
14
+ // Already there, newer (a local build), or a version that does not parse (a dev build like
15
+ // 0.31.0-dev): never install over it.
16
+ if (order === undefined || order >= 0)
17
+ return { kind: "current" };
18
+ // Every release this machine would cross that asked for the person, newest first.
19
+ const crossed = Object.entries(release.attention)
20
+ .filter(([version]) => (0, self_upgrade_1.compareVersions)(version, current) === 1 && ((0, self_upgrade_1.compareVersions)(version, release.version) ?? 1) <= 0)
21
+ .sort(([a], [b]) => (0, self_upgrade_1.compareVersions)(b, a) ?? 0)
22
+ .map(([, reason]) => reason);
23
+ const major = (v) => Number(v.split(".")[0]);
24
+ if (major(release.version) > major(current))
25
+ crossed.push(`a new major version (${major(release.version)}.x)`);
26
+ if (crossed.length === 0)
27
+ return { kind: "auto", version: release.version };
28
+ return { kind: "ask", version: release.version, reason: clip(crossed.join("; "), REASON_MAX_CHARS) };
29
+ }
30
+ /** Environment values that switch automatic updates off (for CI and companies). */
31
+ const ENV_OFF = new Set(["0", "off", "false", "no"]);
32
+ exports.AUTO_UPDATE_ENV = "BAYCHAT_AUTO_UPDATE";
33
+ /** Why automatic updates are off, or undefined when they are on. The environment wins. */
34
+ function autoUpdateOffReason(env, setting) {
35
+ const raw = env[exports.AUTO_UPDATE_ENV];
36
+ if (raw !== undefined && ENV_OFF.has(raw.trim().toLowerCase()))
37
+ return `${exports.AUTO_UPDATE_ENV}=${raw.trim()}`;
38
+ if (setting === "off")
39
+ return "baychat update --auto off";
40
+ return undefined;
41
+ }
42
+ // ─── Timing ───────────────────────────────────────────────────────────────────────────────
43
+ const MINUTE = 60_000;
44
+ const HOUR = 60 * MINUTE;
45
+ const DAY = 24 * HOUR;
46
+ /** About once a day — invisible on the registry, and soon enough that nobody runs `update`. */
47
+ exports.CHECK_INTERVAL_MS = DAY;
48
+ /** Spread across an hour so a fleet of relays started together never asks npm together. */
49
+ const CHECK_SPREAD_MS = HOUR;
50
+ /** The first check after a start: soon, but not in the busy first minutes. */
51
+ const FIRST_CHECK_MIN_MS = 5 * MINUTE;
52
+ const FIRST_CHECK_SPREAD_MS = 10 * MINUTE;
53
+ /** A check that failed (offline, npm down) is tried again sooner than a day, then backs off. */
54
+ const CHECK_RETRY_MS = HOUR;
55
+ /** A failed install of one version waits a day, then two, … up to a week. A newer version resets it. */
56
+ const INSTALL_RETRY_MS = DAY;
57
+ const INSTALL_RETRY_MAX_MS = 7 * DAY;
58
+ /** "Later" on the card: asked again after this long. */
59
+ exports.ASK_AGAIN_MS = 3 * DAY;
60
+ /** How often a waiting card is looked at. A tap on "Update now" lands within this. */
61
+ exports.CARD_POLL_MS = 5 * MINUTE;
62
+ /** No message for this long before a restart: an agent woken just now is likely mid-turn. */
63
+ exports.QUIET_MS = 10 * MINUTE;
64
+ /** A held message younger than this is a session mid-turn; an older one is a session that died. */
65
+ exports.HELD_FRESH_MS = 30 * MINUTE;
66
+ /**
67
+ * When the next registry check is due. `random` is in [0, 1) and spreads relays apart; it is a
68
+ * parameter so a test can pin it.
69
+ */
70
+ function nextCheckAt(timing, startedAt, random) {
71
+ const firstAfterStart = startedAt + FIRST_CHECK_MIN_MS + random * FIRST_CHECK_SPREAD_MS;
72
+ if (timing.lastCheckAt === undefined)
73
+ return firstAfterStart;
74
+ const failures = timing.checkFailures ?? 0;
75
+ const wait = failures > 0 ? Math.min(CHECK_RETRY_MS * 2 ** (failures - 1), exports.CHECK_INTERVAL_MS) : exports.CHECK_INTERVAL_MS + random * CHECK_SPREAD_MS;
76
+ return Math.max(timing.lastCheckAt + wait, firstAfterStart);
77
+ }
78
+ /** When installing `version` may be tried again after failing; 0 when nothing holds it back. */
79
+ function installAllowedAt(failure, version) {
80
+ if (!failure || failure.version !== version || failure.count < 1)
81
+ return 0;
82
+ return failure.at + Math.min(INSTALL_RETRY_MS * 2 ** (failure.count - 1), INSTALL_RETRY_MAX_MS);
83
+ }
84
+ /**
85
+ * Why the relay must not restart now, or undefined when it may. Fail closed on everything the
86
+ * relay can see: a restart is never worth an interrupted turn, a lost session command, or a card
87
+ * whose answer would arrive at a relay that is not there.
88
+ */
89
+ function busyReason({ activity, cardsWaiting, dependents, now }) {
90
+ if (activity.deliveries.length)
91
+ return `delivering to ${activity.deliveries.join(", ")}`;
92
+ if (activity.headless.length)
93
+ return `a headless turn is running for ${activity.headless.join(", ")}`;
94
+ if (activity.acp.length)
95
+ return `an agent turn is running for ${activity.acp.join(", ")}`;
96
+ if (activity.launching)
97
+ return "a session is being started from BayChat";
98
+ if (cardsWaiting > 0)
99
+ return `${cardsWaiting} card(s) on the phone are waiting for an answer`;
100
+ if (activity.heldAt.some((at) => now - Date.parse(at) < exports.HELD_FRESH_MS))
101
+ return "a session is mid-turn with messages waiting for it";
102
+ if (activity.lastActivityAt !== undefined && now - activity.lastActivityAt < exports.QUIET_MS)
103
+ return "messages arrived in the last 10 minutes";
104
+ if (dependents.length)
105
+ return `processes the relay started are still running (pid ${dependents.slice(0, 3).join(", ")})`;
106
+ return undefined;
107
+ }
108
+ function clip(text, max) {
109
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
110
+ }
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.startRelayUpdater = startRelayUpdater;
4
+ const api_1 = require("../api");
5
+ const config_1 = require("../config");
6
+ const self_restart_1 = require("../relay/self-restart");
7
+ const session_reply_1 = require("../relay/session-reply");
8
+ const self_upgrade_1 = require("../self-upgrade");
9
+ const refresh_clients_1 = require("../refresh-clients");
10
+ const waiting_cards_1 = require("../waiting-cards");
11
+ const policy_1 = require("./policy");
12
+ const store_1 = require("./store");
13
+ const updater_1 = require("./updater");
14
+ /**
15
+ * The automatic update wired to a real relay: npm, the disk, the device API for the card, and the
16
+ * init system for the restart. Everything that decides is in `updater.ts` and `policy.ts`; this
17
+ * file only connects it. Run by the SUPERVISING relay only — a per-Bay child restarts with it.
18
+ */
19
+ function startRelayUpdater(opts) {
20
+ const supervisor = (0, self_restart_1.detectSupervisor)(self_restart_1.realSupervisorProbe);
21
+ const install = (0, self_upgrade_1.classifyInstall)((0, self_upgrade_1.currentEntry)());
22
+ const current = (0, self_upgrade_1.ownVersion)();
23
+ const deps = {
24
+ now: Date.now,
25
+ random: Math.random,
26
+ log: opts.log,
27
+ current,
28
+ ability: { install, supervised: supervisor !== undefined, root: process.getuid?.() === 0, platform: process.platform },
29
+ env: process.env,
30
+ setting: store_1.loadAutoSetting,
31
+ loadState: store_1.loadAutoUpdateState,
32
+ saveState: store_1.saveAutoUpdateState,
33
+ notify: store_1.writeUpdateNotice,
34
+ lookupLatest: () => (0, self_upgrade_1.lookupLatest)(self_upgrade_1.realNpm),
35
+ installVersion: (version) => installGlobal(install, version, current, opts.log),
36
+ busyReason: () => (0, policy_1.busyReason)({
37
+ activity: opts.daemon.activity(),
38
+ cardsWaiting: (0, waiting_cards_1.countWaitingCards)(),
39
+ dependents: supervisor === "systemd" ? (0, self_restart_1.processesEndedByRestart)(self_restart_1.realSupervisorProbe) : [],
40
+ now: Date.now(),
41
+ }),
42
+ restart: async () => restartUnder(supervisor, opts.shutdown),
43
+ refreshClients: async () => {
44
+ const device = (0, config_1.loadDeviceCredentials)();
45
+ if (device)
46
+ for (const line of (0, refresh_clients_1.refreshConnectedClients)(device, opts.log))
47
+ opts.log(`auto-update: ${line}`);
48
+ },
49
+ cards: deviceCards(opts.daemon, opts.log),
50
+ };
51
+ const updater = new updater_1.AutoUpdater(deps);
52
+ updater.start();
53
+ return updater;
54
+ }
55
+ /**
56
+ * Install `version` over the running global copy, holding the install lock. If npm failed in a way
57
+ * that left something other than the running version on disk, put the running version back, so a
58
+ * relay that restarts later (a reboot) still starts working code.
59
+ */
60
+ async function installGlobal(install, version, current, log) {
61
+ if (install.kind !== "global")
62
+ return { ok: false, reason: `a ${install.kind} copy is never updated automatically` };
63
+ const unlock = (0, store_1.acquireUpdateLock)();
64
+ if (!unlock)
65
+ return { ok: false, reason: "another update of baychat is running" };
66
+ try {
67
+ const deps = { npm: self_upgrade_1.realNpm, readVersion: self_upgrade_1.readPackageVersion, install };
68
+ const installed = await (0, self_upgrade_1.installRelease)(deps, version);
69
+ if (installed.ok || (0, self_upgrade_1.readPackageVersion)(install.packageRoot) === current)
70
+ return installed;
71
+ const restored = await (0, self_upgrade_1.installRelease)(deps, current);
72
+ log(`auto-update: ${restored.ok ? `put ${current} back on disk` : `could not put ${current} back (${restored.reason})`}`);
73
+ return installed;
74
+ }
75
+ finally {
76
+ unlock();
77
+ }
78
+ }
79
+ function restartUnder(supervisor, shutdown) {
80
+ // `mode` is never "auto" without a supervisor, so this is an invariant, not a branch.
81
+ if (!supervisor)
82
+ throw new Error("refusing to restart: no init system would start the relay again");
83
+ (0, self_restart_1.arrangeRestart)(supervisor);
84
+ shutdown();
85
+ }
86
+ /** The decision card, over the device API — the same endpoint the hooks use, owner-only answers. */
87
+ function deviceCards(daemon, log) {
88
+ const device = () => {
89
+ const creds = (0, config_1.loadDeviceCredentials)();
90
+ if (!creds)
91
+ throw new Error("this computer is not signed in to BayChat");
92
+ return creds;
93
+ };
94
+ return {
95
+ async ask(question) {
96
+ const session = daemon.hostedLiveSessions()[0];
97
+ if (!session)
98
+ return undefined; // A card goes to the private chat with a session; none is live.
99
+ try {
100
+ const reply = await (0, api_1.apiRequest)(device(), "POST", "/api/device-api/approvals", {
101
+ session,
102
+ question,
103
+ options: [...updater_1.UPDATE_OPTIONS],
104
+ direct: true,
105
+ wait: 1,
106
+ });
107
+ if (typeof reply.requestId !== "string" || !reply.requestId)
108
+ return undefined;
109
+ return {
110
+ requestId: reply.requestId,
111
+ conversationId: typeof reply.conversationId === "string" ? reply.conversationId : undefined,
112
+ session: typeof reply.sessionName === "string" ? reply.sessionName : session,
113
+ };
114
+ }
115
+ catch (err) {
116
+ log(`auto-update: could not put the update card up (${err instanceof Error ? err.message : String(err)})`);
117
+ return undefined;
118
+ }
119
+ },
120
+ async answer(requestId) {
121
+ try {
122
+ const reply = await (0, api_1.apiRequest)(device(), "GET", `/api/device-api/approvals/${encodeURIComponent(requestId)}?wait=1`);
123
+ if (reply.status === "waiting")
124
+ return { kind: "waiting" };
125
+ if (reply.status === "answered")
126
+ return { kind: reply.answerIndex === updater_1.UPDATE_NOW_INDEX ? "update" : "later" };
127
+ return { kind: "gone" };
128
+ }
129
+ catch (err) {
130
+ // The card no longer exists for us: gone. A blip: ask again on the next poll.
131
+ if (err instanceof api_1.ApiError && (err.status === 404 || err.status === 403))
132
+ return { kind: "gone" };
133
+ return { kind: "waiting" };
134
+ }
135
+ },
136
+ async withdraw(requestId) {
137
+ await (0, api_1.apiRequest)(device(), "POST", `/api/device-api/approvals/${encodeURIComponent(requestId)}/withdraw`, {});
138
+ },
139
+ async say(conversationId, session, text) {
140
+ await (0, session_reply_1.postSessionReply)(device(), conversationId, session, text);
141
+ },
142
+ };
143
+ }