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/dist/doctor.js CHANGED
@@ -475,7 +475,11 @@ function runtimeReport(runtime, env) {
475
475
  const spec = runtimes_1.RUNTIME_SPECS[runtime];
476
476
  const mcp = mcpCheck(runtime, env);
477
477
  const skill = skillCheck(runtime, env);
478
- if (mcp.status === "fail" && skill.status !== "pass") {
478
+ // A Claude Desktop that names BayChat in one of its files is connected, even when the
479
+ // file it reads does not — that is the half-finished install `mcpCheck` fails on, and it
480
+ // must be shown, not folded into "not configured".
481
+ const halfConnected = runtime === "desktop" && desktopConfigState(env).named.length > 0;
482
+ if (mcp.status === "fail" && skill.status !== "pass" && !halfConnected) {
479
483
  return {
480
484
  runtime,
481
485
  label: spec.label,
@@ -587,10 +591,12 @@ function mcpCheck(runtime, env) {
587
591
  ? { name: "mcp", status: "pass", detail: `${command} lists baychat` }
588
592
  : { name: "mcp", status: "fail", detail: `\`${command} mcp list\` does not list baychat`, remedy };
589
593
  }
594
+ if (runtime === "desktop")
595
+ return desktopMcpCheck(env, remedy);
590
596
  // No CLI to ask — a GUI client. Every location this client might read is
591
597
  // checked, because "the file we would write" and "the file it opens" are not
592
598
  // reliably the same question.
593
- const paths = (0, client_paths_1.configPathsFor)(runtime, { home: env.home, platform: env.platform });
599
+ const paths = (0, client_paths_1.configPathsFor)(runtime, pathEnvOf(env));
594
600
  if (paths.length === 0) {
595
601
  return { name: "mcp", status: "skip", detail: `no known config location on ${env.platform}` };
596
602
  }
@@ -607,6 +613,50 @@ function mcpCheck(runtime, env) {
607
613
  remedy,
608
614
  };
609
615
  }
616
+ function pathEnvOf(env) {
617
+ return env.pathEnv ?? { home: env.home, platform: env.platform };
618
+ }
619
+ /** Claude Desktop's config files here: all of them, the ones naming BayChat, and the Store copies. */
620
+ function desktopConfigState(env) {
621
+ const pathEnv = pathEnvOf(env);
622
+ const paths = (0, client_paths_1.inspectableConfigPaths)("desktop", pathEnv);
623
+ const named = paths.filter((p) => env.readText(p)?.includes("baychat"));
624
+ return { paths, named, store: (0, client_paths_1.claudeStoreConfigPaths)(pathEnv) };
625
+ }
626
+ /**
627
+ * Claude Desktop's `mcp` check — a GUI app, so its config files are the only evidence.
628
+ *
629
+ * THE MICROSOFT STORE CASE. Claude from the Store reads its own virtualised copy
630
+ * (`%LOCALAPPDATA%\Packages\Claude_…\LocalCache\Roaming\Claude\`), so a BayChat entry
631
+ * in the classic `%APPDATA%\Claude` file — which is what `connect desktop` wrote before
632
+ * 0.29.9 — is a config that looks right and that the app never opens. That is a FAIL that
633
+ * names both files, not a pass on the one we happened to find.
634
+ */
635
+ function desktopMcpCheck(env, remedy) {
636
+ const { paths, named, store } = desktopConfigState(env);
637
+ if (paths.length === 0) {
638
+ return { name: "mcp", status: "skip", detail: `no known config location on ${env.platform}` };
639
+ }
640
+ const label = (p) => `${display(p, env)}${(0, client_paths_1.isClaudeStoreConfigPath)(p) ? " (Microsoft Store)" : ""}`;
641
+ const missingStore = store.filter((p) => !named.includes(p));
642
+ if (named.length > 0 && missingStore.length > 0) {
643
+ return {
644
+ name: "mcp",
645
+ status: "fail",
646
+ detail: `baychat is in ${named.map(label).join(" and ")}, but not in ${missingStore.map(label).join(" or ")}` +
647
+ " — the copy Claude from the Microsoft Store reads",
648
+ remedy,
649
+ };
650
+ }
651
+ if (named.length > 0)
652
+ return { name: "mcp", status: "pass", detail: named.map(label).join(" + ") };
653
+ return {
654
+ name: "mcp",
655
+ status: "fail",
656
+ detail: `no baychat entry in ${paths.map(label).join(" or ")}`,
657
+ remedy,
658
+ };
659
+ }
610
660
  /**
611
661
  * Is the runtime's BayChat skill on disk?
612
662
  *
@@ -136,10 +136,11 @@ A session is one terminal, named by you. It appears in the app as an agent your
136
136
  messages can reach, and it survives being parked.
137
137
 
138
138
  join_session({ session: "Session-A" }) → a 1:1 with you
139
- join_session({ session: "Session-A", group: "Ad Review" }) → that group + your private chat
139
+ join_session({ session: "Session-A", group: "Ad Review" }) → that group only
140
140
 
141
- **The group form also keeps a private chat with you**, under the same agent and
142
- session name. The join result includes its directConversationId.
141
+ **A group join creates no private chat.** If you already have one with this
142
+ session (same agent, same name), the result names it as directConversationId;
143
+ otherwise there is none until you start one.
143
144
 
144
145
  list_sessions() → name, live or idle, last seen
145
146
  end_session(...) → park it; the chat and its history survive, and rejoining
@@ -167,7 +168,14 @@ a decision that expires silently is worse than one that waits.
167
168
  cards. It is on only for Claude Code sessions BayChat starts (from the phone,
168
169
  \`baychat claude\` or \`baychat start\`), never for a plain \`claude\`, and it moves
169
170
  the last line between an agent and your machine onto a phone — read
170
- docs/features/REMOTE_APPROVAL_HOOK.md. It fails CLOSED: every error denies.`,
171
+ docs/features/REMOTE_APPROVAL_HOOK.md. It fails CLOSED: every error denies.
172
+
173
+ When auto mode blocks a call outright there is no prompt to answer. With
174
+ \`baychat permissions on\` (off by default), BayChat-started sessions then put a
175
+ card in BayChat — Allow once / Always allow this exact command / No — and a yes
176
+ writes that one exact rule to the project's .claude/settings.local.json and lets
177
+ the session retry. \`baychat permissions\` lists and removes what it kept. See
178
+ docs/features/DENIED_COMMAND_CARD.md.`,
171
179
  };
172
180
  exports.HELP_TOPICS = [GROUPS, SESSIONS, TOOLS, APPROVALS];
173
181
  /** Exact name first, then keyword, then a substring of the body. */
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ // A Claude Code hook's JSON input, read from stdin with a deadline. Imports nothing but Node, so
3
+ // the fast-path hooks (denied/after-hook.ts) can use it without loading the CLI.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.readStdin = readStdin;
6
+ /**
7
+ * The hook's JSON input.
8
+ *
9
+ * Bounded, because a stdin that never closes would stall us until the handler timeout kills the
10
+ * process — and for approve-hook being killed ALLOWS. On timeout we return what arrived (usually
11
+ * nothing), which fails to parse, which each hook turns into its own fail-closed answer.
12
+ */
13
+ function readStdin(stream = process.stdin, timeoutMs = 10_000) {
14
+ return new Promise((resolve) => {
15
+ let text = "";
16
+ let done = false;
17
+ const finish = () => {
18
+ if (done)
19
+ return;
20
+ done = true;
21
+ clearTimeout(timer);
22
+ resolve(text);
23
+ };
24
+ const timer = setTimeout(finish, timeoutMs);
25
+ // Do not hold the process open on the timer alone.
26
+ if (typeof timer.unref === "function")
27
+ timer.unref();
28
+ stream.setEncoding?.("utf8");
29
+ stream.on("data", (chunk) => {
30
+ text += typeof chunk === "string" ? chunk : chunk.toString("utf8");
31
+ });
32
+ stream.on("end", finish);
33
+ stream.on("error", finish);
34
+ });
35
+ }
package/dist/index.js CHANGED
@@ -12,7 +12,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  /** Kept in step with `FAST_CONTEXT_COMMANDS` in claude-context.ts (a test pins the two). */
13
13
  const FAST_COMMANDS = new Set(["context-statusline", "context-hook", "context-flush"]);
14
14
  const command = process.argv[2];
15
- if (command !== undefined && FAST_COMMANDS.has(command)) {
15
+ if (command === "denied-after") {
16
+ // The denied-command card's after-hook runs on every matching tool call (as an async hook), so
17
+ // it too must not load the CLI. It imports only its own files and Node.
18
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
19
+ const after = require("./denied/after-hook");
20
+ after.cmdDeniedAfter().then((code) => {
21
+ process.exitCode = code;
22
+ }, (err) => {
23
+ process.stderr.write(`baychat denied-after: ${err instanceof Error ? err.message : String(err)}\n`);
24
+ process.exitCode = 0;
25
+ });
26
+ }
27
+ else if (command !== undefined && FAST_COMMANDS.has(command)) {
16
28
  // eslint-disable-next-line @typescript-eslint/no-require-imports
17
29
  const fast = require("./claude-context");
18
30
  fast.runFastContextCommand(command, process.argv.slice(3)).then(
package/dist/main.js CHANGED
@@ -3,6 +3,8 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const commands_1 = require("./commands");
5
5
  const approve_hook_1 = require("./approve-hook");
6
+ const denied_hook_1 = require("./denied-hook");
7
+ const permissions_command_1 = require("./permissions-command");
6
8
  const context_hooks_command_1 = require("./context-hooks-command");
7
9
  const session_name_1 = require("./session-name");
8
10
  const start_command_1 = require("./start-command");
@@ -301,6 +303,13 @@ async function main() {
301
303
  // exit-1 hook with no JSON on stdout is a NON-BLOCKING error: the tool call proceeds. The
302
304
  // guard in the catch below covers that anyway.
303
305
  return await (0, approve_hook_1.cmdApproveHook)(args);
306
+ case "denied-hook":
307
+ // Never throws and always returns 0; prints `retry` or nothing (see denied-hook.ts). For
308
+ // this event Claude Code ignores the exit code, and printing nothing leaves the denial
309
+ // standing — so a crash here is already the safe outcome.
310
+ return await (0, denied_hook_1.cmdDeniedHook)(args);
311
+ case "permissions":
312
+ return await (0, permissions_command_1.cmdPermissions)(args);
304
313
  case "context-hooks":
305
314
  return await (0, context_hooks_command_1.cmdContextHooks)(args);
306
315
  case "doctor":
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cmdPermissions = cmdPermissions;
4
+ // `baychat permissions` — the denied-command card's switch, and the rules it left on this computer.
5
+ //
6
+ // baychat permissions [list] what BayChat keeps (and any once-rule still waiting)
7
+ // baychat permissions remove <id> take one back out of its settings file
8
+ // baychat permissions on | off the card itself, for sessions BayChat starts from now on
9
+ // baychat permissions sweep (internal) take back once-rules whose time is up
10
+ //
11
+ // Only rules BayChat wrote are listed or removed. A rule the person wrote themselves never
12
+ // appears here, because a rule that was already in the file was never recorded (denied/grants.ts).
13
+ const grants_1 = require("./denied/grants");
14
+ const opt_in_1 = require("./denied/opt-in");
15
+ /** The longest a scheduled sweep may sleep: a once-rule's TTL is minutes, never hours. */
16
+ const MAX_SWEEP_DELAY_MS = 60 * 60_000;
17
+ const USAGE = "Usage: baychat permissions [list | remove <id> | on | off]";
18
+ async function cmdPermissions(args, deps = {}) {
19
+ const out = deps.out ?? ((line) => console.log(line));
20
+ const err = deps.err ?? ((line) => console.error(line));
21
+ const [sub = "list", ...rest] = args;
22
+ switch (sub) {
23
+ case "list": {
24
+ out((0, opt_in_1.deniedCardEnabled)()
25
+ ? "The denied-command card is ON for sessions BayChat starts (`baychat permissions off` turns it off)."
26
+ : "The denied-command card is OFF (`baychat permissions on` turns it on).");
27
+ const grants = (0, grants_1.listGrants)();
28
+ if (grants.length === 0) {
29
+ out("BayChat keeps no permission rules on this computer.");
30
+ return 0;
31
+ }
32
+ out("");
33
+ for (const grant of grants)
34
+ out(describeGrant(grant));
35
+ return 0;
36
+ }
37
+ case "remove": {
38
+ const id = rest[0];
39
+ if (!id) {
40
+ err(USAGE);
41
+ return 1;
42
+ }
43
+ if (!(await (0, grants_1.revokeGrant)(id))) {
44
+ err(`No rule with id ${id}. \`baychat permissions list\` shows them.`);
45
+ return 1;
46
+ }
47
+ out(`Removed ${id}. Claude Code reloads its settings, so running sessions stop allowing it too.`);
48
+ return 0;
49
+ }
50
+ case "on": {
51
+ // A deliberate act by a person: the card lets a phone add rules on this computer, so a
52
+ // session's own shell (no terminal) cannot switch it on for itself.
53
+ if (!(deps.isInteractive ?? (() => Boolean(process.stdin.isTTY && process.stdout.isTTY)))()) {
54
+ err("`baychat permissions on` must be run by you, in a terminal.");
55
+ return 1;
56
+ }
57
+ (0, opt_in_1.enableDeniedCard)();
58
+ out("The denied-command card is ON. Sessions BayChat starts from now on will put a card in BayChat when");
59
+ out("Claude Code's auto mode blocks a call. Read docs/features/DENIED_COMMAND_CARD.md for what it can and can't allow.");
60
+ return 0;
61
+ }
62
+ case "off":
63
+ (0, opt_in_1.disableDeniedCard)();
64
+ out("The denied-command card is OFF for sessions BayChat starts from now on. Rules it kept stay until removed.");
65
+ return 0;
66
+ case "sweep": {
67
+ const now = deps.now ?? Date.now;
68
+ const at = Number(rest[rest.indexOf("--at") + 1]);
69
+ if (rest.includes("--at") && Number.isFinite(at)) {
70
+ const delay = Math.min(Math.max(at - now(), 0), MAX_SWEEP_DELAY_MS);
71
+ await (deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(delay + 1000);
72
+ }
73
+ await (0, grants_1.sweepExpiredGrants)(now());
74
+ return 0;
75
+ }
76
+ default:
77
+ err(USAGE);
78
+ return 1;
79
+ }
80
+ }
81
+ function describeGrant(grant) {
82
+ const when = grant.kind === "once" ? `once, until ${grant.expiresAt ?? "?"}` : `always, since ${grant.addedAt}`;
83
+ // A once-rule can carry a secret from the command (it is never offered as "always" then), so it
84
+ // is listed as the card showed it, masked; an always-rule never holds one and is listed as written.
85
+ const rules = grant.kind === "once" ? grant.shown : grant.rules.length ? grant.rules.join(", ") : "(the rule was already there)";
86
+ return `${grant.id} ${rules}\n ${when} · ${grant.settingsFile}${grant.session ? ` · ${grant.session}` : ""}`;
87
+ }