baychat 0.28.0 → 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 (49) hide show
  1. package/.claude-plugin/plugin.json +24 -4
  2. package/README.md +41 -3
  3. package/dist/approve-hook.js +93 -3
  4. package/dist/channel/claude-link.js +145 -0
  5. package/dist/channel/feed.js +26 -12
  6. package/dist/channel/launch.js +59 -45
  7. package/dist/channel/plugin.js +182 -0
  8. package/dist/channel/relay-link.js +257 -0
  9. package/dist/channel/server.js +92 -7
  10. package/dist/cli-help.js +235 -0
  11. package/dist/commands.js +4 -4
  12. package/dist/connect-acp.js +3 -2
  13. package/dist/connect-claude.js +26 -10
  14. package/dist/device-login.js +2 -2
  15. package/dist/doctor-command.js +2 -0
  16. package/dist/doctor.js +97 -3
  17. package/dist/help-topics.js +4 -3
  18. package/dist/launch/claude-trust.js +221 -0
  19. package/dist/launch/command.js +177 -0
  20. package/dist/launch/policy.js +152 -0
  21. package/dist/machine-name.js +76 -0
  22. package/dist/main.js +16 -170
  23. package/dist/multiplexer-launch.js +271 -0
  24. package/dist/protocol-content.js +1 -1
  25. package/dist/relay/acp/agents.js +42 -19
  26. package/dist/relay/acp/daemon-glue.js +3 -2
  27. package/dist/relay/acp/runner.js +8 -7
  28. package/dist/relay/channel-sessions.js +66 -0
  29. package/dist/relay/commands.js +9 -2
  30. package/dist/relay/daemon.js +160 -9
  31. package/dist/relay/launcher/approval-settings.js +140 -0
  32. package/dist/relay/launcher/argv.js +83 -0
  33. package/dist/relay/launcher/capabilities.js +76 -0
  34. package/dist/relay/launcher/dev-channel-screen.fixtures.js +41 -0
  35. package/dist/relay/launcher/dev-channel-screen.js +47 -0
  36. package/dist/relay/launcher/handle.js +303 -0
  37. package/dist/relay/launcher/loop.js +231 -0
  38. package/dist/relay/launcher/multiplexer.js +168 -0
  39. package/dist/relay/launcher/started.js +63 -0
  40. package/dist/relay/profiles.js +1 -1
  41. package/dist/relay/registry.js +26 -0
  42. package/dist/relay/terminal-pane.js +31 -2
  43. package/dist/relay/transport.js +7 -2
  44. package/dist/relay/ws.js +18 -1
  45. package/dist/runtime-install.js +19 -3
  46. package/dist/runtimes.js +3 -0
  47. package/dist/start-command.js +12 -56
  48. package/dist/tool-defs.js +23 -36
  49. package/package.json +3 -3
@@ -1,12 +1,32 @@
1
1
  {
2
2
  "name": "baychat",
3
+ "version": "0.29.0",
3
4
  "displayName": "BayChat",
4
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).",
5
- "author": { "name": "BayChat", "url": "https://baychat.io" },
6
+ "author": {
7
+ "name": "BayChat",
8
+ "url": "https://baychat.io"
9
+ },
6
10
  "homepage": "https://baychat.io",
7
- "keywords": ["baychat", "chat", "channel", "mobile", "notifications"],
11
+ "keywords": [
12
+ "baychat",
13
+ "chat",
14
+ "channel",
15
+ "mobile",
16
+ "notifications"
17
+ ],
8
18
  "mcpServers": {
9
- "channel": { "command": "baychat", "args": ["channel"] }
19
+ "channel": {
20
+ "command": "baychat",
21
+ "args": [
22
+ "channel"
23
+ ]
24
+ }
10
25
  },
11
- "channels": [{ "server": "channel", "displayName": "BayChat" }]
26
+ "channels": [
27
+ {
28
+ "server": "channel",
29
+ "displayName": "BayChat"
30
+ }
31
+ ]
12
32
  }
package/README.md CHANGED
@@ -9,8 +9,10 @@ This uses the separately deployed remote protocol 1.10.
9
9
  When login really needs renewal, an agent can run `baychat login --start`, show
10
10
  the approval link immediately, then run `baychat login --finish` once you approve.
11
11
  There is no waiting login process. Reconnect BayChat in the client afterwards.
12
- `baychat login --help` displays help without starting authentication. Login
13
- network requests time out after 15 seconds instead of waiting indefinitely.
12
+ `--help` (or `-h`) on any command prints its usage and runs nothing — `baychat update --help`
13
+ does not update. The one exception is `baychat claude`, whose arguments are all Claude Code's:
14
+ `baychat claude --help` is Claude Code's own help. Login network requests time out after 15
15
+ seconds instead of waiting indefinitely.
14
16
 
