baychat 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/dist/client-config-writer.js +149 -0
- package/dist/client-paths.js +84 -0
- package/dist/commands.js +8 -4
- package/dist/connect-plan.js +122 -0
- package/dist/connect.js +219 -0
- package/dist/index.js +58 -1
- package/dist/mcp-config.js +20 -116
- package/dist/mcp-dialects.js +143 -0
- package/dist/relay/adapters.js +130 -0
- package/dist/relay/commands.js +312 -0
- package/dist/relay/daemon.js +301 -0
- package/dist/relay/queue.js +108 -0
- package/dist/relay/registry.js +151 -0
- package/dist/relay/socket.js +125 -0
- package/dist/relay/types.js +11 -0
- package/dist/relay/updates.js +131 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,6 +66,10 @@ per session, never one that another integration already uses.
|
|
|
66
66
|
| `baychat search <query> [--limit <n>]` | Search the web through BayChat — ranked results with title, URL, and snippet (see [Tools](#tools)) |
|
|
67
67
|
| `baychat fetch <url> [--max-chars <n>]` | Fetch one public `http(s)` page through BayChat and print its readable text (see [Tools](#tools)) |
|
|
68
68
|
| `baychat watch <conv> [--interval <sec>] [--timeout <sec>]` | Block until new messages arrive (exit 0) or timeout (exit 2) |
|
|
69
|
+
| `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)) |
|
|
70
|
+
| `baychat relay status` | Sessions, cursor, and any **delivery pending** — messages that reached this box and that nothing answered (exit 2 if any) |
|
|
71
|
+
| `baychat relay stop` | Stop the relay and disable it at boot |
|
|
72
|
+
| `baychat relay attach --session <name> [--runtime claude\|codex\|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) |
|
|
69
73
|
| `baychat mcp` | Run a local **stdio MCP server** so MCP-aware clients (Claude Desktop, Claude Code, Cursor) get BayChat as native tools (see below) |
|
|
70
74
|
| `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)) |
|
|
71
75
|
|
|
@@ -77,6 +81,45 @@ returning agent needs, in one command.
|
|
|
77
81
|
`check`/`watch` skip your own and deleted messages. The first `check` on a
|
|
78
82
|
conversation anchors its cursor to *now* (no history dump).
|
|
79
83
|
|
|
84
|
+
## Relay
|
|
85
|
+
|
|
86
|
+
A Claude Code or Codex session has **no background listener**. It runs when a
|
|
87
|
+
human prompts it, so a message sent from a phone sits unread until someone
|
|
88
|
+
happens to type in the terminal — even though the server delivered it instantly
|
|
89
|
+
and flagged it correctly. WebSockets do not fix this: a socket still needs a
|
|
90
|
+
process holding it, and that process still has to wake the session.
|
|
91
|
+
|
|
92
|
+
`baychat relay` is that process.
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
baychat relay start # systemd user unit, survives reboot
|
|
96
|
+
baychat relay attach --session my-sess --conversation <conv>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`attach` blocks until a message lands, prints it, and **exits 0**. That exit is
|
|
100
|
+
the wake — a harness that launched it in the background re-invokes the session
|
|
101
|
+
with its full context. Run it as a background process from your session and you
|
|
102
|
+
get near-instant delivery.
|
|
103
|
+
|
|
104
|
+
When no session is attached, the relay falls back to a **headless resume**
|
|
105
|
+
(`claude -p --resume`, `codex exec resume`) if it has a resume id for that
|
|
106
|
+
session. Pass `--resume-id` on attach so it can.
|
|
107
|
+
|
|
108
|
+
Three things it will not do:
|
|
109
|
+
|
|
110
|
+
- **It never decides who replies.** The wake carries the messages and tells the
|
|
111
|
+
session to re-check `shouldRespond` itself. Reply policy stays server-side.
|
|
112
|
+
- **It never runs two turns for one session at once.** An interactive wake and a
|
|
113
|
+
headless resume answering the same room as the same identity is the failure
|
|
114
|
+
mode; a per-session lock makes it impossible. Messages arriving mid-turn
|
|
115
|
+
coalesce into one follow-up batch.
|
|
116
|
+
- **It never claims an answer it cannot evidence.** No resume id, a self-hosted
|
|
117
|
+
runtime with no local resume, or a headless turn that exited non-zero — all
|
|
118
|
+
are recorded as `DELIVERY PENDING` and shown by `relay status`, which exits 2.
|
|
119
|
+
|
|
120
|
+
`baychat connect` installs and starts the relay for you. Set
|
|
121
|
+
`BAYCHAT_NO_RELAY_AUTOSTART=1` to opt out.
|
|
122
|
+
|
|
80
123
|
## Group instructions
|
|
81
124
|
|
|
82
125
|
Group conversations carry a short, server-authored **primer** — who's in the
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Writing BayChat into a client's MCP config file, in place, without breaking it.
|
|
3
|
+
//
|
|
4
|
+
// `baychat mcp-config` PRINTS a config and asks the user to paste it. That is not
|
|
5
|
+
// an install: it demands the user know what TOML is, where their client keeps its
|
|
6
|
+
// config, and which half of the printed text is the part to paste. `baychat
|
|
7
|
+
// connect` writes the file itself, and this module is the part that touches it.
|
|
8
|
+
//
|
|
9
|
+
// THE ONE RULE: NEVER DESTROY WHAT IS ALREADY THERE. These files hold the user's
|
|
10
|
+
// other MCP servers, their editor settings, and in Codex's case a lot more
|
|
11
|
+
// besides. A naive write costs someone their whole configuration, and they will
|
|
12
|
+
// discover it later, in another tool, with no idea what did it. Everything below
|
|
13
|
+
// exists to make that impossible:
|
|
14
|
+
//
|
|
15
|
+
// - The merge is a SPLICE, not a rewrite: exactly the `baychat` server entry is
|
|
16
|
+
// replaced, byte-for-byte around it.
|
|
17
|
+
// - It is IDEMPOTENT: running connect twice replaces the block, never appends a
|
|
18
|
+
// second one (a duplicate `[mcp_servers.baychat]` makes the file invalid TOML,
|
|
19
|
+
// so an append-only implementation would break the client on the second run).
|
|
20
|
+
// - JSON is parsed and re-serialised; a file that does not parse is REFUSED
|
|
21
|
+
// rather than overwritten, because the only safe response to "I do not
|
|
22
|
+
// understand this file" is to keep your hands off it.
|
|
23
|
+
// - Callers back up first (`backupPathFor`), so there is always a way back.
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.mergeTomlConfig = mergeTomlConfig;
|
|
26
|
+
exports.mergeJsonConfig = mergeJsonConfig;
|
|
27
|
+
exports.mergeClientConfig = mergeClientConfig;
|
|
28
|
+
exports.backupPathFor = backupPathFor;
|
|
29
|
+
exports.expandHome = expandHome;
|
|
30
|
+
/**
|
|
31
|
+
* The TOML table header this owns, and the only span it may replace.
|
|
32
|
+
*
|
|
33
|
+
* Sub-tables (`[mcp_servers.baychat.env]`) belong to the same server and are
|
|
34
|
+
* replaced with it; a header for any OTHER server ends the span.
|
|
35
|
+
*/
|
|
36
|
+
const TOML_TABLE = "mcp_servers.baychat";
|
|
37
|
+
/** Matches a top-level TOML table header line, capturing its dotted key. */
|
|
38
|
+
const TOML_HEADER = /^\s*\[\s*([^\]]+?)\s*\]\s*$/;
|
|
39
|
+
/**
|
|
40
|
+
* Splice the BayChat server block into an existing TOML file.
|
|
41
|
+
*
|
|
42
|
+
* @param existing current file content, or null when the file does not exist yet
|
|
43
|
+
* @param body the `[mcp_servers.baychat]` block to install
|
|
44
|
+
*/
|
|
45
|
+
function mergeTomlConfig(existing, body) {
|
|
46
|
+
const block = body.trimEnd();
|
|
47
|
+
if (existing === null || existing.trim() === "") {
|
|
48
|
+
return { content: `${block}\n`, action: "created" };
|
|
49
|
+
}
|
|
50
|
+
const lines = existing.split("\n");
|
|
51
|
+
let start = -1;
|
|
52
|
+
let end = lines.length;
|
|
53
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
54
|
+
const match = TOML_HEADER.exec(lines[i] ?? "");
|
|
55
|
+
if (!match)
|
|
56
|
+
continue;
|
|
57
|
+
const key = (match[1] ?? "").replace(/\s+/g, "");
|
|
58
|
+
const ours = key === TOML_TABLE || key.startsWith(`${TOML_TABLE}.`);
|
|
59
|
+
if (start === -1) {
|
|
60
|
+
if (ours)
|
|
61
|
+
start = i;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
// Inside our span: a header that is not ours closes it.
|
|
65
|
+
if (!ours) {
|
|
66
|
+
end = i;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (start === -1) {
|
|
71
|
+
// Not present — append, keeping exactly one blank line as a separator so the
|
|
72
|
+
// result is readable whether or not the file ended with a newline.
|
|
73
|
+
const prefix = existing.endsWith("\n") ? existing.replace(/\n+$/, "\n") : `${existing}\n`;
|
|
74
|
+
return { content: `${prefix}\n${block}\n`, action: "appended" };
|
|
75
|
+
}
|
|
76
|
+
const before = lines.slice(0, start);
|
|
77
|
+
const after = lines.slice(end);
|
|
78
|
+
const merged = [...before, ...block.split("\n"), ...after].join("\n");
|
|
79
|
+
return { content: merged.endsWith("\n") ? merged : `${merged}\n`, action: "updated" };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Merge the BayChat server entry into a JSON MCP config (Cursor, Claude Desktop).
|
|
83
|
+
*
|
|
84
|
+
* @throws when the existing file is present but not valid JSON, or is not a JSON
|
|
85
|
+
* object. Overwriting in that case would silently discard whatever the user had —
|
|
86
|
+
* including a file that is merely mid-edit. Refusing lets the CLI say which file
|
|
87
|
+
* to look at.
|
|
88
|
+
*/
|
|
89
|
+
function mergeJsonConfig(existing, body) {
|
|
90
|
+
// `body` is ours, produced by buildClientConfig — a parse failure here is a bug
|
|
91
|
+
// in this package, not user input, so it is allowed to throw plainly.
|
|
92
|
+
const incoming = JSON.parse(body);
|
|
93
|
+
const entry = incoming.mcpServers?.baychat;
|
|
94
|
+
if (entry === undefined) {
|
|
95
|
+
throw new Error("Generated config has no mcpServers.baychat entry — this is a bug.");
|
|
96
|
+
}
|
|
97
|
+
if (existing === null || existing.trim() === "") {
|
|
98
|
+
return { content: `${JSON.stringify(incoming, null, 2)}\n`, action: "created" };
|
|
99
|
+
}
|
|
100
|
+
let parsed;
|
|
101
|
+
try {
|
|
102
|
+
parsed = JSON.parse(existing);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw new Error("Your existing config is not valid JSON, so it was left untouched. Fix or move it, then run this again.");
|
|
106
|
+
}
|
|
107
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
108
|
+
throw new Error("Your existing config is not a JSON object, so it was left untouched. Fix or move it, then run this again.");
|
|
109
|
+
}
|
|
110
|
+
const current = parsed;
|
|
111
|
+
const servers = { ...(current.mcpServers ?? {}) };
|
|
112
|
+
const had = Object.prototype.hasOwnProperty.call(servers, "baychat");
|
|
113
|
+
servers.baychat = entry;
|
|
114
|
+
const merged = { ...current, mcpServers: servers };
|
|
115
|
+
return {
|
|
116
|
+
content: `${JSON.stringify(merged, null, 2)}\n`,
|
|
117
|
+
action: had ? "updated" : "appended",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** Merge by dialect. Keeps the format decision in one place. */
|
|
121
|
+
function mergeClientConfig(config, existing) {
|
|
122
|
+
return config.format === "toml"
|
|
123
|
+
? mergeTomlConfig(existing, config.body)
|
|
124
|
+
: mergeJsonConfig(existing, config.body);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Where to copy a file before rewriting it.
|
|
128
|
+
*
|
|
129
|
+
* Deliberately a FIXED name rather than a timestamp: a user who runs connect ten
|
|
130
|
+
* times should end up with one backup they can find, not ten they have to date.
|
|
131
|
+
* The pre-edit state is the only one worth keeping.
|
|
132
|
+
*/
|
|
133
|
+
function backupPathFor(path) {
|
|
134
|
+
return `${path}.baychat-backup`;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Expand a leading `~` to the given home directory.
|
|
138
|
+
*
|
|
139
|
+
* The dialect table stores display paths (`~/.codex/config.toml`) because that is
|
|
140
|
+
* what a human reads; writing needs a real path. Only a LEADING `~/` is expanded —
|
|
141
|
+
* a tilde anywhere else is a legitimate filename character.
|
|
142
|
+
*/
|
|
143
|
+
function expandHome(path, home) {
|
|
144
|
+
if (path === "~")
|
|
145
|
+
return home;
|
|
146
|
+
if (path.startsWith("~/"))
|
|
147
|
+
return `${home}/${path.slice(2)}`;
|
|
148
|
+
return path;
|
|
149
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Where each MCP client actually keeps its config on disk.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS IS NOT `CLIENT_FILES`. That table in `mcp-dialects.ts` holds strings
|
|
5
|
+
// written for a human to read — `"~/.cursor/mcp.json (or a project
|
|
6
|
+
// .cursor/mcp.json)"`, `"claude_desktop_config.json (Settings → Developer → Edit
|
|
7
|
+
// Config)"`. Perfect for a printed instruction, useless for `writeFileSync`: one
|
|
8
|
+
// carries a parenthetical, the other is not a path at all. Treating them as paths
|
|
9
|
+
// would have created a file literally named `claude_desktop_config.json (Settings
|
|
10
|
+
// → Developer → Edit Config)` in the working directory, and the user's real
|
|
11
|
+
// config would never have been touched — an install that reports success and
|
|
12
|
+
// changes nothing.
|
|
13
|
+
//
|
|
14
|
+
// So writing gets its own table, resolved per platform, and returns null when we
|
|
15
|
+
// genuinely do not know. Guessing a path is worse than declining: a wrong guess
|
|
16
|
+
// writes a file no client reads, and the user is left debugging a config that
|
|
17
|
+
// looks correct.
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.configPathFor = configPathFor;
|
|
20
|
+
exports.currentPathEnv = currentPathEnv;
|
|
21
|
+
exports.needsRestart = needsRestart;
|
|
22
|
+
/**
|
|
23
|
+
* Claude Desktop's config location, which is the only genuinely
|
|
24
|
+
* platform-dependent one of the three.
|
|
25
|
+
*
|
|
26
|
+
* Returns null on an unrecognised platform, and on Windows without `APPDATA` —
|
|
27
|
+
* there is no sound fallback for either, and inventing one writes a file nothing
|
|
28
|
+
* will read.
|
|
29
|
+
*/
|
|
30
|
+
function claudeDesktopPath(env) {
|
|
31
|
+
if (env.platform === "darwin") {
|
|
32
|
+
return `${env.home}/Library/Application Support/Claude/claude_desktop_config.json`;
|
|
33
|
+
}
|
|
34
|
+
if (env.platform === "win32") {
|
|
35
|
+
if (!env.appData)
|
|
36
|
+
return null;
|
|
37
|
+
return `${env.appData}\\Claude\\claude_desktop_config.json`;
|
|
38
|
+
}
|
|
39
|
+
if (env.platform === "linux") {
|
|
40
|
+
const base = env.xdgConfigHome && env.xdgConfigHome.trim() !== ""
|
|
41
|
+
? env.xdgConfigHome
|
|
42
|
+
: `${env.home}/.config`;
|
|
43
|
+
return `${base}/Claude/claude_desktop_config.json`;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The absolute config path to write for a client, or null when it cannot be
|
|
49
|
+
* determined on this platform.
|
|
50
|
+
*
|
|
51
|
+
* Codex and Cursor keep theirs in the home directory on every platform. Cursor
|
|
52
|
+
* also supports a per-project `.cursor/mcp.json`; the global file is chosen
|
|
53
|
+
* deliberately, because `connect` is establishing this machine's identity, not
|
|
54
|
+
* one repository's.
|
|
55
|
+
*/
|
|
56
|
+
function configPathFor(client, env) {
|
|
57
|
+
switch (client) {
|
|
58
|
+
case "codex":
|
|
59
|
+
return `${env.home}/.codex/config.toml`;
|
|
60
|
+
case "cursor":
|
|
61
|
+
return `${env.home}/.cursor/mcp.json`;
|
|
62
|
+
case "desktop":
|
|
63
|
+
return claudeDesktopPath(env);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Read the path environment from the current process. */
|
|
67
|
+
function currentPathEnv() {
|
|
68
|
+
return {
|
|
69
|
+
home: process.env.HOME || process.env.USERPROFILE || "",
|
|
70
|
+
platform: process.platform,
|
|
71
|
+
appData: process.env.APPDATA,
|
|
72
|
+
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Whether this client needs a restart to pick up a changed config.
|
|
77
|
+
*
|
|
78
|
+
* Only stated where it is true, so the instruction carries weight. A CLI that
|
|
79
|
+
* tells everyone to restart trains them to ignore it — and Codex and Cursor read
|
|
80
|
+
* their MCP config per session, so most users need nothing.
|
|
81
|
+
*/
|
|
82
|
+
function needsRestart(client) {
|
|
83
|
+
return client === "desktop";
|
|
84
|
+
}
|
package/dist/commands.js
CHANGED
|
@@ -651,8 +651,10 @@ async function cmdLogin(opts = {}) {
|
|
|
651
651
|
expiresAt: me.expiresAt,
|
|
652
652
|
});
|
|
653
653
|
console.log(`✓ Logged in as ${me.user.name} (${me.tenant.name})`);
|
|
654
|
-
|
|
655
|
-
|
|
654
|
+
if (opts.registerClaude !== false)
|
|
655
|
+
registerWithClaude(base, opts.token);
|
|
656
|
+
if (opts.hint !== false)
|
|
657
|
+
console.log("\n Run /baychat <name> in any session.");
|
|
656
658
|
return true;
|
|
657
659
|
}
|
|
658
660
|
// The hostname labels this laptop in the approve UI; the server caps the field
|
|
@@ -704,8 +706,10 @@ async function cmdLogin(opts = {}) {
|
|
|
704
706
|
expiresAt: status.expiresAt,
|
|
705
707
|
});
|
|
706
708
|
console.log(`✓ Logged in as ${status.user.name}`);
|
|
707
|
-
|
|
708
|
-
|
|
709
|
+
if (opts.registerClaude !== false)
|
|
710
|
+
registerWithClaude(base, status.token);
|
|
711
|
+
if (opts.hint !== false)
|
|
712
|
+
console.log("\n Run /baychat <name> in any session.");
|
|
709
713
|
return true;
|
|
710
714
|
}
|
|
711
715
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// What `baychat connect <client>` should DO, decided before anything is done.
|
|
3
|
+
//
|
|
4
|
+
// ONE LOGIN PER LAPTOP, THEN NOTHING. `connect` shows the device-link QR once,
|
|
5
|
+
// writes the DEVICE credential into the client's MCP config, and stops. Every
|
|
6
|
+
// coding session afterwards joins a room by itself:
|
|
7
|
+
//
|
|
8
|
+
// /baychat <name> -> join_session({ session: "<name>" })
|
|
9
|
+
//
|
|
10
|
+
// which mints (or reattaches) that session's own agent and puts it in a chat.
|
|
11
|
+
// Same name, same agent, same history.
|
|
12
|
+
//
|
|
13
|
+
// WHY THE DEVICE CREDENTIAL AND NOT AN AGENT TOKEN. An agent token pins the
|
|
14
|
+
// client to ONE pre-made agent in ONE room, so every new session would need
|
|
15
|
+
// another trip to the phone for another code — a recurring chore dressed up as an
|
|
16
|
+
// install. The device credential represents the PERSON, and the session tools it
|
|
17
|
+
// unlocks let each session create its own identity on demand. That is the whole
|
|
18
|
+
// difference between setting something up once and setting it up forever.
|
|
19
|
+
//
|
|
20
|
+
// Agent tokens and pairing codes are not gone; they are the path for EXTERNAL and
|
|
21
|
+
// LEGACY runtimes (a self-hosted gateway, anything driving the Agent API itself),
|
|
22
|
+
// which have no MCP client to configure and no session concept. `baychat pair`
|
|
23
|
+
// still serves them.
|
|
24
|
+
//
|
|
25
|
+
// The two halves people conflate remain distinct, and this module keeps them so:
|
|
26
|
+
//
|
|
27
|
+
// 1. CONNECT THIS LAPTOP - a device credential. Authorising a machine to act as
|
|
28
|
+
// you, the way WhatsApp Web authorises a browser. Creates NO agent and puts
|
|
29
|
+
// nobody in a room. Lives under Account -> Devices in the app.
|
|
30
|
+
// 2. JOIN A SESSION - `/baychat <name>` inside the client, per session. This is
|
|
31
|
+
// what actually ends with something able to speak, and it needs no QR, no
|
|
32
|
+
// code, and no pre-created agent.
|
|
33
|
+
//
|
|
34
|
+
// Steps are computed from observed state here, as data, and the executor only
|
|
35
|
+
// carries them out - so the sequencing is testable without a network, a terminal,
|
|
36
|
+
// or a phone.
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
exports.deviceStateFrom = deviceStateFrom;
|
|
39
|
+
exports.planConnect = planConnect;
|
|
40
|
+
exports.planNeedsPhoneApproval = planNeedsPhoneApproval;
|
|
41
|
+
exports.expiryNudge = expiryNudge;
|
|
42
|
+
exports.describeConnection = describeConnection;
|
|
43
|
+
const THREE_DAYS_MS = 3 * 86_400_000;
|
|
44
|
+
/** Read the on-disk credential into a state, without judging what to do about it. */
|
|
45
|
+
function deviceStateFrom(device, now = Date.now()) {
|
|
46
|
+
if (!device)
|
|
47
|
+
return { kind: "absent" };
|
|
48
|
+
const expires = new Date(device.expiresAt).getTime();
|
|
49
|
+
// An unparseable expiry is treated as expired. Treating it as live would send
|
|
50
|
+
// the user into a flow that fails at the first authenticated call, with an
|
|
51
|
+
// error that points at the server rather than at their credential.
|
|
52
|
+
if (Number.isNaN(expires) || expires <= now) {
|
|
53
|
+
return { kind: "expired", userName: device.user.name };
|
|
54
|
+
}
|
|
55
|
+
return { kind: "live", userName: device.user.name, msLeft: expires - now };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The ordered steps for one `connect` run.
|
|
59
|
+
*
|
|
60
|
+
* `needsRestart` is injected rather than imported so the sequencing can be tested
|
|
61
|
+
* against both answers without reaching for a specific client.
|
|
62
|
+
*/
|
|
63
|
+
function planConnect(input) {
|
|
64
|
+
const { client, device, needsRestart } = input;
|
|
65
|
+
const steps = [];
|
|
66
|
+
if (device.kind === "absent" || device.kind === "expired") {
|
|
67
|
+
steps.push({ kind: "device-login", reason: device.kind });
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
steps.push({ kind: "device-ok", userName: device.userName, msLeft: device.msLeft });
|
|
71
|
+
}
|
|
72
|
+
// No code, no room, no agent. The config carries the device credential, and the
|
|
73
|
+
// session tools it unlocks let `/baychat <name>` do the rest, per session.
|
|
74
|
+
steps.push({ kind: "write-config", client });
|
|
75
|
+
if (needsRestart)
|
|
76
|
+
steps.push({ kind: "restart-note", client });
|
|
77
|
+
return steps;
|
|
78
|
+
}
|
|
79
|
+
/** Whether the plan will ask the user to approve a QR on their phone. */
|
|
80
|
+
function planNeedsPhoneApproval(steps) {
|
|
81
|
+
return steps.some((s) => s.kind === "device-login");
|
|
82
|
+
}
|
|
83
|
+
/** The expiry nudge for a live credential, or null when it is not due yet. */
|
|
84
|
+
function expiryNudge(device) {
|
|
85
|
+
if (device.kind !== "live")
|
|
86
|
+
return null;
|
|
87
|
+
if (device.msLeft >= THREE_DAYS_MS)
|
|
88
|
+
return null;
|
|
89
|
+
const hours = Math.max(1, Math.round(device.msLeft / 3_600_000));
|
|
90
|
+
return `Your laptop login expires in about ${hours}h — run \`npx baychat login\` to renew.`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Describe the finished state.
|
|
94
|
+
*
|
|
95
|
+
* The note is the one thing a user cannot infer from a successful install: the
|
|
96
|
+
* command connected a LAPTOP, and nothing is in a room yet. Without that line the
|
|
97
|
+
* obvious reading of "✓ done" is that an agent is now listening somewhere, and the
|
|
98
|
+
* user waits for a reply that was never going to come.
|
|
99
|
+
*
|
|
100
|
+
* Revocation is coherent under this design, which is a reason to prefer it:
|
|
101
|
+
* revoking the laptop under Account → Devices cuts every session it opened,
|
|
102
|
+
* because they all speak through its credential.
|
|
103
|
+
*/
|
|
104
|
+
function describeConnection(input) {
|
|
105
|
+
const { device, agentName, groupTitle } = input;
|
|
106
|
+
const laptop = device.kind === "live"
|
|
107
|
+
? `connected as ${device.userName} (${Math.floor(device.msLeft / 86_400_000)}d left)`
|
|
108
|
+
: device.kind === "expired"
|
|
109
|
+
? `login expired — run \`npx baychat connect\` again`
|
|
110
|
+
: "not connected";
|
|
111
|
+
const agent = agentName === null
|
|
112
|
+
? "none yet — run /baychat <name> in a session"
|
|
113
|
+
: groupTitle
|
|
114
|
+
? `${agentName} in ${groupTitle}`
|
|
115
|
+
: agentName;
|
|
116
|
+
return {
|
|
117
|
+
laptop,
|
|
118
|
+
agent,
|
|
119
|
+
note: "Each session makes its own agent: run /baychat <name> inside the client. " +
|
|
120
|
+
"The same name always reattaches to the same agent and history.",
|
|
121
|
+
};
|
|
122
|
+
}
|
package/dist/connect.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// `baychat connect <client>` — one command, once per laptop.
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT DOES. Shows the device-link QR, waits for the phone to approve it, and
|
|
5
|
+
// writes the DEVICE credential into the client's MCP config. Then it stops.
|
|
6
|
+
//
|
|
7
|
+
// WHAT IT DELIBERATELY DOES NOT DO. Ask for a code. Ask which room. Create an
|
|
8
|
+
// agent. All three used to be here, and all three were the same mistake: they
|
|
9
|
+
// bound the client to one pre-made agent in one conversation, so every new
|
|
10
|
+
// coding session needed another trip to the phone. Setup you repeat is not setup.
|
|
11
|
+
//
|
|
12
|
+
// Instead, each session names itself, inside the client:
|
|
13
|
+
//
|
|
14
|
+
// /baychat <name> -> join_session({ session: "<name>" })
|
|
15
|
+
//
|
|
16
|
+
// which mints or reattaches that session's own agent and puts it in a chat. The
|
|
17
|
+
// session tools that make this possible are exactly what a device credential
|
|
18
|
+
// unlocks — which is why the config carries that credential and not an agent
|
|
19
|
+
// token.
|
|
20
|
+
//
|
|
21
|
+
// Pairing codes still exist, for EXTERNAL and LEGACY runtimes: a self-hosted
|
|
22
|
+
// gateway, or anything driving the Agent API directly. Those have no MCP client
|
|
23
|
+
// to configure and no session concept, so `baychat pair <code>` remains their
|
|
24
|
+
// path. It is not this one.
|
|
25
|
+
//
|
|
26
|
+
// FILE SAFETY. This edits config a user already has, so the merge is a splice
|
|
27
|
+
// rather than a rewrite, it is idempotent, unparseable JSON is refused rather
|
|
28
|
+
// than overwritten, the previous file is backed up first, and the result is 0600
|
|
29
|
+
// because it holds a bearer token. See client-config-writer.ts.
|
|
30
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
31
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
32
|
+
};
|
|
33
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
exports.renderConnectMenu = renderConnectMenu;
|
|
35
|
+
exports.parseConnectClient = parseConnectClient;
|
|
36
|
+
exports.writeClientConfig = writeClientConfig;
|
|
37
|
+
exports.cmdConnect = cmdConnect;
|
|
38
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
39
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
40
|
+
const client_config_writer_1 = require("./client-config-writer");
|
|
41
|
+
const client_paths_1 = require("./client-paths");
|
|
42
|
+
const commands_1 = require("./commands");
|
|
43
|
+
const config_1 = require("./config");
|
|
44
|
+
const connect_plan_1 = require("./connect-plan");
|
|
45
|
+
const mcp_dialects_1 = require("./mcp-dialects");
|
|
46
|
+
const commands_2 = require("./relay/commands");
|
|
47
|
+
/** Clients `connect` can configure. `claude` is an alias users reach for. */
|
|
48
|
+
const CLIENT_ALIASES = {
|
|
49
|
+
codex: "codex",
|
|
50
|
+
cursor: "cursor",
|
|
51
|
+
desktop: "desktop",
|
|
52
|
+
"claude-desktop": "desktop",
|
|
53
|
+
};
|
|
54
|
+
/** What `baychat connect` prints with no client: the menu. Names no credential. */
|
|
55
|
+
function renderConnectMenu() {
|
|
56
|
+
const rows = mcp_dialects_1.MCP_CLIENTS.map((c) => ` npx baychat connect ${c.padEnd(8)}→ ${mcp_dialects_1.CLIENT_LABELS[c]}`);
|
|
57
|
+
return [
|
|
58
|
+
"Connect this laptop to BayChat, once, and configure your client:",
|
|
59
|
+
"",
|
|
60
|
+
...rows,
|
|
61
|
+
"",
|
|
62
|
+
"You approve a QR on your phone. After that, in any coding session:",
|
|
63
|
+
"",
|
|
64
|
+
" /baychat <name> join BayChat as a session called <name>",
|
|
65
|
+
"",
|
|
66
|
+
"Claude Code needs no config — `npx baychat login` sets it up for you.",
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Narrow a client argument.
|
|
71
|
+
*
|
|
72
|
+
* @throws on anything unsupported, listing what is supported. A typo must not
|
|
73
|
+
* fall through to a default and configure the wrong client.
|
|
74
|
+
*/
|
|
75
|
+
function parseConnectClient(value) {
|
|
76
|
+
const match = CLIENT_ALIASES[value.trim().toLowerCase()];
|
|
77
|
+
if (!match) {
|
|
78
|
+
throw new Error(`Unknown client "${value}" — supported: ${mcp_dialects_1.MCP_CLIENTS.join(", ")}. ` +
|
|
79
|
+
"Claude Code is registered automatically by `npx baychat login`.");
|
|
80
|
+
}
|
|
81
|
+
return match;
|
|
82
|
+
}
|
|
83
|
+
/** Write the client config, backing up whatever was there first. */
|
|
84
|
+
function writeClientConfig(client, endpoint, io, env = (0, client_paths_1.currentPathEnv)()) {
|
|
85
|
+
const target = (0, client_paths_1.configPathFor)(client, env);
|
|
86
|
+
if (target === null) {
|
|
87
|
+
throw new Error(`Could not work out where ${mcp_dialects_1.CLIENT_LABELS[client]} keeps its config on this platform. ` +
|
|
88
|
+
"Run `npx baychat mcp-config --client " +
|
|
89
|
+
client +
|
|
90
|
+
"` and paste it yourself.");
|
|
91
|
+
}
|
|
92
|
+
const config = (0, mcp_dialects_1.buildClientConfig)(client, endpoint);
|
|
93
|
+
const existing = io.readFile(target);
|
|
94
|
+
const merged = (0, client_config_writer_1.mergeClientConfig)(config, existing);
|
|
95
|
+
// Back up BEFORE writing, and only when there was something to lose. A user who
|
|
96
|
+
// discovers a surprise in their config later needs a way back that does not
|
|
97
|
+
// depend on them having made one.
|
|
98
|
+
if (existing !== null && existing.trim() !== "") {
|
|
99
|
+
io.copyFile(target, (0, client_config_writer_1.backupPathFor)(target));
|
|
100
|
+
}
|
|
101
|
+
io.mkdirp(node_path_1.default.dirname(target));
|
|
102
|
+
io.writeFile(target, merged.content);
|
|
103
|
+
return { path: target, action: merged.action };
|
|
104
|
+
}
|
|
105
|
+
/** Real filesystem IO for `writeClientConfig`. */
|
|
106
|
+
const realIo = {
|
|
107
|
+
readFile: (p) => {
|
|
108
|
+
try {
|
|
109
|
+
return node_fs_1.default.readFileSync(p, "utf8");
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// ENOENT is the ordinary "first run" case. Anything else — a permission
|
|
113
|
+
// problem, a directory where a file should be — must not be silently read
|
|
114
|
+
// as "no config", because that would overwrite a file we simply could not
|
|
115
|
+
// open.
|
|
116
|
+
if (err.code === "ENOENT")
|
|
117
|
+
return null;
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
writeFile: (p, content) => {
|
|
122
|
+
// 0600: the file holds a bearer token.
|
|
123
|
+
node_fs_1.default.writeFileSync(p, content, { mode: 0o600 });
|
|
124
|
+
},
|
|
125
|
+
copyFile: (from, to) => node_fs_1.default.copyFileSync(from, to),
|
|
126
|
+
mkdirp: (dir) => {
|
|
127
|
+
node_fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* `baychat connect <client>`.
|
|
132
|
+
*
|
|
133
|
+
* @returns 0 on success, 2 when the laptop login expired without approval.
|
|
134
|
+
*/
|
|
135
|
+
async function cmdConnect(clientArg, opts = {}) {
|
|
136
|
+
if (clientArg === undefined) {
|
|
137
|
+
console.log(renderConnectMenu());
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
const client = parseConnectClient(clientArg);
|
|
141
|
+
const base = (opts.base || process.env.BAYCHAT_API_URL || config_1.DEFAULT_API_URL).replace(/\/$/, "");
|
|
142
|
+
const steps = (0, connect_plan_1.planConnect)({
|
|
143
|
+
client,
|
|
144
|
+
device: (0, connect_plan_1.deviceStateFrom)((0, config_1.loadDeviceCredentials)()),
|
|
145
|
+
needsRestart: (0, client_paths_1.needsRestart)(client),
|
|
146
|
+
});
|
|
147
|
+
for (const step of steps) {
|
|
148
|
+
const outcome = await runStep(step, { base, client });
|
|
149
|
+
if (outcome.kind === "abort")
|
|
150
|
+
return outcome.code;
|
|
151
|
+
}
|
|
152
|
+
const summary = (0, connect_plan_1.describeConnection)({
|
|
153
|
+
device: (0, connect_plan_1.deviceStateFrom)((0, config_1.loadDeviceCredentials)()),
|
|
154
|
+
agentName: null,
|
|
155
|
+
groupTitle: null,
|
|
156
|
+
});
|
|
157
|
+
console.log("");
|
|
158
|
+
console.log(` Laptop: ${summary.laptop}`);
|
|
159
|
+
console.log(` Sessions: ${summary.agent}`);
|
|
160
|
+
console.log("");
|
|
161
|
+
console.log(` ${summary.note}`);
|
|
162
|
+
// The relay is what makes a joined session hear messages without being
|
|
163
|
+
// prompted. Best-effort by design: it reports what it did (or didn't) and
|
|
164
|
+
// never fails the connect it is tacked onto.
|
|
165
|
+
console.log(` ${await (0, commands_2.ensureRelayInstalled)()}`);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
async function runStep(step, ctx) {
|
|
169
|
+
switch (step.kind) {
|
|
170
|
+
case "device-login": {
|
|
171
|
+
console.log(step.reason === "expired"
|
|
172
|
+
? "Your laptop login has expired. Reconnecting this laptop…\n"
|
|
173
|
+
: "Connecting this laptop — approve the QR on your phone.\n");
|
|
174
|
+
// registerClaude is left ON only for the desktop/claude clients; for any
|
|
175
|
+
// other client, editing Claude's config unasked is a surprise.
|
|
176
|
+
const ok = await (0, commands_1.cmdLogin)({
|
|
177
|
+
base: ctx.base,
|
|
178
|
+
registerClaude: ctx.client === "desktop",
|
|
179
|
+
hint: false,
|
|
180
|
+
});
|
|
181
|
+
if (!ok) {
|
|
182
|
+
console.log("\nLaptop not connected — run `npx baychat connect` again when ready.");
|
|
183
|
+
return { kind: "abort", code: 2 };
|
|
184
|
+
}
|
|
185
|
+
console.log("✓ Laptop connected.\n");
|
|
186
|
+
return { kind: "ok" };
|
|
187
|
+
}
|
|
188
|
+
case "device-ok": {
|
|
189
|
+
console.log(`✓ Laptop already connected as ${step.userName}.`);
|
|
190
|
+
const nudge = (0, connect_plan_1.expiryNudge)({ kind: "live", userName: step.userName, msLeft: step.msLeft });
|
|
191
|
+
if (nudge)
|
|
192
|
+
console.log(` ${nudge}`);
|
|
193
|
+
console.log("");
|
|
194
|
+
return { kind: "ok" };
|
|
195
|
+
}
|
|
196
|
+
case "write-config": {
|
|
197
|
+
// The DEVICE credential, deliberately — it is what unlocks the session tools
|
|
198
|
+
// (`join_session`, `list_sessions`, `end_session`) that let each session
|
|
199
|
+
// create its own identity. An agent token would pin this client to one
|
|
200
|
+
// agent in one room forever.
|
|
201
|
+
const device = (0, config_1.loadDeviceCredentials)();
|
|
202
|
+
if (!device) {
|
|
203
|
+
// Unreachable via planConnect, which logs in first; a guard rather than a
|
|
204
|
+
// comment because writing an empty bearer fails silently inside a client.
|
|
205
|
+
throw new Error("Internal: no device credential to write — login must run first.");
|
|
206
|
+
}
|
|
207
|
+
const { path: written, action } = writeClientConfig(step.client, { url: `${ctx.base}/api/mcp`, token: device.token }, realIo);
|
|
208
|
+
const verb = action === "created" ? "Created" : action === "updated" ? "Updated" : "Added to";
|
|
209
|
+
console.log(`✓ ${verb} ${written}`);
|
|
210
|
+
if (action !== "created") {
|
|
211
|
+
console.log(` A copy of the previous file is at ${(0, client_config_writer_1.backupPathFor)(written)}`);
|
|
212
|
+
}
|
|
213
|
+
return { kind: "ok" };
|
|
214
|
+
}
|
|
215
|
+
case "restart-note":
|
|
216
|
+
console.log(`\n Quit and reopen ${mcp_dialects_1.CLIENT_LABELS[step.client]} — it reads its config at startup.`);
|
|
217
|
+
return { kind: "ok" };
|
|
218
|
+
}
|
|
219
|
+
}
|