baychat 0.27.1 → 0.29.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 (53) hide show
  1. package/.claude-plugin/plugin.json +32 -0
  2. package/README.md +42 -4
  3. package/dist/approve-hook.js +93 -3
  4. package/dist/channel/claude-link.js +145 -0
  5. package/dist/channel/detect.js +10 -2
  6. package/dist/channel/feed.js +27 -13
  7. package/dist/channel/launch.js +67 -12
  8. package/dist/channel/permission.js +118 -0
  9. package/dist/channel/plugin.js +182 -0
  10. package/dist/channel/relay-link.js +257 -0
  11. package/dist/channel/server.js +191 -10
  12. package/dist/claude-onboarding.js +7 -5
  13. package/dist/cli-help.js +235 -0
  14. package/dist/commands.js +4 -4
  15. package/dist/connect-acp.js +3 -2
  16. package/dist/connect-claude.js +31 -10
  17. package/dist/device-login.js +2 -2
  18. package/dist/doctor-command.js +2 -0
  19. package/dist/doctor.js +97 -3
  20. package/dist/help-topics.js +4 -3
  21. package/dist/launch/claude-trust.js +221 -0
  22. package/dist/launch/command.js +177 -0
  23. package/dist/launch/policy.js +152 -0
  24. package/dist/machine-name.js +76 -0
  25. package/dist/main.js +16 -170
  26. package/dist/multiplexer-launch.js +271 -0
  27. package/dist/protocol-content.js +1 -1
  28. package/dist/relay/acp/agents.js +42 -19
  29. package/dist/relay/acp/daemon-glue.js +3 -2
  30. package/dist/relay/acp/runner.js +8 -7
  31. package/dist/relay/channel-sessions.js +66 -0
  32. package/dist/relay/commands.js +9 -2
  33. package/dist/relay/daemon.js +160 -9
  34. package/dist/relay/launcher/approval-settings.js +140 -0
  35. package/dist/relay/launcher/argv.js +83 -0
  36. package/dist/relay/launcher/capabilities.js +76 -0
  37. package/dist/relay/launcher/dev-channel-screen.fixtures.js +41 -0
  38. package/dist/relay/launcher/dev-channel-screen.js +47 -0
  39. package/dist/relay/launcher/handle.js +303 -0
  40. package/dist/relay/launcher/loop.js +231 -0
  41. package/dist/relay/launcher/multiplexer.js +168 -0
  42. package/dist/relay/launcher/started.js +63 -0
  43. package/dist/relay/profiles.js +1 -1
  44. package/dist/relay/registry.js +26 -0
  45. package/dist/relay/terminal-pane.js +31 -2
  46. package/dist/relay/transport.js +7 -2
  47. package/dist/relay/ws.js +18 -1
  48. package/dist/runtime-install.js +19 -3
  49. package/dist/runtimes.js +3 -0
  50. package/dist/skill-bootstrap.js +1 -1
  51. package/dist/start-command.js +12 -56
  52. package/dist/tool-defs.js +23 -36
  53. package/package.json +4 -3
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.cmdConnectClaude = cmdConnectClaude;
4
+ exports.channelReport = channelReport;
4
5
  const commands_1 = require("./commands");
5
6
  const config_1 = require("./config");
6
7
  const bay_mcp_1 = require("./bay-mcp");
@@ -9,6 +10,8 @@ const runtime_install_1 = require("./runtime-install");
9
10
  const bay_mcp_2 = require("./bay-mcp");
10
11
  const context_hooks_command_1 = require("./context-hooks-command");
11
12
  const launch_1 = require("./channel/launch");
13
+ const plugin_1 = require("./channel/plugin");
14
+ const ws_1 = require("./relay/ws");
12
15
  /** One-time Claude setup reuses a valid login, registers MCP, and refreshes its skill. */
