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.
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SessionQueue = void 0;
4
+ /**
5
+ * Per-session serialisation.
6
+ *
7
+ * The hard invariant: for any one session there is at most ONE delivery in
8
+ * flight at a time. Two concurrent wakes for the same session means two turns
9
+ * answering the same room from the same identity — an interactive turn and a
10
+ * headless resume racing, each unaware of the other, both replying. That is the
11
+ * failure this class exists to prevent, and it is why every wake path goes
12
+ * through `push` rather than calling the adapter directly.
13
+ *
14
+ * Messages that arrive while a turn is running are not dropped and do not
15
+ * spawn a second turn: they accumulate and are handed to the next delivery as
16
+ * one batch. Coalescing matters because a headless resume is expensive — three
17
+ * messages arriving during one run should produce one follow-up turn, not
18
+ * three.
19
+ */
20
+ class SessionQueue {
21
+ deliver;
22
+ onError;
23
+ /** Buffered messages per session, awaiting the next free slot. */
24
+ buffers = new Map();
25
+ /** Sessions with a delivery currently in flight. */
26
+ running = new Set();
27
+ /** Resolvers for `idle()`, so tests and shutdown can await quiescence. */
28
+ idleWaiters = [];
29
+ constructor(
30
+ /**
31
+ * Delivers one batch. Must not throw for ordinary failures — a failed
32
+ * delivery is reported through DeliveryOutcome, not by rejecting. A
33
+ * rejection is treated as a crashed turn: it is logged by the caller's
34
+ * handler and the slot is freed so the session isn't wedged forever.
35
+ */
36
+ deliver, onError = () => { }) {
37
+ this.deliver = deliver;
38
+ this.onError = onError;
39
+ }
40
+ /** Queue a message for a session, starting a delivery if none is running. */
41
+ push(session, message) {
42
+ const buf = this.buffers.get(session) ?? [];
43
+ // De-dupe inside the buffer too: the same event can legitimately arrive
44
+ // twice across a cursor re-baseline (409 recovery re-reads from a
45
+ // watermark), and a batch must not show the room the same line twice.
46
+ if (!buf.some((m) => m.id === message.id)) {
47
+ buf.push(message);
48
+ this.buffers.set(session, buf);
49
+ }
50
+ void this.drain(session);
51
+ }
52
+ async drain(session) {
53
+ if (this.running.has(session))
54
+ return; // a turn owns this session — it will pick the buffer up
55
+ const buf = this.buffers.get(session);
56
+ if (!buf || buf.length === 0)
57
+ return;
58
+ this.running.add(session);
59
+ try {
60
+ // Loop rather than recurse: messages that land *during* the delivery are
61
+ // picked up here without releasing the slot in between, so there is no
62
+ // window where a second drain could start a concurrent turn.
63
+ while (true) {
64
+ const batch = this.buffers.get(session) ?? [];
65
+ if (batch.length === 0)
66
+ break;
67
+ this.buffers.delete(session);
68
+ try {
69
+ await this.deliver(session, batch);
70
+ }
71
+ catch (err) {
72
+ // A thrown delivery must not strand the session. Report and move on:
73
+ // the batch is already consumed, and retrying it blindly risks
74
+ // double-answering if the turn actually ran.
75
+ this.onError(session, err);
76
+ }
77
+ }
78
+ }
79
+ finally {
80
+ this.running.delete(session);
81
+ this.buffers.delete(session);
82
+ this.notifyIfIdle();
83
+ }
84
+ }
85
+ /** True while any session has a delivery in flight. */
86
+ get busy() {
87
+ return this.running.size > 0;
88
+ }
89
+ /** Sessions with a delivery in flight — surfaced by `relay status`. */
90
+ activeSessions() {
91
+ return [...this.running];
92
+ }
93
+ /** Resolves once every in-flight delivery has finished. */
94
+ idle() {
95
+ if (!this.busy)
96
+ return Promise.resolve();
97
+ return new Promise((resolve) => this.idleWaiters.push(resolve));
98
+ }
99
+ notifyIfIdle() {
100
+ if (this.busy)
101
+ return;
102
+ const waiters = this.idleWaiters;
103
+ this.idleWaiters = [];
104
+ for (const w of waiters)
105
+ w();
106
+ }
107
+ }
108
+ exports.SessionQueue = SessionQueue;
@@ -0,0 +1,151 @@
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.SessionRegistry = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const config_1 = require("../config");
40
+ const FILE = "relay-sessions.json";
41
+ function registryPath() {
42
+ return path.join((0, config_1.configDir)(), FILE);
43
+ }
44
+ /**
45
+ * The set of local sessions the relay can wake, keyed by BayChat session name.
46
+ *
47
+ * Persisted because `resumeId` is the difference between a headless wake that
48
+ * continues the right conversation and one that is refused as `pending`. A
49
+ * daemon restart (or a reboot) must not silently downgrade every session to
50
+ * "cannot resume safely" — so what `attach` learned is written to disk.
51
+ *
52
+ * `attached` is deliberately NOT persisted: it describes a live socket, and a
53
+ * process that just started holds none. Loading it as `true` would make the
54
+ * daemon believe someone is listening and route wakes into nothing.
55
+ */
56
+ class SessionRegistry {
57
+ filePath;
58
+ sessions = new Map();
59
+ constructor(filePath = registryPath()) {
60
+ this.filePath = filePath;
61
+ }
62
+ load() {
63
+ try {
64
+ const raw = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
65
+ if (!raw || typeof raw !== "object")
66
+ return;
67
+ for (const [name, value] of Object.entries(raw)) {
68
+ if (!value || typeof value !== "object")
69
+ continue;
70
+ this.sessions.set(name, { ...value, name, attached: false });
71
+ }
72
+ }
73
+ catch {
74
+ // Missing or corrupt — an empty registry is the correct reading. Every
75
+ // wake then reports `pending` with a reason, which is visible in
76
+ // `relay status`, rather than failing silently.
77
+ }
78
+ }
79
+ save() {
80
+ const out = {};
81
+ for (const [name, target] of this.sessions) {
82
+ const { attached: _attached, ...persisted } = target;
83
+ out[name] = persisted;
84
+ }
85
+ try {
86
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
87
+ fs.writeFileSync(this.filePath, JSON.stringify(out, null, 2), { mode: 0o600 });
88
+ }
89
+ catch {
90
+ // A registry we cannot persist still works for this process's lifetime;
91
+ // losing it costs resume ids after a restart, not correctness now.
92
+ }
93
+ }
94
+ /**
95
+ * Record (or update) a target. Merges rather than replaces: an `attach` that
96
+ * omits `resumeId` must not erase one an earlier attach established, or the
97
+ * next detached wake would report `pending` for a session we can in fact
98
+ * resume.
99
+ */
100
+ upsert(target) {
101
+ const existing = this.sessions.get(target.name);
102
+ const merged = {
103
+ registeredAt: existing?.registeredAt ?? new Date().toISOString(),
104
+ ...existing,
105
+ ...stripUndefined(target),
106
+ name: target.name,
107
+ runtime: target.runtime,
108
+ };
109
+ this.sessions.set(target.name, merged);
110
+ this.save();
111
+ return merged;
112
+ }
113
+ get(name) {
114
+ return this.sessions.get(name);
115
+ }
116
+ all() {
117
+ return [...this.sessions.values()];
118
+ }
119
+ setAttached(name, attached) {
120
+ const t = this.sessions.get(name);
121
+ if (t)
122
+ t.attached = attached;
123
+ }
124
+ /**
125
+ * Drop targets whose session is no longer live server-side.
126
+ *
127
+ * A session ended from the app (Settings → Devices → Sessions → End) stops
128
+ * appearing in the device poll. Keeping a local target for it would leave a
129
+ * headless resume firing into a terminal the owner deliberately parked.
130
+ *
131
+ * Attached sessions are never pruned: a live socket outranks a poll, which
132
+ * may simply have raced a join that hasn't been stamped live yet.
133
+ */
134
+ pruneToLive(liveNames) {
135
+ const live = new Set(liveNames);
136
+ const dropped = [];
137
+ for (const [name, target] of this.sessions) {
138
+ if (!live.has(name) && !target.attached) {
139
+ this.sessions.delete(name);
140
+ dropped.push(name);
141
+ }
142
+ }
143
+ if (dropped.length > 0)
144
+ this.save();
145
+ return dropped;
146
+ }
147
+ }
148
+ exports.SessionRegistry = SessionRegistry;
149
+ function stripUndefined(obj) {
150
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
151
+ }
@@ -0,0 +1,125 @@
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.socketPath = socketPath;
37
+ exports.pidFilePath = pidFilePath;
38
+ exports.createFrameReader = createFrameReader;
39
+ exports.writeFrame = writeFrame;
40
+ exports.probeSocket = probeSocket;
41
+ exports.unlinkStaleSocket = unlinkStaleSocket;
42
+ const fs = __importStar(require("fs"));
43
+ const net = __importStar(require("net"));
44
+ const path = __importStar(require("path"));
45
+ const config_1 = require("../config");
46
+ /**
47
+ * Where the attach socket lives.
48
+ *
49
+ * `XDG_RUNTIME_DIR` is the right home: it is user-private (0700), on tmpfs, and
50
+ * cleared on logout, so a stale socket never outlives the session that made it.
51
+ * The config-dir fallback covers boxes without it (some containers, macOS).
52
+ */
53
+ function socketPath() {
54
+ const override = process.env.BAYCHAT_RELAY_SOCKET;
55
+ if (override)
56
+ return override;
57
+ const runtimeDir = process.env.XDG_RUNTIME_DIR;
58
+ if (runtimeDir)
59
+ return path.join(runtimeDir, "baychat-relay.sock");
60
+ return path.join((0, config_1.configDir)(), "relay.sock");
61
+ }
62
+ function pidFilePath() {
63
+ return path.join((0, config_1.configDir)(), "relay.pid");
64
+ }
65
+ /** Frames are newline-delimited JSON; this splits a stream into whole ones. */
66
+ function createFrameReader(onFrame, onBad) {
67
+ let buffer = "";
68
+ return (chunk) => {
69
+ buffer += chunk.toString();
70
+ // A peer that never sends a newline must not grow this unboundedly.
71
+ if (buffer.length > 1_000_000) {
72
+ buffer = "";
73
+ onBad?.("frame too large");
74
+ return;
75
+ }
76
+ let idx;
77
+ while ((idx = buffer.indexOf("\n")) >= 0) {
78
+ const line = buffer.slice(0, idx).trim();
79
+ buffer = buffer.slice(idx + 1);
80
+ if (!line)
81
+ continue;
82
+ try {
83
+ onFrame(JSON.parse(line));
84
+ }
85
+ catch {
86
+ onBad?.(line);
87
+ }
88
+ }
89
+ };
90
+ }
91
+ function writeFrame(sock, frame) {
92
+ sock.write(JSON.stringify(frame) + "\n");
93
+ }
94
+ /**
95
+ * Is a daemon already listening?
96
+ *
97
+ * A socket file on disk proves nothing — a killed daemon leaves one behind. The
98
+ * only honest test is to connect: ECONNREFUSED means the file is stale and safe
99
+ * to unlink, which is what lets `relay start` recover from a hard kill without
100
+ * a human deleting files.
101
+ */
102
+ function probeSocket(sockPath, timeoutMs = 1_000) {
103
+ return new Promise((resolve) => {
104
+ if (!fs.existsSync(sockPath))
105
+ return resolve(false);
106
+ const sock = net.createConnection(sockPath);
107
+ const done = (alive) => {
108
+ sock.destroy();
109
+ resolve(alive);
110
+ };
111
+ sock.setTimeout(timeoutMs, () => done(false));
112
+ sock.on("connect", () => done(true));
113
+ sock.on("error", () => done(false));
114
+ });
115
+ }
116
+ /** Remove a socket file we have already proven dead. */
117
+ function unlinkStaleSocket(sockPath) {
118
+ try {
119
+ fs.unlinkSync(sockPath);
120
+ }
121
+ catch {
122
+ // Already gone, or not ours to remove — the listen() that follows will
123
+ // surface anything that actually matters.
124
+ }
125
+ }
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ /**
3
+ * Shared types for the relay daemon.
4
+ *
5
+ * The relay's job is narrow: hold `/updates`, decide which local session an
6
+ * event belongs to, and wake that session — once, never twice concurrently.
7
+ * It deliberately does NOT decide whether the session should reply. That
8
+ * judgement is the server's (`shouldRespond`) and the woken session's; the
9
+ * relay only carries the message across the process boundary.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runUpdatesLoop = runUpdatesLoop;
4
+ const api_1 = require("../api");
5
+ /** Server clamps `wait` into [1s, 30s]; 25s stays under the usual 60s proxy idle timeout. */
6
+ const WAIT_SECONDS = 25;
7
+ /** Backoff bounds for transient failures (5xx, 429, network). */
8
+ const BACKOFF_MIN_MS = 1_000;
9
+ const BACKOFF_MAX_MS = 30_000;
10
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
11
+ /**
12
+ * Transient failures are ridden out, not surfaced. A 502 while the api
13
+ * container restarts, a network throw, a 429 — all mean "later", not "stop".
14
+ * Real 4xx (401 expired device credential, 403) are terminal: they need a
15
+ * human, and retrying them forever would hide that.
16
+ *
17
+ * 409 is deliberately NOT here — it has its own recovery path and must never
18
+ * be treated as a generic hiccup, because backing off on it would leave the
19
+ * cursor permanently unusable and the relay silently deaf.
20
+ */
21
+ function isTransient(err) {
22
+ if (err instanceof api_1.ApiError)
23
+ return err.status >= 500 || err.status === 429;
24
+ return err instanceof TypeError; // fetch rejects with TypeError on network failure
25
+ }
26
+ /**
27
+ * Holds `GET /api/device-api/updates` and calls `onEvents` for every batch,
28
+ * until `signal` aborts.
29
+ *
30
+ * The credential is the machine's DEVICE credential, not an agent token. A
31
+ * laptop has one device login and its sessions mint their own agents when they
32
+ * join, so no single agent token can see them all — the server fans out across
33
+ * the device's live sessions and tags each event with the session it belongs to.
34
+ *
35
+ * Cursor discipline: the cursor is an opaque blob (really one cursor per session
36
+ * agent, encoded server-side) and advances only after a batch has been handed to
37
+ * `onEvents`. The caller queues synchronously, so a crash between poll and queue
38
+ * costs a re-read — harmless, ids de-dupe — rather than a lost message.
39
+ *
40
+ * On 409 `cursor_expired` recovery is ours: the server holds no record of what
41
+ * this client consumed. We re-read each watched conversation from our own
42
+ * watermark, then poll again with no cursor.
43
+ */
44
+ async function runUpdatesLoop(opts) {
45
+ const { auth, watermarks, onEvents, onSessions, onPoll, onError, signal, request = api_1.apiRequest } = opts;
46
+ let cursor;
47
+ let backoff = BACKOFF_MIN_MS;
48
+ while (!signal.aborted) {
49
+ try {
50
+ const q = new URLSearchParams({ wait: String(WAIT_SECONDS) });
51
+ if (cursor)
52
+ q.set("cursor", cursor);
53
+ const res = await request(auth, "GET", `/api/device-api/updates?${q.toString()}`);
54
+ onSessions?.(res.sessions ?? []);
55
+ if (res.events.length > 0) {
56
+ onEvents(res.events.map((e) => ({
57
+ conversationId: e.conversationId,
58
+ sessionName: e.sessionName,
59
+ message: { ...e.message, conversationId: e.conversationId },
60
+ })));
61
+ }
62
+ cursor = res.cursor;
63
+ onPoll?.(res.cursor);
64
+ backoff = BACKOFF_MIN_MS; // a clean poll resets the ladder
65
+ }
66
+ catch (err) {
67
+ if (signal.aborted)
68
+ return;
69
+ if (err instanceof api_1.ApiError && err.status === 409) {
70
+ onError?.(err, true);
71
+ try {
72
+ const recovered = await catchUp(auth, watermarks, request);
73
+ if (recovered.length > 0)
74
+ onEvents(recovered);
75
+ }
76
+ catch (catchUpErr) {
77
+ // Catch-up itself failed. Do NOT clear the cursor and carry on as if
78
+ // recovered — that is the silent-gap case. Back off and let the next
79
+ // 409 retry the whole recovery.
80
+ onError?.(catchUpErr, true);
81
+ await sleep(backoff);
82
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
83
+ continue;
84
+ }
85
+ cursor = undefined;
86
+ continue;
87
+ }
88
+ if (!isTransient(err)) {
89
+ onError?.(err, false);
90
+ throw err; // terminal — an expired device credential needs `npx baychat login`
91
+ }
92
+ onError?.(err, true);
93
+ await sleep(backoff);
94
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
95
+ }
96
+ }
97
+ }
98
+ /**
99
+ * Re-read each watched conversation from its watermark. Used only on 409
100
+ * recovery. Messages we already delivered come back here; the caller's
101
+ * per-session queue de-dupes them by id, so a replay is cheap rather than
102
+ * duplicated into the room.
103
+ *
104
+ * A 404 for one conversation is skipped rather than fatal: a session may have
105
+ * ended or left the room between the poll and the recovery, and one dead room
106
+ * must not strand the catch-up for every other.
107
+ */
108
+ async function catchUp(auth, watermarks, request) {
109
+ const events = [];
110
+ for (const [conversationId, since] of watermarks) {
111
+ let res;
112
+ try {
113
+ res = await request(auth, "GET", `/api/device-api/conversations/${conversationId}/messages?since=${encodeURIComponent(since)}`);
114
+ }
115
+ catch (err) {
116
+ if (err instanceof api_1.ApiError && err.status === 404)
117
+ continue;
118
+ throw err;
119
+ }
120
+ for (const m of res.messages) {
121
+ if (m.deletedAt)
122
+ continue;
123
+ events.push({
124
+ conversationId,
125
+ sessionName: res.sessionName,
126
+ message: { ...m, conversationId },
127
+ });
128
+ }
129
+ }
130
+ return events;
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "BayChat connector CLI — pair an agent session (Claude Code, Codex) with BayChat and chat in groups",
5
5
  "bin": {
6
6
  "baychat": "dist/index.js"