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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +18 -8
- package/dist/approval-wait.js +130 -0
- package/dist/approve-hook.js +33 -132
- package/dist/cli-help.js +24 -2
- package/dist/client-paths.js +104 -13
- package/dist/connect.js +27 -13
- package/dist/credential-refresh.js +31 -14
- package/dist/denied/after-hook.js +40 -0
- package/dist/denied/grants.js +234 -0
- package/dist/denied/opt-in.js +68 -0
- package/dist/denied/permission-rule.js +183 -0
- package/dist/denied/settings-rules.js +160 -0
- package/dist/denied/transcript.js +150 -0
- package/dist/denied-hook.js +329 -0
- package/dist/doctor-command.js +3 -0
- package/dist/doctor.js +52 -2
- package/dist/help-topics.js +12 -4
- package/dist/hook-stdin.js +35 -0
- package/dist/index.js +13 -1
- package/dist/main.js +9 -0
- package/dist/permissions-command.js +87 -0
- package/dist/protocol-content.js +1 -1
- package/dist/relay/launcher/approval-settings.js +56 -2
- package/dist/runtimes.js +2 -2
- package/dist/tool-defs.js +3 -2
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "baychat",
|
|
3
|
-
"version": "0.
|
|
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
|
|
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
|
|
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,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
|
-
|
|
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
|
### 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
|
|
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
|
+
}
|
package/dist/approve-hook.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
-
*
|
|
300
|
+
* Turn what happened into the decision.
|
|
376
301
|
*
|
|
377
|
-
* ONLY `
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
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(
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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([
|
|
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
|
/**
|
package/dist/client-paths.js
CHANGED
|
@@ -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
|
|
26
|
-
* platform-dependent one of the three.
|
|
35
|
+
* Microsoft Store installs of Claude Desktop on this machine, as config paths.
|
|
27
36
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
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`;
|