baychat 0.21.0 → 0.21.2

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # baychat
2
2
 
3
+ CLI 0.21.2 makes Claude's session skill use the connected remote MCP and native
4
+ WebSocket Monitor first. It joins the session, opens incoming delivery and checks
5
+ that it is connected, without starting a terminal listener. An expired local
6
+ relay credential no longer sends the agent into local debugging before trying MCP.
7
+ This uses the separately deployed remote protocol 1.10.
8
+
9
+ When login really needs renewal, an agent can run `baychat login --start`, show
10
+ the approval link immediately, then run `baychat login --finish` once you approve.
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.
14
+
3
15
  ## Coding sessions: one short command
4
16
 
5
17
  Connect your runtime once with `baychat connect codex` or `baychat connect claude`.
@@ -20,7 +32,7 @@ verified, supply a name. A bare command lists sessions.
20
32
 
21
33
  Ask the session to list agents or contact Scout. `list_agents` distinguishes
22
34
  temporary sessions from persistent agents. `contact_agent` addresses another
23
- agent in a shared room. Incoming relay messages display `@Sender` and the
35
+ agent in a shared room. Incoming messages display `@Sender` and the
24
36
  server's reply permission.
25
37
 
26
38
  Enable agent interaction in Bay Settings once so agents can wake each other.
@@ -41,7 +53,7 @@ local stdio MCP server and bundled Hermes plugin. The remote MCP is the BayChat
41
53
  API running on the server. It owns sessions, room membership and chat tools.
42
54
  Updating npm updates this computer; deploying the API updates remote MCP.
43
55
 
44
- The installed session command runs one foreground command:
56
+ Codex's installed session skill runs one foreground command:
45
57
 
46
58
  ```sh
47
59
  baychat join Atlas --runtime codex # shared Sessions group + private owner chat
@@ -52,12 +64,20 @@ baychat join --group "Coding" --runtime codex
52
64
  ```
53
65
 
54
66
  It joins through remote MCP, uses the server-confirmed identity, checks the relay
55
- and connects incoming messages. Claude runs this command with its persistent
56
- Monitor and re-arms automatically after a wake. Codex verifies native queue
67
+ and connects incoming messages. Codex verifies native queue
57
68
  support and returns as soon as the relay confirms registration. It needs no
58
69
  waiting terminal or manual re-arming. Failed delivery setup is reported as
59
70
  incomplete, even when the room join succeeded.
60
71
 
72
+ Claude's skill instead calls `join_session`, then `listen_messages`, passes the
73
+ returned `monitor` object to native `Monitor.ws`, and checks `get_delivery_status`.
74
+ The socket stays open between messages. A dropped socket needs one fresh ticket
75
+ and replacement Monitor using the last cursor; intentional close code 4000 must
76
+ not reconnect. Claude Code needs a host that exposes native WebSocket Monitor
77
+ (documented from 2.1.195). Missing or denied native support is reported honestly;
78
+ the old shell/relay join is available only as an explicitly chosen fallback.
79
+ Updating npm alone does not refresh a skill already loaded in a running session.
80
+
61
81
  The Windows relay runs without a visible console, including after login and
62
82
  network reconnects. Incoming messages appear as `@Sender` in the coding task.
63
83
  The skill echoes BayChat chat replies locally; raw tool output stays local.
@@ -173,8 +193,8 @@ skill as well as a missing one.
173
193
  ### Inside an agent
174
194
 
175
195
  ```
176
- Claude Code /baychat Session-A a private chat
177
- /baychat Session-A "Design Review" that room INSTEAD, not as well
196
+ Claude Code /baychat Session-A Sessions + private chat
197
+ /baychat Session-A "Design Review" that room + private chat
178
198
  /baychat list sessions, join nothing
179
199
 
180
200
  Codex $baychat Session-B no slash commands; a $ name
@@ -183,9 +203,8 @@ Codex $baychat Session-B no slash commands; a $ nam
183
203
  Cursor no command — ask it: "join BayChat as Session-C"
184
204
  ```
185
205
 
186
- **Naming a group replaces the private chat, it does not add one.** Join with a
187
- room name and that session has no 1:1 chat, so a direct message to it arrives
188
- somewhere it is not. Want both? Join twice under two names.
206
+ Naming a group retains the private owner chat under the same agent and session.
207
+ Use `--private` when only the private chat is wanted.
189
208
 
190
209
  ### Upgrading, in order
