baychat 0.28.0 → 0.29.1
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 +24 -4
- package/README.md +41 -3
- package/dist/approve-hook.js +93 -3
- package/dist/channel/claude-link.js +145 -0
- package/dist/channel/feed.js +26 -12
- package/dist/channel/launch.js +59 -45
- package/dist/channel/plugin.js +182 -0
- package/dist/channel/relay-link.js +257 -0
- package/dist/channel/server.js +92 -7
- package/dist/cli-help.js +235 -0
- package/dist/commands.js +4 -4
- package/dist/connect-acp.js +3 -2
- package/dist/connect-claude.js +26 -10
- package/dist/device-login.js +2 -2
- package/dist/doctor-command.js +2 -0
- package/dist/doctor.js +97 -3
- package/dist/help-topics.js +4 -3
- package/dist/launch/claude-trust.js +221 -0
- package/dist/launch/command.js +177 -0
- package/dist/launch/policy.js +152 -0
- package/dist/machine-name.js +76 -0
- package/dist/main.js +16 -170
- package/dist/multiplexer-launch.js +271 -0
- package/dist/protocol-content.js +1 -1
- package/dist/relay/acp/agents.js +42 -19
- package/dist/relay/acp/daemon-glue.js +3 -2
- package/dist/relay/acp/runner.js +8 -7
- package/dist/relay/autostart.js +47 -8
- package/dist/relay/channel-sessions.js +66 -0
- package/dist/relay/commands.js +9 -2
- package/dist/relay/daemon.js +160 -9
- package/dist/relay/launcher/approval-settings.js +140 -0
- package/dist/relay/launcher/argv.js +83 -0
- package/dist/relay/launcher/capabilities.js +76 -0
- package/dist/relay/launcher/dev-channel-screen.fixtures.js +41 -0
- package/dist/relay/launcher/dev-channel-screen.js +47 -0
- package/dist/relay/launcher/handle.js +303 -0
- package/dist/relay/launcher/loop.js +231 -0
- package/dist/relay/launcher/multiplexer.js +168 -0
- package/dist/relay/launcher/started.js +63 -0
- package/dist/relay/profiles.js +1 -1
- package/dist/relay/registry.js +26 -0
- package/dist/relay/terminal-pane.js +31 -2
- package/dist/relay/transport.js +7 -2
- package/dist/relay/ws.js +18 -1
- package/dist/runtime-install.js +19 -3
- package/dist/runtimes.js +3 -0
- package/dist/start-command.js +12 -56
- package/dist/tool-defs.js +23 -36
- package/package.json +3 -3
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "baychat",
|
|
3
|
+
"version": "0.29.1",
|
|
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": {
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "BayChat",
|
|
8
|
+
"url": "https://baychat.io"
|
|
9
|
+
},
|
|
6
10
|
"homepage": "https://baychat.io",
|
|
7
|
-
"keywords": [
|
|
11
|
+
"keywords": [
|
|
12
|
+
"baychat",
|
|
13
|
+
"chat",
|
|
14
|
+
"channel",
|
|
15
|
+
"mobile",
|
|
16
|
+
"notifications"
|
|
17
|
+
],
|
|
8
18
|
"mcpServers": {
|
|
9
|
-
"channel": {
|
|
19
|
+
"channel": {
|
|
20
|
+
"command": "baychat",
|
|
21
|
+
"args": [
|
|
22
|
+
"channel"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
10
25
|
},
|
|
11
|
-
"channels": [
|
|
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
|
-
`
|
|
13
|
-
|
|
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
|
|
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)) |
|
package/dist/approve-hook.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/channel/feed.js
CHANGED
|
@@ -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.
|
|
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) {
|
package/dist/channel/launch.js
CHANGED
|
@@ -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.
|
|
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
|
|
121
|
-
*
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
}
|