baychat 0.11.2 → 0.11.4

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
@@ -69,7 +69,7 @@ per session, never one that another integration already uses.
69
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
70
  | `baychat relay status` | Sessions, cursor, and any **delivery pending** — messages that reached this box and that nothing answered (exit 2 if any) |
71
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) |
72
+ | `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) |
73
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) |
74
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)) |
75
75
 
@@ -102,8 +102,23 @@ with its full context. Run it as a background process from your session and you
102
102
  get near-instant delivery.
103
103
 
104
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.
105
+ (`claude -p --resume`, `codex exec resume`) but only once it knows *which*
106
+ runtime session that BayChat session is. It finds out three ways, and refuses
107
+ rather than guess:
108
+
109
+ 1. **You tell it**: `--resume-id <id>` on attach.
110
+ 2. **The session tells it**: `attach` reads the id out of the environment its own
111
+ runtime gave it (`CLAUDE_CODE_SESSION_ID` for Claude Code), so no flag is
112
+ needed. The installed skill passes it explicitly as well.
113
+ 3. **The relay finds it**: it searches the runtime's own state —
114
+ `~/.claude/projects`, `~/.codex/sessions` — for the session whose transcript
115
+ records *this* attach. Sub-agent threads are skipped, and if two sessions
116
+ claimed the name it names both and resumes neither.
117
+
118
+ Nothing here ranks by recency: `claude --continue` and `codex exec resume --last`
119
+ mean "whoever ran last in this directory", which on a working machine is
120
+ routinely a different session. `relay status` prints where each id came from so
121
+ you can see which of the three you got.
107
122
 
108
123
  Three things it will not do:
109
124
 
package/dist/index.js CHANGED
@@ -60,15 +60,22 @@ Usage:
60
60
  process instead
61
61
  baychat relay status Transport, sessions, cursor, and any DELIVERY
62
62
  PENDING — messages that reached this box and
63
- that nothing answered (exit 2 if any pending)
63
+ that nothing answered (exit 2 if any pending).
64
+ Each session also prints WHERE its resume id
65
+ came from, so "we can wake this headlessly"
66
+ is a claim you can check
64
67
  baychat relay stop Stop the relay and disable it at boot
65
68
  baychat relay attach --session <name> [--runtime claude|codex|hermes]
66
69
  [--resume-id <id>] [--timeout <sec>]
67
70
  Register this session with the relay and block
68
71
  until it is woken (exit 0), or the wait lapses
69
- (exit 2). Pass --resume-id so the relay can
70
- still reach the session headlessly once this
71
- process is gone
72
+ (exit 2). Without --resume-id the session is
73
+ asked to identify itself from its runtime's
74
+ environment ($CLAUDE_CODE_SESSION_ID for Claude
75
+ Code); failing that the relay searches the
76
+ runtime's own session state when a message
77
+ arrives. A session it cannot identify is
78
+ reported pending, never guessed at
72
79
 
73
80
  Connect flow: in BayChat, open the agent -> Connect -> copy the pairing code,
74
81
  then run \`baychat pair <code>\`. Pairing rotates the agent token; use a
@@ -1,10 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KNOWN_RUNTIMES = void 0;
3
4
  exports.buildWakePrompt = buildWakePrompt;
4
5
  exports.adapterFor = adapterFor;
5
6
  exports.isKnownRuntime = isKnownRuntime;
6
7
  exports.runHeadless = runHeadless;
7
8
  const child_process_1 = require("child_process");
9
+ const resume_1 = require("./resume");
8
10
  /** How long a headless turn may run before the relay gives up on it. */
9
11
  const HEADLESS_TIMEOUT_MS = 10 * 60_000;
10
12
  /**
@@ -40,6 +42,9 @@ const claudeAdapter = {
40
42
  }
41
43
  return { ok: true };
42
44
  },
45
+ discoverResume(target, opts) {
46
+ return (0, resume_1.discoverClaudeResume)(target.name, opts);
47
+ },
43
48
  headlessCommand(target, prompt) {
44
49
  return { file: "claude", args: ["-p", prompt, "--resume", target.resumeId] };
45
50
  },
@@ -55,10 +60,47 @@ const codexAdapter = {
55
60
  }
56
61
  return { ok: true };
57
62
  },
63
+ discoverResume(target, opts) {
64
+ return (0, resume_1.discoverCodexResume)(target.name, opts);
65
+ },
58
66
  headlessCommand(target, prompt) {
59
67
  return { file: "codex", args: ["exec", "resume", target.resumeId, prompt] };
60
68
  },
61
69
  };
70
+ /**
71
+ * Cursor can be woken, and cannot be resumed. Both halves matter.
72
+ *
73
+ * A Cursor session that launched `baychat relay attach` in the background is
74
+ * woken exactly like Claude Code or Codex: the attach socket does not care which
75
+ * runtime wrote to it, and the exit of the attach process is what re-invokes the
76
+ * session. That covers the ordinary case — a developer with Cursor open — which
77
+ * is why refusing to let Cursor attach at all was the wrong reading of "Cursor
78
+ * cannot be resumed headlessly".
79
+ *
80
+ * What Cursor has no equivalent of is `claude -p --resume <id>`: no documented
81
+ * entry point continues one specific Cursor conversation from outside it. So a
82
+ * wake that finds it detached is `pending`, with the reason — never a fresh
83
+ * process answering a room it has no memory of.
84
+ */
85
+ const cursorAdapter = {
86
+ runtime: "cursor",
87
+ canResume() {
88
+ return {
89
+ ok: false,
90
+ reason: "Cursor has no headless resume — it is reachable only while `baychat relay attach` is running, so attach must be re-armed after every wake",
91
+ };
92
+ },
93
+ async discoverResume() {
94
+ // Nothing to discover: Cursor writes no on-disk session state this package
95
+ // can identify a conversation from, and a search that cannot succeed is
96
+ // only a way to produce a confident-looking wrong answer.
97
+ return { ok: false, reason: "Cursor records no resumable session id on this machine" };
98
+ },
99
+ headlessCommand() {
100
+ // Unreachable: the daemon consults canResume first and reports pending.
101
+ throw new Error("cursor has no headless command");
102
+ },
103
+ };
62
104
  /**
63
105
  * Hermes is self-hosted and already receives messages through its own agent
64
106
  * webhook — there is no local process for the relay to resume. When Hermes is
@@ -74,6 +116,11 @@ const hermesAdapter = {
74
116
  reason: "Hermes is self-hosted and has no local headless resume — it must attach to the relay, or receive the message over its own agent webhook",
75
117
  };
76
118
  },
119
+ async discoverResume() {
120
+ // Nothing to discover: Hermes runs somewhere else and leaves no local
121
+ // transcript. Searching would only produce a confident-looking wrong answer.
122
+ return { ok: false, reason: "Hermes keeps no local session state on this machine" };
123
+ },
77
124
  headlessCommand() {
78
125
  // Unreachable: the daemon consults canResume first and reports pending.
79
126
  throw new Error("hermes has no headless command");
@@ -82,13 +129,21 @@ const hermesAdapter = {
82
129
  const ADAPTERS = {
83
130
  claude: claudeAdapter,
84
131
  codex: codexAdapter,
132
+ cursor: cursorAdapter,
85
133
  hermes: hermesAdapter,
86
134
  };
87
135
  function adapterFor(runtime) {
88
136
  return ADAPTERS[runtime];
89
137
  }
138
+ /** Every value `relay attach --runtime` accepts, for the error that lists them. */
139
+ exports.KNOWN_RUNTIMES = Object.keys(ADAPTERS);
140
+ /**
141
+ * Derived from the adapter table rather than restated, because a second copy of
142
+ * this list is exactly how Cursor came to be told to attach with a value the
143
+ * relay rejected. Having an adapter IS being attachable.
144
+ */
90
145
  function isKnownRuntime(value) {
91
- return value === "claude" || value === "codex" || value === "hermes";
146
+ return Object.prototype.hasOwnProperty.call(ADAPTERS, value);
92
147
  }
93
148
  /**
94
149
  * Run a headless turn to completion.
@@ -46,6 +46,7 @@ const path = __importStar(require("path"));
46
46
  const util_1 = require("util");
47
47
  const adapters_1 = require("./adapters");
48
48
  const daemon_1 = require("./daemon");
49
+ const resume_1 = require("./resume");
49
50
  const socket_1 = require("./socket");
50
51
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
51
52
  const UNIT_NAME = "baychat-relay.service";
@@ -202,6 +203,7 @@ async function cmdRelayStatus() {
202
203
  for (const s of status.sessions) {
203
204
  const state = s.attached ? "attached" : s.resumeId ? "detached (headless resume ready)" : "detached (no resume id)";
204
205
  console.log(` ${s.name} [${s.runtime}] ${state}`);
206
+ console.log(` ${resumeLabel(s)}`);
205
207
  }
206
208
  // Pending is the point of the whole command: these are messages that reached
207
209
  // this machine and that nobody answered.
@@ -215,6 +217,23 @@ async function cmdRelayStatus() {
215
217
  }
216
218
  return 0;
217
219
  }
220
+ /**
221
+ * Where this session's resume id came from, in one line.
222
+ *
223
+ * "We can wake this headlessly" is a claim about identity, and a human has to
224
+ * be able to judge it: a session that named itself out of its own environment
225
+ * is a different level of confidence from one the relay matched to a transcript
226
+ * on disk. Printing only the id would hide that difference, and printing
227
+ * nothing would hide the whole question — which is how two production sessions
228
+ * sat detached and unwakeable without it being anyone's obvious problem.
229
+ */
230
+ function resumeLabel(s) {
231
+ if (!s.resumeId) {
232
+ return "resume: none — a wake while detached is reported DELIVERY PENDING, not delivered";
233
+ }
234
+ const provenance = s.resumeEvidence ?? s.resumeSource ?? "origin not recorded (registered before provenance existed)";
235
+ return `resume: ${s.resumeId} — ${provenance}`;
236
+ }
218
237
  /**
219
238
  * Which event source is live, in one line.
220
239
  *
@@ -260,10 +279,11 @@ async function cmdRelayStop() {
260
279
  */
261
280
  async function cmdRelayAttach(opts) {
262
281
  if (!(0, adapters_1.isKnownRuntime)(opts.runtime)) {
263
- console.log(`unknown runtime "${opts.runtime}" — expected claude, codex, or hermes`);
282
+ console.log(`unknown runtime "${opts.runtime}" — expected one of: ${adapters_1.KNOWN_RUNTIMES.join(", ")}`);
264
283
  return 1;
265
284
  }
266
285
  const runtime = opts.runtime;
286
+ const resume = await resolveAttachResumeId(runtime, opts.resumeId, opts.discovery);
267
287
  let sock;
268
288
  try {
269
289
  sock = await connectOrFail();
@@ -320,8 +340,41 @@ async function cmdRelayAttach(opts) {
320
340
  type: "attach",
321
341
  session: opts.session,
322
342
  runtime,
323
- resumeId: opts.resumeId,
343
+ resumeId: resume.ok ? resume.resumeId : undefined,
344
+ resumeSource: resume.ok ? resume.source : undefined,
345
+ resumeEvidence: resume.ok ? resume.evidence : undefined,
346
+ resumeCwd: resume.ok ? resume.cwd : undefined,
324
347
  cwd: process.cwd(),
325
348
  });
326
349
  });
327
350
  }
351
+ /**
352
+ * Decide what resume id this attach registers, and say so out loud.
353
+ *
354
+ * An explicit `--resume-id` always wins: a human (or a skill that knows the
355
+ * runtime's own variable) said this is the session, and second-guessing them
356
+ * would break the escape hatch. Otherwise the session is asked to identify
357
+ * itself out of its runtime's environment — the only method that cannot name
358
+ * somebody else's session, because it runs inside this one.
359
+ *
360
+ * Both outcomes print. A silent "no resume id" is how a session ends up
361
+ * unwakeable without anyone noticing until a message goes unanswered.
362
+ */
363
+ async function resolveAttachResumeId(runtime, explicit, discovery) {
364
+ const given = explicit?.trim();
365
+ if (given) {
366
+ // Not validated: `--resume-id` is the escape hatch, and a runtime whose ids
367
+ // are not uuids must still be able to use it.
368
+ console.log(`Resume id: ${given} (passed with --resume-id)`);
369
+ return { ok: true, resumeId: given, source: "flag", evidence: "passed with --resume-id" };
370
+ }
371
+ const found = await (0, resume_1.resumeIdFromSessionEnv)(runtime, discovery);
372
+ if (found.ok) {
373
+ console.log(`Resume id: ${found.resumeId} (${found.evidence})`);
374
+ return found;
375
+ }
376
+ console.log(`No resume id: ${found.reason}.`);
377
+ console.log(" The relay will search this runtime's own session state when a message arrives while you are detached,");
378
+ console.log(" and report DELIVERY PENDING rather than resume a session it cannot identify.");
379
+ return found;
380
+ }
@@ -42,14 +42,23 @@ const queue_1 = require("./queue");
42
42
  const registry_1 = require("./registry");
43
43
  const socket_1 = require("./socket");
44
44
  const transport_1 = require("./transport");
45
+ const watermarks_1 = require("./watermarks");
46
+ /**
47
+ * How long a failed resume-id discovery is believed before it is retried.
48
+ *
49
+ * A session whose runtime left no identifying trace stays undiscoverable, so
50
+ * re-scanning every transcript on the box per message would make an
51
+ * unanswerable delivery expensive as well as pending.
52
+ */
53
+ const DISCOVERY_INTERVAL_MS = 10 * 60_000;
45
54
  class RelayDaemon {
46
55
  registry = new registry_1.SessionRegistry();
47
56
  queue;
48
57
  abort = new AbortController();
49
58
  /** Live attach sockets by session name. */
50
59
  attached = new Map();
51
- /** Per-conversation delivery watermark — the 409 catch-up baseline. */
52
- watermarks = new Map();
60
+ /** Per (session, conversation) delivery positions — the 409 catch-up baseline. */
61
+ watermarks = new watermarks_1.Watermarks();
53
62
  pending = [];
54
63
  server;
55
64
  startedAt = new Date().toISOString();
@@ -62,9 +71,15 @@ class RelayDaemon {
62
71
  transportDetail = "starting";
63
72
  /** Live session names from the last poll, for pruning and for `relay status`. */
64
73
  liveSessions = [];
74
+ /** Why discovery last refused, per session, and when — throttles the rescan. */
75
+ discoveryReasons = new Map();
76
+ discovery;
77
+ spawnHeadless;
65
78
  log;
66
79
  constructor(opts = {}) {
67
80
  this.log = opts.log ?? ((line) => console.log(`[relay] ${line}`));
81
+ this.discovery = opts.discovery ?? {};
82
+ this.spawnHeadless = opts.spawnHeadless ?? adapters_1.runHeadless;
68
83
  this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
69
84
  this.lastError = `delivery failed for ${session}: ${errText(err)}`;
70
85
  this.log(this.lastError);
@@ -119,6 +134,9 @@ class RelayDaemon {
119
134
  },
120
135
  onSessions: (names) => {
121
136
  this.liveSessions = names;
137
+ // A recovery must cover every live session, including one this relay has
138
+ // never had to route an event to — skipping it re-baselines it at "now".
139
+ this.watermarks.noteLiveSessions(names);
122
140
  const dropped = this.registry.pruneToLive(names);
123
141
  for (const name of dropped)
124
142
  this.log(`session ended server-side, dropped: ${name}`);
@@ -140,7 +158,7 @@ class RelayDaemon {
140
158
  });
141
159
  }
142
160
  /**
143
- * Route one event and record the conversation watermark.
161
+ * Route one event and record this session's position in that conversation.
144
162
  *
145
163
  * Routing is the SERVER's answer (`sessionName`), not a local guess: the
146
164
  * device poll already knows which session agent each event was queued for.
@@ -150,9 +168,10 @@ class RelayDaemon {
150
168
  */
151
169
  onEvent(event) {
152
170
  const { conversationId, sessionName, message } = event;
153
- const prior = this.watermarks.get(conversationId);
154
- if (!prior || message.createdAt > prior)
155
- this.watermarks.set(conversationId, message.createdAt);
171
+ // Per SESSION, not per conversation: a sibling in the same room that was
172
+ // behind must not have its recovery window truncated by a session that was
173
+ // ahead. An event with no session still registers the room — see Watermarks.
174
+ this.watermarks.record(sessionName, conversationId, message.createdAt);
156
175
  if (!sessionName) {
157
176
  this.log(`event ${message.id} carries no session (renamed or ended mid-poll) — ignoring`);
158
177
  return;
@@ -183,15 +202,25 @@ class RelayDaemon {
183
202
  return;
184
203
  }
185
204
  const adapter = (0, adapters_1.adapterFor)(target.runtime);
186
- const check = adapter.canResume(target);
205
+ // A target with no resume id is the unbounded failure this whole path
206
+ // exists to close: without one, `canResume` says no and the message waits
207
+ // for a human. Ask the runtime's own on-disk state who this session is
208
+ // before accepting that answer.
209
+ const resolved = await this.resolveResume(target);
210
+ const check = adapter.canResume(resolved);
187
211
  if (!check.ok) {
188
212
  // The honest outcome: it reached this box, and nothing answered it.
189
- this.record({ kind: "pending", session, reason: check.reason ?? "cannot resume" }, session, batch);
213
+ const why = this.discoveryReasons.get(session)?.reason;
214
+ const reason = why ? `${check.reason ?? "cannot resume"}; discovery: ${why}` : (check.reason ?? "cannot resume");
215
+ this.record({ kind: "pending", session, reason }, session, batch);
190
216
  return;
191
217
  }
192
218
  const prompt = (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch);
193
- const { file, args } = adapter.headlessCommand(target, prompt);
194
- const { exitCode, stderr } = await (0, adapters_1.runHeadless)(file, args, { cwd: target.cwd });
219
+ const { file, args } = adapter.headlessCommand(resolved, prompt);
220
+ // The session's own directory when we know it: `cwd` is only where the
221
+ // attach process ran, and `codex exec` refuses to start outside a trusted
222
+ // directory at all.
223
+ const { exitCode, stderr } = await this.spawnHeadless(file, args, { cwd: resolved.resumeCwd ?? resolved.cwd });
195
224
  if (exitCode !== 0) {
196
225
  // A non-zero headless turn did not necessarily reply. Recording it as
197
226
  // delivered would claim an answer we cannot evidence.
@@ -200,6 +229,43 @@ class RelayDaemon {
200
229
  }
201
230
  this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
202
231
  }
232
+ /**
233
+ * Fill in a missing resume id from the runtime's on-disk state, at most once
234
+ * every `DISCOVERY_INTERVAL_MS` per session.
235
+ *
236
+ * Throttled because a refusal is the common steady state — a session whose
237
+ * runtime left no trace will never become discoverable — and re-reading every
238
+ * transcript on the box for each arriving message would make an unanswerable
239
+ * delivery expensive as well as pending.
240
+ *
241
+ * A discovered id is persisted with its provenance, so the search happens
242
+ * once per session rather than once per wake, and `relay status` can show a
243
+ * human where the id came from.
244
+ */
245
+ async resolveResume(target) {
246
+ if (target.resumeId)
247
+ return target;
248
+ const last = this.discoveryReasons.get(target.name);
249
+ if (last && Date.now() - last.at < DISCOVERY_INTERVAL_MS)
250
+ return target;
251
+ const result = await (0, adapters_1.adapterFor)(target.runtime).discoverResume(target, this.discovery);
252
+ if (!result.ok) {
253
+ this.discoveryReasons.set(target.name, { at: Date.now(), reason: result.reason });
254
+ this.log(`no resume id for ${target.name}: ${result.reason}`);
255
+ return target;
256
+ }
257
+ this.discoveryReasons.delete(target.name);
258
+ const updated = this.registry.upsert({
259
+ name: target.name,
260
+ runtime: target.runtime,
261
+ resumeId: result.resumeId,
262
+ resumeSource: result.source,
263
+ resumeEvidence: result.evidence,
264
+ resumeCwd: result.cwd,
265
+ });
266
+ this.log(`resume id for ${target.name}: ${result.resumeId} (${result.evidence})`);
267
+ return updated;
268
+ }
203
269
  record(outcome, session, batch) {
204
270
  if (outcome.kind === "woken") {
205
271
  this.delivered += batch.length;
@@ -233,8 +299,15 @@ class RelayDaemon {
233
299
  name: frame.session,
234
300
  runtime: frame.runtime,
235
301
  resumeId: frame.resumeId,
302
+ resumeSource: frame.resumeSource,
303
+ resumeEvidence: frame.resumeEvidence,
304
+ resumeCwd: frame.resumeCwd,
236
305
  cwd: frame.cwd,
237
306
  });
307
+ // A session that names itself supersedes anything discovery guessed
308
+ // for it, so drop the throttle and let the next wake use the new id.
309
+ if (frame.resumeId)
310
+ this.discoveryReasons.delete(frame.session);
238
311
  this.attached.set(frame.session, sock);
239
312
  this.registry.setAttached(frame.session, true);
240
313
  (0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session });