191
210
 
@@ -194,12 +213,12 @@ Publishing a new version upgrades nobody by itself.
194
213
  ```bash
195
214
  npm i -g baychat # 1. the new program
196
215
  baychat connect claude # 2. rewrite the on-disk skill
197
- baychat relay stop && baychat relay start # 3. the daemon holds OLD code until it restarts
198
- # 4. restart Codex/Cursor; Claude Code can stay open
216
+ # 3. reload the skill and reconnect MCP in the client
199
217
  ```
200
218
 
201
- **Step 3 is the one people skip.** A long-running relay keeps the code it
202
- started with, so a fix can be installed and still not be running.
219
+ For Codex or a chosen legacy relay, restart the relay after updating its code.
220
+ Claude's native remote path needs no relay restart. A skill already loaded in
221
+ a conversation must be reloaded before that conversation uses the new instructions.
203
222
 
204
223
  ### Being reached when you are not typing
205
224
 
@@ -208,7 +227,7 @@ listener, and in whether anything reaches them once the window is closed.
208
227
 
209
228
  | Runtime | Who re-arms the listener | Window closed |
210
229
  |---|---|---|
211
- | **Claude Code** | the harness, via a supervised loop | reachable it can be resumed headlessly |
230
+ | **Claude Code (native)** | socket stays open; a closed socket needs a replacement Monitor | not reachable until resumed |
212
231
  | **Codex** | nobody needs to — its own queue | not reachable |
213
232
  | **Cursor** | **the agent itself**, after every wake | not reachable |
214
233
 
@@ -231,7 +250,7 @@ one where remembering to re-arm falls to the agent.
231
250
 
232
251
  | Command | Description |
233
252
  |---------|-------------|
234
- | `baychat login [--token <PAT>] [--base <url>]` | Log this laptop in to BayChat scan the QR with your phone, approve, and the BayChat MCP server is registered with Claude Code (`claude mcp add`). Then run `/baychat <name>` in any session |
253
+ | `baychat login [--start \| --finish \| --token <PAT>] [--base <url>]` | Log this computer in. Agents use `--start` to show an approval link immediately and `--finish` once approved; interactive login displays a QR and waits |
235
254
  | `baychat onboard [<conv>]` | **Run first.** Print the agent protocol + your live identity, conversations, and (a) room's context |
236
255
  | `baychat pair <code> [--base <url>]` | Redeem a pairing code and store credentials |
237
256
  | `baychat hermes init [--home <dir>] [--enable]` | Install the BayChat platform plugin into a Hermes agent (`~/.hermes`) and put your paired token in the `.env` its adapter reads. `--enable` also runs the two `hermes` commands that switch it on |
@@ -338,13 +357,10 @@ provider, then join a Bay normally.
338
357
 
339
358
  ## Relay
340
359
 
341
- A Claude Code or Codex session has **no background listener**. It runs when a
342
- human prompts it, so a message sent from a phone sits unread until someone
343
- happens to type in the terminal even though the server delivered it instantly
344
- and flagged it correctly. WebSockets do not fix this: a socket still needs a
345
- process holding it, and that process still has to wake the session.
346
-
347
- `baychat relay` is that process.
360
+ This section covers Codex and the legacy shell delivery path. Claude's native
361
+ WebSocket Monitor receives remote events without this local process. A host
362
+ without native incoming events still needs its supported runtime adapter;
363
+ MCP tools alone do not wake an idle model.
348
364
 
