baychat 0.11.2 → 0.12.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 +21 -3
- package/dist/commands.js +17 -0
- package/dist/index.js +21 -4
- package/dist/mcp.js +26 -5
- package/dist/protocol-content.js +1 -1
- package/dist/relay/adapters.js +56 -1
- package/dist/relay/commands.js +55 -2
- package/dist/relay/daemon.js +83 -10
- package/dist/relay/resume.js +615 -0
- package/dist/relay/updates.js +28 -11
- package/dist/relay/watermarks.js +113 -0
- package/dist/runtime-install.js +1 -1
- package/dist/runtimes.js +96 -2
- package/dist/tool-defs.js +23 -1
- package/package.json +1 -1
package/dist/relay/daemon.js
CHANGED
|
@@ -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
|
|
52
|
-
watermarks = new
|
|
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
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
194
|
-
|
|
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 });
|