baychat 0.23.1 → 0.25.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 +120 -15
- package/dist/api.js +19 -2
- package/dist/claude-context-flush.js +219 -0
- package/dist/claude-context-state.js +191 -0
- package/dist/claude-context.js +311 -0
- package/dist/claude-settings.js +503 -0
- package/dist/codex-onboarding.js +33 -23
- package/dist/context-hooks-command.js +165 -0
- package/dist/context-report.js +307 -0
- package/dist/index.js +26 -497
- package/dist/main.js +511 -0
- package/dist/mcp-notes.js +110 -0
- package/dist/mcp.js +84 -5
- package/dist/protocol-content.js +1 -1
- package/dist/relay/acp/client.js +18 -0
- package/dist/relay/acp/prompt.js +54 -3
- package/dist/relay/acp/runner.js +100 -2
- package/dist/relay/acp/turn-queue.js +99 -3
- package/dist/relay/codex-context.js +242 -0
- package/dist/relay/compact-command.js +63 -0
- package/dist/relay/compact-focus.attacks.js +36 -0
- package/dist/relay/compact-focus.js +64 -0
- package/dist/relay/context-notice.js +144 -0
- package/dist/relay/daemon.js +429 -73
- package/dist/relay/fresh-start-verify.js +237 -0
- package/dist/relay/fresh-start.js +141 -0
- package/dist/relay/message-format.js +34 -15
- package/dist/relay/project-brief.js +195 -0
- package/dist/relay/project-notes.js +300 -0
- package/dist/relay/resume.js +25 -0
- package/dist/relay/server-registration.js +40 -0
- package/dist/relay/session-commands.js +2 -0
- package/dist/relay/terminal-text.js +13 -0
- package/dist/relay/transport.js +2 -1
- package/dist/relay/updates.js +46 -8
- package/dist/relay/ws.js +49 -6
- package/dist/runtimes.js +24 -6
- package/dist/tool-defs.js +93 -0
- package/package.json +1 -1
package/dist/relay/daemon.js
CHANGED
|
@@ -33,15 +33,23 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.RelayDaemon = void 0;
|
|
36
|
+
exports.FRESH_START_SETTLE_MS = exports.RelayDaemon = void 0;
|
|
37
37
|
exports.splitSessionCommands = splitSessionCommands;
|
|
38
38
|
const child_process_1 = require("child_process");
|
|
39
39
|
const fs = __importStar(require("fs"));
|
|
40
40
|
const net = __importStar(require("net"));
|
|
41
41
|
const path = __importStar(require("path"));
|
|
42
42
|
const config_1 = require("../config");
|
|
43
|
+
const context_report_1 = require("../context-report");
|
|
44
|
+
const codex_context_1 = require("./codex-context");
|
|
43
45
|
const session_commands_1 = require("./session-commands");
|
|
44
46
|
const terminal_pane_1 = require("./terminal-pane");
|
|
47
|
+
const compact_command_1 = require("./compact-command");
|
|
48
|
+
const fresh_start_1 = require("./fresh-start");
|
|
49
|
+
const project_brief_1 = require("./project-brief");
|
|
50
|
+
const project_notes_1 = require("./project-notes");
|
|
51
|
+
const context_notice_1 = require("./context-notice");
|
|
52
|
+
const fresh_start_verify_1 = require("./fresh-start-verify");
|
|
45
53
|
const commands_1 = require("./acp/commands");
|
|
46
54
|
const daemon_glue_1 = require("./acp/daemon-glue");
|
|
47
55
|
const runner_1 = require("./acp/runner");
|
|
@@ -54,6 +62,7 @@ const queue_1 = require("./queue");
|
|
|
54
62
|
const held_1 = require("./held");
|
|
55
63
|
const provider_env_1 = require("./provider-env");
|
|
56
64
|
const registry_1 = require("./registry");
|
|
65
|
+
const resume_1 = require("./resume");
|
|
57
66
|
const mailbox_1 = require("./mailbox");
|
|
58
67
|
const mailbox_watcher_1 = require("./mailbox-watcher");
|
|
59
68
|
const socket_1 = require("./socket");
|
|
@@ -72,6 +81,19 @@ class RelayDaemon {
|
|
|
72
81
|
acpQueue = new turn_queue_1.AcpTurnQueue();
|
|
73
82
|
acp;
|
|
74
83
|
acpAlive;
|
|
84
|
+
/** How full each session's context is, as its runtime reports it (contract §7). */
|
|
85
|
+
context;
|
|
86
|
+
/** Reads each Codex session's own rollout for its context numbers. */
|
|
87
|
+
codexContext;
|
|
88
|
+
/** The room each session was last woken in — where its context numbers belong. */
|
|
89
|
+
lastRoom = new Map();
|
|
90
|
+
/** Project briefs whose own message was a session command, waiting for the next chat message. */
|
|
91
|
+
carriedBriefs = new project_brief_1.CarriedBriefs();
|
|
92
|
+
carriedNotes = new project_notes_1.CarriedNotes();
|
|
93
|
+
carriedNotices = new context_notice_1.CarriedNotices();
|
|
94
|
+
/** Fresh-start steps already handled, on disk: a replayed step never runs twice. */
|
|
95
|
+
handledHandoffs = new fresh_start_verify_1.HandledHandoffs();
|
|
96
|
+
freshStartOpts;
|
|
75
97
|
acpKeepAlive;
|
|
76
98
|
queue;
|
|
77
99
|
abort = new AbortController();
|
|
@@ -137,6 +159,10 @@ class RelayDaemon {
|
|
|
137
159
|
* reporting nothing at all. See `held.ts`.
|
|
138
160
|
*/
|
|
139
161
|
held = new held_1.HeldStore();
|
|
162
|
+
/** Server-fed registrations being applied right now, by session. See `deliver`. */
|
|
163
|
+
serverRegistrations = new Map();
|
|
164
|
+
/** Registrations whose thread id did not corroborate, by session + registeredAt → when. */
|
|
165
|
+
refusedRegistrations = new Map();
|
|
140
166
|
/** Beyond this a session is not "busy", it is broken; stop growing and report. */
|
|
141
167
|
static MAX_HELD = 200;
|
|
142
168
|
reattachGraceMs;
|
|
@@ -201,10 +227,22 @@ class RelayDaemon {
|
|
|
201
227
|
opts.resolveBinary ??
|
|
202
228
|
((name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, (0, runtime_binary_1.currentBinaryEnv)()));
|
|
203
229
|
this.runTurn = opts.runTurn;
|
|
230
|
+
this.freshStartOpts = opts.freshStart ?? {};
|
|
204
231
|
this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
|
|
205
232
|
this.lastError = `delivery failed for ${session}: ${errText(err)}`;
|
|
206
233
|
this.log(this.lastError);
|
|
207
234
|
});
|
|
235
|
+
this.context = new context_report_1.ContextReporter({ auth: () => this.auth, log: (line) => this.log(line) });
|
|
236
|
+
this.codexContext = new codex_context_1.CodexContextTailer({
|
|
237
|
+
// Codex sessions this relay can name a thread for, and that the server still lists as live.
|
|
238
|
+
targets: () => this.registry
|
|
239
|
+
.all()
|
|
240
|
+
.filter((t) => t.runtime === "codex" && t.resumeId && (t.attached || this.liveSessions.includes(t.name)))
|
|
241
|
+
.map((t) => ({ session: t.name, threadId: t.resumeId })),
|
|
242
|
+
report: (session, event) => this.reportContext(session, event),
|
|
243
|
+
conversationFor: (session) => this.lastRoom.get(session),
|
|
244
|
+
log: (line) => this.log(line),
|
|
245
|
+
});
|
|
208
246
|
this.acpAlive = (0, daemon_glue_1.acpKeepAliveScheduler)(() => (0, daemon_glue_1.keepAcpSessionsAlive)({ registry: this.registry, auth: this.auth, caller: opts.acpCaller, log: this.log }));
|
|
209
247
|
this.acp = new runner_1.AcpSessionRunner({
|
|
210
248
|
registry: this.registry,
|
|
@@ -220,6 +258,7 @@ class RelayDaemon {
|
|
|
220
258
|
onPending: (session, batch, reason) => this.record({ kind: "pending", session, reason }, session, batch),
|
|
221
259
|
runTurn: opts.acpRunTurn,
|
|
222
260
|
caller: opts.acpCaller,
|
|
261
|
+
reportContext: (session, event) => this.reportContext(session, event),
|
|
223
262
|
});
|
|
224
263
|
}
|
|
225
264
|
async start() {
|
|
@@ -245,6 +284,14 @@ class RelayDaemon {
|
|
|
245
284
|
// handed what the previous one was holding rather than a clean slate.
|
|
246
285
|
this.held.load();
|
|
247
286
|
this.acpQueue.load();
|
|
287
|
+
// A clear that was being typed when the previous relay stopped: the session may well have been
|
|
288
|
+
// cleared, so it is reported cleared-but-not-pointed (the server re-arms it), never left to
|
|
289
|
+
// time out as "not cleared".
|
|
290
|
+
for (const { handoffId, session } of this.handledHandoffs.interrupted()) {
|
|
291
|
+
this.log(`fresh start ${handoffId} for ${session} was interrupted mid-clear — reporting it cleared`);
|
|
292
|
+
this.handledHandoffs.mark(handoffId, "clear", session, "done");
|
|
293
|
+
void (0, fresh_start_1.postHandoffReport)(auth, session, { handoffId, status: "fresh", reason: "FIRST_PROMPT_FAILED" }, (line) => this.log(line));
|
|
294
|
+
}
|
|
248
295
|
const sockPath = (0, socket_1.socketPath)();
|
|
249
296
|
if (await (0, socket_1.probeSocket)(sockPath)) {
|
|
250
297
|
throw new Error(`a relay is already listening on ${sockPath} — run \`baychat relay status\``);
|
|
@@ -272,6 +319,7 @@ class RelayDaemon {
|
|
|
272
319
|
writePidFile();
|
|
273
320
|
// After the socket is ours: a relay refused as a duplicate must not keep sessions alive.
|
|
274
321
|
this.acpKeepAlive = (0, daemon_glue_1.startAcpUpkeep)(this.acpAlive.run);
|
|
322
|
+
this.codexContext.start();
|
|
275
323
|
this.log(`listening on ${sockPath} (pid ${process.pid}) as device "${device.user.name}"`);
|
|
276
324
|
this.log(`${this.registry.all().length} known session target(s)`);
|
|
277
325
|
// The event SOURCE is pluggable (socket preferred, long-poll floor); every
|
|
@@ -284,15 +332,20 @@ class RelayDaemon {
|
|
|
284
332
|
for (const e of events)
|
|
285
333
|
this.onEvent(e);
|
|
286
334
|
},
|
|
287
|
-
onSessions: (names) => {
|
|
335
|
+
onSessions: (names, registrations) => {
|
|
288
336
|
this.liveSessions = names;
|
|
289
337
|
// A recovery must cover every live session, including one this relay has
|
|
290
338
|
// never had to route an event to — skipping it re-baselines it at "now".
|
|
291
339
|
this.watermarks.noteLiveSessions(names);
|
|
292
340
|
const dropped = this.registry.pruneToLive(names);
|
|
293
|
-
for (const name of dropped)
|
|
341
|
+
for (const name of dropped) {
|
|
294
342
|
this.log(`session ended server-side, dropped: ${name}`);
|
|
343
|
+
// A name reused later is a new session: nothing kept for the old one may reach it.
|
|
344
|
+
this.forgetCarried(name);
|
|
345
|
+
}
|
|
295
346
|
void this.acpAlive.lapsed(this.registry, names);
|
|
347
|
+
if (registrations)
|
|
348
|
+
this.applyServerRegistrations(registrations, names);
|
|
296
349
|
},
|
|
297
350
|
onPoll: (cursor) => {
|
|
298
351
|
this.cursor = cursor;
|
|
@@ -381,7 +434,7 @@ class RelayDaemon {
|
|
|
381
434
|
* apart from silence. A refusal that names its reason is the whole difference between
|
|
382
435
|
* "this feature is broken" and "start your agent inside tmux".
|
|
383
436
|
*/
|
|
384
|
-
async runSessionCommand(session, message, command) {
|
|
437
|
+
async runSessionCommand(session, message, command, compact = {}) {
|
|
385
438
|
const say = async (text) => {
|
|
386
439
|
if (!this.auth)
|
|
387
440
|
return;
|
|
@@ -404,6 +457,13 @@ class RelayDaemon {
|
|
|
404
457
|
await say((0, session_commands_1.sessionCommandHelp)());
|
|
405
458
|
return;
|
|
406
459
|
}
|
|
460
|
+
if (command.id === "compact") {
|
|
461
|
+
if (compact.refusal)
|
|
462
|
+
return say(compact.refusal);
|
|
463
|
+
if (target.acp) {
|
|
464
|
+
return say(`"${session}" is run by the relay and tidies its own memory; /compact cannot reach it. /new starts it fresh.`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
407
467
|
// `/mode-*`, `/stop`, and what `/status` `/exit` `/clear` `/new` mean with no terminal.
|
|
408
468
|
if (await (0, commands_1.runAcpSessionCommand)(this.acp, session, target, message, command, say))
|
|
409
469
|
return;
|
|
@@ -441,7 +501,13 @@ class RelayDaemon {
|
|
|
441
501
|
await say((0, session_commands_1.noTerminalReason)());
|
|
442
502
|
return;
|
|
443
503
|
}
|
|
444
|
-
|
|
504
|
+
let keys = command.keys?.(target.runtime);
|
|
505
|
+
if (command.id === "compact") {
|
|
506
|
+
const typed = (0, compact_command_1.compactKeys)(target.runtime, compact.focus);
|
|
507
|
+
if (!typed.ok)
|
|
508
|
+
return say(typed.reason);
|
|
509
|
+
keys = typed.keys;
|
|
510
|
+
}
|
|
445
511
|
if (!keys) {
|
|
446
512
|
await say(`${command.trigger} has nothing to type for a ${target.runtime} session.`);
|
|
447
513
|
return;
|
|
@@ -460,8 +526,158 @@ class RelayDaemon {
|
|
|
460
526
|
// See `SessionRegistry.clearResume`: the live session's transcript is gone, so the
|
|
461
527
|
// recorded resume id now points at something no half of this session has.
|
|
462
528
|
this.registry.clearResume(session);
|
|
529
|
+
// …and whatever was kept to show it (briefs, notes deltas, the notice) described that memory.
|
|
530
|
+
this.forgetCarried(session);
|
|
531
|
+
}
|
|
532
|
+
await say(command.id === "compact"
|
|
533
|
+
? `Typed ${keys} into "${session}". It is tidying its context now.`
|
|
534
|
+
: `Typed ${keys} into "${session}". Its context is cleared.`);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* One step of "Fresh start with a handoff" (fresh-start.ts).
|
|
538
|
+
*
|
|
539
|
+
* `note`: if this relay could clear the session afterwards, the request joins `chat` and wakes
|
|
540
|
+
* the session like any message; if it could not, the server hears why at once and the session
|
|
541
|
+
* is never asked. `clear` (sent only once the note is saved): wait until the session is idle,
|
|
542
|
+
* clear it the way `/clear` and `/new` do, point it at its note, see where that first prompt
|
|
543
|
+
* landed, and report (fresh-start-verify.ts). A session this machine does not know is left to
|
|
544
|
+
* the relay that does; a step already handled — a re-served event, a replay after a restart —
|
|
545
|
+
* is never run twice; a clear past its `expiresAt` is not started.
|
|
546
|
+
*/
|
|
547
|
+
async runFreshStart(session, message, fresh, chat) {
|
|
548
|
+
const target = this.registry.get(session);
|
|
549
|
+
if (!target) {
|
|
550
|
+
this.log(`fresh start ${fresh.handoffId} for ${session}: not a session on this machine — left to its own relay`);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (this.handledHandoffs.has(fresh.handoffId, fresh.phase)) {
|
|
554
|
+
this.log(`fresh start ${fresh.handoffId} for ${session}: the ${fresh.phase} step was already handled — ignored`);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const done = () => this.handledHandoffs.mark(fresh.handoffId, fresh.phase, session, "done");
|
|
558
|
+
const report = (outcome) => this.auth ? (0, fresh_start_1.postHandoffReport)(this.auth, session, outcome, (line) => this.log(line)) : Promise.resolve();
|
|
559
|
+
const plan = (0, fresh_start_1.freshStartPlan)(target);
|
|
560
|
+
if (plan.kind === "refuse") {
|
|
561
|
+
this.log(`fresh start ${fresh.handoffId} for ${session} refused: ${plan.reason}`);
|
|
562
|
+
done();
|
|
563
|
+
await report({ handoffId: fresh.handoffId, status: "failed", reason: plan.reason });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (fresh.phase === "note") {
|
|
567
|
+
done();
|
|
568
|
+
chat.push((0, fresh_start_1.noteRequestAsChat)(message, fresh.handoffId));
|
|
569
|
+
return;
|
|
463
570
|
}
|
|
464
|
-
|
|
571
|
+
if (fresh.expiresAt !== undefined && Date.now() > fresh.expiresAt) {
|
|
572
|
+
// The server has already given up on this step (CLEAR_TIMEOUT); clearing now would clear a
|
|
573
|
+
// session nobody is watching any more. Nothing is typed.
|
|
574
|
+
this.log(`fresh start ${fresh.handoffId} for ${session}: the clear step expired before it could start — not cleared`);
|
|
575
|
+
done();
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (plan.kind === "acp") {
|
|
579
|
+
// No terminal: forget the ACP session and make the note its next prompt, alone; a turn
|
|
580
|
+
// running now is stopped and its messages wait behind the note (acp/runner.ts).
|
|
581
|
+
done();
|
|
582
|
+
this.forgetCarried(session);
|
|
583
|
+
this.acp.freshStart(session, (0, fresh_start_1.freshStartMessage)(message, fresh), fresh.handoffId);
|
|
584
|
+
this.log(`fresh start ${fresh.handoffId}: ${session} will start a new ACP session from its note`);
|
|
585
|
+
await report({ handoffId: fresh.handoffId, status: "fresh" });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const pane = target.pane;
|
|
589
|
+
const opts = this.freshStartOpts;
|
|
590
|
+
// 1. Not in the middle of a turn: typed then, the clear is queued or refused and the pointer
|
|
591
|
+
// lands in the old conversation.
|
|
592
|
+
const idle = await (0, fresh_start_verify_1.waitForIdle)({
|
|
593
|
+
sample: () => (opts.samplePane ?? fresh_start_verify_1.samplePane)(pane),
|
|
594
|
+
attached: () => {
|
|
595
|
+
const sock = this.attached.get(session);
|
|
596
|
+
return !!sock && !sock.destroyed;
|
|
597
|
+
},
|
|
598
|
+
capMs: opts.idleCapMs,
|
|
599
|
+
stepMs: opts.idleStepMs,
|
|
600
|
+
});
|
|
601
|
+
if (idle === "busy") {
|
|
602
|
+
this.log(`fresh start ${fresh.handoffId}: ${session} stayed busy — nothing typed; its note is kept`);
|
|
603
|
+
done();
|
|
604
|
+
await report({ handoffId: fresh.handoffId, status: "failed", reason: "CLEAR_FAILED" });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
// 2. Clear. Recorded as started first: a relay that dies from here on reports it on restart.
|
|
608
|
+
this.handledHandoffs.mark(fresh.handoffId, "clear", session, "started");
|
|
609
|
+
const typedAt = Date.now();
|
|
610
|
+
const cleared = await (0, terminal_pane_1.typeIntoPane)(pane, target.runtime, plan.clearKeys, { ownerPid: target.ownerPid });
|
|
611
|
+
if (!cleared.ok) {
|
|
612
|
+
this.log(`fresh start ${fresh.handoffId}: could not clear ${session} — ${cleared.reason}`);
|
|
613
|
+
done();
|
|
614
|
+
await report({ handoffId: fresh.handoffId, status: "failed", reason: "CLEAR_FAILED" });
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const oldResumeId = target.resumeId;
|
|
618
|
+
// Same as `/clear`: the recorded resume id names a transcript this session no longer has, and
|
|
619
|
+
// anything kept to show it described the old memory.
|
|
620
|
+
this.registry.clearResume(session);
|
|
621
|
+
this.forgetCarried(session);
|
|
622
|
+
// Let the runtime's TUI finish clearing before the first prompt is typed into it.
|
|
623
|
+
await new Promise((resolve) => setTimeout(resolve, opts.settleMs ?? exports.FRESH_START_SETTLE_MS));
|
|
624
|
+
const typed = await (0, terminal_pane_1.typeIntoPane)(pane, target.runtime, (0, fresh_start_1.freshStartLine)(fresh.note.id, session), { ownerPid: target.ownerPid });
|
|
625
|
+
if (!typed.ok) {
|
|
626
|
+
// Cleared, but not pointed at its note: still fresh (the server must re-arm it), said so.
|
|
627
|
+
this.log(`fresh start ${fresh.handoffId}: ${session} was cleared but its first prompt was not typed — ${typed.reason}`);
|
|
628
|
+
done();
|
|
629
|
+
await report({ handoffId: fresh.handoffId, status: "fresh", reason: "FIRST_PROMPT_FAILED" });
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
// 3. Where did the first prompt land? Only the pointer line names this note.
|
|
633
|
+
const runtime = target.runtime === "codex" ? "codex" : "claude";
|
|
634
|
+
const landing = await (opts.awaitPointer ?? fresh_start_verify_1.awaitPointer)({
|
|
635
|
+
runtime,
|
|
636
|
+
noteId: fresh.note.id,
|
|
637
|
+
sinceMs: typedAt - 1_000,
|
|
638
|
+
...(oldResumeId ? { oldResumeId } : {}),
|
|
639
|
+
discovery: this.discovery,
|
|
640
|
+
timeoutMs: opts.pointerTimeoutMs,
|
|
641
|
+
});
|
|
642
|
+
done();
|
|
643
|
+
if (landing?.kind === "old") {
|
|
644
|
+
this.log(`fresh start ${fresh.handoffId}: ${plan.clearKeys} did not take in ${session} — its first prompt went to the old conversation`);
|
|
645
|
+
if (oldResumeId)
|
|
646
|
+
this.registry.upsert({ name: session, runtime: target.runtime, resumeId: oldResumeId });
|
|
647
|
+
await report({ handoffId: fresh.handoffId, status: "failed", reason: "CLEAR_FAILED" });
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (landing?.kind === "new") {
|
|
651
|
+
this.registry.upsert({
|
|
652
|
+
name: session,
|
|
653
|
+
runtime: target.runtime,
|
|
654
|
+
resumeId: landing.id,
|
|
655
|
+
resumeSource: "transcript",
|
|
656
|
+
resumeEvidence: `the fresh session BayChat pointed at note ${fresh.note.id}`,
|
|
657
|
+
});
|
|
658
|
+
this.log(`fresh start ${fresh.handoffId}: ${session} started fresh (${landing.id}) and was pointed at its note`);
|
|
659
|
+
await report({ handoffId: fresh.handoffId, status: "fresh" });
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
this.log(`fresh start ${fresh.handoffId}: typed ${plan.clearKeys} into ${session}, but could not see the fresh session start`);
|
|
663
|
+
await report({ handoffId: fresh.handoffId, status: "fresh", reason: "UNVERIFIED" });
|
|
664
|
+
}
|
|
665
|
+
/** The session's own context report — and what a new context means for what the relay keeps. */
|
|
666
|
+
reportContext(session, event) {
|
|
667
|
+
if (event.event === "session_start")
|
|
668
|
+
this.forgetCarried(session);
|
|
669
|
+
this.context.report(session, event);
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Forget the briefs, notes deltas and notice kept for this session: it started fresh (a fresh
|
|
673
|
+
* start, `/clear`, `/new`, a new thread) or ended, so they describe a memory it no longer has —
|
|
674
|
+
* and a reused name must not inherit them. The server re-sends the whole set after a re-arm.
|
|
675
|
+
*/
|
|
676
|
+
forgetCarried(session) {
|
|
677
|
+
this.carriedBriefs.forget(session);
|
|
678
|
+
this.carriedNotes.forget(session);
|
|
679
|
+
this.carriedNotices.forget(session);
|
|
680
|
+
this.acpQueue.forgetCarried(session);
|
|
465
681
|
}
|
|
466
682
|
/**
|
|
467
683
|
* Wait for a session to re-arm its socket, up to `graceMs`.
|
|
@@ -494,13 +710,52 @@ class RelayDaemon {
|
|
|
494
710
|
// obey something its HARNESS owns — `/clear` is read by the runtime's TUI, and no model
|
|
495
711
|
// can run it for itself. So the commands are executed here and everything else wakes
|
|
496
712
|
// the session exactly as it always did.
|
|
497
|
-
const { commands, chat } = splitSessionCommands(batch);
|
|
498
|
-
for (const
|
|
499
|
-
|
|
713
|
+
const { commands, chat, dropped } = splitSessionCommands(batch);
|
|
714
|
+
for (const message of dropped) {
|
|
715
|
+
this.log(`session command ${message.id} for ${session} dropped: its sender may no longer command this session`);
|
|
716
|
+
}
|
|
717
|
+
// What rode on a COMMAND — the project brief, the notes delta, BayChat's notice — never reaches
|
|
718
|
+
// the session with it (the command is typed, not read), and the server will not send it again;
|
|
719
|
+
// a WITHDRAWN command's too (the server recorded it as handed whether or not the message is
|
|
720
|
+
// shown). It moves FORWARD to the next chat message for that room — in this batch, or on disk
|
|
721
|
+
// until the next one (project-brief.ts, project-notes.ts, context-notice.ts). What was kept
|
|
722
|
+
// from earlier batches is applied AFTER, underneath: it is older than anything here.
|
|
723
|
+
const homeless = [];
|
|
724
|
+
for (const message of [...commands.map((c) => c.message), ...dropped]) {
|
|
725
|
+
const lost = { message };
|
|
726
|
+
if (message.projectBrief && !(0, project_brief_1.carryBrief)(message.projectBrief, message.conversationId, chat))
|
|
727
|
+
lost.brief = message.projectBrief;
|
|
728
|
+
if (message.projectNotes && !(0, project_notes_1.carryNotes)(message.projectNotes, message, chat))
|
|
729
|
+
lost.notes = message.projectNotes;
|
|
730
|
+
if (message.notice && !(0, context_notice_1.carryNotice)(message.notice, chat))
|
|
731
|
+
lost.notice = message.notice;
|
|
732
|
+
if (lost.brief || lost.notes || lost.notice)
|
|
733
|
+
homeless.push(lost);
|
|
734
|
+
}
|
|
735
|
+
this.carriedBriefs.apply(session, chat);
|
|
736
|
+
this.carriedNotes.apply(session, chat);
|
|
737
|
+
this.carriedNotices.apply(session, chat);
|
|
738
|
+
for (const { message, brief, notes, notice } of homeless) {
|
|
739
|
+
if (brief)
|
|
740
|
+
this.carriedBriefs.keep(session, message.conversationId, brief);
|
|
741
|
+
if (notes)
|
|
742
|
+
this.carriedNotes.keep(session, message.conversationId, notes);
|
|
743
|
+
if (notice)
|
|
744
|
+
this.carriedNotices.keep(session, notice);
|
|
745
|
+
}
|
|
746
|
+
for (const { message, command, focus, refusal, fresh } of commands) {
|
|
747
|
+
if (fresh)
|
|
748
|
+
await this.runFreshStart(session, message, fresh, chat);
|
|
749
|
+
else
|
|
750
|
+
await this.runSessionCommand(session, message, command, { focus, refusal });
|
|
500
751
|
}
|
|
501
752
|
if (chat.length === 0)
|
|
502
753
|
return;
|
|
503
754
|
batch = chat;
|
|
755
|
+
// A registration from the server feed may still be corroborating: the roster that
|
|
756
|
+
// carried it always precedes the session's first event, but checking a thread id reads
|
|
757
|
+
// the disk. Wait for it rather than drop a message for a session we are registering.
|
|
758
|
+
await this.serverRegistrations.get(session);
|
|
504
759
|
const target = this.registry.get(session);
|
|
505
760
|
if (!target) {
|
|
506
761
|
// NOT reported as pending, though an earlier version of this did.
|
|
@@ -1004,6 +1259,9 @@ class RelayDaemon {
|
|
|
1004
1259
|
record(outcome, session, batch) {
|
|
1005
1260
|
if (outcome.kind === "woken") {
|
|
1006
1261
|
this.delivered += batch.length;
|
|
1262
|
+
const room = batch.at(-1)?.conversationId;
|
|
1263
|
+
if (room)
|
|
1264
|
+
this.lastRoom.set(session, room);
|
|
1007
1265
|
// The one witness `relay status` may treat as proof that a session is
|
|
1008
1266
|
// reachable again. A headless turn only counts when it exited cleanly: a
|
|
1009
1267
|
// spawn that ran and failed delivered nothing, and recording it would
|
|
@@ -1032,6 +1290,134 @@ class RelayDaemon {
|
|
|
1032
1290
|
}
|
|
1033
1291
|
this.log(`ignored ${batch.length} message(s): ${outcome.reason}`);
|
|
1034
1292
|
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Record what an attach taught us about a session — the part every attach shares, whether
|
|
1295
|
+
* it arrived as a local `attach` frame or as a registration from the server feed.
|
|
1296
|
+
*
|
|
1297
|
+
* `sock` is the socket the attach arrived on; a server-fed registration has none, and so
|
|
1298
|
+
* displaces any socket holding the name, exactly as a newer local attach would.
|
|
1299
|
+
*/
|
|
1300
|
+
recordAttach(frame, sock) {
|
|
1301
|
+
this.registry.upsert({
|
|
1302
|
+
name: frame.session,
|
|
1303
|
+
runtime: frame.runtime,
|
|
1304
|
+
delivery: frame.delivery,
|
|
1305
|
+
resumeId: frame.resumeId,
|
|
1306
|
+
resumeSource: frame.resumeSource,
|
|
1307
|
+
resumeEvidence: frame.resumeEvidence,
|
|
1308
|
+
resumeCwd: frame.resumeCwd,
|
|
1309
|
+
cwd: frame.cwd,
|
|
1310
|
+
runtimeBin: frame.runtimeBin,
|
|
1311
|
+
ownerPid: frame.ownerPid,
|
|
1312
|
+
pane: frame.pane,
|
|
1313
|
+
providerUrl: frame.providerUrl,
|
|
1314
|
+
providerVar: frame.providerVar,
|
|
1315
|
+
serverRegisteredAt: frame.serverRegisteredAt,
|
|
1316
|
+
});
|
|
1317
|
+
// A session that names itself supersedes anything discovery guessed
|
|
1318
|
+
// for it, so drop the throttle and let the next wake use the new id.
|
|
1319
|
+
if (frame.resumeId)
|
|
1320
|
+
this.discoveryReasons.delete(frame.session);
|
|
1321
|
+
// OVERLAP, AND WE CANNOT PREVENT IT — only report it. A headless turn
|
|
1322
|
+
// already running was spawned because this session looked gone; it is
|
|
1323
|
+
// mid-turn now, and the daemon will not kill a model that may be part
|
|
1324
|
+
// way through writing something. Two agents briefly hold this name.
|
|
1325
|
+
// Said out loud because the alternative is that nobody ever knows.
|
|
1326
|
+
if (this.headlessInFlight.has(frame.session)) {
|
|
1327
|
+
this.log(`WARNING: ${frame.session} attached while a headless turn for it is still running — two agents hold this session name until that turn ends`);
|
|
1328
|
+
}
|
|
1329
|
+
// EVICT THE PREVIOUS HOLDER. A session name has exactly one listener,
|
|
1330
|
+
// and `set` alone would leave the old socket open and its process
|
|
1331
|
+
// running — which is how two orphaned attaches came to be stacked on
|
|
1332
|
+
// one name on 2026-08-30, each one a place a wake could vanish into.
|
|
1333
|
+
// Destroying it makes the displaced attach exit instead of lingering.
|
|
1334
|
+
const previous = this.attached.get(frame.session);
|
|
1335
|
+
if (previous && previous !== sock) {
|
|
1336
|
+
this.log(`replacing attach for ${frame.session}: dropping the previous one`);
|
|
1337
|
+
previous.destroy();
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Native delivery (`codex queue`) owns this session from now on: no socket is waited on,
|
|
1342
|
+
* and anything held for it goes straight to the queue. The tail of a local
|
|
1343
|
+
* `attach { delivery: "queue" }` AND of a server-fed registration — one path, not two.
|
|
1344
|
+
*/
|
|
1345
|
+
armNativeQueue(session, runtime, via = "") {
|
|
1346
|
+
this.attached.delete(session);
|
|
1347
|
+
this.registry.setAttached(session, false);
|
|
1348
|
+
this.log(`registered native delivery: ${session} (${runtime})${via}`);
|
|
1349
|
+
for (const room of this.held.roomsFor(session)) {
|
|
1350
|
+
for (const message of room.messages)
|
|
1351
|
+
this.queue.push(session, message);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Apply the server's registrations for this device, once each.
|
|
1356
|
+
*
|
|
1357
|
+
* Called with every roster. A registration is repeated on every one, so it is applied
|
|
1358
|
+
* only when its `registeredAt` differs from the one the local target was built from — a
|
|
1359
|
+
* new MCP join — and never for a session the same roster does not list as live.
|
|
1360
|
+
*/
|
|
1361
|
+
applyServerRegistrations(regs, live) {
|
|
1362
|
+
const liveNames = new Set(live);
|
|
1363
|
+
for (const reg of regs) {
|
|
1364
|
+
if (!liveNames.has(reg.session))
|
|
1365
|
+
continue;
|
|
1366
|
+
if (this.registry.get(reg.session)?.serverRegisteredAt === reg.registeredAt)
|
|
1367
|
+
continue;
|
|
1368
|
+
if (this.serverRegistrations.has(reg.session))
|
|
1369
|
+
continue;
|
|
1370
|
+
const refusedAt = this.refusedRegistrations.get(`${reg.session}\u0000${reg.registeredAt}`);
|
|
1371
|
+
if (refusedAt !== undefined && Date.now() - refusedAt < DISCOVERY_INTERVAL_MS)
|
|
1372
|
+
continue;
|
|
1373
|
+
const applying = this.applyServerRegistration(reg)
|
|
1374
|
+
.catch((err) => this.log(`server registration for ${reg.session} failed: ${errText(err)}`))
|
|
1375
|
+
.finally(() => this.serverRegistrations.delete(reg.session));
|
|
1376
|
+
this.serverRegistrations.set(reg.session, applying);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Register one session exactly as a local `attach { delivery: "queue" }` frame would.
|
|
1381
|
+
*
|
|
1382
|
+
* SECURITY. The registration reached us only over this relay's OWN authenticated feed —
|
|
1383
|
+
* the server sends a device only the registrations its own credential made. Even so the
|
|
1384
|
+
* thread id is not taken on the server's word: it is corroborated against THIS machine's
|
|
1385
|
+
* Codex rollouts, the same check `$CODEX_THREAD_ID` gets at a local attach. So a thread id
|
|
1386
|
+
* can only ever become the target of `codex queue` / `thread/resume` for a LOCAL,
|
|
1387
|
+
* top-level Codex thread — never a subagent, never a thread on another machine, never
|
|
1388
|
+
* anything that is not a Codex thread at all.
|
|
1389
|
+
*/
|
|
1390
|
+
async applyServerRegistration(reg) {
|
|
1391
|
+
const relayRun = (0, daemon_glue_1.acpAttachRefusal)(this.registry, reg.session);
|
|
1392
|
+
if (relayRun)
|
|
1393
|
+
return this.log(`server registration ignored: ${relayRun}`);
|
|
1394
|
+
const resume = await (0, resume_1.resumeIdFromSessionEnv)("codex", {
|
|
1395
|
+
...this.discovery,
|
|
1396
|
+
env: { [resume_1.SESSION_ID_ENV.codex]: reg.threadId },
|
|
1397
|
+
});
|
|
1398
|
+
if (!resume.ok) {
|
|
1399
|
+
this.refusedRegistrations.set(`${reg.session}\u0000${reg.registeredAt}`, Date.now());
|
|
1400
|
+
this.log(`server registration for ${reg.session} refused: ${resume.reason}`);
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
const codex = this.binaryFor("codex");
|
|
1404
|
+
if (!codex.ok) {
|
|
1405
|
+
// Registered anyway: delivery reports its own `pending` reason per message, and a
|
|
1406
|
+
// user who installs Codex is believed on the next message.
|
|
1407
|
+
this.log(`server registration for ${reg.session}: no usable codex binary yet (${(0, runtime_binary_1.summarizeResolutionFailure)(codex)})`);
|
|
1408
|
+
}
|
|
1409
|
+
this.recordAttach({
|
|
1410
|
+
session: reg.session,
|
|
1411
|
+
runtime: "codex",
|
|
1412
|
+
delivery: "queue",
|
|
1413
|
+
resumeId: resume.resumeId,
|
|
1414
|
+
resumeSource: resume.source,
|
|
1415
|
+
resumeEvidence: `registered through MCP join_session; ${resume.evidence}`,
|
|
1416
|
+
resumeCwd: resume.cwd,
|
|
1417
|
+
serverRegisteredAt: reg.registeredAt,
|
|
1418
|
+
});
|
|
1419
|
+
this.armNativeQueue(reg.session, "codex", " via the server feed");
|
|
1420
|
+
}
|
|
1035
1421
|
onConnection(sock) {
|
|
1036
1422
|
let session;
|
|
1037
1423
|
const read = (0, socket_1.createFrameReader)((frame) => {
|
|
@@ -1048,56 +1434,14 @@ class RelayDaemon {
|
|
|
1048
1434
|
return;
|
|
1049
1435
|
}
|
|
1050
1436
|
session = frame.session;
|
|
1051
|
-
this.
|
|
1052
|
-
name: frame.session,
|
|
1053
|
-
runtime: frame.runtime,
|
|
1054
|
-
delivery: frame.delivery,
|
|
1055
|
-
resumeId: frame.resumeId,
|
|
1056
|
-
resumeSource: frame.resumeSource,
|
|
1057
|
-
resumeEvidence: frame.resumeEvidence,
|
|
1058
|
-
resumeCwd: frame.resumeCwd,
|
|
1059
|
-
cwd: frame.cwd,
|
|
1060
|
-
runtimeBin: frame.runtimeBin,
|
|
1061
|
-
ownerPid: frame.ownerPid,
|
|
1062
|
-
pane: frame.pane,
|
|
1063
|
-
providerUrl: frame.providerUrl,
|
|
1064
|
-
providerVar: frame.providerVar,
|
|
1065
|
-
});
|
|
1066
|
-
// A session that names itself supersedes anything discovery guessed
|
|
1067
|
-
// for it, so drop the throttle and let the next wake use the new id.
|
|
1068
|
-
if (frame.resumeId)
|
|
1069
|
-
this.discoveryReasons.delete(frame.session);
|
|
1070
|
-
// OVERLAP, AND WE CANNOT PREVENT IT — only report it. A headless turn
|
|
1071
|
-
// already running was spawned because this session looked gone; it is
|
|
1072
|
-
// mid-turn now, and the daemon will not kill a model that may be part
|
|
1073
|
-
// way through writing something. Two agents briefly hold this name.
|
|
1074
|
-
// Said out loud because the alternative is that nobody ever knows.
|
|
1075
|
-
if (this.headlessInFlight.has(frame.session)) {
|
|
1076
|
-
this.log(`WARNING: ${frame.session} attached while a headless turn for it is still running — two agents hold this session name until that turn ends`);
|
|
1077
|
-
}
|
|
1078
|
-
// EVICT THE PREVIOUS HOLDER. A session name has exactly one listener,
|
|
1079
|
-
// and `set` alone would leave the old socket open and its process
|
|
1080
|
-
// running — which is how two orphaned attaches came to be stacked on
|
|
1081
|
-
// one name on 2026-08-30, each one a place a wake could vanish into.
|
|
1082
|
-
// Destroying it makes the displaced attach exit instead of lingering.
|
|
1083
|
-
const previous = this.attached.get(frame.session);
|
|
1084
|
-
if (previous && previous !== sock) {
|
|
1085
|
-
this.log(`replacing attach for ${frame.session}: dropping the previous one`);
|
|
1086
|
-
previous.destroy();
|
|
1087
|
-
}
|
|
1437
|
+
this.recordAttach(frame, sock);
|
|
1088
1438
|
if (frame.delivery === "queue") {
|
|
1089
|
-
this.
|
|
1090
|
-
this.registry.setAttached(frame.session, false);
|
|
1439
|
+
this.armNativeQueue(frame.session, frame.runtime);
|
|
1091
1440
|
(0, socket_1.writeFrame)(sock, {
|
|
1092
1441
|
type: "attached",
|
|
1093
1442
|
session: frame.session,
|
|
1094
1443
|
delivery: "queue",
|
|
1095
1444
|
});
|
|
1096
|
-
this.log(`registered native delivery: ${frame.session} (${frame.runtime})`);
|
|
1097
|
-
for (const room of this.held.roomsFor(frame.session)) {
|
|
1098
|
-
for (const message of room.messages)
|
|
1099
|
-
this.queue.push(frame.session, message);
|
|
1100
|
-
}
|
|
1101
1445
|
return;
|
|
1102
1446
|
}
|
|
1103
1447
|
this.attached.set(frame.session, sock);
|
|
@@ -1164,6 +1508,8 @@ class RelayDaemon {
|
|
|
1164
1508
|
}
|
|
1165
1509
|
async stop() {
|
|
1166
1510
|
clearInterval(this.acpKeepAlive);
|
|
1511
|
+
this.codexContext.stop();
|
|
1512
|
+
this.context.close();
|
|
1167
1513
|
this.acp.shutdown();
|
|
1168
1514
|
this.abort.abort();
|
|
1169
1515
|
for (const sock of this.attached.values())
|
|
@@ -1185,34 +1531,44 @@ class RelayDaemon {
|
|
|
1185
1531
|
}
|
|
1186
1532
|
}
|
|
1187
1533
|
exports.RelayDaemon = RelayDaemon;
|
|
1188
|
-
/**
|
|
1189
|
-
|
|
1190
|
-
*
|
|
1191
|
-
* THREE CONDITIONS, ALL REQUIRED, and each closes a different door:
|
|
1192
|
-
*
|
|
1193
|
-
* - `shouldRespond` — the message is addressed to THIS session. Without it, a `/clear`
|
|
1194
|
-
* typed into a group chat that the session merely watches would clear it.
|
|
1195
|
-
* - `commandAuthorized` — the SERVER says this sender may command this session. The relay
|
|
1196
|
-
* never decides that itself (see `RelayMessage.commandAuthorized`), and an absent field
|
|
1197
|
-
* is a no, so an older server can only ever under-permit.
|
|
1198
|
-
* - an exact trigger match — "can you /clear please" is a sentence ABOUT a command and
|
|
1199
|
-
* must reach the agent as one.
|
|
1200
|
-
*
|
|
1201
|
-
* Anything failing any of them is chat, and wakes the session exactly as it does today.
|
|
1202
|
-
*/
|
|
1534
|
+
/** How long a runtime's TUI is given to clear itself before the fresh session's first prompt. */
|
|
1535
|
+
exports.FRESH_START_SETTLE_MS = 1_500;
|
|
1203
1536
|
function splitSessionCommands(batch) {
|
|
1204
1537
|
const commands = [];
|
|
1205
1538
|
const chat = [];
|
|
1539
|
+
const dropped = [];
|
|
1206
1540
|
for (const message of batch) {
|
|
1207
|
-
const
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
if (command)
|
|
1541
|
+
const allowed = message.shouldRespond === true && message.commandAuthorized === true;
|
|
1542
|
+
const command = allowed ? (0, session_commands_1.parseSessionCommand)(message.content) : undefined;
|
|
1543
|
+
if (command) {
|
|
1211
1544
|
commands.push({ message, command });
|
|
1545
|
+
continue;
|
|
1546
|
+
}
|
|
1547
|
+
// A REFUSED /compact is still a command, not chat: a focus carrying a newline or an escape
|
|
1548
|
+
// sequence must not reach the agent as a message it might act on either.
|
|
1549
|
+
const compact = allowed ? (0, compact_command_1.parseCompactCommand)(message.content) : undefined;
|
|
1550
|
+
if (compact?.kind === "compact")
|
|
1551
|
+
commands.push({ message, command: compact_command_1.COMPACT_COMMAND, ...(compact.focus ? { focus: compact.focus } : {}) });
|
|
1552
|
+
else if (compact?.kind === "refused")
|
|
1553
|
+
commands.push({ message, command: compact_command_1.COMPACT_COMMAND, refusal: compact.reason });
|
|
1554
|
+
// The app's "Fresh start": a directed command whose text is never typed (fresh-start.ts).
|
|
1555
|
+
else if (allowed && (0, fresh_start_1.parseFreshStart)(message)) {
|
|
1556
|
+
commands.push({ message, command: fresh_start_1.FRESH_START_COMMAND, fresh: (0, fresh_start_1.parseFreshStart)(message) });
|
|
1557
|
+
}
|
|
1558
|
+
// A DIRECTED command (the app's "Tidy up now": an event that exists on this session's feed
|
|
1559
|
+
// only, `metadata.type: "session.command"`) is never chat. If its authority was withdrawn
|
|
1560
|
+
// between the request and the hand-over, the server says `commandAuthorized: false` — and
|
|
1561
|
+
// the text must then reach the agent neither as keystrokes nor as a message to act on.
|
|
1562
|
+
else if (isDirectedCommand(message))
|
|
1563
|
+
dropped.push(message);
|
|
1212
1564
|
else
|
|
1213
1565
|
chat.push(message);
|
|
1214
1566
|
}
|
|
1215
|
-
return { commands, chat };
|
|
1567
|
+
return { commands, chat, dropped };
|
|
1568
|
+
}
|
|
1569
|
+
function isDirectedCommand(message) {
|
|
1570
|
+
const metadata = message.metadata;
|
|
1571
|
+
return !!metadata && typeof metadata === "object" && metadata.type === "session.command";
|
|
1216
1572
|
}
|
|
1217
1573
|
function writePidFile() {
|
|
1218
1574
|
try {
|