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.
Files changed (45) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +43 -10
  3. package/dist/approval-wait.js +143 -0
  4. package/dist/approve-hook.js +68 -133
  5. package/dist/auto-update/describe.js +66 -0
  6. package/dist/auto-update/notice.js +40 -0
  7. package/dist/auto-update/policy.js +110 -0
  8. package/dist/auto-update/relay-glue.js +143 -0
  9. package/dist/auto-update/store.js +140 -0
  10. package/dist/auto-update/updater.js +324 -0
  11. package/dist/channel/permission.js +6 -0
  12. package/dist/cli-help.js +30 -3
  13. package/dist/denied/after-hook.js +46 -0
  14. package/dist/denied/confirm.js +131 -0
  15. package/dist/denied/grants.js +292 -0
  16. package/dist/denied/hook-log.js +75 -0
  17. package/dist/denied/live-session.js +154 -0
  18. package/dist/denied/notice.js +29 -0
  19. package/dist/denied/opt-in.js +68 -0
  20. package/dist/denied/permission-rule.js +186 -0
  21. package/dist/denied/settings-rules.js +221 -0
  22. package/dist/denied/transcript.js +173 -0
  23. package/dist/denied-hook.js +431 -0
  24. package/dist/doctor-command.js +7 -1
  25. package/dist/doctor.js +28 -0
  26. package/dist/help-topics.js +12 -4
  27. package/dist/hook-stdin.js +35 -0
  28. package/dist/index.js +13 -1
  29. package/dist/main.js +20 -2
  30. package/dist/permissions-command.js +104 -0
  31. package/dist/protocol-content.js +1 -1
  32. package/dist/refresh-clients.js +36 -0
  33. package/dist/relay/acp/runner.js +4 -0
  34. package/dist/relay/commands.js +19 -3
  35. package/dist/relay/daemon.js +37 -1
  36. package/dist/relay/launcher/approval-settings.js +56 -2
  37. package/dist/relay/launcher/loop.js +4 -1
  38. package/dist/relay/self-restart.js +149 -0
  39. package/dist/relay/terminal-pane.js +74 -2
  40. package/dist/runtimes.js +2 -2
  41. package/dist/self-upgrade.js +92 -27
  42. package/dist/start-command.js +15 -1
  43. package/dist/update-command.js +51 -31
  44. package/dist/waiting-cards.js +107 -0
  45. package/package.json +4 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.29.9",
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, joins the group, and retains a private
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 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)
@@ -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 + private chat
312
- /baychat Session-A "Design Review" that room + private chat
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
- Naming a group retains the private owner chat under the same agent and session.
322
- Use `--private` when only the private chat is wanted.
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
- ### 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
@@ -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 and retains the same agent's private chat |
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
+ }
@@ -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 api_1 = require("./api");
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
- // ── Create. One round trip that also parks for the first window, because the common case
313
- // is answered within seconds. A failure here is final: no card exists, so there is nothing
314
- // a retry could rescue and nothing left pending on anyone's phone.
315
- //
316
- // `direct: true` sends the card to the private chat between this session and its owner,
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
- * Read one server reply as a verdict, or null to keep waiting.
305
+ * Turn what happened into the decision.
376
306
  *
377
- * ONLY `status === "answered"` WITH `answerIndex === 0` ALLOWS. Every other combination — a
378
- * cancelled card, an index we did not offer, a status this client has never heard of, a missing
379
- * field — is a deny. That asymmetry is the point: a future server change can make this hook
380
- * over-refuse, and can never make it over-permit.
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(reply, requestId) {
383
- const status = str(reply.status);
384
- if (status === "waiting")
385
- return null;
386
- if (status === "answered") {
387
- if (reply.answerIndex === ALLOW_INDEX)
388
- return allowDecision();
389
- return deny(`answered "${reply.answer ?? exports.APPROVE_OPTIONS[1]}" in BayChat.`);
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
- * The hook's JSON input.
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 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"));
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
+ }