13
16
  async function cmdConnectClaude(opts = {}) {
14
17
  try {
@@ -46,7 +49,8 @@ async function cmdConnectClaude(opts = {}) {
46
49
  console.log(" " + line);
47
50
  if (!registered)
48
51
  return 1;
49
- reportChannel(registerChannel());
52
+ for (const line of channelReport(registerChannel(), { version: process.version, webSocket: (0, ws_1.hasBuiltInWebSocket)() }))
53
+ console.log(line);
50
54
  console.log(' Restart Claude Code once, then use /baychat <name> "<group>".');
51
55
  return 0;
52
56
  }
@@ -66,7 +70,12 @@ function registerChannel() {
66
70
  const problem = (0, context_hooks_command_1.launchProblem)(launch);
67
71
  if (problem)
68
72
  return { skipped: problem };
69
- return (0, launch_1.registerChannelWithClaude)(launch, (0, bay_mcp_2.realClaudeRun)())
73
+ const run = (0, bay_mcp_2.realClaudeRun)();
74
+ // The plugin first: it updates with the npm package and is the form Anthropic can approve.
75
+ // Where this Claude Code cannot install it, the same server is registered by hand.
76
+ if ((0, plugin_1.installPluginWithClaude)(run))
77
+ return "registered";
78
+ return (0, launch_1.registerChannelWithClaude)(launch, run)
70
79
  ? "registered"
71
80
  : { skipped: "Claude Code did not accept the channel server." };
72
81
  }
@@ -74,13 +83,25 @@ function registerChannel() {
74
83
  return { skipped: err instanceof Error ? err.message : String(err) };
75
84
  }
76
85
  }
77
- function reportChannel(result) {
78
- if (result === "registered") {
79
- console.log("✓ BayChat channel added to Claude Code (wakes a session with no Monitor deadline)");
80
- console.log(` Start Claude Code with \`${launch_1.CHANNEL_LAUNCH_COMMAND}\` (or \`baychat start\`) to use it.`);
81
- console.log(" Without it, incoming messages use the native WebSocket Monitor as before.");
82
- return;
86
+ /**
87
+ * What `connect` says about the channel. Installed on a Node without the built-in WebSocket it is
88
+ * installed but cannot start (Claude Code shows it failed, sessions fall back to the Monitor) —
89
+ * said here, at the moment of setup, rather than discovered as a silent session later.
90
+ */
91
+ function channelReport(result, node) {
92
+ if (result !== "registered") {
93
+ return [
94
+ ` BayChat channel not added: ${result.skipped}`,
95
+ " Incoming messages use native WebSocket Monitor; the session will verify its connection after joining.",
96
+ ];
97
+ }
98
+ const lines = [
99
+ "✓ BayChat channel added to Claude Code (wakes a session with no Monitor deadline)",
100
+ ` Start Claude Code with \`${launch_1.CHANNEL_LAUNCH_COMMAND}\` (or \`baychat start\`) to use it.`,
101
+ " Without it, incoming messages use the native WebSocket Monitor as before.",
102
+ ];
103
+ if (!node.webSocket) {
104
+ lines.push(` ⚠ This Node (${node.version}) has no built-in WebSocket, so the channel cannot start yet: it needs Node ${ws_1.WEBSOCKET_MIN_NODE}+.`, " Until then sessions use the Monitor, and the relay long-polls instead of using its WebSocket.", ` To fix: ${ws_1.NODE_UPGRADE_HINT}.`);
83
105
  }
84
- console.log(` BayChat channel not added: ${result.skipped}`);
85
- console.log(" Incoming messages use native WebSocket Monitor; the session will verify its connection after joining.");
106
+ return lines;
86
107
  }
@@ -37,11 +37,11 @@ exports.parseBayLogins = parseBayLogins;
37
37
  exports.startDeviceLogin = startDeviceLogin;
38
38
  exports.finishDeviceLogin = finishDeviceLogin;
39
39
  const fs = __importStar(require("node:fs"));
40
- const os = __importStar(require("node:os"));
41
40
  const path = __importStar(require("node:path"));
42
41
  const node_crypto_1 = require("node:crypto");
43
42
  const zod_1 = require("zod");
44
43
  const api_1 = require("./api");
44
+ const machine_name_1 = require("./machine-name");
45
45
  const config_1 = require("./config");
46
46
  const bayLoginSchema = zod_1.z.object({
47
47
  bayId: zod_1.z.string().min(1),
@@ -103,7 +103,7 @@ async function startDeviceLogin(baseUrl) {
103
103
  Date.parse(previous.expiresAt) > Date.now()) {
104
104
  return { url: previous.url, expiresAt: previous.expiresAt };
105
105
  }
106
- const request = await (0, api_1.createDeviceLink)(base, os.hostname().slice(0, 60));
106
+ const request = await (0, api_1.createDeviceLink)(base, (0, machine_name_1.machineName)());
107
107
  const parsed = pendingSchema.safeParse({ ...request, baseUrl: base });
108
108
  if (!parsed.success)
109
109
  throw new Error("BayChat returned an invalid login link. Try baychat login --start again.");
@@ -54,6 +54,7 @@ const registry_1 = require("./relay/registry");
54
54
  const credential_refresh_1 = require("./credential-refresh");
55
55
  const client_paths_1 = require("./client-paths");
56
56
  const runtime_binary_1 = require("./runtime-binary");
57
+ const ws_1 = require("./relay/ws");
57
58
  /** How long `claude mcp list` may take before we treat it as unanswerable. */
58
59
  const MCP_LIST_TIMEOUT_MS = 15_000;
59
60
  /**
@@ -95,6 +96,7 @@ async function currentDoctorEnv() {
95
96
  now: Date.now(),
96
97
  device: (0, config_1.loadDeviceCredentials)(),
97
98
  relay: await (0, commands_1.tryRelayStatus)(),
99
+ node: { version: process.version, webSocket: (0, ws_1.hasBuiltInWebSocket)() },
98
100
  targets: registry.all(),
99
101
  readText(filePath) {
100
102
  try {
package/dist/doctor.js CHANGED
@@ -71,6 +71,11 @@ const agents_1 = require("./relay/acp/agents");
71
71
  const policy_1 = require("./relay/acp/policy");
72
72
  const runtime_binary_1 = require("./runtime-binary");
73
73
  const runtimes_1 = require("./runtimes");
74
+ const runtime_install_1 = require("./runtime-install");
75
+ const launch_1 = require("./channel/launch");
76
+ const plugin_1 = require("./channel/plugin");
77
+ const transport_1 = require("./relay/transport");
78
+ const ws_1 = require("./relay/ws");
74
79
  /**
75
80
  * The runtimes worth reporting on.
76
81
  *
@@ -99,6 +104,14 @@ const SETUP_COMMAND = {
99
104
  cursor: "baychat connect cursor",
100
105
  desktop: "baychat connect desktop",
101
106
  };
107
+ /**
108
+ * What rewrites a stale skill without signing in again. `baychat update` reinstalls Claude
109
+ * Code's skill as part of re-registering it; `baychat login` would too, but only after an
110
+ * interactive phone approval the person does not need.
111
+ */
112
+ const SKILL_REFRESH_COMMAND = {
113
+ claude: "baychat update",
114
+ };
102
115
  function setupCommand(runtime) {
103
116
  return SETUP_COMMAND[runtime] ?? `baychat connect ${runtime}`;
104
117
  }
@@ -217,6 +230,11 @@ function acpChecks(env) {
217
230
  remedy: row.installHint,
218
231
  };
219
232
  }
233
+ // The relay refuses this binary before every turn; say so here rather than "pass".
234
+ const binaryProblem = (0, agents_1.binaryRefusal)(row, bin.path, bin.version);
235
+ if (binaryProblem) {
236
+ return { name, status: "fail", detail: `${row.label} on this computer will not be started`, remedy: binaryProblem };
237
+ }
220
238
  const folder = (0, policy_1.checkFolder)(policy, row.id, t.acp.cwd);
221
239
  if (!folder.ok) {
222
240
  return {
@@ -411,6 +429,18 @@ function relayCheck(env) {
411
429
  const transport = env.relay.transport
412
430
  ? `, transport ${env.relay.transport}${env.relay.transportDetail ? ` (${env.relay.transportDetail})` : ""}`
413
431
  : "";
432
+ if (env.relay.transport === "long-poll" && env.relay.transportDetail === transport_1.NO_WEBSOCKET_DETAIL) {
433
+ // The relay's OWN Node has no WebSocket, so it long-polls for good — and the server throttles
434
+ // that ("Too many update polls", 15,000 times in three days on one laptop). A failure, not a
435
+ // pass with a note. Which fix depends on the Node this doctor runs on: if it has WebSocket,
436
+ // restarting the relay from it is enough (`update` rewrites the service with this Node).
437
+ return {
438
+ name: "relay",
439
+ status: "fail",
440
+ detail: `running (pid ${env.relay.pid}) but long-polling: its Node has no built-in WebSocket (Node ${ws_1.WEBSOCKET_MIN_NODE}+), and the server throttles the polls`,
441
+ remedy: env.node.webSocket ? `baychat update — restarts the relay on this Node (${env.node.version})` : ws_1.NODE_UPGRADE_HINT,
442
+ };
443
+ }
414
444
  return { name: "relay", status: "pass", detail: `running (pid ${env.relay.pid})${transport}` };
415
445
  }
416
446
  function pendingCheck(env) {
@@ -459,12 +489,72 @@ function runtimeReport(runtime, env) {
459
489
  ],
460
490
  };
461
491
  }
492
+ const extra = RUNTIME_EXTRA_CHECKS[runtime];
462
493
  return {
463
494
  runtime,
464
495
  label: spec.label,
465
- checks: [mcp, skill, binaryCheck(runtime, env), sessionCheck(runtime, env)],
496
+ checks: [mcp, skill, binaryCheck(runtime, env), sessionCheck(runtime, env), ...(extra ? [extra(env)] : [])],
466
497
  };
467
498
  }
499
+ /** Checks only one runtime has. Claude Code alone has the wake-up channel. */
500
+ const RUNTIME_EXTRA_CHECKS = {
501
+ claude: channelCheck,
502
+ };
503
+ /** `baychat@baychat 0.28.0`; Claude Code records "unknown" for a manifest that names no version. */
504
+ function pluginLabel(version) {
505
+ return version === "unknown" ? "baychat@baychat (version unknown)" : `baychat@baychat ${version}`;
506
+ }
507
+ /** What installs the channel, in either form (docs/features/CLAUDE_CODE_CHANNEL.md). */
508
+ const CHANNEL_SETUP_COMMAND = "baychat connect claude";
509
+ /**
510
+ * Is Claude Code's wake-up channel installed in a form that can deliver?
511
+ *
512
+ * Two forms count: BayChat's plugin, switched on AND declaring the channel in its own manifest,
513
+ * or the hand-registered `baychat_channel` server. Not having either is a `skip` — a session then
514
+ * listens through a Monitor, which works.
515
+ *
516
+ * A `baychat@baychat` that is switched on but carries no channel is a FAILURE, because it is what
517
+ * a silently failed setup leaves behind: computers set up before the channel have a skill-only
518
+ * plugin of that very name, from a GitHub catalog also called `baychat`. On those `connect claude`
519
+ * used to see "already installed", remove the hand-registered server and report success, leaving
520
+ * the channel nowhere (2026-10-03). Its remedy replaces the old catalog (`channel/plugin.ts`).
521
+ */
522
+ function channelCheck(env) {
523
+ const files = { home: env.home, read: env.readText };
524
+ const installed = (0, plugin_1.installedPlugin)(files);
525
+ const enabled = (0, plugin_1.pluginEnabled)(files);
526
+ if (enabled && installed.state !== "channel") {
527
+ const origin = (0, plugin_1.baychatMarketplace)(files);
528
+ const from = origin.kind === "foreign" ? ` — the old skill-only plugin, from ${origin.source}` : "";
529
+ const what = installed.state === "absent"
530
+ ? "baychat@baychat is switched on but not installed"
531
+ : `${pluginLabel(installed.version)} is switched on but carries no channel: ${installed.why}`;
532
+ return { name: "channel", status: "fail", detail: `${what}${from}`, remedy: CHANNEL_SETUP_COMMAND };
533
+ }
534
+ const forms = [
535
+ ...(enabled && installed.state === "channel" ? [`plugin ${pluginLabel(installed.version)}`] : []),
536
+ ...((0, launch_1.channelRegistered)((0, launch_1.claudeUserConfigPath)(env.home), (p) => env.readText(p) ?? "") ? ["server baychat_channel"] : []),
537
+ ];
538
+ if (forms.length === 0) {
539
+ return {
540
+ name: "channel",
541
+ status: "skip",
542
+ detail: "not set up — sessions listen through a Monitor, which ends every 30 minutes",
543
+ remedy: CHANNEL_SETUP_COMMAND,
544
+ };
545
+ }
546
+ if (!env.node.webSocket) {
547
+ // Set up, and unable to run: the channel needs the built-in WebSocket, so Claude Code would
548
+ // show it failed and every session would quietly fall back to the Monitor.
549
+ return {
550
+ name: "channel",
551
+ status: "fail",
552
+ detail: `${forms.join(" + ")}, but this Node (${env.node.version}) has no built-in WebSocket — the channel needs Node ${ws_1.WEBSOCKET_MIN_NODE}+ and cannot start`,
553
+ remedy: ws_1.NODE_UPGRADE_HINT,
554
+ };
555
+ }
556
+ return { name: "channel", status: "pass", detail: `${forms.join(" + ")} — start sessions with \`baychat claude\`` };
557
+ }
468
558
  /**
469
559
  * Is BayChat actually registered as an MCP server for this runtime?
470
560
  *
@@ -549,13 +639,17 @@ function skillCheck(runtime, env) {
549
639
  //
550
640
  // A doc that is wrong is worse than one that is missing, because a missing one
551
641
  // sends the agent looking.
552
- const current = (0, runtimes_1.renderCommandFor)(runtime);
642
+ //
643
+ // Compared against what the INSTALLER writes, not against the full workflow: Claude
644
+ // Code and Codex get a bootstrap that loads the workflow at run time, so comparing
645
+ // against the workflow failed every machine `baychat update` had just refreshed.
646
+ const current = (0, runtime_install_1.skillFileBody)(runtime);
553
647
  if (current !== null && installed.trim() !== current.trim()) {
554
648
  return {
555
649
  name: "skill",
556
650
  status: "fail",
557
651
  detail: `${display(file, env)} is out of date — it does not match the skill this version installs`,
558
- remedy: setupCommand(runtime),
652
+ remedy: SKILL_REFRESH_COMMAND[runtime] ?? setupCommand(runtime),
559
653
  };
560
654
  }
561
655
  return { name: "skill", status: "pass", detail: `${display(file, env)} (current)` };
@@ -162,9 +162,10 @@ There is no timeout by design. A question worth asking is worth waiting for, and
162
162
  a decision that expires silently is worse than one that waits.
163
163
 
164
164
  \`baychat approve-hook\` wires Claude Code's own permission prompts to the same
165
- cards. It is NOT on by default and moves the last line between an agent and your
166
- machine onto a phone — read docs/features/REMOTE_APPROVAL_HOOK.md before enabling
167
- it. It fails CLOSED: every error denies.`,
165
+ cards. It is on only for Claude Code sessions BayChat starts (from the phone,
166
+ \`baychat claude\` or \`baychat start\`), never for a plain \`claude\`, and it moves
167
+ the last line between an agent and your machine onto a phone — read
168
+ docs/features/REMOTE_APPROVAL_HOOK.md. It fails CLOSED: every error denies.`,
168
169
  };
169
170
  exports.HELP_TOPICS = [GROUPS, SESSIONS, TOOLS, APPROVALS];
170
171
  /** Exact name first, then keyword, then a substring of the body. */
@@ -0,0 +1,221 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.TRUST_KEY = void 0;
37
+ exports.claudeConfigPath = claudeConfigPath;
38
+ exports.trustKeysFor = trustKeysFor;
39
+ exports.isFolderTrusted = isFolderTrusted;
40
+ exports.trustProblemText = trustProblemText;
41
+ exports.trustFolder = trustFolder;
42
+ const fs = __importStar(require("fs"));
43
+ const os = __importStar(require("os"));
44
+ const path = __importStar(require("path"));
45
+ /**
46
+ * Claude Code's "Quick safety check" (workspace trust), read — and, only at the computer, written.
47
+ *
48
+ * A `claude` started unattended in a folder Claude Code has not trusted sits at
49
+ * "Is this a project you created or one you trust? ❯ No, exit / Yes, I trust this folder"
50
+ * forever, with nobody there to answer. Claude Code records the answer in `~/.claude.json`
51
+ * (or `$CLAUDE_CONFIG_DIR/.claude.json`) as `projects["<absolute path>"].hasTrustDialogAccepted`.
52
+ * (Key verified against the real file on Claude Code 2.1.289.)
53
+ *
54
+ * ── WHO MAY WRITE TRUST ───────────────────────────────────────────────────────────────
55
+ * Only a person at this computer, through `baychat launch add` answering y. The relay only READS
56
+ * (`isFolderTrusted`) and refuses with FOLDER_NOT_TRUSTED; nothing that arrives over the network
57
+ * ever calls `trustFolder`.
58
+ *
59
+ * The file holds the person's whole Claude Code state (and may hold secrets), so the write is
60
+ * as small as it can be: that one boolean on that one project key, everything else re-serialised
61
+ * unchanged, a `.bak` copy first, then a temp file renamed over the original with its mode.
62
+ * A missing or unparsable file is never written: we report and let the person run `claude`.
63
+ */
64
+ exports.TRUST_KEY = "hasTrustDialogAccepted";
65
+ /** `$CLAUDE_CONFIG_DIR/.claude.json` when set (as Claude Code does), else `~/.claude.json`. */
66
+ function claudeConfigPath(env = process.env, home = os.homedir()) {
67
+ const dir = env.CLAUDE_CONFIG_DIR?.trim();
68
+ return dir ? path.join(dir, ".claude.json") : path.join(home, ".claude.json");
69
+ }
70
+ /**
71
+ * The keys Claude Code may have recorded for `folder`: the path as given, its realpath, and on
72
+ * macOS the same path with and without `/private` (/tmp, /var and /etc are symlinks there).
73
+ */
74
+ function trustKeysFor(folder, platform = process.platform) {
75
+ const keys = new Set([folder]);
76
+ try {
77
+ keys.add(fs.realpathSync(folder));
78
+ }
79
+ catch {
80
+ // A folder that does not resolve is checked by the name given only.
81
+ }
82
+ if (platform === "darwin") {
83
+ for (const k of [...keys]) {
84
+ const m = /^\/private(\/(?:tmp|var|etc)(?:\/.*)?)$/.exec(k);
85
+ if (m)
86
+ keys.add(m[1]);
87
+ else if (/^\/(?:tmp|var|etc)(?:\/|$)/.test(k))
88
+ keys.add(`/private${k}`);
89
+ }
90
+ }
91
+ return [...keys];
92
+ }
93
+ function readConfig(file) {
94
+ let raw;
95
+ try {
96
+ raw = fs.readFileSync(file, "utf8");
97
+ }
98
+ catch (err) {
99
+ return { ok: false, reason: err.code === "ENOENT" ? "CONFIG_MISSING" : "CONFIG_UNREADABLE" };
100
+ }
101
+ let json;
102
+ try {
103
+ json = JSON.parse(raw);
104
+ }
105
+ catch {
106
+ return { ok: false, reason: "CONFIG_UNPARSABLE" };
107
+ }
108
+ if (!json || typeof json !== "object" || Array.isArray(json))
109
+ return { ok: false, reason: "CONFIG_UNPARSABLE" };
110
+ const projects = json.projects;
111
+ if (projects !== undefined && (!projects || typeof projects !== "object" || Array.isArray(projects))) {
112
+ return { ok: false, reason: "CONFIG_UNPARSABLE" };
113
+ }
114
+ return { ok: true, raw, json: json };
115
+ }
116
+ /** Read-only: has Claude Code's trust dialog been accepted for `folder` (under any of its names)? */
117
+ function isFolderTrusted(folder, file = claudeConfigPath()) {
118
+ const parsed = readConfig(file);
119
+ if (!parsed.ok)
120
+ return false;
121
+ const projects = (parsed.json.projects ?? {});
122
+ return trustKeysFor(folder).some((key) => {
123
+ const entry = Object.prototype.hasOwnProperty.call(projects, key) ? projects[key] : undefined;
124
+ return !!entry && typeof entry === "object" && entry[exports.TRUST_KEY] === true;
125
+ });
126
+ }
127
+ /** A sentence for each problem, for the person at the terminal. */
128
+ function trustProblemText(reason, file) {
129
+ switch (reason) {
130
+ case "CONFIG_MISSING":
131
+ return `${file} does not exist yet (Claude Code has not run on this computer).`;
132
+ case "CONFIG_UNREADABLE":
133
+ return `${file} could not be read.`;
134
+ case "CONFIG_UNPARSABLE":
135
+ return `${file} is not the JSON BayChat expects, so it was left alone.`;
136
+ case "CONFIG_UNSAFE":
137
+ return `${file} holds numbers BayChat could not rewrite exactly, so it was left alone.`;
138
+ case "CONFIG_CHANGING":
139
+ return `${file} kept changing while BayChat tried to write it (is Claude Code busy?).`;
140
+ case "WRITE_FAILED":
141
+ return `${file} could not be written.`;
142
+ }
143
+ }
144
+ /**
145
+ * Set `projects[folder].hasTrustDialogAccepted = true` and nothing else. `folder` should be the
146
+ * realpath (the spot's stored path). Called ONLY from `baychat launch add` after a person said y.
147
+ */
148
+ function trustFolder(folder, file = claudeConfigPath()) {
149
+ // Write through a symlinked ~/.claude.json (dotfile managers) instead of replacing the link.
150
+ let target = file;
151
+ try {
152
+ target = fs.realpathSync(file);
153
+ }
154
+ catch {
155
+ // Missing: readConfig reports it below.
156
+ }
157
+ for (let attempt = 0; attempt < 3; attempt++) {
158
+ const parsed = readConfig(target);
159
+ if (!parsed.ok)
160
+ return parsed;
161
+ if (hasLossyNumbers(parsed.raw))
162
+ return { ok: false, reason: "CONFIG_UNSAFE" };
163
+ const projects = (parsed.json.projects ?? {});
164
+ const current = Object.prototype.hasOwnProperty.call(projects, folder) ? projects[folder] : undefined;
165
+ if (current !== undefined && (!current || typeof current !== "object" || Array.isArray(current))) {
166
+ return { ok: false, reason: "CONFIG_UNPARSABLE" };
167
+ }
168
+ if (current?.[exports.TRUST_KEY] === true)
169
+ return { ok: true, changed: false };
170
+ const next = {
171
+ ...parsed.json,
172
+ projects: { ...projects, [folder]: { ...(current ?? {}), [exports.TRUST_KEY]: true } },
173
+ };
174
+ const text = serialiseLike(parsed.raw, next);
175
+ let mode;
176
+ try {
177
+ mode = fs.statSync(target).mode & 0o777;
178
+ const bak = `${target}.bak`;
179
+ fs.copyFileSync(target, bak);
180
+ fs.chmodSync(bak, mode);
181
+ }
182
+ catch {
183
+ return { ok: false, reason: "WRITE_FAILED" };
184
+ }
185
+ const tmp = path.join(path.dirname(target), `.${path.basename(target)}.baychat-${process.pid}-${Date.now()}.tmp`);
186
+ try {
187
+ fs.writeFileSync(tmp, text, { mode });
188
+ fs.chmodSync(tmp, mode); // writeFile's mode is masked by the umask
189
+ // Claude Code rewrites this file often. If it changed since we read it, start over
190
+ // rather than rename our older copy over its newer one.
191
+ if (fs.readFileSync(target, "utf8") !== parsed.raw) {
192
+ fs.rmSync(tmp, { force: true });
193
+ continue;
194
+ }
195
+ fs.renameSync(tmp, target);
196
+ return { ok: true, changed: true };
197
+ }
198
+ catch {
199
+ fs.rmSync(tmp, { force: true });
200
+ return { ok: false, reason: "WRITE_FAILED" };
201
+ }
202
+ }
203
+ return { ok: false, reason: "CONFIG_CHANGING" };
204
+ }
205
+ /** JSON.stringify in the file's own style: its indent, and its trailing newline or lack of one. */
206
+ function serialiseLike(raw, value) {
207
+ const indent = /^\{\r?\n(\t| +)"/.exec(raw)?.[1] ?? (raw.trimStart().startsWith("{\n") ? 2 : 0);
208
+ const body = JSON.stringify(value, null, indent);
209
+ return raw.endsWith("\n") ? `${body}\n` : body;
210
+ }
211
+ /**
212
+ * A number JSON.parse cannot hold exactly (more than 15 significant digits) would be silently
213
+ * changed by the rewrite. Strings are blanked first so ids and tokens inside quotes don't count.
214
+ */
215
+ function hasLossyNumbers(raw) {
216
+ const outsideStrings = raw.replace(/"(?:[^"\\]|\\.)*"/g, '""');
217
+ return (outsideStrings.match(/-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g) ?? []).some((n) => {
218
+ const digits = n.replace(/^-/, "").split(/[eE]/)[0].replace(".", "").replace(/^0+/, "");
219
+ return digits.length > 15;
220
+ });
221
+ }