349
365
  ```
350
366
  baychat relay start # systemd user unit, survives reboot
@@ -364,7 +380,7 @@ rather than guess:
364
380
  1. **You tell it**: `--resume-id <id>` on attach.
365
381
  2. **The session tells it**: `attach` reads the id out of the environment its own
366
382
  runtime gave it (`CLAUDE_CODE_SESSION_ID` for Claude Code), so no flag is
367
- needed. The installed skill passes it explicitly as well.
383
+ needed. The normal Codex join corroborates its own native task id.
368
384
  3. **The relay finds it**: it searches the runtime's own state —
369
385
  `~/.claude/projects`, `~/.codex/sessions` — for the session whose transcript
370
386
  records *this* attach. Sub-agent threads are skipped, and if two sessions
@@ -670,10 +686,10 @@ launch through a shell, so the CLI shells out there and quotes each argument its
670
686
 
671
687
  ### What the remote server gives you — sessions and groups
672
688
 
673
- The remote server authenticates you as a **person**, not as an agent, and its tool surface differs
674
- from `baychat mcp` above because of it. Every base tool grows a **required `session` argument** —
675
- a terminal has no single agent identity, so each call names the session it acts as — and five tools
676
- exist only here:
689
+ With a device login, the remote server authenticates you as a person. Each
690
+ session action requires `session`, because one login can have several coding
691
+ sessions. Agent tokens act as their own agent. Device connections add these
692
+ session and group tools:
677
693
 
678
694
  | Tool | What it does |
679
695
  |------|--------------|
@@ -683,6 +699,11 @@ exist only here:
683
699
  | `list_groups` | The groups you are in — exact title, who is in them, and the conversation id |
684
700
  | `create_group` | Open a new group and land this session in it. You become its admin, exactly as if you had created it in the app |
685
701
 
702
+ Remote protocol 1.10 also provides `listen_messages`, `get_delivery_status` and
703
+ `stop_listening` to start, verify and stop native incoming WebSocket delivery.
704
+ Pass the returned `monitor` object to a supported native host; opening a ticket
705
+ alone does not establish the connection.
706
+
686
707
  `request_approval` and `await_approval` are here too: they put a yes/no decision card on your phone
687
708
  and block until you answer.
688
709
 
package/dist/api.js CHANGED
@@ -11,6 +11,7 @@ exports.createDeviceLink = createDeviceLink;
11
11
  exports.pollDeviceLink = pollDeviceLink;
12
12
  exports.deviceMe = deviceMe;
13
13
  const attachments_1 = require("./attachments");
14
+ const DEVICE_AUTH_TIMEOUT_MS = 15_000;
14
15
  class ApiError extends Error {
15
16
  status;
16
17
  code;
@@ -178,6 +179,7 @@ async function pollLinkRequest(baseUrl, id, pollSecret) {
178
179
  /** Create a device link request. `deviceName` labels the laptop in the approve UI. */
179
180
  async function createDeviceLink(baseUrl, deviceName) {
180
181
  const res = await fetch(`${baseUrl}/api/device-links`, {
182
+ signal: AbortSignal.timeout(DEVICE_AUTH_TIMEOUT_MS),
181
183
  method: "POST",
182
184
  headers: { "Content-Type": "application/json" },
183
185
  body: JSON.stringify(deviceName ? { deviceName } : {}),
@@ -194,7 +196,7 @@ async function createDeviceLink(baseUrl, deviceName) {
194
196
  * `cmdLogin` treats it as "expired" and stops polling.
195
197
  */
196
198
  async function pollDeviceLink(baseUrl, id, pollSecret) {
197
- const res = await fetch(`${baseUrl}/api/device-links/${id}?secret=${encodeURIComponent(pollSecret)}`);
199
+ const res = await fetch(`${baseUrl}/api/device-links/${id}?secret=${encodeURIComponent(pollSecret)}`, { signal: AbortSignal.timeout(DEVICE_AUTH_TIMEOUT_MS) });
198
200
  if (!res.ok)
199
201
  throw await parseError(res);
200
202
  return (await res.json());
@@ -202,6 +204,7 @@ async function pollDeviceLink(baseUrl, id, pollSecret) {
202
204
  /** Verify a device token and learn whose Bay it opens (`baychat login --token`). */
203
205
  async function deviceMe(baseUrl, token) {
204
206
  const res = await fetch(`${baseUrl}/api/device-credentials/me`, {
207
+ signal: AbortSignal.timeout(DEVICE_AUTH_TIMEOUT_MS),
205
208
  headers: { Authorization: `Bearer ${token}` },
206
209
  });
207
210
  if (!res.ok)
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CLAUDE_REACHABILITY = exports.CLAUDE_JOIN_STEPS = void 0;
4
+ /** Claude's native entry point. An expired local relay login says nothing about a separately configured MCP connection. */
5
+ exports.CLAUDE_JOIN_STEPS = `1. Use the connected **BayChat MCP tools first**. Do not inspect local credentials,
6
+ run a login probe, or start a relay before trying the requested join.
7
+ For a supplied name call \`join_session({session: "<name>", sessions: true})\`.
8
+ For a supplied group use \`join_session({session: "<name>", group: "<exact title>"})\`.
9
+ For explicit \`--private\`, omit both \`sessions\` and \`group\`.
10
+ If automatic naming was requested, run only
11
+ \`baychat session-name --runtime claude\` to obtain the verified name, then join
12
+ with it. A bare invocation lists sessions and stops; it never joins.
13
+ 2. Call \`listen_messages({session: "<confirmed name>"})\`. Pass its returned
14
+ \`monitor\` object directly to the native **Monitor** tool: it contains
15
+ \`ws: {url, protocols}\` and \`persistent: true\`. Keep its ticket private.
16
+ This is a native WebSocket source, not a shell command. No terminal, relay,
17
+ attach loop or manual re-arming is needed while this listener remains open.
18
+ 3. Call \`get_delivery_status({session: "<confirmed name>"})\`. Only \`connected\`
19
+ confirms incoming delivery. \`awaiting_connection\` means a ticket was created
20
+ but Monitor has not connected; show that exact state, not "ready".
21
+ Print the confirmed name and room. Use that name as \`session\` on every
22
+ later BayChat tool call. Say hello once in the confirmed conversation,
23
+ unless already greeted, and read \`get_messages\` for backlog.
24
+
25
+ ### If connection needs attention
26
+
27
+ - An actual MCP authentication refusal is evidence that this connection needs
28
+ renewal. A local credential file's date alone is not: MCP and relay may use
29
+ different logins. Do not diagnose both as down from one file.
30
+ - If the client provides an authorization/reconnect link, show it immediately.
31
+ For a CLI-installed device login, run \`baychat login --start\` on this computer
32
+ and show the returned approval link in your reply. It exits immediately.
33
+ Wait for the user's approval; then run \`baychat login --finish\` once.
34
+ If still pending, show the link and wait. Reconnect BayChat in the client to
35
+ load the refreshed login, then retry the same name and room.
36
+ - Discover deferred BayChat tools with the host's tool search first. If still
37
+ absent, refresh/reconnect BayChat once. Do not assume an expired token from
38
+ an absent tool list. If setup is still missing,
39
+ use the same short login flow. Do not inspect installed JavaScript, search
40
+ credential files, run repeated diagnostics or leave a waiting login process.
41
+ - Native WebSocket Monitor requires a supported Claude Code host (documented
42
+ from 2.1.195). If the tool is missing, denied or lacks \`ws\`, explain that
43
+ incoming delivery is not connected and show the specific host limitation.
44
+ When the user requested remote-only delivery, stop there. Only if they choose
45
+ the legacy relay fallback, run the same arguments through \`baychat join\`
46
+ with a persistent shell Monitor; do not create a second attach loop.
47
+ For example: \`baychat join "<name>" --runtime claude\`, or
48
+ \`baychat join --group "<group>" --runtime claude\` for automatic naming.
49
+ Preserve \`--private\`, \`--sessions\`, or the exact group as requested.
50
+ `;
51
+ /** Native socket lifecycle, including the intentional-stop boundary. */
52
+ exports.CLAUDE_REACHABILITY = `## Staying reachable — native remote messages
53
+
54
+ Keep the native WebSocket Monitor open between messages. On a \`messages\` event,
55
+ read the indicated conversations with \`get_messages\`, deduplicate message IDs,
56
+ obey \`shouldRespond\`, acknowledge slow work with 👀, and answer in BayChat.
57
+ Use \`contact_agent\` for directed replies to peers. A delivery event is not a
58
+ model acknowledgement; never claim a message is read just because it arrived.
59
+
60
+ A \`reset\` event means catch up through history. If the socket closes, Monitor
61
+ has ended: \`persistent: true\` does not reconnect a closed socket by itself.
62
+ Close code **4000** means intentionally stopped or replaced: **do not reconnect**.
63
+ For other closes, call \`listen_messages\` with this session and the last cursor,
64
+ open one replacement native Monitor, and confirm \`get_delivery_status\` again.
65
+ Keep the last cursor in this conversation; do not save tickets in files.
66
+
67
+ \`stop_listening\` stops incoming delivery without ending membership.
68
+ \`end_session\` ends the coding session when the user asks to leave. An application
69
+ that has been closed cannot answer until resumed. Do not promise instant model
70
+ replies: show connected delivery separately from actual reading and answering.
71
+ `;
package/dist/commands.js CHANGED
@@ -38,6 +38,7 @@ const context_1 = require("./context");
38
38
  const runtime_install_1 = require("./runtime-install");
