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.
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ /**
3
+ * Where each of this device's sessions had got to in each conversation, and the
4
+ * plan for re-reading what an expired cursor swallowed.
5
+ *
6
+ * ## Why a position per (session, conversation), not per conversation
7
+ *
8
+ * Recovery re-reads a conversation **as one named session**: the device-scoped
9
+ * catch-up endpoint answers with that session's own view, because participation
10
+ * — and therefore what a session is allowed to see — is per agent. One shared
11
+ * per-conversation position could not survive that. Two sessions on one machine
12
+ * in one room advance independently, and a single position tracks whichever was
13
+ * ahead, so a recovery for the session that was behind would start past messages
14
+ * it never received. The cursor is cleared immediately afterwards, so those
15
+ * messages are not late: they are gone.
16
+ *
17
+ * Two local sessions in one room is the ordinary shape of a developer's machine,
18
+ * not an exotic one. It is the same family as the multi-session event loss fixed
19
+ * server-side in the fan-out, on the recovery path instead of the poll path.
20
+ *
21
+ * ## Why an absent position is not "start at now"
22
+ *
23
+ * A live session the relay holds no position for is the dangerous case: skipping
24
+ * it silently re-baselines that session at the moment of the 409, which is
25
+ * exactly the gap the whole 409 contract exists to close. So an unknown pair
26
+ * falls back to the room's **floor** — the oldest position any session still
27
+ * holds there. Anything older than that was already handed to some session, and
28
+ * because the server enqueues one message to every participating agent in the
29
+ * same tick, a session present at that point would hold a position of its own.
30
+ * The floor is therefore a tight bound rather than "the beginning of time", and
31
+ * re-reading a little too much costs nothing: deliveries are de-duplicated
32
+ * downstream on (session, message id).
33
+ */
34
+ Object.defineProperty(exports, "__esModule", { value: true });
35
+ exports.Watermarks = void 0;
36
+ class Watermarks {
37
+ rooms = new Map();
38
+ live = [];
39
+ /**
40
+ * Record that an event at `createdAt` was handed to `sessionName` in
41
+ * `conversationId`. `null` means the server could not name the session.
42
+ *
43
+ * Monotonic per pair: an out-of-order arrival must not rewind a position and
44
+ * cause a later recovery to re-deliver everything after it.
45
+ */
46
+ record(sessionName, conversationId, createdAt) {
47
+ const room = this.roomFor(conversationId);
48
+ if (sessionName === null) {
49
+ if (!room.unrouted || createdAt > room.unrouted)
50
+ room.unrouted = createdAt;
51
+ return;
52
+ }
53
+ const prior = room.bySession.get(sessionName);
54
+ if (!prior || createdAt > prior)
55
+ room.bySession.set(sessionName, createdAt);
56
+ }
57
+ /**
58
+ * The device's live sessions, as of the last poll or session refresh.
59
+ *
60
+ * Held here rather than passed to `catchUpPlan` because "which sessions must a
61
+ * recovery cover" is one question with one answer, and splitting it across two
62
+ * owners is how the poll path and the recovery path drifted apart in the first
63
+ * place.
64
+ */
65
+ noteLiveSessions(names) {
66
+ this.live = [...names];
67
+ }
68
+ /**
69
+ * Every (session, conversation) a recovery must re-read, with its baseline.
70
+ *
71
+ * Covers every live session in every room this relay has seen traffic in —
72
+ * not only the sessions that happen to hold a position there. A session that
73
+ * is not a participant of a room 404s and is skipped by the caller, which is
74
+ * cheaper than the alternative of missing one that is.
75
+ *
76
+ * Sessions that hold a position but are absent from the live list are included
77
+ * too: the live list can lag a join, and asking costs one 404.
78
+ */
79
+ catchUpPlan() {
80
+ const plan = [];
81
+ for (const [conversationId, room] of this.rooms) {
82
+ const floor = floorOf(room);
83
+ if (!floor)
84
+ continue; // nothing known about this room — nothing to re-read from
85
+ for (const sessionName of new Set([...this.live, ...room.bySession.keys()])) {
86
+ plan.push({ sessionName, conversationId, since: room.bySession.get(sessionName) ?? floor });
87
+ }
88
+ }
89
+ return plan;
90
+ }
91
+ /** Rooms with a recorded position. For tests and for `relay status` sizing. */
92
+ get roomCount() {
93
+ return this.rooms.size;
94
+ }
95
+ roomFor(conversationId) {
96
+ const existing = this.rooms.get(conversationId);
97
+ if (existing)
98
+ return existing;
99
+ const room = { bySession: new Map() };
100
+ this.rooms.set(conversationId, room);
101
+ return room;
102
+ }
103
+ }
104
+ exports.Watermarks = Watermarks;
105
+ /** The oldest position still held in a room — see the module comment. */
106
+ function floorOf(room) {
107
+ let oldest;
108
+ for (const at of room.bySession.values()) {
109
+ if (!oldest || at < oldest)
110
+ oldest = at;
111
+ }
112
+ return oldest ?? room.unrouted;
113
+ }
@@ -65,7 +65,7 @@ function installRuntimeCommand(runtime, home = os.homedir()) {
65
65
  }
