baychat 0.29.9 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +43 -10
- package/dist/approval-wait.js +143 -0
- package/dist/approve-hook.js +68 -133
- 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/permission.js +6 -0
- package/dist/cli-help.js +30 -3
- package/dist/denied/after-hook.js +46 -0
- package/dist/denied/confirm.js +131 -0
- package/dist/denied/grants.js +292 -0
- 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/opt-in.js +68 -0
- package/dist/denied/permission-rule.js +186 -0
- package/dist/denied/settings-rules.js +221 -0
- package/dist/denied/transcript.js +173 -0
- package/dist/denied-hook.js +431 -0
- package/dist/doctor-command.js +7 -1
- package/dist/doctor.js +28 -0
- package/dist/help-topics.js +12 -4
- package/dist/hook-stdin.js +35 -0
- package/dist/index.js +13 -1
- package/dist/main.js +20 -2
- package/dist/permissions-command.js +104 -0
- 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/approval-settings.js +56 -2
- 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/runtimes.js +2 -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.31.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
|
@@ -61,8 +61,8 @@ $baychat --update # Codex updates BayChat from the session
|
|
|
61
61
|
Use the exact group title (or the group's id, which `list_groups` prints); a missing
|
|
62
62
|
group is created when requested by title this way, never by id.
|
|
63
63
|
An existing group requires membership and admin rights. The command creates or
|
|
64
|
-
reuses the agent and session together
|
|
65
|
-
chat with you. Automatic names, such as `Codex-7c3a912b84d2`, come from a verified
|
|
64
|
+
reuses the agent and session together and joins the group. It creates no private
|
|
65
|
+
chat with you (start one from the app, or use `--private`). Automatic names, such as `Codex-7c3a912b84d2`, come from a verified
|
|
66
66
|
native session identity; retries reuse the name. If that identity cannot be
|
|
67
67
|
verified, supply a name. A bare command lists sessions.
|
|
68
68
|
|
|
@@ -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)
|
|
@@ -308,8 +308,8 @@ skill as well as a missing one.
|
|
|
308
308
|
### Inside an agent
|
|
309
309
|
|
|
310
310
|
```
|
|
311
|
-
Claude Code /baychat Session-A Sessions
|
|
312
|
-
/baychat Session-A "Design Review" that room
|
|
311
|
+
Claude Code /baychat Session-A the Sessions group
|
|
312
|
+
/baychat Session-A "Design Review" that room
|
|
313
313
|
/baychat list sessions, join nothing
|
|
314
314
|
|
|
315
315
|
Codex $baychat Session-B no slash commands; a $ name
|
|
@@ -318,12 +318,45 @@ Codex $baychat Session-B no slash commands; a $ nam
|
|
|
318
318
|
Cursor no command — ask it: "join BayChat as Session-C"
|
|
319
319
|
```
|
|
320
320
|
|
|
321
|
-
|
|
322
|
-
Use `--private`
|
|
321
|
+
A group or Sessions join creates no private owner chat; one you already have stays
|
|
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
|
|
@@ -992,7 +1025,7 @@ session and group tools:
|
|
|
992
1025
|
|
|
993
1026
|
| Tool | What it does |
|
|
994
1027
|
|------|--------------|
|
|
995
|
-
| `join_session` | Name this terminal and put it in a room. `{ session }` opens a 1:1 with you; `{ session, group }` joins that group
|
|
1028
|
+
| `join_session` | Name this terminal and put it in a room. `{ session }` opens a 1:1 with you; `{ session, group }` joins that group (no private chat is created) |
|
|
996
1029
|
| `list_sessions` | This login's sessions — name, live/idle, last seen |
|
|
997
1030
|
| `end_session` | Park a session. The chat and its history survive; rejoining the same name revives it |
|
|
998
1031
|
| `list_groups` | The groups you are in — exact title, who is in them, and the conversation id |
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.askOwnerAndWait = askOwnerAndWait;
|
|
4
|
+
exports.withdrawOwnerQuestion = withdrawOwnerQuestion;
|
|
5
|
+
exports.reasonOf = reasonOf;
|
|
6
|
+
// Asking this machine's owner a decision card, and waiting for the tap.
|
|
7
|
+
//
|
|
8
|
+
// Shared by the two Claude Code hooks that move a question off the terminal and onto a phone:
|
|
9
|
+
// `approve-hook` (a permission prompt) and `denied-hook` (an auto-mode denial). Both talk to
|
|
10
|
+
// `POST /api/device-api/approvals` with the device credential, both park on the same bounded
|
|
11
|
+
// long-poll, and both must read "waiting" as "nobody has tapped yet" — never a yes, never a no.
|
|
12
|
+
//
|
|
13
|
+
// WHAT THIS MODULE DOES NOT DO: decide. It reports what happened (`OwnerAnswer`) and leaves the
|
|
14
|
+
// meaning to the caller, because the two hooks fail in opposite directions — a PermissionRequest
|
|
15
|
+
// hook that prints nothing ALLOWS, a PermissionDenied hook that prints nothing leaves the denial
|
|
16
|
+
// standing. Each caller turns every outcome below into its own fail-closed answer.
|
|
17
|
+
const api_1 = require("./api");
|
|
18
|
+
const waiting_cards_1 = require("./waiting-cards");
|
|
19
|
+
/** One HTTP hold. The server clamps to 30; asking for less than it allows leaves headroom for a
|
|
20
|
+
* proxy with its own idle timeout. */
|
|
21
|
+
const POLL_WAIT_SECONDS = 25;
|
|
22
|
+
/** Consecutive transport failures tolerated while WAITING before giving up.
|
|
23
|
+
*
|
|
24
|
+
* Not zero, and not unlimited. The card is already on somebody's phone by then, so giving up on a
|
|
25
|
+
* single dropped long-poll is both a bad experience and the fastest way to get a hook turned off;
|
|
26
|
+
* but a network that stays down must still end the wait, and does — here, or at the deadline,
|
|
27
|
+
* whichever comes first. Failures on CREATE are not retried at all: nothing exists yet, so there
|
|
28
|
+
* is nothing to lose by refusing immediately. */
|
|
29
|
+
const MAX_CONSECUTIVE_POLL_FAILURES = 3;
|
|
30
|
+
const POLL_RETRY_DELAY_MS = 2000;
|
|
31
|
+
/**
|
|
32
|
+
* Put the card in the private chat between the session and its owner, and wait until somebody
|
|
33
|
+
* answers it, it is withdrawn, BayChat stops answering, or `deadlineMs` (epoch ms) passes.
|
|
34
|
+
* NEVER THROWS.
|
|
35
|
+
*
|
|
36
|
+
* `direct: true` sends the card to that private chat, never to a group: the question is about one
|
|
37
|
+
* person's terminal. It also stops the server from guessing a room (a session in a group AND its
|
|
38
|
+
* owner's private chat used to get 409 APPROVAL_ROOM_AMBIGUOUS).
|
|
39
|
+
*
|
|
40
|
+
* `label` prefixes the stderr lines, so a person reading a log can tell which hook wrote them.
|
|
41
|
+
*/
|
|
42
|
+
async function askOwnerAndWait(device, ask, deadlineMs, deps, label) {
|
|
43
|
+
try {
|
|
44
|
+
// Create. One round trip that also parks for the first window, because the common case is
|
|
45
|
+
// answered within seconds. A failure here is final: no card exists, so a retry could rescue
|
|
46
|
+
// nothing and nothing is left pending on anyone's phone.
|
|
47
|
+
let reply;
|
|
48
|
+
try {
|
|
49
|
+
reply = await (0, api_1.apiRequest)(device, "POST", "/api/device-api/approvals", {
|
|
50
|
+
session: ask.session,
|
|
51
|
+
question: ask.question,
|
|
52
|
+
options: ask.options,
|
|
53
|
+
...(ask.guardedOptions?.length ? { guardedOptions: ask.guardedOptions } : {}),
|
|
54
|
+
direct: true,
|
|
55
|
+
wait: holdSeconds(deadlineMs - deps.now()),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return { kind: "failed", reason: `BayChat could not be asked — ${reasonOf(err)}` };
|
|
60
|
+
}
|
|
61
|
+
const requestId = typeof reply.requestId === "string" ? reply.requestId : "";
|
|
62
|
+
if (!requestId)
|
|
63
|
+
return { kind: "failed", reason: "BayChat did not return an approval id" };
|
|
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();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
// The exception nobody predicted. Reported, never thrown: both callers must still print their
|
|
76
|
+
// fail-closed answer.
|
|
77
|
+
return { kind: "failed", reason: `waiting for BayChat failed unexpectedly — ${reasonOf(err)}` };
|
|
78
|
+
}
|
|
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
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Take back a card nobody will act on any more (the waiting process is about to give up). Best
|
|
119
|
+
* effort and NEVER THROWS: a card left pending is harmless — the answer would reach nobody — so
|
|
120
|
+
* a failure here is only logged.
|
|
121
|
+
*/
|
|
122
|
+
async function withdrawOwnerQuestion(device, requestId, log) {
|
|
123
|
+
try {
|
|
124
|
+
await (0, api_1.apiRequest)(device, "POST", `/api/device-api/approvals/${encodeURIComponent(requestId)}/withdraw`, {});
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
log(`could not withdraw the card ${requestId} (${reasonOf(err)})`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** How long to ask the server to hold, never past our own deadline. */
|
|
131
|
+
function holdSeconds(remainingMs) {
|
|
132
|
+
return Math.max(1, Math.min(POLL_WAIT_SECONDS, Math.ceil(remainingMs / 1000)));
|
|
133
|
+
}
|
|
134
|
+
/** A refusal the server MEANT — no live session, an ambiguous one, a bad credential, a request
|
|
135
|
+
* that is not ours. Retrying these is pointless; a transport failure or a 5xx might be a blip. */
|
|
136
|
+
function isDefinite(err) {
|
|
137
|
+
return err instanceof api_1.ApiError && err.status < 500 && err.status !== 429;
|
|
138
|
+
}
|
|
139
|
+
function reasonOf(err) {
|
|
140
|
+
if (err instanceof api_1.ApiError)
|
|
141
|
+
return `${err.message} (HTTP ${err.status})`;
|
|
142
|
+
return err instanceof Error ? err.message : String(err);
|
|
143
|
+
}
|
package/dist/approve-hook.js
CHANGED
|
@@ -33,14 +33,17 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.MAX_CLAUDE_HOPS = exports.APPROVE_OPTIONS = exports.RECOMMENDED_HANDLER_TIMEOUT_SECONDS = exports.DEFAULT_WAIT_SECONDS = void 0;
|
|
36
|
+
exports.MAX_CLAUDE_HOPS = exports.readStdin = exports.APPROVE_OPTIONS = exports.RECOMMENDED_HANDLER_TIMEOUT_SECONDS = exports.DEFAULT_WAIT_SECONDS = void 0;
|
|
37
37
|
exports.allowDecision = allowDecision;
|
|
38
38
|
exports.denyDecision = denyDecision;
|
|
39
39
|
exports.describeToolRequest = describeToolRequest;
|
|
40
40
|
exports.composeQuestion = composeQuestion;
|
|
41
41
|
exports.decideApproval = decideApproval;
|
|
42
|
-
exports.readStdin = readStdin;
|
|
43
42
|
exports.sessionFromClaude = sessionFromClaude;
|
|
43
|
+
exports.procStatFields = procStatFields;
|
|
44
|
+
exports.procStartTicks = procStartTicks;
|
|
45
|
+
exports.psParentOf = psParentOf;
|
|
46
|
+
exports.psAgeMs = psAgeMs;
|
|
44
47
|
exports.parseEtime = parseEtime;
|
|
45
48
|
exports.decideApprovalOrDefer = decideApprovalOrDefer;
|
|
46
49
|
exports.cmdApproveHook = cmdApproveHook;
|
|
@@ -94,9 +97,11 @@ exports.cmdApproveHook = cmdApproveHook;
|
|
|
94
97
|
// exactly like a terminal prompt. Our deadline bounds only how long THIS PROCESS waits, and it
|
|
95
98
|
// exists solely because the alternative (being killed by the handler timeout) would allow.
|
|
96
99
|
const child_process_1 = require("child_process");
|
|
100
|
+
const fs = __importStar(require("fs"));
|
|
97
101
|
const os = __importStar(require("os"));
|
|
98
|
-
const
|
|
102
|
+
const approval_wait_1 = require("./approval-wait");
|
|
99
103
|
const config_1 = require("./config");
|
|
104
|
+
const hook_stdin_1 = require("./hook-stdin");
|
|
100
105
|
const claude_link_1 = require("./channel/claude-link");
|
|
101
106
|
function allowDecision() {
|
|
102
107
|
return {
|
|
@@ -148,18 +153,6 @@ exports.DEFAULT_WAIT_SECONDS = 3600;
|
|
|
148
153
|
/** What the documented settings.json block must set. Strictly greater than DEFAULT_WAIT_SECONDS,
|
|
149
154
|
* with room for process start-up and the final write, so OUR deadline always lands first. */
|
|
150
155
|
exports.RECOMMENDED_HANDLER_TIMEOUT_SECONDS = 3900;
|
|
151
|
-
/** One HTTP hold. The server clamps to 30; asking for less than it allows leaves headroom for a
|
|
152
|
-
* proxy with its own idle timeout. */
|
|
153
|
-
const POLL_WAIT_SECONDS = 25;
|
|
154
|
-
/** Consecutive transport failures tolerated while WAITING, before denying.
|
|
155
|
-
*
|
|
156
|
-
* Not zero, and not unlimited. The card is already on somebody's phone by then, so denying on a
|
|
157
|
-
* single dropped long-poll is both a bad experience and the fastest way to get this hook turned
|
|
158
|
-
* off; but a network that stays down must still land on a deny, and does — here, or at the
|
|
159
|
-
* deadline, whichever comes first. Failures on CREATE are not retried at all: nothing exists
|
|
160
|
-
* yet, so there is nothing to lose by refusing immediately. */
|
|
161
|
-
const MAX_CONSECUTIVE_POLL_FAILURES = 3;
|
|
162
|
-
const POLL_RETRY_DELAY_MS = 2000;
|
|
163
156
|
/** The card's own limits, mirrored from apps/api/src/lib/cards.ts. Restating them is how they
|
|
164
157
|
* drift, so they are only ever used to TRIM — the server still refuses anything malformed. */
|
|
165
158
|
const QUESTION_MAX = 2000;
|
|
@@ -255,21 +248,6 @@ function parseArgs(argv) {
|
|
|
255
248
|
waitSeconds: Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : exports.DEFAULT_WAIT_SECONDS,
|
|
256
249
|
};
|
|
257
250
|
}
|
|
258
|
-
/** How long to ask the server to hold, never past our own deadline. */
|
|
259
|
-
function holdSeconds(remainingMs) {
|
|
260
|
-
return Math.max(1, Math.min(POLL_WAIT_SECONDS, Math.ceil(remainingMs / 1000)));
|
|
261
|
-
}
|
|
262
|
-
/** A refusal the server MEANT — no live session, an ambiguous one, a bad credential, a request
|
|
263
|
-
* that is not ours. Retrying these is pointless, so they deny immediately; a transport failure
|
|
264
|
-
* or a 5xx might be a blip and is retried by the caller. */
|
|
265
|
-
function isDefinite(err) {
|
|
266
|
-
return err instanceof api_1.ApiError && err.status < 500 && err.status !== 429;
|
|
267
|
-
}
|
|
268
|
-
function reasonOf(err) {
|
|
269
|
-
if (err instanceof api_1.ApiError)
|
|
270
|
-
return `${err.message} (HTTP ${err.status})`;
|
|
271
|
-
return err instanceof Error ? err.message : String(err);
|
|
272
|
-
}
|
|
273
251
|
// ─── The decision ────────────────────────────────────────────────────────────
|
|
274
252
|
/**
|
|
275
253
|
* Turn one hook invocation into one decision. NEVER THROWS and never returns undefined: the
|
|
@@ -309,121 +287,49 @@ async function decideApproval(rawStdin, argv = [], deps = {}) {
|
|
|
309
287
|
host: (deps.hostname ?? os.hostname)(),
|
|
310
288
|
permissionMode: str(input.permission_mode),
|
|
311
289
|
});
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
// never to a group. A prompt about one person's terminal belongs to that person. It also
|
|
318
|
-
// stops the server from guessing a room: a session in a group AND its owner's private chat
|
|
319
|
-
// used to get 409 APPROVAL_ROOM_AMBIGUOUS, which denied every prompt with no card shown.
|
|
320
|
-
// The Claude Code channel's relay (channel/permission.ts) asks the same way.
|
|
321
|
-
let reply;
|
|
322
|
-
try {
|
|
323
|
-
reply = await (0, api_1.apiRequest)(device, "POST", "/api/device-api/approvals", {
|
|
324
|
-
session: opts.session,
|
|
325
|
-
question,
|
|
326
|
-
options: [...exports.APPROVE_OPTIONS],
|
|
327
|
-
direct: true,
|
|
328
|
-
wait: holdSeconds(deadline - now()),
|
|
329
|
-
});
|
|
330
|
-
}
|
|
331
|
-
catch (err) {
|
|
332
|
-
return denyClosed(`BayChat could not be asked — ${reasonOf(err)}`);
|
|
333
|
-
}
|
|
334
|
-
const requestId = str(reply.requestId);
|
|
335
|
-
if (!requestId)
|
|
336
|
-
return denyClosed("BayChat did not return an approval id");
|
|
337
|
-
// ── Wait. Re-arm across the bounded holds exactly as `await_approval` does: "waiting" is a
|
|
338
|
-
// success meaning nobody has tapped yet, never a lapse and never a no.
|
|
339
|
-
let failures = 0;
|
|
340
|
-
for (;;) {
|
|
341
|
-
const settled = verdictOf(reply, requestId);
|
|
342
|
-
if (settled)
|
|
343
|
-
return settled;
|
|
344
|
-
const remaining = deadline - now();
|
|
345
|
-
if (remaining <= 0) {
|
|
346
|
-
return deny(`nobody answered within ${opts.waitSeconds}s.`, "The decision card is still in BayChat; this call was refused because waiting longer " +
|
|
347
|
-
"would have let the hook time out, and a timed-out hook silently ALLOWS.");
|
|
348
|
-
}
|
|
349
|
-
try {
|
|
350
|
-
reply = await (0, api_1.apiRequest)(device, "GET", `/api/device-api/approvals/${encodeURIComponent(requestId)}?wait=${holdSeconds(remaining)}`);
|
|
351
|
-
failures = 0;
|
|
352
|
-
}
|
|
353
|
-
catch (err) {
|
|
354
|
-
if (isDefinite(err)) {
|
|
355
|
-
return denyClosed(`BayChat refused the wait — ${reasonOf(err)}`);
|
|
356
|
-
}
|
|
357
|
-
failures += 1;
|
|
358
|
-
log(`baychat approve-hook: waiting failed (${reasonOf(err)}) — attempt ${failures}`);
|
|
359
|
-
if (failures >= MAX_CONSECUTIVE_POLL_FAILURES) {
|
|
360
|
-
return denyClosed(`BayChat became unreachable while waiting — ${reasonOf(err)}`);
|
|
361
|
-
}
|
|
362
|
-
await sleep(POLL_RETRY_DELAY_MS);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
290
|
+
// The card goes to the private chat between this session and its owner, and the wait
|
|
291
|
+
// re-arms across bounded holds until somebody taps (approval-wait.ts). Every outcome that is
|
|
292
|
+
// not "answered with index 0" becomes a deny below.
|
|
293
|
+
const outcome = await (0, approval_wait_1.askOwnerAndWait)(device, { session: opts.session, question, options: [...exports.APPROVE_OPTIONS] }, deadline, { now, sleep, log }, "baychat approve-hook");
|
|
294
|
+
return verdictOf(outcome, opts.waitSeconds);
|
|
365
295
|
}
|
|
366
296
|
catch (err) {
|
|
367
297
|
// The catch that makes the guarantee true. Anything at all — a thrown non-Error, an OOM in a
|
|
368
298
|
// dependency, a bug three refactors from now — lands here as a deny rather than as a crash,
|
|
369
299
|
// and a crash is an ALLOW.
|
|
370
|
-
log(`baychat approve-hook: unexpected failure (${reasonOf(err)})`);
|
|
300
|
+
log(`baychat approve-hook: unexpected failure (${(0, approval_wait_1.reasonOf)(err)})`);
|
|
371
301
|
return denyClosed("the approval gate hit an unexpected error");
|
|
372
302
|
}
|
|
373
303
|
}
|
|
374
304
|
/**
|
|
375
|
-
*
|
|
305
|
+
* Turn what happened into the decision.
|
|
376
306
|
*
|
|
377
|
-
* ONLY `
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
307
|
+
* ONLY `answered` WITH index 0 ALLOWS. Every other combination — a cancelled card, an index we
|
|
308
|
+
* did not offer, a status this client has never heard of, a missing field — is a deny. That
|
|
309
|
+
* asymmetry is the point: a future server change can make this hook over-refuse, and can never
|
|
310
|
+
* make it over-permit.
|
|
381
311
|
*/
|
|
382
|
-
function verdictOf(
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
312
|
+
function verdictOf(outcome, waitSeconds) {
|
|
313
|
+
switch (outcome.kind) {
|
|
314
|
+
case "answered":
|
|
315
|
+
if (outcome.index === ALLOW_INDEX)
|
|
316
|
+
return allowDecision();
|
|
317
|
+
return deny(`answered "${outcome.answer ?? exports.APPROVE_OPTIONS[1]}" in BayChat.`);
|
|
318
|
+
case "cancelled":
|
|
319
|
+
return deny("the decision was withdrawn in BayChat without an answer.");
|
|
320
|
+
case "deadline":
|
|
321
|
+
return deny(`nobody answered within ${waitSeconds}s.`, "The decision card is still in BayChat; this call was refused because waiting longer " +
|
|
322
|
+
"would have let the hook time out, and a timed-out hook silently ALLOWS.");
|
|
323
|
+
case "failed":
|
|
324
|
+
return denyClosed(outcome.reason);
|
|
325
|
+
case "unrecognised":
|
|
326
|
+
return denyClosed(`BayChat answered with a status this client does not understand ("${outcome.status}") for ${outcome.requestId}`);
|
|
390
327
|
}
|
|
391
|
-
if (status === "cancelled") {
|
|
392
|
-
return deny("the decision was withdrawn in BayChat without an answer.");
|
|
393
|
-
}
|
|
394
|
-
return denyClosed(`BayChat answered with a status this client does not understand ("${status}") for ${requestId}`);
|
|
395
328
|
}
|
|
396
329
|
// ─── stdin ───────────────────────────────────────────────────────────────────
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
* Bounded, because a stdin that never closes would stall us until the handler timeout kills the
|
|
401
|
-
* process — and being killed ALLOWS. On timeout we return what arrived (usually nothing), which
|
|
402
|
-
* fails to parse, which denies.
|
|
403
|
-
*/
|
|
404
|
-
function readStdin(stream = process.stdin, timeoutMs = 10_000) {
|
|
405
|
-
return new Promise((resolve) => {
|
|
406
|
-
let text = "";
|
|
407
|
-
let done = false;
|
|
408
|
-
const finish = () => {
|
|
409
|
-
if (done)
|
|
410
|
-
return;
|
|
411
|
-
done = true;
|
|
412
|
-
clearTimeout(timer);
|
|
413
|
-
resolve(text);
|
|
414
|
-
};
|
|
415
|
-
const timer = setTimeout(finish, timeoutMs);
|
|
416
|
-
// Do not hold the process open on the timer alone.
|
|
417
|
-
if (typeof timer.unref === "function")
|
|
418
|
-
timer.unref();
|
|
419
|
-
stream.setEncoding?.("utf8");
|
|
420
|
-
stream.on("data", (chunk) => {
|
|
421
|
-
text += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
422
|
-
});
|
|
423
|
-
stream.on("end", finish);
|
|
424
|
-
stream.on("error", finish);
|
|
425
|
-
});
|
|
426
|
-
}
|
|
330
|
+
// The bounded stdin read lives in hook-stdin.ts, shared with the denied-command hooks.
|
|
331
|
+
var hook_stdin_2 = require("./hook-stdin");
|
|
332
|
+
Object.defineProperty(exports, "readStdin", { enumerable: true, get: function () { return hook_stdin_2.readStdin; } });
|
|
427
333
|
// ─── The command ─────────────────────────────────────────────────────────────
|
|
428
334
|
// ─── --session-from-claude ───────────────────────────────────────────────────
|
|
429
335
|
/** How many ancestors of the hook process are searched for the Claude Code that ran it. The hook
|
|
@@ -470,10 +376,39 @@ function psField(pid, field) {
|
|
|
470
376
|
const out = String(r.stdout ?? "").trim();
|
|
471
377
|
return out || undefined;
|
|
472
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. */
|
|
473
406
|
function psParentOf(pid) {
|
|
474
|
-
const
|
|
407
|
+
const fromProc = Number(procStatFields(pid)?.[4]);
|
|
408
|
+
const n = Number.isInteger(fromProc) && fromProc > 0 ? fromProc : Number(psField(pid, "ppid"));
|
|
475
409
|
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
476
410
|
}
|
|
411
|
+
/** How long `pid` has been running, from `ps -o etime` (whole seconds). */
|
|
477
412
|
function psAgeMs(pid) {
|
|
478
413
|
const etime = psField(pid, "etime");
|
|
479
414
|
return etime ? parseEtime(etime) : undefined;
|
|
@@ -508,7 +443,7 @@ async function decideApprovalOrDefer(rawStdin, argv = [], deps = {}) {
|
|
|
508
443
|
async function cmdApproveHook(argv = [], stdin = process.stdin) {
|
|
509
444
|
let decision;
|
|
510
445
|
try {
|
|
511
|
-
decision = await decideApprovalOrDefer(await readStdin(stdin), argv);
|
|
446
|
+
decision = await decideApprovalOrDefer(await (0, hook_stdin_1.readStdin)(stdin), argv);
|
|
512
447
|
}
|
|
513
448
|
catch (err) {
|
|
514
449
|
// decideApproval does not throw; readStdin does not reject. This is here anyway, because the
|
|
@@ -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
|
+
}
|