baychat 0.29.8 → 0.30.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.29.8",
3
+ "version": "0.30.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
 
@@ -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,8 +318,8 @@ 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
324
  ### Upgrading, in order
325
325
 
@@ -365,6 +365,14 @@ one where remembering to re-arm falls to the agent.
365
365
  `.claude/skills/baychat/`, that copy wins and `connect` will never update it.
366
366
  - **`connect desktop` means Claude Desktop**, Anthropic's chat app — not a
367
367
  BayChat desktop application.
368
+ - **Claude from the Microsoft Store reads a different file.** A Store (MSIX)
369
+ install never opens `%APPDATA%\Claude\claude_desktop_config.json`; it reads
370
+ `%LOCALAPPDATA%\Packages\Claude_<id>\LocalCache\Roaming\Claude\claude_desktop_config.json`.
371
+ `connect desktop` finds every `Claude_*` package and writes each one (plus the
372
+ classic file when that installer is present too), and prints every path it
373
+ wrote. Before 0.29.9 it wrote only the classic file — `baychat update` or
374
+ `baychat connect desktop` fixes such a machine, and `baychat doctor` names the
375
+ mismatch.
368
376
 
369
377
  ## Commands
370
378
 
@@ -888,7 +896,9 @@ claude mcp add baychat -- npx -y baychat mcp
888
896
 
889
897
  ### Claude Desktop
890
898
 