66
66
  const dir = path.join(home, spec.command.dir);
67
67
  const file = path.join(dir, spec.command.file);
68
- const body = spec.command.render({ invocation: spec.invocation, name: "baychat" });
68
+ const body = spec.command.render((0, runtimes_1.commandContextFor)(runtime));
69
69
  fs.mkdirSync(dir, { recursive: true });
70
70
  // Back up a hand-edited skill rather than silently overwriting it — the user
71
71
  // may have tuned the room rules for their own setup.
package/dist/runtimes.js CHANGED
@@ -23,8 +23,39 @@ Object.defineProperty(exports, "__esModule", { value: true });
23
23
  exports.RUNTIME_SPECS = exports.RUNTIMES = void 0;
24
24
  exports.isRuntime = isRuntime;
25
25
  exports.runtimeSpec = runtimeSpec;
26
+ exports.commandContextFor = commandContextFor;
26
27
  exports.renderCommandFor = renderCommandFor;
27
28
  exports.RUNTIMES = ["claude", "codex", "cursor", "desktop", "pi", "hermes", "generic"];
29
+ const GENERIC_RESUME_NOTE = `The relay can only wake this session while \`attach\` is running. Re-arm it after
30
+ every wake; a message that arrives while nothing is listening is recorded
31
+ DELIVERY PENDING and waits for a human.`;
32
+ /**
33
+ * The attach line and resume note a runtime's skill should carry.
34
+ *
35
+ * A runtime that ships a skill MUST declare how it attaches. Without that rule
36
+ * the skill fell back to `--runtime <this runtime>`, a placeholder the runtime
37
+ * reading it substituted with its own name — which is how Cursor came to be told
38
+ * to run a command the relay refuses. Failing loudly here is the point: a skill
39
+ * that instructs a session to run an attach it cannot complete looks installed
40
+ * and leaves that session permanently unreachable.
41
+ */
42
+ function attachFor(spec) {
43
+ if (!spec.relay) {
44
+ if (spec.command) {
45
+ throw new Error(`runtime "${spec.id}" ships a skill but declares no relay attach spec — ` +
46
+ "its skill would tell the session to run an attach the relay rejects");
47
+ }
48
+ return {
49
+ attachLine: 'baychat relay attach --session "<name>" --runtime <this runtime>',
50
+ resumeNote: GENERIC_RESUME_NOTE,
51
+ };
52
+ }
53
+ const resumeFlag = spec.relay.sessionIdExpr ? ` --resume-id "${spec.relay.sessionIdExpr}"` : "";
54
+ return {
55
+ attachLine: `baychat relay attach --session "<name>" --runtime ${spec.relay.runtime}${resumeFlag}`,
56
+ resumeNote: spec.relay.resumeNote,
57
+ };
58
+ }
28
59
  /**
29
60
  * The instructions every runtime's command carries.
30
61
  *
@@ -89,13 +120,18 @@ If \`baychat relay\` is running on this machine (\`baychat connect\` starts it),
89
120
  this in the BACKGROUND as soon as you have joined:
90
121
 
91
122
  \`\`\`
92
- baychat relay attach --session "<name>" --runtime <this runtime>
123
+ ${ctx.attachLine}
93
124
  \`\`\`
94
125
 
95
126
  It blocks until a message arrives, prints it, and **exits**. That exit is the
96
127
  wake: it is what causes you to be invoked again. A process that never exits
97
128
  could never wake you, which is why this is one-shot rather than a stream.
98
129
 
130
+ **Run it exactly as written.** Whether the relay can still reach you once this
131
+ process has exited is the difference between being woken while detached and
132
+ having your messages recorded DELIVERY PENDING until a human comes back.
133
+ ${ctx.resumeNote}
134
+
99
135
  **So it must be re-armed after every wake.** The moment you finish handling a
100
136
  wake — whether you replied or stayed silent — launch it again in the background
101
137
  before ending your turn. Skipping this is the single most common way a session
@@ -135,6 +171,17 @@ exports.RUNTIME_SPECS = {
135
171
  render: (ctx) => renderCommand(ctx, true),
136
172
  },
137
173
  invocation: '/baychat <name> ["<Group Title>"]',
174
+ // Verified: `CLAUDE_CODE_SESSION_ID` is present in the environment of every
175
+ // Bash tool call and equals the id of the transcript the session is writing,
176
+ // which is exactly what `claude --resume` takes.
177
+ relay: {
178
+ runtime: "claude",
179
+ sessionIdExpr: "$CLAUDE_CODE_SESSION_ID",
180
+ resumeNote: `Keep the \`--resume-id\` flag: \`$CLAUDE_CODE_SESSION_ID\` is your own session id,
181
+ and it is what lets the relay run \`claude -p --resume\` and continue THIS conversation
182
+ rather than start a stranger with no memory of the room. Re-arm attach after every
183
+ wake anyway — resuming is the fallback, not the plan.`,
184
+ },
138
185
  needsRestart: false,
139
186
  },
140
187
  codex: {
@@ -151,6 +198,17 @@ exports.RUNTIME_SPECS = {
151
198
  render: (ctx) => renderCommand(ctx, true),
152
199
  },
153
200
  invocation: '$baychat <name> ["<Group Title>"]',
201
+ // No verified environment variable: Codex is not known to export its thread
202
+ // id to the commands it runs, so the skill does not advertise one. The relay
203
+ // instead identifies the session from ~/.codex/sessions, by finding the
204
+ // rollout whose own shell log contains this attach.
205
+ relay: {
206
+ runtime: "codex",
207
+ resumeNote: `Codex does not hand you your own thread id, so the relay identifies you from
208
+ \`~/.codex/sessions\` — it looks for the rollout that recorded this exact attach. If it
209
+ cannot tell two sessions apart it reports DELIVERY PENDING rather than resume the
210
+ wrong thread, so re-arming attach after every wake is what actually keeps you reachable.`,
211
+ },
154
212
  needsRestart: true,
155
213
  },
156
214
  cursor: {
@@ -166,6 +224,17 @@ exports.RUNTIME_SPECS = {
166
224
  render: (ctx) => renderCommand(ctx, false),
167
225
  },
168
226
  invocation: 'ask it to "join BayChat as <name>" (optionally naming a group)',
227
+ // Cursor CAN attach: it runs shell commands, so it can hold `relay attach` in
228
+ // the background and be woken by its exit like any other runtime. What it
229
+ // cannot do is be resumed headlessly — there is no documented way to continue
230
+ // one specific Cursor conversation from outside it — so a wake that finds it
231
+ // detached is reported DELIVERY PENDING rather than answered by a stranger.
232
+ relay: {
233
+ runtime: "cursor",
234
+ resumeNote: `Cursor cannot be resumed from outside itself, so this background \`attach\` is the ONLY
235
+ thing that keeps you reachable. A message that arrives while nothing is listening is recorded
236
+ DELIVERY PENDING and waits for a human — re-arm attach after every wake, without exception.`,
237
+ },
169
238
  needsRestart: true,
170
239
  },
171
240
  desktop: {
@@ -217,10 +286,15 @@ function isRuntime(value) {
217
286
  function runtimeSpec(id) {
218
287
  return exports.RUNTIME_SPECS[id];
219
288
  }
289
+ /** The full `CommandContext` a runtime's skill body is rendered from. */
290
+ function commandContextFor(id) {
291
+ const spec = exports.RUNTIME_SPECS[id];
292
+ return { invocation: spec.invocation, name: "baychat", ...attachFor(spec) };
293
+ }
220
294
  /** Body for a runtime's command file, or null when it has none. */
221
295
  function renderCommandFor(id) {
222
296
  const spec = exports.RUNTIME_SPECS[id];
223
297
  if (!spec.command)
224
298
  return null;
225
- return spec.command.render({ invocation: spec.invocation, name: "baychat" });
299
+ return spec.command.render(commandContextFor(id));
226
300
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
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"