baychat 0.24.0 → 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 +98 -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/context-hooks-command.js +165 -0
- package/dist/context-report.js +307 -0
- package/dist/help-topics.js +2 -23
- package/dist/index.js +26 -499
- 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 +287 -28
- 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/session-commands.js +2 -4
- package/dist/relay/terminal-text.js +13 -0
- package/dist/relay/updates.js +42 -7
- package/dist/relay/ws.js +34 -2
- package/dist/runtimes.js +11 -0
- 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");
|
|
@@ -73,6 +81,19 @@ class RelayDaemon {
|
|
|
73
81
|
acpQueue = new turn_queue_1.AcpTurnQueue();
|
|
74
82
|
acp;
|
|
75
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;
|
|
76
97
|
acpKeepAlive;
|
|
77
98
|
queue;
|
|
78
99
|
abort = new AbortController();
|
|
@@ -206,10 +227,22 @@ class RelayDaemon {
|
|
|
206
227
|
opts.resolveBinary ??
|
|
207
228
|
((name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, (0, runtime_binary_1.currentBinaryEnv)()));
|
|
208
229
|
this.runTurn = opts.runTurn;
|
|
230
|
+
this.freshStartOpts = opts.freshStart ?? {};
|
|
209
231
|
this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
|
|
210
232
|
this.lastError = `delivery failed for ${session}: ${errText(err)}`;
|
|
211
233
|
this.log(this.lastError);
|
|
212
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
|
+
});
|
|
213
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 }));
|
|
214
247
|
this.acp = new runner_1.AcpSessionRunner({
|
|
215
248
|
registry: this.registry,
|
|
@@ -225,6 +258,7 @@ class RelayDaemon {
|
|
|
225
258
|
onPending: (session, batch, reason) => this.record({ kind: "pending", session, reason }, session, batch),
|
|
226
259
|
runTurn: opts.acpRunTurn,
|
|
227
260
|
caller: opts.acpCaller,
|
|
261
|
+
reportContext: (session, event) => this.reportContext(session, event),
|
|
228
262
|
});
|
|
229
263
|
}
|
|
230
264
|
async start() {
|
|
@@ -250,6 +284,14 @@ class RelayDaemon {
|
|
|
250
284
|
// handed what the previous one was holding rather than a clean slate.
|
|
251
285
|
this.held.load();
|
|
252
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
|
+
}
|
|
253
295
|
const sockPath = (0, socket_1.socketPath)();
|
|
254
296
|
if (await (0, socket_1.probeSocket)(sockPath)) {
|
|
255
297
|
throw new Error(`a relay is already listening on ${sockPath} — run \`baychat relay status\``);
|
|
@@ -277,6 +319,7 @@ class RelayDaemon {
|
|
|
277
319
|
writePidFile();
|
|
278
320
|
// After the socket is ours: a relay refused as a duplicate must not keep sessions alive.
|
|
279
321
|
this.acpKeepAlive = (0, daemon_glue_1.startAcpUpkeep)(this.acpAlive.run);
|
|
322
|
+
this.codexContext.start();
|
|
280
323
|
this.log(`listening on ${sockPath} (pid ${process.pid}) as device "${device.user.name}"`);
|
|
281
324
|
this.log(`${this.registry.all().length} known session target(s)`);
|
|
282
325
|
// The event SOURCE is pluggable (socket preferred, long-poll floor); every
|
|
@@ -295,8 +338,11 @@ class RelayDaemon {
|
|
|
295
338
|
// never had to route an event to — skipping it re-baselines it at "now".
|
|
296
339
|
this.watermarks.noteLiveSessions(names);
|
|
297
340
|
const dropped = this.registry.pruneToLive(names);
|
|
298
|
-
for (const name of dropped)
|
|
341
|
+
for (const name of dropped) {
|
|
299
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
|
+
}
|
|
300
346
|
void this.acpAlive.lapsed(this.registry, names);
|
|
301
347
|
if (registrations)
|
|
302
348
|
this.applyServerRegistrations(registrations, names);
|
|
@@ -388,7 +434,7 @@ class RelayDaemon {
|
|
|
388
434
|
* apart from silence. A refusal that names its reason is the whole difference between
|
|
389
435
|
* "this feature is broken" and "start your agent inside tmux".
|
|
390
436
|
*/
|
|
391
|
-
async runSessionCommand(session, message, command) {
|
|
437
|
+
async runSessionCommand(session, message, command, compact = {}) {
|
|
392
438
|
const say = async (text) => {
|
|
393
439
|
if (!this.auth)
|
|
394
440
|
return;
|
|
@@ -411,6 +457,13 @@ class RelayDaemon {
|
|
|
411
457
|
await say((0, session_commands_1.sessionCommandHelp)());
|
|
412
458
|
return;
|
|
413
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
|
+
}
|
|
414
467
|
// `/mode-*`, `/stop`, and what `/status` `/exit` `/clear` `/new` mean with no terminal.
|
|
415
468
|
if (await (0, commands_1.runAcpSessionCommand)(this.acp, session, target, message, command, say))
|
|
416
469
|
return;
|
|
@@ -448,7 +501,13 @@ class RelayDaemon {
|
|
|
448
501
|
await say((0, session_commands_1.noTerminalReason)());
|
|
449
502
|
return;
|
|
450
503
|
}
|
|
451
|
-
|
|
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
|
+
}
|
|
452
511
|
if (!keys) {
|
|
453
512
|
await say(`${command.trigger} has nothing to type for a ${target.runtime} session.`);
|
|
454
513
|
return;
|
|
@@ -467,8 +526,158 @@ class RelayDaemon {
|
|
|
467
526
|
// See `SessionRegistry.clearResume`: the live session's transcript is gone, so the
|
|
468
527
|
// recorded resume id now points at something no half of this session has.
|
|
469
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);
|
|
470
531
|
}
|
|
471
|
-
await say(
|
|
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;
|
|
570
|
+
}
|
|
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);
|
|
472
681
|
}
|
|
473
682
|
/**
|
|
474
683
|
* Wait for a session to re-arm its socket, up to `graceMs`.
|
|
@@ -501,9 +710,44 @@ class RelayDaemon {
|
|
|
501
710
|
// obey something its HARNESS owns — `/clear` is read by the runtime's TUI, and no model
|
|
502
711
|
// can run it for itself. So the commands are executed here and everything else wakes
|
|
503
712
|
// the session exactly as it always did.
|
|
504
|
-
const { commands, chat } = splitSessionCommands(batch);
|
|
505
|
-
for (const
|
|
506
|
-
|
|
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 });
|
|
507
751
|
}
|
|
508
752
|
if (chat.length === 0)
|
|
509
753
|
return;
|
|
@@ -1015,6 +1259,9 @@ class RelayDaemon {
|
|
|
1015
1259
|
record(outcome, session, batch) {
|
|
1016
1260
|
if (outcome.kind === "woken") {
|
|
1017
1261
|
this.delivered += batch.length;
|
|
1262
|
+
const room = batch.at(-1)?.conversationId;
|
|
1263
|
+
if (room)
|
|
1264
|
+
this.lastRoom.set(session, room);
|
|
1018
1265
|
// The one witness `relay status` may treat as proof that a session is
|
|
1019
1266
|
// reachable again. A headless turn only counts when it exited cleanly: a
|
|
1020
1267
|
// spawn that ran and failed delivered nothing, and recording it would
|
|
@@ -1261,6 +1508,8 @@ class RelayDaemon {
|
|
|
1261
1508
|
}
|
|
1262
1509
|
async stop() {
|
|
1263
1510
|
clearInterval(this.acpKeepAlive);
|
|
1511
|
+
this.codexContext.stop();
|
|
1512
|
+
this.context.close();
|
|
1264
1513
|
this.acp.shutdown();
|
|
1265
1514
|
this.abort.abort();
|
|
1266
1515
|
for (const sock of this.attached.values())
|
|
@@ -1282,34 +1531,44 @@ class RelayDaemon {
|
|
|
1282
1531
|
}
|
|
1283
1532
|
}
|
|
1284
1533
|
exports.RelayDaemon = RelayDaemon;
|
|
1285
|
-
/**
|
|
1286
|
-
|
|
1287
|
-
*
|
|
1288
|
-
* THREE CONDITIONS, ALL REQUIRED, and each closes a different door:
|
|
1289
|
-
*
|
|
1290
|
-
* - `shouldRespond` — the message is addressed to THIS session. Without it, a `/clear`
|
|
1291
|
-
* typed into a group chat that the session merely watches would clear it.
|
|
1292
|
-
* - `commandAuthorized` — the SERVER says this sender may command this session. The relay
|
|
1293
|
-
* never decides that itself (see `RelayMessage.commandAuthorized`), and an absent field
|
|
1294
|
-
* is a no, so an older server can only ever under-permit.
|
|
1295
|
-
* - an exact trigger match — "can you /clear please" is a sentence ABOUT a command and
|
|
1296
|
-
* must reach the agent as one.
|
|
1297
|
-
*
|
|
1298
|
-
* Anything failing any of them is chat, and wakes the session exactly as it does today.
|
|
1299
|
-
*/
|
|
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;
|
|
1300
1536
|
function splitSessionCommands(batch) {
|
|
1301
1537
|
const commands = [];
|
|
1302
1538
|
const chat = [];
|
|
1539
|
+
const dropped = [];
|
|
1303
1540
|
for (const message of batch) {
|
|
1304
|
-
const
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
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) {
|
|
1308
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);
|
|
1309
1564
|
else
|
|
1310
1565
|
chat.push(message);
|
|
1311
1566
|
}
|
|
1312
|
-
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";
|
|
1313
1572
|
}
|
|
1314
1573
|
function writePidFile() {
|
|
1315
1574
|
try {
|
|
@@ -0,0 +1,237 @@
|
|
|
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.HandledHandoffs = void 0;
|
|
37
|
+
exports.samplePane = samplePane;
|
|
38
|
+
exports.waitForIdle = waitForIdle;
|
|
39
|
+
exports.pointerMarker = pointerMarker;
|
|
40
|
+
exports.locatePointer = locatePointer;
|
|
41
|
+
exports.awaitPointer = awaitPointer;
|
|
42
|
+
exports.handledHandoffsPath = handledHandoffsPath;
|
|
43
|
+
const child_process_1 = require("child_process");
|
|
44
|
+
const fs = __importStar(require("fs"));
|
|
45
|
+
const os = __importStar(require("os"));
|
|
46
|
+
const path = __importStar(require("path"));
|
|
47
|
+
const config_1 = require("../config");
|
|
48
|
+
const resume_1 = require("./resume");
|
|
49
|
+
/** What both Claude Code and Codex print in their status line while a turn runs. */
|
|
50
|
+
const BUSY_MARKER = /esc to interrupt/i;
|
|
51
|
+
function execText(file, args) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
(0, child_process_1.execFile)(file, args, { timeout: 5_000, encoding: "utf8", maxBuffer: 1024 * 1024 }, (err, stdout) => err ? reject(err) : resolve(stdout));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** Read the bottom of the pane and say whether a turn is running. Never throws. */
|
|
57
|
+
async function samplePane(handle, exec = execText) {
|
|
58
|
+
try {
|
|
59
|
+
if (handle.kind === "tmux") {
|
|
60
|
+
const text = await exec("tmux", ["-S", handle.socket, "capture-pane", "-p", "-t", handle.pane, "-S", "-30"]);
|
|
61
|
+
return BUSY_MARKER.test(text) ? "busy" : "idle";
|
|
62
|
+
}
|
|
63
|
+
if (handle.kind === "screen") {
|
|
64
|
+
const file = path.join(os.tmpdir(), `baychat-pane-${process.pid}-${Date.now()}.txt`);
|
|
65
|
+
try {
|
|
66
|
+
await exec("screen", ["-S", handle.session, "-p", handle.window, "-X", "hardcopy", file]);
|
|
67
|
+
const text = fs.readFileSync(file, "utf8");
|
|
68
|
+
return BUSY_MARKER.test(text.split("\n").slice(-30).join("\n")) ? "busy" : "idle";
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
fs.rmSync(file, { force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return "unknown";
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return "unknown";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* "idle" once two readings in a row say so; "busy" if a turn is still running at the cap;
|
|
82
|
+
* "unknown" when the pane could never be read and no socket vouched for the session.
|
|
83
|
+
*/
|
|
84
|
+
async function waitForIdle(opts) {
|
|
85
|
+
const cap = opts.capMs ?? 60_000;
|
|
86
|
+
const step = opts.stepMs ?? 1_000;
|
|
87
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
88
|
+
const now = opts.now ?? Date.now;
|
|
89
|
+
const deadline = now() + cap;
|
|
90
|
+
let idleRun = 0;
|
|
91
|
+
let sawBusy = false;
|
|
92
|
+
for (;;) {
|
|
93
|
+
const state = await opts.sample();
|
|
94
|
+
if (state === "idle")
|
|
95
|
+
idleRun += 1;
|
|
96
|
+
else
|
|
97
|
+
idleRun = 0;
|
|
98
|
+
if (state === "busy")
|
|
99
|
+
sawBusy = true;
|
|
100
|
+
if (idleRun >= 2)
|
|
101
|
+
return "idle";
|
|
102
|
+
if (state === "unknown" && opts.attached())
|
|
103
|
+
return "idle";
|
|
104
|
+
if (now() >= deadline)
|
|
105
|
+
return sawBusy ? "busy" : state === "unknown" ? "unknown" : "idle";
|
|
106
|
+
await sleep(step);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** The words only the pointer line carries (fresh-start.ts `freshStartLine`). */
|
|
110
|
+
function pointerMarker(noteId) {
|
|
111
|
+
return `Your handoff note is note ${noteId}:`;
|
|
112
|
+
}
|
|
113
|
+
async function readTail(file, bytes = 256 * 1024) {
|
|
114
|
+
const handle = await fs.promises.open(file, "r");
|
|
115
|
+
try {
|
|
116
|
+
const { size } = await handle.stat();
|
|
117
|
+
const start = Math.max(0, size - bytes);
|
|
118
|
+
const buf = Buffer.alloc(size - start);
|
|
119
|
+
await handle.read(buf, 0, buf.length, start);
|
|
120
|
+
return buf.toString("utf8");
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
await handle.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Where the pointer line was written, if anywhere yet. Never throws. */
|
|
127
|
+
async function locatePointer(search) {
|
|
128
|
+
const marker = pointerMarker(search.noteId);
|
|
129
|
+
let files;
|
|
130
|
+
try {
|
|
131
|
+
files = await (search.list ?? resume_1.transcriptsWrittenSince)(search.runtime, search.sinceMs, search.discovery);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
for (const { file, id } of files) {
|
|
137
|
+
let text;
|
|
138
|
+
try {
|
|
139
|
+
text = await (search.readTail ?? readTail)(file);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (!text.includes(marker))
|
|
145
|
+
continue;
|
|
146
|
+
return search.oldResumeId !== undefined && id === search.oldResumeId ? { kind: "old" } : { kind: "new", id };
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
/** `locatePointer`, asked again until the window closes. */
|
|
151
|
+
async function awaitPointer(search) {
|
|
152
|
+
const sleep = search.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
153
|
+
const tries = Math.max(1, Math.ceil((search.timeoutMs ?? 20_000) / (search.stepMs ?? 1_000)));
|
|
154
|
+
for (let i = 0; i < tries; i++) {
|
|
155
|
+
const found = await locatePointer(search);
|
|
156
|
+
if (found)
|
|
157
|
+
return found;
|
|
158
|
+
if (i < tries - 1)
|
|
159
|
+
await sleep(search.stepMs ?? 1_000);
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
// ─── Never twice ──────────────────────────────────────────────────────────────────────
|
|
164
|
+
function handledHandoffsPath() {
|
|
165
|
+
return path.join((0, config_1.configDir)(), "fresh-starts.json");
|
|
166
|
+
}
|
|
167
|
+
/** Kept for two days: far longer than the server's event ring could ever replay a step. */
|
|
168
|
+
const KEEP_MS = 48 * 60 * 60_000;
|
|
169
|
+
/**
|
|
170
|
+
* Fresh-start steps this relay has already handled, by `<handoffId>:<phase>`. ON DISK, 0600, next
|
|
171
|
+
* to the relay's other state: the feed replays after a restart, and a replayed `clear` must not
|
|
172
|
+
* clear a session a second time.
|
|
173
|
+
*/
|
|
174
|
+
class HandledHandoffs {
|
|
175
|
+
file;
|
|
176
|
+
now;
|
|
177
|
+
steps;
|
|
178
|
+
constructor(file = handledHandoffsPath(), now = Date.now) {
|
|
179
|
+
this.file = file;
|
|
180
|
+
this.now = now;
|
|
181
|
+
}
|
|
182
|
+
has(handoffId, phase) {
|
|
183
|
+
return this.load().has(`${handoffId}:${phase}`);
|
|
184
|
+
}
|
|
185
|
+
mark(handoffId, phase, session, state) {
|
|
186
|
+
this.load().set(`${handoffId}:${phase}`, { session, state, at: this.now() });
|
|
187
|
+
this.save();
|
|
188
|
+
}
|
|
189
|
+
/** Clears that were being typed when the relay stopped: `{ handoffId, session }`. */
|
|
190
|
+
interrupted() {
|
|
191
|
+
return [...this.load()]
|
|
192
|
+
.filter(([key, step]) => step.state === "started" && key.endsWith(":clear"))
|
|
193
|
+
.map(([key, step]) => ({ handoffId: key.slice(0, -":clear".length), session: step.session }));
|
|
194
|
+
}
|
|
195
|
+
load() {
|
|
196
|
+
if (this.steps)
|
|
197
|
+
return this.steps;
|
|
198
|
+
this.steps = new Map();
|
|
199
|
+
let raw;
|
|
200
|
+
try {
|
|
201
|
+
raw = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
if (err.code !== "ENOENT") {
|
|
205
|
+
process.stderr.write(`baychat relay: could not read handled fresh starts — ${err instanceof Error ? err.message : String(err)}\n`);
|
|
206
|
+
}
|
|
207
|
+
return this.steps;
|
|
208
|
+
}
|
|
209
|
+
const cutoff = this.now() - KEEP_MS;
|
|
210
|
+
if (raw && typeof raw === "object") {
|
|
211
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
212
|
+
const v = value;
|
|
213
|
+
if (!v || typeof v.session !== "string" || typeof v.at !== "number" || v.at < cutoff)
|
|
214
|
+
continue;
|
|
215
|
+
if (v.state !== "started" && v.state !== "done")
|
|
216
|
+
continue;
|
|
217
|
+
this.steps.set(key, { session: v.session, state: v.state, at: v.at });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return this.steps;
|
|
221
|
+
}
|
|
222
|
+
save() {
|
|
223
|
+
const cutoff = this.now() - KEEP_MS;
|
|
224
|
+
for (const [key, step] of this.steps ?? [])
|
|
225
|
+
if (step.at < cutoff)
|
|
226
|
+
this.steps.delete(key);
|
|
227
|
+
try {
|
|
228
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
229
|
+
fs.writeFileSync(this.file, JSON.stringify(Object.fromEntries(this.steps ?? new Map())), { mode: 0o600 });
|
|
230
|
+
fs.chmodSync(this.file, 0o600);
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
process.stderr.write(`baychat relay: could not persist handled fresh starts — ${err instanceof Error ? err.message : String(err)}\n`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
exports.HandledHandoffs = HandledHandoffs;
|