891
- Add to `claude_desktop_config.json` (Settings → Developer → Edit Config):
899
+ Add to `claude_desktop_config.json` (Settings → Developer → Edit Config — that button
900
+ opens the file this install really reads; on Windows a Microsoft Store install keeps it
901
+ under `%LOCALAPPDATA%\Packages\Claude_<id>\LocalCache\Roaming\Claude\`, not `%APPDATA%\Claude\`):
892
902
 
893
903
  ```json
894
904
  {
@@ -982,7 +992,7 @@ session and group tools:
982
992
 
983
993
  | Tool | What it does |
984
994
  |------|--------------|
985
- | `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 |
995
+ | `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) |
986
996
  | `list_sessions` | This login's sessions — name, live/idle, last seen |
987
997
  | `end_session` | Park a session. The chat and its history survive; rejoining the same name revives it |
988
998
  | `list_groups` | The groups you are in — exact title, who is in them, and the conversation id |
@@ -0,0 +1,130 @@
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
+ /** One HTTP hold. The server clamps to 30; asking for less than it allows leaves headroom for a
19
+ * proxy with its own idle timeout. */
20
+ const POLL_WAIT_SECONDS = 25;
21
+ /** Consecutive transport failures tolerated while WAITING before giving up.
22
+ *
23
+ * Not zero, and not unlimited. The card is already on somebody's phone by then, so giving up on a
24
+ * single dropped long-poll is both a bad experience and the fastest way to get a hook turned off;
25
+ * but a network that stays down must still end the wait, and does — here, or at the deadline,
26
+ * whichever comes first. Failures on CREATE are not retried at all: nothing exists yet, so there
27
+ * is nothing to lose by refusing immediately. */
28
+ const MAX_CONSECUTIVE_POLL_FAILURES = 3;
29
+ const POLL_RETRY_DELAY_MS = 2000;
30
+ /**
31
+ * Put the card in the private chat between the session and its owner, and wait until somebody
32
+ * answers it, it is withdrawn, BayChat stops answering, or `deadlineMs` (epoch ms) passes.
33
+ * NEVER THROWS.
34
+ *
35
+ * `direct: true` sends the card to that private chat, never to a group: the question is about one
36
+ * person's terminal. It also stops the server from guessing a room (a session in a group AND its
37
+ * owner's private chat used to get 409 APPROVAL_ROOM_AMBIGUOUS).
38
+ *
39
+ * `label` prefixes the stderr lines, so a person reading a log can tell which hook wrote them.
40
+ */
41
+ async function askOwnerAndWait(device, ask, deadlineMs, deps, label) {
42
+ try {
43
+ // Create. One round trip that also parks for the first window, because the common case is
44
+ // answered within seconds. A failure here is final: no card exists, so a retry could rescue
45
+ // nothing and nothing is left pending on anyone's phone.
46
+ let reply;
47
+ try {
48
+ reply = await (0, api_1.apiRequest)(device, "POST", "/api/device-api/approvals", {
49
+ session: ask.session,
50
+ question: ask.question,
51
+ options: ask.options,
52
+ ...(ask.guardedOptions?.length ? { guardedOptions: ask.guardedOptions } : {}),
53
+ direct: true,
54
+ wait: holdSeconds(deadlineMs - deps.now()),
55
+ });
56
+ }
57
+ catch (err) {
58
+ return { kind: "failed", reason: `BayChat could not be asked — ${reasonOf(err)}` };
59
+ }
60
+ const requestId = typeof reply.requestId === "string" ? reply.requestId : "";
61
+ if (!requestId)
62
+ return { kind: "failed", reason: "BayChat did not return an approval id" };
63
+ // Wait. Re-arm across the bounded holds exactly as `await_approval` does.
64
+ let failures = 0;
65
+ for (;;) {
66
+ const status = typeof reply.status === "string" ? reply.status : "";
67
+ if (status === "answered") {
68
+ return {
69
+ kind: "answered",
70
+ requestId,
71
+ index: typeof reply.answerIndex === "number" ? reply.answerIndex : null,
72
+ answer: typeof reply.answer === "string" ? reply.answer : null,
73
+ };
74
+ }
75
+ if (status === "cancelled")
76
+ return { kind: "cancelled", requestId };
77
+ if (status !== "waiting")
78
+ return { kind: "unrecognised", requestId, status };
79
+ const remaining = deadlineMs - deps.now();
80
+ if (remaining <= 0)
81
+ return { kind: "deadline", requestId };
82
+ try {
83
+ reply = await (0, api_1.apiRequest)(device, "GET", `/api/device-api/approvals/${encodeURIComponent(requestId)}?wait=${holdSeconds(remaining)}`);
84
+ failures = 0;
85
+ }
86
+ catch (err) {
87
+ if (isDefinite(err))
88
+ return { kind: "failed", reason: `BayChat refused the wait — ${reasonOf(err)}` };
89
+ failures += 1;
90
+ deps.log(`${label}: waiting failed (${reasonOf(err)}) — attempt ${failures}`);
91
+ if (failures >= MAX_CONSECUTIVE_POLL_FAILURES) {
92
+ return { kind: "failed", reason: `BayChat became unreachable while waiting — ${reasonOf(err)}` };
93
+ }
94
+ await deps.sleep(POLL_RETRY_DELAY_MS);
95
+ }
96
+ }
97
+ }
98
+ catch (err) {
99
+ // The exception nobody predicted. Reported, never thrown: both callers must still print their
100
+ // fail-closed answer.
101
+ return { kind: "failed", reason: `waiting for BayChat failed unexpectedly — ${reasonOf(err)}` };
102
+ }
103
+ }
104
+ /**
105
+ * Take back a card nobody will act on any more (the waiting process is about to give up). Best
106
+ * effort and NEVER THROWS: a card left pending is harmless — the answer would reach nobody — so
107
+ * a failure here is only logged.
108
+ */
109
+ async function withdrawOwnerQuestion(device, requestId, log) {
110
+ try {
111
+ await (0, api_1.apiRequest)(device, "POST", `/api/device-api/approvals/${encodeURIComponent(requestId)}/withdraw`, {});
112
+ }
113
+ catch (err) {
114
+ log(`could not withdraw the card ${requestId} (${reasonOf(err)})`);
115
+ }
116
+ }
117
+ /** How long to ask the server to hold, never past our own deadline. */
118
+ function holdSeconds(remainingMs) {
119
+ return Math.max(1, Math.min(POLL_WAIT_SECONDS, Math.ceil(remainingMs / 1000)));
120
+ }
121
+ /** A refusal the server MEANT — no live session, an ambiguous one, a bad credential, a request
122
+ * that is not ours. Retrying these is pointless; a transport failure or a 5xx might be a blip. */
123
+ function isDefinite(err) {
124
+ return err instanceof api_1.ApiError && err.status < 500 && err.status !== 429;
125
+ }
126
+ function reasonOf(err) {
127
+ if (err instanceof api_1.ApiError)
128
+ return `${err.message} (HTTP ${err.status})`;
129
+ return err instanceof Error ? err.message : String(err);
130
+ }
@@ -33,13 +33,12 @@ 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;
44
43
  exports.parseEtime = parseEtime;
45
44
  exports.decideApprovalOrDefer = decideApprovalOrDefer;
@@ -95,8 +94,9 @@ exports.cmdApproveHook = cmdApproveHook;
95
94
  // exists solely because the alternative (being killed by the handler timeout) would allow.
96
95
  const child_process_1 = require("child_process");
97
96
  const os = __importStar(require("os"));
98
- const api_1 = require("./api");
97
+ const approval_wait_1 = require("./approval-wait");
99
98
  const config_1 = require("./config");
99
+ const hook_stdin_1 = require("./hook-stdin");
100
100
  const claude_link_1 = require("./channel/claude-link");
101
101
  function allowDecision() {
102
102
  return {
@@ -148,18 +148,6 @@ exports.DEFAULT_WAIT_SECONDS = 3600;
148
148
  /** What the documented settings.json block must set. Strictly greater than DEFAULT_WAIT_SECONDS,
149
149
  * with room for process start-up and the final write, so OUR deadline always lands first. */
150
150
  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
151
  /** The card's own limits, mirrored from apps/api/src/lib/cards.ts. Restating them is how they
164
152
  * drift, so they are only ever used to TRIM — the server still refuses anything malformed. */
165
153
  const QUESTION_MAX = 2000;
@@ -255,21 +243,6 @@ function parseArgs(argv) {
255
243
  waitSeconds: Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : exports.DEFAULT_WAIT_SECONDS,
256
244
  };
257
245
  }
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
246
  // ─── The decision ────────────────────────────────────────────────────────────
274
247
  /**
275
248
  * Turn one hook invocation into one decision. NEVER THROWS and never returns undefined: the
@@ -309,121 +282,49 @@ async function decideApproval(rawStdin, argv = [], deps = {}) {
309
282
  host: (deps.hostname ?? os.hostname)(),
310
283
  permissionMode: str(input.permission_mode),
311
284
  });
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
- }
285
+ // The card goes to the private chat between this session and its owner, and the wait
286
+ // re-arms across bounded holds until somebody taps (approval-wait.ts). Every outcome that is
287
+ // not "answered with index 0" becomes a deny below.
288
+ const outcome = await (0, approval_wait_1.askOwnerAndWait)(device, { session: opts.session, question, options: [...exports.APPROVE_OPTIONS] }, deadline, { now, sleep, log }, "baychat approve-hook");
289
+ return verdictOf(outcome, opts.waitSeconds);
365
290
  }
366
291
  catch (err) {
367
292
  // The catch that makes the guarantee true. Anything at all — a thrown non-Error, an OOM in a
368
293
  // dependency, a bug three refactors from now — lands here as a deny rather than as a crash,
369
294
  // and a crash is an ALLOW.
370
- log(`baychat approve-hook: unexpected failure (${reasonOf(err)})`);
295
+ log(`baychat approve-hook: unexpected failure (${(0, approval_wait_1.reasonOf)(err)})`);
371
296
  return denyClosed("the approval gate hit an unexpected error");
372
297
  }
373
298
  }
374
299
  /**
375
- * Read one server reply as a verdict, or null to keep waiting.
300
+ * Turn what happened into the decision.
376
301
  *
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.
302
+ * ONLY `answered` WITH index 0 ALLOWS. Every other combination — a cancelled card, an index we
303
+ * did not offer, a status this client has never heard of, a missing field — is a deny. That
304
+ * asymmetry is the point: a future server change can make this hook over-refuse, and can never
305
+ * make it over-permit.
381
306
  */
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.`);
390
- }
391
- if (status === "cancelled") {
392
- return deny("the decision was withdrawn in BayChat without an answer.");
307
+ function verdictOf(outcome, waitSeconds) {
308
+ switch (outcome.kind) {
309
+ case "answered":
310
+ if (outcome.index === ALLOW_INDEX)
311
+ return allowDecision();
312
+ return deny(`answered "${outcome.answer ?? exports.APPROVE_OPTIONS[1]}" in BayChat.`);
313
+ case "cancelled":
314
+ return deny("the decision was withdrawn in BayChat without an answer.");
315
+ case "deadline":
316
+ return deny(`nobody answered within ${waitSeconds}s.`, "The decision card is still in BayChat; this call was refused because waiting longer " +
317
+ "would have let the hook time out, and a timed-out hook silently ALLOWS.");
318
+ case "failed":
319
+ return denyClosed(outcome.reason);
320
+ case "unrecognised":
321
+ return denyClosed(`BayChat answered with a status this client does not understand ("${outcome.status}") for ${outcome.requestId}`);
393
322
  }
394
- return denyClosed(`BayChat answered with a status this client does not understand ("${status}") for ${requestId}`);
395
323
  }
396
324
  // ─── 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
- }
325
+ // The bounded stdin read lives in hook-stdin.ts, shared with the denied-command hooks.
326
+ var hook_stdin_2 = require("./hook-stdin");
327
+ Object.defineProperty(exports, "readStdin", { enumerable: true, get: function () { return hook_stdin_2.readStdin; } });
427
328
  // ─── The command ─────────────────────────────────────────────────────────────
428
329
  // ─── --session-from-claude ───────────────────────────────────────────────────
429
330
  /** How many ancestors of the hook process are searched for the Claude Code that ran it. The hook
@@ -508,7 +409,7 @@ async function decideApprovalOrDefer(rawStdin, argv = [], deps = {}) {
508
409
  async function cmdApproveHook(argv = [], stdin = process.stdin) {
509
410
  let decision;
510
411
  try {
511
- decision = await decideApprovalOrDefer(await readStdin(stdin), argv);
412
+ decision = await decideApprovalOrDefer(await (0, hook_stdin_1.readStdin)(stdin), argv);
512
413
  }
513
414
  catch (err) {
514
415
  // decideApproval does not throw; readStdin does not reject. This is here anyway, because the
package/dist/cli-help.js CHANGED
@@ -159,6 +159,17 @@ Usage:
159
159
  docs/features/REMOTE_APPROVAL_HOOK.md before
160
160
  switching it on, because it moves the last line
161
161
  between an agent and this machine onto a phone
162
+ baychat denied-hook [--session <name> | --session-from-claude] [--timeout <sec>]
163
+ Claude Code PermissionDenied hook: when auto
164
+ mode blocks a call, put a card in BayChat; on
165
+ your yes, write that exact allow rule to the
166
+ project's .claude/settings.local.json and let
167
+ the session retry. Prints the retry or nothing;
168
+ every error leaves the denial standing
169
+ baychat permissions [list | remove <id> | on | off]
170
+ The denied-command card: on/off for sessions
171
+ BayChat starts (OFF by default), and the rules
172
+ it kept. See docs/features/DENIED_COMMAND_CARD.md
162
173
  baychat relay attach --session <name> [--runtime claude|codex|hermes]
163
174
  [--resume-id <id>] [--timeout <sec>]
164
175
  Register this session with the relay and block
@@ -206,10 +217,21 @@ function helpRequest(command, args) {
206
217
  * Commands whose arguments belong to someone else, so `--help` among them is data:
207
218
  * - `claude` passes every argument to Claude Code — `baychat claude --help` is Claude Code's help;
208
219
  * - `send` and `search` take free text, which may say "--help";
209
- * - `approve-hook` must write exactly one decision object to stdout, whatever it is given;
220
+ * - `approve-hook` must write exactly one decision object to stdout, whatever it is given, and
221
+ * `denied-hook` / `denied-after` must never print help where Claude Code reads a hook's output;
210
222
  * - `help` (and its spellings) already prints help, and its arguments are a question.
211
223
  */
212
- const ARGS_ARE_NOT_OURS = new Set(["claude", "send", "search", "approve-hook", "help", "--help", "-h"]);
224
+ const ARGS_ARE_NOT_OURS = new Set([
225
+ "claude",
226
+ "send",
227
+ "search",
228
+ "approve-hook",
229
+ "denied-hook",
230
+ "denied-after",
231
+ "help",
232
+ "--help",
233
+ "-h",
234
+ ]);
213
235
  /** Commands with a usage of their own, fuller than their lines in `HELP`. */
214
236
  const OWN_USAGE = { login: exports.LOGIN_HELP, bays: bays_command_1.BAYS_USAGE, launch: command_1.LAUNCH_USAGE };
215
237
  /**
@@ -15,36 +15,89 @@
15
15
  // genuinely do not know. Guessing a path is worse than declining: a wrong guess
16
16
  // writes a file no client reads, and the user is left debugging a config that
17
17
  // looks correct.
18
+ var __importDefault = (this && this.__importDefault) || function (mod) {
19
+ return (mod && mod.__esModule) ? mod : { "default": mod };
20
+ };
18
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.claudeStoreConfigPaths = claudeStoreConfigPaths;
23
+ exports.isClaudeStoreConfigPath = isClaudeStoreConfigPath;
19
24
  exports.configPathFor = configPathFor;
25
+ exports.configDirOf = configDirOf;
20
26
  exports.currentPathEnv = currentPathEnv;
21
27
  exports.needsRestart = needsRestart;
22
28
  exports.configPathsFor = configPathsFor;
23
29
  exports.inspectableConfigPaths = inspectableConfigPaths;
30
+ const node_fs_1 = __importDefault(require("node:fs"));
31
+ const node_path_1 = __importDefault(require("node:path"));
32
+ /** The name Claude's MSIX package directory has under `%LOCALAPPDATA%\Packages`. */
33
+ const CLAUDE_STORE_PACKAGE = /^Claude_[A-Za-z0-9]+$/i;
24
34
  /**
25
- * Claude Desktop's config location, which is the only genuinely
26
- * platform-dependent one of the three.
35
+ * Microsoft Store installs of Claude Desktop on this machine, as config paths.
27
36
  *
28
- * Returns null on an unrecognised platform, and on Windows without `APPDATA` —
29
- * there is no sound fallback for either, and inventing one writes a file nothing
30
- * will read.
37
+ * WHY THIS EXISTS. Claude from the Microsoft Store is an MSIX package, and
38
+ * Windows gives a packaged app a virtualised AppData: it reads
39
+ * `%LOCALAPPDATA%\Packages\Claude_<publisherId>\LocalCache\Roaming\Claude\
40
+ * claude_desktop_config.json`, not `%APPDATA%\Claude\…`. On 2026-10-12 a real
41
+ * user ran `baychat connect desktop`, was told it worked, and Claude never saw
42
+ * BayChat — the file had gone to `%APPDATA%`, next to a Store install
43
+ * (`Claude_pzs8sxrjxfjjc`) that does not read there.
44
+ *
45
+ * Found by LOOKING, never by building a publisher id: the id is Microsoft's, and
46
+ * a guessed one is a file no Claude opens. More than one package can exist, so
47
+ * every match is returned, sorted, so the order never depends on the disk.
48
+ */
49
+ function claudeStoreConfigPaths(env) {
50
+ if (env.platform !== "win32" || !env.localAppData || !env.listDirs)
51
+ return [];
52
+ const packages = `${env.localAppData}\\Packages`;
53
+ return env
54
+ .listDirs(packages)
55
+ .filter((name) => CLAUDE_STORE_PACKAGE.test(name))
56
+ .sort()
57
+ .map((name) => `${packages}\\${name}\\LocalCache\\Roaming\\Claude\\claude_desktop_config.json`);
58
+ }
59
+ /** Is this one of the Microsoft Store (MSIX) copies of Claude Desktop's config? */
60
+ function isClaudeStoreConfigPath(filePath) {
61
+ return /\\Packages\\Claude_[A-Za-z0-9]+\\LocalCache\\Roaming\\Claude\\/i.test(filePath);
62
+ }
63
+ /** The classic installer's Windows location, or null without `APPDATA`. */
64
+ function claudeClassicWindowsPath(env) {
65
+ return env.appData ? `${env.appData}\\Claude\\claude_desktop_config.json` : null;
66
+ }
67
+ /**
68
+ * Every Claude Desktop config location to WRITE here, most likely-read first.
69
+ *
70
+ * The only genuinely platform-dependent client of the three. On Windows it is a
71
+ * list: each Store package is a real installation, and the classic
72
+ * `%APPDATA%\Claude` is kept only when that directory exists (the plain
73
+ * installer has run) or when no Store package was found (a plain installer is
74
+ * then the only thing it can be). Writing the classic file next to a Store-only
75
+ * install is how this bug looked like success.
76
+ *
77
+ * Empty on an unrecognised platform, and on Windows with neither location known —
78
+ * there is no sound fallback, and inventing one writes a file nothing will read.
31
79
  */
32
- function claudeDesktopPath(env) {
80
+ function claudeDesktopPaths(env) {
33
81
  if (env.platform === "darwin") {
34
- return `${env.home}/Library/Application Support/Claude/claude_desktop_config.json`;
82
+ return [`${env.home}/Library/Application Support/Claude/claude_desktop_config.json`];
35
83
  }
36
84
  if (env.platform === "win32") {
37
- if (!env.appData)
38
- return null;
39
- return `${env.appData}\\Claude\\claude_desktop_config.json`;
85
+ const store = claudeStoreConfigPaths(env);
86
+ const classic = claudeClassicWindowsPath(env);
87
+ if (!classic)
88
+ return store;
89
+ if (store.length === 0)
90
+ return [classic];
91
+ const classicDirExists = (env.listDirs?.(env.appData) ?? []).some((name) => name.toLowerCase() === "claude");
92
+ return classicDirExists ? [...store, classic] : store;
40
93
  }
41
94
  if (env.platform === "linux") {
42
95
  const base = env.xdgConfigHome && env.xdgConfigHome.trim() !== ""
43
96
  ? env.xdgConfigHome
44
97
  : `${env.home}/.config`;
45
- return `${base}/Claude/claude_desktop_config.json`;
98
+ return [`${base}/Claude/claude_desktop_config.json`];
46
99
  }
47
- return null;
100
+ return [];
48
101
  }
49
102
  /**
50
103
  * The absolute config path to write for a client, or null when it cannot be
@@ -62,7 +115,32 @@ function configPathFor(client, env) {
62
115
  case "cursor":
63
116
  return `${env.home}/.cursor/mcp.json`;
64
117
  case "desktop":
65
- return claudeDesktopPath(env);
118
+ // The first location the installed app reads — the Store copy when there is one.
119
+ return claudeDesktopPaths(env)[0] ?? null;
120
+ }
121
+ }
122
+ /**
123
+ * The directory a config file lives in, for `mkdirp`.
124
+ *
125
+ * A drive-letter path is split the Windows way whatever this process runs on, so
126
+ * a Windows target resolved in a test (or under WSL) gets its real parent rather
127
+ * than `.`.
128
+ */
129
+ function configDirOf(filePath) {
130
+ return /^[A-Za-z]:\\/.test(filePath) ? node_path_1.default.win32.dirname(filePath) : node_path_1.default.dirname(filePath);
131
+ }
132
+ /** Real `listDirs`: subdirectory names, or `[]` when the directory cannot be read. */
133
+ function listDirsOnDisk(dir) {
134
+ try {
135
+ return node_fs_1.default
136
+ .readdirSync(dir, { withFileTypes: true })
137
+ .filter((entry) => entry.isDirectory())
138
+ .map((entry) => entry.name);
139
+ }
140
+ catch {
141
+ // Missing or unreadable is the ordinary answer for `Packages` on a machine
142
+ // with no Store apps, and "nothing found here" is exactly what it means.
143
+ return [];
66
144
  }
67
145
  }
68
146
  /** Read the path environment from the current process. */
@@ -71,6 +149,8 @@ function currentPathEnv() {
71
149
  home: process.env.HOME || process.env.USERPROFILE || "",
72
150
  platform: process.platform,
73
151
  appData: process.env.APPDATA,
152
+ localAppData: process.env.LOCALAPPDATA,
153
+ listDirs: listDirsOnDisk,
74
154
  xdgConfigHome: process.env.XDG_CONFIG_HOME,
75
155
  };
76
156
  }
@@ -108,6 +188,10 @@ function needsRestart(client) {
108
188
  * whose binary we could not resolve, is still served.
109
189
  */
110
190
  function configPathsFor(client, env) {
191
+ // Claude Desktop can be several real installations at once (Store packages and
192
+ // the classic installer); every one of them is a target.
193
+ if (client === "desktop")
194
+ return claudeDesktopPaths(env);
111
195
  const canonical = configPathFor(client, env);
112
196
  const paths = [];
113
197
  const snap = snapConfigPath(client, env);
@@ -146,6 +230,13 @@ function snapConfigPath(client, env) {
146
230
  */
147
231
  function inspectableConfigPaths(client, env) {
148
232
  const paths = configPathsFor(client, env);
233
+ if (client === "desktop" && env.platform === "win32") {
234
+ // The classic file is worth reading even when it is not a write target: a
235
+ // copy left there by an older `connect` still holds a live token, and it is
236
+ // the evidence `doctor` needs to name the mismatch.
237
+ const classic = claudeClassicWindowsPath(env);
238
+ return classic && !paths.includes(classic) ? [...paths, classic] : paths;
239
+ }
149
240
  if (env.platform !== "linux")
150
241
  return paths;
151
242
  const snap = `${env.home}/snap/${client}/current/config.toml`;