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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +36 -3
- package/dist/approval-wait.js +46 -33
- package/dist/approve-hook.js +35 -1
- package/dist/auto-update/describe.js +66 -0
- package/dist/auto-update/notice.js +40 -0
- package/dist/auto-update/policy.js +110 -0
- package/dist/auto-update/relay-glue.js +143 -0
- package/dist/auto-update/store.js +140 -0
- package/dist/auto-update/updater.js +324 -0
- package/dist/channel/feed.js +50 -1
- package/dist/channel/permission.js +6 -0
- package/dist/channel/server.js +1 -0
- package/dist/claude-onboarding.js +3 -1
- package/dist/cli-help.js +6 -1
- package/dist/denied/after-hook.js +8 -2
- package/dist/denied/confirm.js +131 -0
- package/dist/denied/grants.js +60 -2
- package/dist/denied/hook-log.js +75 -0
- package/dist/denied/live-session.js +154 -0
- package/dist/denied/notice.js +29 -0
- package/dist/denied/permission-rule.js +9 -6
- package/dist/denied/settings-rules.js +61 -0
- package/dist/denied/transcript.js +39 -16
- package/dist/denied-hook.js +132 -30
- package/dist/doctor-command.js +7 -1
- package/dist/doctor.js +28 -0
- package/dist/main.js +11 -2
- package/dist/permissions-command.js +18 -1
- package/dist/protocol-content.js +1 -1
- package/dist/refresh-clients.js +36 -0
- package/dist/relay/acp/runner.js +4 -0
- package/dist/relay/commands.js +19 -3
- package/dist/relay/daemon.js +37 -1
- package/dist/relay/launcher/loop.js +4 -1
- package/dist/relay/self-restart.js +149 -0
- package/dist/relay/terminal-pane.js +74 -2
- package/dist/self-upgrade.js +92 -27
- package/dist/start-command.js +15 -1
- package/dist/update-command.js +51 -31
- package/dist/waiting-cards.js +107 -0
- package/package.json +4 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "baychat",
|
|
3
|
-
"version": "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
|
|
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
|
-
###
|
|
324
|
+
### Staying up to date
|
|
325
325
|
|
|
326
|
-
|
|
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
|
package/dist/approval-wait.js
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
package/dist/approve-hook.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|