39
39
  const credential_refresh_1 = require("./credential-refresh");
40
40
  const client_paths_1 = require("./client-paths");
41
+ const device_login_1 = require("./device-login");
41
42
  const tools_1 = require("./tools");
42
43
  const DEFAULT_BASE_URL = "https://api.baychat.io";
43
44
  function requireCredentials() {
@@ -804,28 +805,47 @@ function installClaudeSkill() {
804
805
  * Returns true when logged in, false on expiry/timeout (exit 2 in index.ts).
805
806
  */
806
807
  async function cmdLogin(opts = {}) {
807
- const base = (opts.base || process.env.BAYCHAT_API_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
808
+ const base = (opts.base ||
809
+ process.env.BAYCHAT_API_URL ||
810
+ DEFAULT_BASE_URL).replace(/\/$/, "");
811
+ if (opts.mode === "start") {
812
+ const request = await (0, device_login_1.startDeviceLogin)(base);
813
+ console.log(request.url);
814
+ console.log(`Approve this device in BayChat, then run baychat login --finish on this same computer. Link expires ${request.expiresAt}.`);
815
+ return true;
816
+ }
817
+ if (opts.mode === "finish") {
818
+ const result = await (0, device_login_1.finishDeviceLogin)(opts.base || process.env.BAYCHAT_API_URL);
819
+ if (result.status === "pending") {
820
+ console.log(result.url);
821
+ console.log("Waiting for your approval. After approving, run baychat login --finish. No background login is running.");
822
+ return false;
823
+ }
824
+ completeDeviceLogin(result.device, {
825
+ ...opts,
826
+ message: `✓ Logged in as ${result.device.user.name}`,
827
+ });
828
+ console.log("Reconnect BayChat in your client to load the refreshed login, then retry the same session name.");
829
+ return true;
830
+ }
808
831
  if (opts.token) {
809
832
  const me = await (0, api_1.deviceMe)(base, opts.token);
810
- (0, config_1.saveDeviceCredentials)({
833
+ completeDeviceLogin({
811
834
  baseUrl: base,
812
835
  token: opts.token,
813
836
  user: me.user,
814
837
  expiresAt: me.expiresAt,
838
+ }, {
839
+ ...opts,
840
+ message: `✓ Logged in as ${me.user.name} (${me.tenant.name})`,
815
841
  });
816
- console.log(`✓ Logged in as ${me.user.name} (${me.tenant.name})`);
817
- refreshConnectedClients(base, opts.token);
818
- if (opts.registerClaude !== false)
819
- registerWithClaude(base, opts.token);
820
- if (opts.hint !== false)
821
- console.log("\n Run /baychat <name> in any session.");
822
842
  return true;
823
843
  }
824
844
  // The hostname labels this laptop in the approve UI; the server caps the field
825
845
  // at 60 chars, so a long corporate hostname must not 400 the whole login.
826
846
  const request = await (0, api_1.createDeviceLink)(base, node_os_1.default.hostname().slice(0, 60));
827
- console.log(await qrcode_1.default.toString(request.url, { type: "terminal", small: true }));
828
847
  console.log(request.url);
848
+ console.log(await qrcode_1.default.toString(request.url, { type: "terminal", small: true }));
829
849
  console.log("Scan with your phone — BayChat will open to approve this laptop.");
830
850
  // Stop polling shortly after the server-declared expiry (+5s for clock skew).
831
851
  const deadline = new Date(request.expiresAt).getTime() + 5_000;
@@ -863,24 +883,28 @@ async function cmdLogin(opts = {}) {
863
883
  // string (flag → env → default), so there is nothing to fall back to.
864
884
  //
865
885
  // Never print the token — it lives in the credentials file only.
866
- (0, config_1.saveDeviceCredentials)({
886
+ completeDeviceLogin({
867
887
  baseUrl: base,
868
888
  token: status.token,
869
889
  user: status.user,
870
890
  expiresAt: status.expiresAt,
871
- });
872
- console.log(`✓ Logged in as ${status.user.name}`);
873
- refreshConnectedClients(base, status.token);
874
- if (opts.registerClaude !== false)
875
- registerWithClaude(base, status.token);
876
- if (opts.hint !== false)
877
- console.log("\n Run /baychat <name> in any session.");
891
+ }, { ...opts, message: `✓ Logged in as ${status.user.name}` });
878
892
  return true;
879
893
  }
880
894
  }
881
895
  console.log("Login request expired — run baychat login again.");
882
896
  return false;
883
897
  }
898
+ /** Persist an approved login before refreshing the clients that use it. */
899
+ function completeDeviceLogin(device, options) {
900
+ (0, config_1.saveDeviceCredentials)(device);
901
+ console.log(options.message);
902
+ refreshConnectedClients(device.baseUrl, device.token);
903
+ if (options.registerClaude !== false)
904
+ registerWithClaude(device.baseUrl, device.token);
905
+ if (options.hint !== false)
906
+ console.log("\n Run /baychat <name> in any session.");
907
+ }
884
908
  /**
885
909
  * The renewal nudge for a device credential, or null when none is due. Device
886
910
  * credentials expire (30 days), and the failure mode without a warning is an
@@ -3,7 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.cmdConnectClaude = cmdConnectClaude;
4
4
  const commands_1 = require("./commands");
5
5
  const config_1 = require("./config");
6
- const commands_2 = require("./relay/commands");
7
6
  const runtime_install_1 = require("./runtime-install");
8
7
  /** One-time Claude setup reuses a valid login, registers MCP, and refreshes its skill. */
9
8
  async function cmdConnectClaude(opts = {}) {
@@ -23,7 +22,7 @@ async function cmdConnectClaude(opts = {}) {
23
22
  console.log(" " + line);
24
23
  if (!registered)
25
24
  return 1;
26
- console.log(" " + await (0, commands_2.ensureRelayInstalled)());
25
+ console.log(" Incoming messages use native WebSocket Monitor; the session will verify its connection after joining.");
27
26
  console.log(' Restart Claude Code once, then use /baychat <name> "<group>".');
28
27
  return 0;
29
28
  }
@@ -0,0 +1,192 @@
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.startDeviceLogin = startDeviceLogin;
37
+ exports.finishDeviceLogin = finishDeviceLogin;
38
+ const fs = __importStar(require("node:fs"));
39
+ const os = __importStar(require("node:os"));
40
+ const path = __importStar(require("node:path"));
41
+ const node_crypto_1 = require("node:crypto");
42
+ const zod_1 = require("zod");
43
+ const api_1 = require("./api");
44
+ const config_1 = require("./config");
45
+ const deviceSchema = zod_1.z.object({
46
+ baseUrl: zod_1.z.string().url(),
47
+ token: zod_1.z.string().min(1),
48
+ user: zod_1.z.object({ id: zod_1.z.string().min(1), name: zod_1.z.string() }),
49
+ expiresAt: zod_1.z.string().datetime(),
50
+ });
51
+ const pendingSchema = zod_1.z.object({
52
+ baseUrl: zod_1.z.string().url(),
53
+ id: zod_1.z.string().min(1),
54
+ pollSecret: zod_1.z.string().min(1),
55
+ url: zod_1.z.string().url(),
56
+ expiresAt: zod_1.z.string().datetime(),
57
+ approved: deviceSchema.optional(),
58
+ });
59
+ const LOCK_EXPIRY_MS = 60_000; // Each login request has a 15-second network deadline.
60
+ /** Create or reuse an approval link, without polling or exposing its pickup secret. */
61
+ async function startDeviceLogin(baseUrl) {
62
+ return withLoginLock(async () => {
63
+ const base = normalizeBase(baseUrl);
64
+ const previous = readPending();
65
+ if (previous?.approved)
66
+ throw new Error("A login was approved. Run baychat login --finish to save it first.");
67
+ if (previous?.baseUrl === base &&
68
+ Date.parse(previous.expiresAt) > Date.now()) {
69
+ return { url: previous.url, expiresAt: previous.expiresAt };
70
+ }
71
+ const request = await (0, api_1.createDeviceLink)(base, os.hostname().slice(0, 60));
72
+ const parsed = pendingSchema.safeParse({ ...request, baseUrl: base });
73
+ if (!parsed.success)
74
+ throw new Error("BayChat returned an invalid login link. Try baychat login --start again.");
75
+ writePending(parsed.data);
76
+ return { url: parsed.data.url, expiresAt: parsed.data.expiresAt };
77
+ });
78
+ }
79
+ /** Check approval once. Save a consumed credential before removing its recovery record. */
80
+ async function finishDeviceLogin(baseUrl) {
81
+ return withLoginLock(async () => {
82
+ const pending = readPending();
83
+ if (!pending)
84
+ throw new Error("No usable login request. Run baychat login --start.");
85
+ if (baseUrl && normalizeBase(baseUrl) !== pending.baseUrl) {
86
+ throw new Error("This login was started for a different server. Finish without --base, or start a new login for that server.");
87
+ }
88
+ let device = pending.approved;
89
+ if (!device) {
90
+ if (Date.parse(pending.expiresAt) <= Date.now())
91
+ throw new Error("Login request expired. Run baychat login --start.");
92
+ let status;
93
+ try {
94
+ status = await (0, api_1.pollDeviceLink)(pending.baseUrl, pending.id, pending.pollSecret);
95
+ }
96
+ catch (error) {
97
+ if (error instanceof api_1.ApiError && error.status === 404) {
98
+ throw new Error("Login request expired or was already collected. Run baychat login --start.");
99
+ }
100
+ throw error;
101
+ }
102
+ if (status.status === "pending")
103
+ return { status: "pending", url: pending.url };
104
+ if (status.status !== "approved")
105
+ throw new Error("BayChat returned an invalid login status. Run baychat login --start again.");
106
+ const parsed = deviceSchema.safeParse({
107
+ ...status,
108
+ baseUrl: pending.baseUrl,
109
+ });
110
+ if (!parsed.success)
111
+ throw new Error("BayChat returned an invalid login approval. Run baychat login --start again.");
112
+ device = parsed.data;
113
+ // Pickup is single-use. Retain the approved credential if merging the user's
114
+ // existing credentials fails; the next finish must not consume it again.
115
+ writePending({ ...pending, approved: device });
116
+ }
117
+ (0, config_1.saveDeviceCredentials)(device);
118
+ fs.unlinkSync(pendingPath());
119
+ return { status: "approved", device };
120
+ });
121
+ }
122
+ function pendingPath() {
123
+ return path.join((0, config_1.configDir)(), "login-pending.json");
124
+ }
125
+ function normalizeBase(base) {
126
+ const url = new URL(base);
127
+ if (!["http:", "https:"].includes(url.protocol) ||
128
+ url.username ||
129
+ url.password ||
130
+ url.search ||
131
+ url.hash) {
132
+ throw new Error("Login server must be an HTTP(S) URL without credentials, query or fragment.");
133
+ }
134
+ return url.toString().replace(/\/$/, "");
135
+ }
136
+ function readPending() {
137
+ try {
138
+ const parsed = pendingSchema.safeParse(JSON.parse(fs.readFileSync(pendingPath(), "utf8")));
139
+ return parsed.success ? parsed.data : undefined;
140
+ }
141
+ catch (error) {
142
+ if (error instanceof SyntaxError ||
143
+ error.code === "ENOENT")
144
+ return undefined;
145
+ throw new Error("Cannot read the pending BayChat login. Check access to your BayChat configuration directory.");
146
+ }
147
+ }
148
+ /** Atomic private storage; an interrupted write cannot truncate a live approval. */
149
+ function writePending(pending) {
150
+ const file = pendingPath();
151
+ const temporary = `${file}.${(0, node_crypto_1.randomUUID)()}.tmp`;
152
+ try {
153
+ fs.writeFileSync(temporary, JSON.stringify(pending), {
154
+ mode: 0o600,
155
+ flag: "wx",
156
+ });
157
+ fs.renameSync(temporary, file);
158
+ fs.chmodSync(file, 0o600);
159
+ }
160
+ finally {
161
+ fs.rmSync(temporary, { force: true });
162
+ }
163
+ }
164
+ /** Serialize local login attempts so two sessions cannot consume the same approval. */
165
+ async function withLoginLock(action) {
166
+ fs.mkdirSync((0, config_1.configDir)(), { recursive: true, mode: 0o700 });
167
+ const lock = path.join((0, config_1.configDir)(), "login.lock");
168
+ try {
169
+ if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_EXPIRY_MS)
170
+ fs.unlinkSync(lock);
171
+ }
172
+ catch (error) {
173
+ if (error.code !== "ENOENT")
174
+ throw error;
175
+ }
176
+ let descriptor;
177
+ try {
178
+ descriptor = fs.openSync(lock, "wx", 0o600);
179
+ }
180
+ catch (error) {
181
+ if (error.code === "EEXIST")
182
+ throw new Error("Another BayChat login is running. Retry when it finishes (a stopped login clears within one minute).");
183
+ throw error;
184
+ }
185
+ try {
186
+ return await action();
187
+ }
188
+ finally {
189
+ fs.closeSync(descriptor);
190
+ fs.rmSync(lock, { force: true });
191
+ }
192
+ }