15
17
  ## Coding sessions: one short command
16
18
 
@@ -117,6 +119,41 @@ a local Claude Code channel (as the `baychat` plugin, from https://baychat.io/cl
117
119
  and the skill calls its `listen` tool instead of opening a Monitor: the channel holds the
118
120
  feed, reconnects by itself, and wakes the session only when a message arrives. Started
119
121
  without the flag, `listen` answers `inactive` and the Monitor path above is used.
122
+ The channel needs **Node 22 or newer** (its built-in WebSocket; the relay's WebSocket needs
123
+ it too). On Node 20 `connect claude` says so, and `baychat doctor` fails the `channel` and
124
+ `relay` rows. A computer that still has the old skill-only `baychat` plugin (from the GitHub
125
+ catalog of the same name) gets it replaced; `connect` reports the channel added only when
126
+ the installed plugin actually declares it.
127
+
128
+ To keep the system Node at 20 for other projects, run only `baychat` on Node 22 (Linux/WSL with
129
+ systemd; details in [`CLAUDE_CODE_CHANNEL.md`](../../docs/features/CLAUDE_CODE_CHANNEL.md)):
130
+ unpack Node 22 into `~/.local/node22`, then put a wrapper `~/.local/bin/baychat` ahead of npm's
131
+ global `bin` on `PATH` (Claude Code starts the channel from `PATH`, so it gets Node 22 too).
132
+ `~/.local/npm-global` below is one machine's npm prefix: write yours (`npm prefix -g`) as a
133
+ literal path — the wrapper runs on every status-line refresh, so it must not call npm.
134
+
135
+ ```sh
136
+ #!/bin/sh
137
+ exec "$HOME/.local/node22/bin/node" "$HOME/.local/npm-global/lib/node_modules/baychat/dist/index.js" "$@"
138
+ ```
139
+
140
+ and a drop-in `~/.config/systemd/user/baychat-relay.service.d/node22.conf` for the relay, then
141
+ `systemctl --user daemon-reload && systemctl --user restart baychat-relay`:
142
+
143
+ ```ini
144
+ [Service]
145
+ ExecStart=
146
+ ExecStart=%h/.local/node22/bin/node %h/.local/npm-global/lib/node_modules/baychat/dist/index.js relay start --foreground
147
+ ```
148
+
149
+ `baychat relay status` should then read `transport: websocket (connected)`. To revert, delete
150
+ both files, `daemon-reload`, restart the relay, and run `baychat update`.
151
+
152
+ **Claude Code background agents** (`claude --bg`, `claude agents`) do not get the channel today:
153
+ their `listen` answers inactive and they use the Monitor. Claude Code also retires an idle
154
+ background agent after about an hour, which ends its Monitor or channel with it — pin the agent
155
+ with **Ctrl+T** in `claude agents` to keep it. A session whose process is gone is not woken
156
+ again: the default join registers nothing the relay could resume.
120
157
 
121
158
  The Windows relay runs without a visible console, including after login and
122
159
  network reconnects. Incoming messages appear as `@Sender` in the coding task.
@@ -317,7 +354,8 @@ one where remembering to re-arm falls to the agent.
317
354
  | `baychat relay start [--foreground]` | Run the **relay**: one long-poll for this whole machine that wakes local sessions the moment a message arrives. Installs a systemd user unit so it returns after a reboot (see [Relay](#relay)) |
318
355
  | `baychat relay status` | Sessions, cursor, and any **delivery pending** — messages that reached this box and that nothing answered (exit 2 if any) |
319
356
  | `baychat relay stop` | Stop the relay and disable it at boot |
320
- | `baychat doctor [--json]` | **Run this when something does not work.** Checks every link between this machine and BayChat — credential, relay, and per runtime its MCP registration, skill, executable and live session — and prints exactly what to type for each thing that is wrong. Exit 0 clear, 1 broken, 2 messages nothing answered. `--json` for pasting into a support thread (see [When something does not work](#when-something-does-not-work)) |
357
+ | `baychat claude [args…]` | Start Claude Code in this terminal with the BayChat channel on (see [No Monitor deadline with the channel](#coding-sessions-one-short-command)). Every argument is passed to Claude Code, `--help` included |
358
+ | `baychat doctor [--json]` | **Run this when something does not work.** Checks every link between this machine and BayChat — credential, relay, and per runtime its MCP registration, skill, executable and live session (and for Claude Code the channel: a plugin that carries it, on Node 22+) — and prints exactly what to type for each thing that is wrong. Exit 0 clear, 1 broken, 2 messages nothing answered. `--json` for pasting into a support thread (see [When something does not work](#when-something-does-not-work)) |
321
359
  | `baychat relay attach --session <name> [--runtime claude\|codex\|cursor\|hermes] [--resume-id <id>] [--conversation <id>] [--timeout <sec>]` | Register this session with the relay and block until it is woken (exit 0) or the wait lapses (exit 2) |
322
360
  | `baychat mcp` | Run a local **stdio MCP server** so MCP-aware clients (Claude Desktop, Claude Code, Cursor) get BayChat as native tools (see below) |
323
361
  | `baychat mcp-config [--client codex\|cursor\|desktop]` | Print a paste-ready config that points another MCP client at the **remote** BayChat server. No `--client` lists what's supported (see [Other MCP clients](#other-mcp-clients)) |
@@ -33,13 +33,16 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.APPROVE_OPTIONS = exports.RECOMMENDED_HANDLER_TIMEOUT_SECONDS = exports.DEFAULT_WAIT_SECONDS = void 0;
36
+ exports.MAX_CLAUDE_HOPS = 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
42
  exports.readStdin = readStdin;
43
+ exports.sessionFromClaude = sessionFromClaude;
44
+ exports.parseEtime = parseEtime;
45
+ exports.decideApprovalOrDefer = decideApprovalOrDefer;
43
46
  exports.cmdApproveHook = cmdApproveHook;
44
47
  // `baychat approve-hook` — the Claude Code PermissionRequest hook that moves a terminal
45
48
  // permission prompt onto a phone.
@@ -79,12 +82,22 @@ exports.cmdApproveHook = cmdApproveHook;
79
82
  // 2. STDOUT IS THE DECISION CHANNEL. Nothing else may ever be written to it — "Stdout must
80
83
  // contain only the JSON object". All diagnostics go to stderr.
81
84
  //
85
+ // THE ONE EXCEPTION: `--session-from-claude` before the session has joined. `baychat claude` and
86
+ // `baychat start` install this hook before the session has a name; until the channel records the
87
+ // name (channel/claude-link.ts) there is no session a card could belong to. Then — and only then —
88
+ // this prints NOTHING and exits 0, and Claude Code shows its own terminal dialog. That is not an
89
+ // allow: it is the ordinary permission prompt, answered by the person who just ran the command at
90
+ // that very terminal. Every other path, including every failure once a session WAS found, still
91
+ // ends in an explicit decision.
92
+ //
82
93
  // The card itself never expires: the ApprovalRequest row stays PENDING for as long as it takes,
83
94
  // exactly like a terminal prompt. Our deadline bounds only how long THIS PROCESS waits, and it
84
95
  // exists solely because the alternative (being killed by the handler timeout) would allow.
96
+ const child_process_1 = require("child_process");
85
97
  const os = __importStar(require("os"));
86
98
  const api_1 = require("./api");
87
99
  const config_1 = require("./config");
100
+ const claude_link_1 = require("./channel/claude-link");
88
101
  function allowDecision() {
89
102
  return {
90
103
  hookSpecificOutput: {
@@ -405,8 +418,82 @@ function readStdin(stream = process.stdin, timeoutMs = 10_000) {
405
418
  });
406
419
  }
407
420
  // ─── The command ─────────────────────────────────────────────────────────────
421
+ // ─── --session-from-claude ───────────────────────────────────────────────────
422
+ /** How many ancestors of the hook process are searched for the Claude Code that ran it. The hook
423
+ * is normally its direct child (exec form); the slack covers a wrapper shell or two. */
424
+ exports.MAX_CLAUDE_HOPS = 4;
425
+ /**
426
+ * The BayChat session of the Claude Code this hook runs under, or undefined when it has not joined
427
+ * (or cannot be found). NEVER THROWS: any failure is "not found", which leaves the prompt to
428
+ * Claude Code's own dialog.
429
+ */
430
+ function sessionFromClaude(deps = {}) {
431
+ const parentOf = deps.parentOf ?? psParentOf;
432
+ const isAlive = deps.isAlive ?? claude_link_1.pidAlive;
433
+ const readLink = deps.readLink ?? ((pid) => (0, claude_link_1.readClaudeLink)(pid));
434
+ const ageMs = deps.ageMs ?? psAgeMs;
435
+ const now = deps.now ?? Date.now;
436
+ try {
437
+ let pid = deps.ppid ?? process.ppid;
438
+ for (let hop = 0; hop < exports.MAX_CLAUDE_HOPS && pid !== undefined && pid > 1; hop++) {
439
+ const link = readLink(pid);
440
+ if (link && isAlive(pid)) {
441
+ // A file older than the process it is named after was left by an earlier process that
442
+ // had the same pid: not this Claude Code's session.
443
+ const age = ageMs(pid);
444
+ const written = Date.parse(link.writtenAt);
445
+ const stale = age !== undefined && Number.isFinite(written) && written < now() - age - 5_000;
446
+ if (!stale)
447
+ return link.session;
448
+ }
449
+ pid = parentOf(pid);
450
+ }
451
+ }
452
+ catch {
453
+ // Not found. See above: no decision, the terminal dialog — never an allow by this code.
454
+ }
455
+ return undefined;
456
+ }
457
+ function psField(pid, field) {
458
+ if (process.platform === "win32")
459
+ return undefined;
460
+ const r = (0, child_process_1.spawnSync)("ps", ["-o", `${field}=`, "-p", String(pid)], { encoding: "utf8", timeout: 2_000 });
461
+ if (r.error || r.status !== 0)
462
+ return undefined;
463
+ const out = String(r.stdout ?? "").trim();
464
+ return out || undefined;
465
+ }
466
+ function psParentOf(pid) {
467
+ const n = Number(psField(pid, "ppid"));
468
+ return Number.isInteger(n) && n > 0 ? n : undefined;
469
+ }
470
+ function psAgeMs(pid) {
471
+ const etime = psField(pid, "etime");
472
+ return etime ? parseEtime(etime) : undefined;
473
+ }
474
+ /** `ps -o etime` → ms: `[[dd-]hh:]mm:ss`. */
475
+ function parseEtime(text) {
476
+ const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(text.trim());
477
+ if (!m)
478
+ return undefined;
479
+ const [d, h, min, sec] = [m[1], m[2], m[3], m[4]].map((v) => Number(v ?? 0));
480
+ return (((d * 24 + h) * 60 + min) * 60 + sec) * 1000;
481
+ }
482
+ /**
483
+ * The decision for one hook run, or null for "no decision": `--session-from-claude` (and no
484
+ * `--session`) while the Claude Code it runs under has not joined a session.
485
+ */
486
+ async function decideApprovalOrDefer(rawStdin, argv = [], deps = {}) {
487
+ const rest = argv.filter((a) => a !== "--session-from-claude");
488
+ if (rest.length === argv.length || rest.includes("--session"))
489
+ return decideApproval(rawStdin, rest, deps);
490
+ const session = sessionFromClaude(deps.claude);
491
+ if (session === undefined)
492
+ return null;
493
+ return decideApproval(rawStdin, ["--session", session, ...rest], deps);
494
+ }
408
495
  /**
409
- * `baychat approve-hook [--session <name>] [--timeout <seconds>]`
496
+ * `baychat approve-hook [--session <name> | --session-from-claude] [--timeout <seconds>]`
410
497
  *
411
498
  * Always returns 0. A non-zero exit is a non-blocking error to Claude Code, which means the tool
412
499
  * call PROCEEDS — so exiting 1 on failure would be precisely backwards.
@@ -414,7 +501,7 @@ function readStdin(stream = process.stdin, timeoutMs = 10_000) {
414
501
  async function cmdApproveHook(argv = [], stdin = process.stdin) {
415
502
  let decision;
416
503
  try {
417
- decision = await decideApproval(await readStdin(stdin), argv);
504
+ decision = await decideApprovalOrDefer(await readStdin(stdin), argv);
418
505
  }
419
506
  catch (err) {
420
507
  // decideApproval does not throw; readStdin does not reject. This is here anyway, because the
@@ -422,6 +509,9 @@ async function cmdApproveHook(argv = [], stdin = process.stdin) {
422
509
  process.stderr.write(`baychat approve-hook: ${err instanceof Error ? err.message : String(err)}\n`);
423
510
  decision = denyClosed("the approval gate could not run");
424
511
  }
512
+ // No decision: `--session-from-claude` before the session joined — Claude Code's own dialog.
513
+ if (decision === null)
514
+ return 0;
425
515
  // The single write to stdout, and the only one in this file.
426
516
  process.stdout.write(`${JSON.stringify(decision)}\n`);
427
517
  return 0;
@@ -0,0 +1,145 @@
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.claudeSessionsDir = claudeSessionsDir;
37
+ exports.claudeLinkPath = claudeLinkPath;
38
+ exports.writeClaudeLink = writeClaudeLink;
39
+ exports.removeClaudeLink = removeClaudeLink;
40
+ exports.readClaudeLink = readClaudeLink;
41
+ exports.pidAlive = pidAlive;
42
+ exports.realClaudeLink = realClaudeLink;
43
+ /**
44
+ * Which BayChat session a running Claude Code is — written by the channel, read by the hook.
45
+ *
46
+ * `baychat claude` / `baychat start` launch Claude Code with a PermissionRequest hook
47
+ * (`approve-hook --session-from-claude`) before the session has a name: it is chosen and joined
48
+ * inside the session, later. So once the channel's `listen` goes active, the channel server —
49
+ * a child Claude Code started — writes `~/.baychat/claude-sessions/<its parent pid>.json`
50
+ * = `{session, writtenAt}`. The hook, also a child of that Claude Code, walks up its own
51
+ * ancestors to find the file (approve-hook.ts) and asks for the card in that session's name.
52
+ *
53
+ * The file is private (0600 in a 0700 folder), removed when the feed stops or the channel exits,
54
+ * and replaced when the same Claude Code listens for another session. A file left behind by a
55
+ * crash is ignored by the hook once its pid is dead, and swept here on the next write.
56
+ */
57
+ const fs = __importStar(require("node:fs"));
58
+ const path = __importStar(require("node:path"));
59
+ const config_1 = require("../config");
60
+ function claudeSessionsDir() {
61
+ return path.join((0, config_1.configDir)(), "claude-sessions");
62
+ }
63
+ function claudeLinkPath(pid, dir = claudeSessionsDir()) {
64
+ return path.join(dir, `${pid}.json`);
65
+ }
66
+ /** Write the link for Claude Code `pid`, sweeping files whose Claude Code is gone. */
67
+ function writeClaudeLink(pid, session, opts = {}) {
68
+ const dir = opts.dir ?? claudeSessionsDir();
69
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
70
+ sweepDeadLinks(dir, opts.isAlive ?? pidAlive);
71
+ const file = claudeLinkPath(pid, dir);
72
+ const record = { session, writtenAt: (opts.now ?? (() => new Date()))().toISOString() };
73
+ // Write-then-rename, so the hook never reads half a file.
74
+ const tmp = `${file}.${process.pid}.tmp`;
75
+ fs.writeFileSync(tmp, `${JSON.stringify(record)}\n`, { mode: 0o600 });
76
+ fs.chmodSync(tmp, 0o600);
77
+ fs.renameSync(tmp, file);
78
+ }
79
+ /** Remove the link for `pid` — only while it still names `session`, so a newer listen's link stays. */
80
+ function removeClaudeLink(pid, session, dir = claudeSessionsDir()) {
81
+ const file = claudeLinkPath(pid, dir);
82
+ if (session !== undefined && readClaudeLink(pid, dir)?.session !== session)
83
+ return;
84
+ fs.rmSync(file, { force: true });
85
+ }
86
+ /** The link for `pid`, or undefined when absent or not well-formed. */
87
+ function readClaudeLink(pid, dir = claudeSessionsDir()) {
88
+ try {
89
+ const raw = JSON.parse(fs.readFileSync(claudeLinkPath(pid, dir), "utf8"));
90
+ if (typeof raw.session !== "string" || !raw.session.trim() || raw.session.length > 60 || typeof raw.writtenAt !== "string")
91
+ return undefined;
92
+ return { session: raw.session, writtenAt: raw.writtenAt };
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ }
98
+ function sweepDeadLinks(dir, isAlive) {
99
+ let names;
100
+ try {
101
+ names = fs.readdirSync(dir);
102
+ }
103
+ catch {
104
+ return;
105
+ }
106
+ for (const name of names) {
107
+ const m = /^(\d+)\.json$/.exec(name);
108
+ if (m && !isAlive(Number(m[1])))
109
+ fs.rmSync(path.join(dir, name), { force: true });
110
+ }
111
+ }
112
+ /** Is `pid` a live process? EPERM means it exists but is someone else's — still alive. */
113
+ function pidAlive(pid) {
114
+ if (!Number.isInteger(pid) || pid <= 1)
115
+ return false;
116
+ try {
117
+ process.kill(pid, 0);
118
+ return true;
119
+ }
120
+ catch (err) {
121
+ return err.code === "EPERM";
122
+ }
123
+ }
124
+ /** The real link: keyed by this process's parent — the Claude Code that started the channel server. */
125
+ function realClaudeLink(log, pid = process.ppid) {
126
+ return {
127
+ write: (session) => {
128
+ try {
129
+ writeClaudeLink(pid, session);
130
+ }
131
+ catch (err) {
132
+ // Approvals then stay in the terminal for this session: the hook finds no link and says nothing.
133
+ log(`could not record session ${session} for approvals: ${err instanceof Error ? err.message : String(err)}`);
134
+ }
135
+ },
136
+ remove: (session) => {
137
+ try {
138
+ removeClaudeLink(pid, session);
139
+ }
140
+ catch (err) {
141
+ log(`could not remove the approvals link: ${err instanceof Error ? err.message : String(err)}`);
142
+ }
143
+ },
144
+ };
145
+ }
@@ -78,8 +78,33 @@ class ChannelFeed {
78
78
  }
79
79
  /** Stop for good: no reconnect, no watchdog. Used on replace and on process exit. */
80
80
  stop() {
81
+ const was = this.state;
81
82
  this.state = "stopped";
82
83
  this.teardown();
84
+ if (was !== "stopped")
85
+ this.deps.onStop?.();
86
+ }
87
+ /**
88
+ * Close the open socket and reconnect with the last cursor — a fresh mint, so the ticket says
89
+ * what is true NOW (e.g. whether this computer's relay runs the session's commands). The
90
+ * cursor replays the gap: nothing is lost and no event is pushed. A feed between sockets
91
+ * needs nothing: its next mint already reads the current state.
92
+ */
93
+ reopen(why) {
94
+ if (this.state !== "open")
95
+ return;
96
+ this.log(`feed for ${this.deps.session} ${why}; reopening`);
97
+ this.generation++;
98
+ const socket = this.socket;
99
+ this.socket = undefined;
100
+ try {
101
+ socket?.close(1000, "Reopening");
102
+ }
103
+ catch {
104
+ /* Already gone. */
105
+ }
106
+ this.attempt = 0;
107
+ this.scheduleReconnect("reopen");
83
108
  }
84
109
  teardown() {
85
110
  this.generation++;
@@ -209,18 +234,7 @@ class ChannelFeed {
209
234
  if (this.state === "open") {
210
235
  const seen = late ? "disconnected" : await this.deps.status();
211
236
  if (this.state === "open" && seen === "disconnected") {
212
- this.log(`feed for ${this.deps.session} ${late ? "outlived a sleep" : "is not seen by the server"}; reopening`);
213
- this.generation++;
214
- const socket = this.socket;
215
- this.socket = undefined;
216
- try {
217
- socket?.close(1000, "Reopening");
218
- }
219
- catch {
220
- /* Already gone. */
221
- }
222
- this.attempt = 0;
223
- this.scheduleReconnect("watchdog");
237
+ this.reopen(late ? "outlived a sleep" : "is not seen by the server");
224
238
  }
225
239
  }
226
240
  else if (late && this.state === "retrying" && this.retryTimer !== undefined) {
@@ -33,18 +33,17 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.CHANNEL_ENV = exports.CHANNEL_LAUNCH_COMMAND = exports.PLUGIN_MARKETPLACE_URL = exports.CHANNEL_LAUNCH_ARGS = exports.CHANNEL_MIN_CLAUDE = void 0;
36
+ exports.CHANNEL_ENV = exports.CHANNEL_LAUNCH_COMMAND = exports.CHANNEL_LAUNCH_ARGS = exports.CHANNEL_MIN_CLAUDE = void 0;
37
37
  exports.parseClaudeVersion = parseClaudeVersion;
38
38
  exports.claudeSupportsChannel = claudeSupportsChannel;
39
39
  exports.channelLaunchFor = channelLaunchFor;
40
40
  exports.cmdClaude = cmdClaude;
41
+ exports.terminalApprovalArgs = terminalApprovalArgs;
42
+ exports.startsInteractiveSession = startsInteractiveSession;
41
43
  exports.claudeChannelAddArgs = claudeChannelAddArgs;
42
44
  exports.registerChannelWithClaude = registerChannelWithClaude;
43
45
  exports.claudeUserConfigPath = claudeUserConfigPath;
44
46
  exports.channelRegistered = channelRegistered;
45
- exports.claudeUserSettingsPath = claudeUserSettingsPath;
46
- exports.pluginEnabled = pluginEnabled;
47
- exports.installPluginWithClaude = installPluginWithClaude;
48
47
  /**
49
48
  * Getting the channel into Claude Code: registering the server, and starting sessions with it on.
50
49
  *
@@ -56,8 +55,11 @@ const node_child_process_1 = require("node:child_process");
56
55
  const fs = __importStar(require("node:fs"));
57
56
  const os = __importStar(require("node:os"));
58
57
  const path = __importStar(require("node:path"));
58
+ const multiplexer_launch_1 = require("../multiplexer-launch");
59
59
  const runtime_binary_1 = require("../runtime-binary");
60
60
  const detect_1 = require("./detect");
61
+ const plugin_1 = require("./plugin");
62
+ const approval_settings_1 = require("../relay/launcher/approval-settings");
61
63
  /**
62
64
  * The first Claude Code this is switched on for. Channels exist from earlier releases, but
63
65
  * 2.1.234 is where Claude Code started sending a channel only what it registered for the
@@ -82,8 +84,6 @@ function claudeSupportsChannel(version) {
82
84
  }
83
85
  /** The arguments that switch the hand-registered channel server on. */
84
86
  exports.CHANNEL_LAUNCH_ARGS = [detect_1.DEV_CHANNELS_FLAG, detect_1.CHANNEL_FLAG_VALUE];
85
- /** Where `claude plugin marketplace add` finds BayChat's plugin catalog (served by the web app). */
86
- exports.PLUGIN_MARKETPLACE_URL = "https://baychat.io/claude/marketplace.json";
87
87
  /** What to type to start Claude Code with the channel on. */
88
88
  exports.CHANNEL_LAUNCH_COMMAND = "baychat claude";
89
89
  /**
@@ -101,7 +101,7 @@ function channelLaunchFor(claudePath, deps = {}) {
101
101
  // move to the plugin; Claude Code then keeps the hand-registered server and hides the plugin's
102
102
  // duplicate, so naming both is what keeps the channel switched on either way.
103
103
  const entries = [
104
- ...((deps.plugin ?? (() => pluginEnabled()))() ? [detect_1.PLUGIN_FLAG_VALUE] : []),
104
+ ...((deps.plugin ?? (() => (0, plugin_1.pluginEnabled)()))() ? [detect_1.PLUGIN_FLAG_VALUE] : []),
105
105
  ...((deps.registered ?? (() => channelRegistered()))() ? [detect_1.CHANNEL_FLAG_VALUE] : []),
106
106
  ];
107
107
  if (!entries.length)
@@ -117,10 +117,13 @@ function probeVersion(claudePath) {
117
117
  return probe.error || probe.status !== 0 ? undefined : String(probe.stdout ?? "");
118
118
  }
119
119
  /**
120
- * `baychat claude [args…]` — Claude Code with the BayChat channel switched on, in this terminal.
121
- * No multiplexer (that is `baychat start`), nothing else changed: every argument is passed on.
120
+ * `baychat claude [args…]` — Claude Code with the BayChat channel switched on. Every argument is
121
+ * passed on. An interactive session starts inside tmux (or screen) when one is installed, so
122
+ * `/clear` from the phone can reach it — through the launcher `baychat start` uses
123
+ * (multiplexer-launch.ts) — in a NEW multiplexer session each time (`claude-<pid>`), never one
124
+ * another terminal is already in. Anything else (`-p`, `--bg`, `mcp list`, …) runs right here.
122
125
  */
123
- function cmdClaude(args) {
126
+ async function cmdClaude(args) {
124
127
  const binary = (0, runtime_binary_1.resolveRuntimeBinary)("claude", (0, runtime_binary_1.currentBinaryEnv)());
125
128
  if (!binary.ok) {
126
129
  console.error(`Could not start Claude Code: ${binary.reason}`);
@@ -129,13 +132,54 @@ function cmdClaude(args) {
129
132
  const launch = channelLaunchFor(binary.path);
130
133
  if (!launch.on)
131
134
  console.error(`Starting Claude Code without the BayChat channel: ${launch.why}. Messages will use the Monitor.`);
132
- const result = (0, node_child_process_1.spawnSync)(binary.path, [...launch.args, ...args], { stdio: "inherit", env: { ...process.env, ...launch.env } });
133
- if (result.error) {
134
- console.error(`Could not start Claude Code: ${result.error.message}`);
135
- return 1;
135
+ const interactive = startsInteractiveSession(args);
136
+ const approvals = interactive && launch.on ? terminalApprovalArgs(args) : [];
137
+ const program = { binary: binary.path, args: [...launch.args, ...approvals, ...args], env: { ...launch.env } };
138
+ return interactive ? (0, multiplexer_launch_1.launchAgent)(program, { kind: "fresh", prefix: "claude" }) : (0, multiplexer_launch_1.runHere)(program);
139
+ }
140
+ /**
141
+ * `--settings <file>` that sends this session's permission prompts to BayChat once it has joined
142
+ * (the owner's decision, 2026-10-05: approvals come inside BayChat for every session BayChat's
143
+ * launcher starts). The file holds only the PermissionRequest hook with `--session-from-claude`
144
+ * (relay/launcher/approval-settings.ts); until the session joins, prompts stay in this terminal.
145
+ *
146
+ * Only for an interactive Claude Code with the channel on: the channel is what records the
147
+ * session's name for the hook (claude-link.ts), so without it the hook could never find one.
148
+ * Never blocks the start — a file that cannot be written means prompts stay in the terminal.
149
+ */
150
+ function terminalApprovalArgs(userArgs, deps = {}) {
151
+ const say = deps.say ?? ((line) => console.error(line));
152
+ if (userArgs.some((a) => a === "--settings" || a.startsWith("--settings="))) {
153
+ say("BayChat: you passed your own --settings, so approvals for this session stay in this terminal.");
154
+ return [];
155
+ }
156
+ try {
157
+ const file = (deps.write ?? ((cli) => (0, approval_settings_1.writeTerminalApprovalSettings)(cli)))((deps.cli ?? approval_settings_1.currentCliLaunch)());
158
+ say("BayChat: once this session joins, its approvals come to BayChat.");
159
+ return ["--settings", file];
136
160
  }
137
- return result.status ?? 0;
161
+ catch (err) {
162
+ say(`BayChat: approvals stay in this terminal (could not write their settings: ${err instanceof Error ? err.message : String(err)}).`);
163
+ return [];
164
+ }
165
+ }
166
+ /**
167
+ * Do these Claude Code arguments start an interactive session — the only thing worth putting in
168
+ * a multiplexer? Not a one-shot (`-p`), a background agent (`--bg`), a version or help line, or
169
+ * a subcommand (`claude --help`, 2.1.289): those print and exit, and inside a tmux session that
170
+ * closes with them, the output would be gone before anyone read it.
171
+ */
172
+ function startsInteractiveSession(args) {
173
+ if (args.some((a) => ONE_SHOT_FLAGS.has(a.split("=")[0])))
174
+ return false;
175
+ const first = args[0];
176
+ return !(first !== undefined && !first.startsWith("-") && CLAUDE_SUBCOMMANDS.has(first));
138
177
  }
178
+ const ONE_SHOT_FLAGS = new Set(["-p", "--print", "--bg", "--background", "-v", "--version", "-h", "--help"]);
179
+ const CLAUDE_SUBCOMMANDS = new Set([
180
+ "agents", "attach", "auth", "auto-mode", "config", "doctor", "gateway", "import", "install", "kill", "logs", "mcp",
181
+ "migrate-installer", "plugin", "plugins", "purge", "respawn", "rm", "setup-token", "stop", "update", "upgrade",
182
+ ]);
139
183
  /** `claude mcp add` argv for the channel server: this CLI by absolute path, run as `channel`. */
140
184
  function claudeChannelAddArgs(launch) {
141
185
  return ["mcp", "add", "--scope", "user", detect_1.CHANNEL_MCP_ENTRY, "--", launch.node, launch.entry, "channel"];
@@ -168,33 +212,3 @@ function channelRegistered(file = claudeUserConfigPath(), read = (p) => fs.readF
168
212
  return false;
169
213
  }
170
214
  }
171
- /** Claude Code's user settings, where installed plugins are switched on. */
172
- function claudeUserSettingsPath(home = os.homedir()) {
173
- return path.join(home, ".claude", "settings.json");
174
- }
175
- /** Is BayChat's plugin installed and switched on for this user? Unreadable counts as no. */
176
- function pluginEnabled(file = claudeUserSettingsPath(), read = (p) => fs.readFileSync(p, "utf8")) {
177
- try {
178
- const parsed = JSON.parse(read(file));
179
- return parsed?.enabledPlugins?.[detect_1.PLUGIN_ID] === true;
180
- }
181
- catch {
182
- return false;
183
- }
184
- }
185
- /**
186
- * Install BayChat as a Claude Code plugin: add the catalog from baychat.io, install from it (the
187
- * plugin itself is fetched from the `baychat` npm package), then remove the hand-registered
188
- * server, which would otherwise hide the plugin's. Returns false at the first step that fails,
189
- * leaving whatever worked before untouched — the caller then registers the server by hand.
190
- */
191
- function installPluginWithClaude(run) {
192
- const added = run(["plugin", "marketplace", "add", exports.PLUGIN_MARKETPLACE_URL]);
193
- if (added.status !== 0 && !/already/i.test(added.stderr))
194
- return false;
195
- const installed = run(["plugin", "install", detect_1.PLUGIN_ID]);
196
- if (installed.status !== 0 && !/already installed/i.test(installed.stderr))
197
- return false;
198
- run(["mcp", "remove", "--scope", "user", detect_1.CHANNEL_MCP_ENTRY]); // Absent is fine.
199
- return true;
200
- }