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.
Files changed (39) hide show
  1. package/README.md +120 -15
  2. package/dist/api.js +19 -2
  3. package/dist/claude-context-flush.js +219 -0
  4. package/dist/claude-context-state.js +191 -0
  5. package/dist/claude-context.js +311 -0
  6. package/dist/claude-settings.js +503 -0
  7. package/dist/codex-onboarding.js +33 -23
  8. package/dist/context-hooks-command.js +165 -0
  9. package/dist/context-report.js +307 -0
  10. package/dist/index.js +26 -497
  11. package/dist/main.js +511 -0
  12. package/dist/mcp-notes.js +110 -0
  13. package/dist/mcp.js +84 -5
  14. package/dist/protocol-content.js +1 -1
  15. package/dist/relay/acp/client.js +18 -0
  16. package/dist/relay/acp/prompt.js +54 -3
  17. package/dist/relay/acp/runner.js +100 -2
  18. package/dist/relay/acp/turn-queue.js +99 -3
  19. package/dist/relay/codex-context.js +242 -0
  20. package/dist/relay/compact-command.js +63 -0
  21. package/dist/relay/compact-focus.attacks.js +36 -0
  22. package/dist/relay/compact-focus.js +64 -0
  23. package/dist/relay/context-notice.js +144 -0
  24. package/dist/relay/daemon.js +429 -73
  25. package/dist/relay/fresh-start-verify.js +237 -0
  26. package/dist/relay/fresh-start.js +141 -0
  27. package/dist/relay/message-format.js +34 -15
  28. package/dist/relay/project-brief.js +195 -0
  29. package/dist/relay/project-notes.js +300 -0
  30. package/dist/relay/resume.js +25 -0
  31. package/dist/relay/server-registration.js +40 -0
  32. package/dist/relay/session-commands.js +2 -0
  33. package/dist/relay/terminal-text.js +13 -0
  34. package/dist/relay/transport.js +2 -1
  35. package/dist/relay/updates.js +46 -8
  36. package/dist/relay/ws.js +49 -6
  37. package/dist/runtimes.js +24 -6
  38. package/dist/tool-defs.js +93 -0
  39. package/package.json +1 -1
@@ -34,18 +34,59 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.AcpSessionRunner = void 0;
37
+ exports.acpContextObserver = acpContextObserver;
37
38
  exports.effectiveMode = effectiveMode;
39
+ exports.looksLikeContextOverflow = looksLikeContextOverflow;
38
40
  const os = __importStar(require("os"));
39
41
  const runtime_binary_1 = require("../../runtime-binary");
40
42
  const spawn_env_1 = require("../spawn-env");
43
+ const fresh_start_1 = require("../fresh-start");
41
44
  const agents_1 = require("./agents");
42
45
  const approval_1 = require("./approval");
46
+ const context_report_1 = require("../../context-report");
43
47
  const client_1 = require("./client");
44
48
  const modes_1 = require("./modes");
45
49
  const policy_1 = require("./policy");
46
50
  const presence_1 = require("./presence");
47
51
  const prompt_1 = require("./prompt");
48
52
  const types_1 = require("./types");
53
+ /**
54
+ * Turns one turn's ACP context updates into contract events for the room that woke it.
55
+ *
56
+ * A compaction is reported when it has COMPLETED, with the last number before it as
57
+ * `beforeTokens` and the first number after it as `afterTokens` — or without `afterTokens` when
58
+ * the turn ends first. There is no `compact_start` for ACP: `in_progress` may end in `failed`,
59
+ * and a start with no end would leave the app showing a tidy that never happened.
60
+ */
61
+ function acpContextObserver(report, conversationId) {
62
+ let lastUsed;
63
+ let afterCompaction;
64
+ const base = { runtime: "acp", source: "exact", conversationId };
65
+ const endCompaction = (after) => {
66
+ if (!afterCompaction)
67
+ return;
68
+ const { before } = afterCompaction;
69
+ afterCompaction = undefined;
70
+ report({ ...base, event: "compact_end", ...(before !== undefined ? { beforeTokens: before } : {}), ...(after !== undefined ? { afterTokens: after } : {}) });
71
+ };
72
+ return {
73
+ onContext(update) {
74
+ if (update.kind === "fresh")
75
+ return report({ ...base, event: "session_start" });
76
+ if (update.kind === "compacted") {
77
+ afterCompaction = { before: lastUsed };
78
+ return;
79
+ }
80
+ const sample = (0, context_report_1.acpUsageFromUpdate)(update);
81
+ if (!sample)
82
+ return;
83
+ endCompaction(sample.used);
84
+ lastUsed = sample.used;
85
+ report({ ...base, event: "usage", used: sample.used, size: sample.size });
86
+ },
87
+ end: () => endCompaction(),
88
+ };
89
+ }
49
90
  /**
50
91
  * How much power ONE turn gets.
51
92
  *
@@ -74,6 +115,15 @@ function isBayChatSend(call, mcpServer) {
74
115
  return true;
75
116
  return call.rawInput.providerIdentifier === mcpServer && call.rawInput.toolName === "send_message";
76
117
  }
118
+ /**
119
+ * Does an ACP turn's failure read as "the context window is full"? The wordings of the providers
120
+ * behind dsh and Cursor (OpenAI-style `context_length_exceeded`, Anthropic's "prompt is too long",
121
+ * "maximum context length"), matched loosely: a false positive only offers a fresh start the
122
+ * owner asked for.
123
+ */
124
+ function looksLikeContextOverflow(reason) {
125
+ return /context[_ ]length[_ ]exceeded|maximum context length|context window (is )?(full|exceeded)|prompt is too long|too many tokens/i.test(reason);
126
+ }
77
127
  const RETRY_MAX_MS = 60_000;
78
128
  const errText = (err) => (err instanceof Error ? err.message : String(err));
79
129
  /**
@@ -90,6 +140,10 @@ class AcpSessionRunner {
90
140
  said = new Map();
91
141
  backoff = new Map();
92
142
  retryTimers = new Set();
143
+ /** A fresh start's first prompt, run as the session's next turn, alone (`freshStart`). */
144
+ freshTurns = new Map();
145
+ /** Sessions whose running turn a fresh start stopped: its messages wait, they are not lost. */
146
+ requeueOnStop = new Map();
93
147
  closed = false;
94
148
  constructor(deps) {
95
149
  this.deps = deps;
@@ -110,6 +164,24 @@ class AcpSessionRunner {
110
164
  reset(session) {
111
165
  this.deps.registry.setAcpSession(session, undefined);
112
166
  }
167
+ /**
168
+ * "Fresh start with a handoff" for an ACP session (fresh-start.ts): forget the ACP session, so
169
+ * the next prompt opens a new one, and make `first` — the note — that prompt, ALONE. A turn
170
+ * running now (the one that wrote the note, or a person's newer one) is stopped and its messages
171
+ * go back in the queue behind the note, never recorded lost — except this fresh start's own
172
+ * note request, which the fresh session must never be asked again.
173
+ */
174
+ freshStart(session, first, handoffId) {
175
+ this.deps.queue.discard(session, (m) => (0, fresh_start_1.isNoteRequestFor)(m, handoffId));
176
+ this.freshTurns.set(session, first);
177
+ const running = this.running.get(session);
178
+ if (running) {
179
+ this.requeueOnStop.set(session, handoffId);
180
+ running.abort();
181
+ }
182
+ this.reset(session);
183
+ this.kick(session);
184
+ }
113
185
  setMode(session, wanted) {
114
186
  const target = this.deps.registry.get(session);
115
187
  if (!target?.acp)
@@ -162,7 +234,10 @@ class AcpSessionRunner {
162
234
  for (;;) {
163
235
  if (this.closed)
164
236
  return "closed";
165
- const turn = this.deps.queue.take(session);
237
+ const first = this.freshTurns.get(session);
238
+ if (first)
239
+ this.freshTurns.delete(session);
240
+ const turn = first ? { addressed: [first], digest: { messages: [], omitted: 0 } } : this.deps.queue.take(session);
166
241
  if (!turn)
167
242
  return "empty";
168
243
  let next;
@@ -256,6 +331,7 @@ class AcpSessionRunner {
256
331
  return answer.allowed ? "allow" : "reject";
257
332
  };
258
333
  const caller = (deps.caller ?? presence_1.deviceMcpCaller)(auth);
334
+ const context = acpContextObserver((event) => deps.reportContext?.(session, event), room);
259
335
  const abort = new AbortController();
260
336
  let typing;
261
337
  let stopGuard;
@@ -287,6 +363,7 @@ class AcpSessionRunner {
287
363
  typing?.stop();
288
364
  },
289
365
  classifyError: (m) => row.classifyError(m),
366
+ onContext: context.onContext,
290
367
  signal: abort.signal,
291
368
  }, { spawn: deps.spawn, log: deps.log, platform });
292
369
  }
@@ -294,6 +371,7 @@ class AcpSessionRunner {
294
371
  // Every exit — outcome or throw — ends typing and the guard and frees `/stop`, or `/stop` would lie.
295
372
  stopGuard?.();
296
373
  typing?.stop();
374
+ context.end();
297
375
  this.running.delete(session);
298
376
  }
299
377
  if (breach !== undefined) {
@@ -306,8 +384,22 @@ class AcpSessionRunner {
306
384
  return "next";
307
385
  }
308
386
  }
387
+ // A fresh start stopped this turn: its messages wait behind the note (minus the note request).
388
+ const requeueFor = this.requeueOnStop.get(session);
389
+ if (requeueFor !== undefined) {
390
+ this.requeueOnStop.delete(session);
391
+ const keep = (m) => !(0, fresh_start_1.isNoteRequestFor)(m, requeueFor);
392
+ deps.queue.putBack(session, {
393
+ addressed: turn.addressed.filter(keep),
394
+ digest: { messages: turn.digest.messages.filter(keep), omitted: turn.digest.omitted },
395
+ });
396
+ deps.log(`acp: ${session} turn stopped for a fresh start — its messages wait for the fresh session`);
397
+ return "next";
398
+ }
309
399
  if (outcome.kind === "completed") {
310
- deps.registry.setAcpSession(session, outcome.sessionId);
400
+ // A fresh start that landed while this turn ran has already forgotten the old session.
401
+ if (!this.freshTurns.has(session))
402
+ deps.registry.setAcpSession(session, outcome.sessionId);
311
403
  deps.onDelivered(session, turn.addressed);
312
404
  this.said.delete(session);
313
405
  this.backoff.delete(session);
@@ -336,6 +428,12 @@ class AcpSessionRunner {
336
428
  const named = reasons[outcome.cause];
337
429
  if (named)
338
430
  return drop(outcome.cause, named);
431
+ // A turn refused because the context is full: say so to BayChat, which tells the session's
432
+ // owner once (protocol 1.17 `overflow`). It starts nothing: a handoff note needs a turn the
433
+ // full session cannot take, so starting fresh is the owner's call from the memory sheet.
434
+ if (looksLikeContextOverflow(outcome.reason)) {
435
+ deps.reportContext?.(session, { runtime: "acp", source: "exact", event: "overflow", conversationId: room });
436
+ }
339
437
  // Unclassified: the raw reason can name paths on that computer, so the room gets a generic
340
438
  // line and the full (already redacted) reason goes to the log only (Ruling R29).
341
439
  deps.log(`acp: ${session} could not answer (${outcome.cause}) — ${outcome.reason}`);
@@ -38,6 +38,9 @@ exports.acpQueuePath = acpQueuePath;
38
38
  const fs = __importStar(require("fs"));
39
39
  const path = __importStar(require("path"));
40
40
  const config_1 = require("../../config");
41
+ const project_brief_1 = require("../project-brief");
42
+ const project_notes_1 = require("../project-notes");
43
+ const context_notice_1 = require("../context-notice");
41
44
  const DEFAULT_CAPS = { digestMessages: 20, digestChars: 4_000, maxAddressed: 50 };
42
45
  function acpQueuePath() {
43
46
  return path.join((0, config_1.configDir)(), "acp-queue.json");
@@ -87,7 +90,17 @@ class AcpTurnQueue {
87
90
  }
88
91
  const rawOmitted = Number(value.omitted);
89
92
  const omitted = Number.isFinite(rawOmitted) && rawOmitted > 0 ? Math.floor(rawOmitted) : 0;
90
- this.sessions.set(name, { addressed, digest, omitted });
93
+ const carried = value.carried && typeof value.carried === "object" ? value.carried : undefined;
94
+ const carriedNotes = parseCarriedNotes(value.carriedNotes);
95
+ const carriedNotice = (0, context_notice_1.contextNoticeFrom)(value.carriedNotice);
96
+ this.sessions.set(name, {
97
+ addressed,
98
+ digest,
99
+ omitted,
100
+ ...(carried ? { carried } : {}),
101
+ ...(carriedNotes ? { carriedNotes } : {}),
102
+ ...(carriedNotice ? { carriedNotice } : {}),
103
+ });
91
104
  }
92
105
  }
93
106
  add(session, batch) {
@@ -95,6 +108,21 @@ class AcpTurnQueue {
95
108
  let addressed = 0;
96
109
  let dropped = 0;
97
110
  for (const message of batch) {
111
+ const kept = state.carried?.[message.conversationId];
112
+ if (kept) {
113
+ delete state.carried[message.conversationId];
114
+ if (!message.projectBrief || kept.version >= message.projectBrief.version)
115
+ message.projectBrief = kept;
116
+ }
117
+ const keptNotes = state.carriedNotes?.[message.conversationId];
118
+ if (keptNotes) {
119
+ delete state.carriedNotes[message.conversationId];
120
+ message.projectNotes = message.projectNotes ? (0, project_notes_1.mergeNotesDeltas)(keptNotes, message.projectNotes) : keptNotes;
121
+ }
122
+ if (state.carriedNotice) {
123
+ message.notice = (0, context_notice_1.laterNotice)(state.carriedNotice, message.notice);
124
+ delete state.carriedNotice;
125
+ }
98
126
  if (message.shouldRespond === true) {
99
127
  if (state.addressed.length >= this.caps.maxAddressed)
100
128
  dropped += 1;
@@ -115,7 +143,14 @@ class AcpTurnQueue {
115
143
  if (!state || state.addressed.length === 0)
116
144
  return undefined;
117
145
  const turn = { addressed: state.addressed, digest: { messages: state.digest, omitted: state.omitted } };
118
- this.sessions.set(session, { addressed: [], digest: [], omitted: 0 });
146
+ this.sessions.set(session, {
147
+ addressed: [],
148
+ digest: [],
149
+ omitted: 0,
150
+ ...(state.carried ? { carried: state.carried } : {}),
151
+ ...(state.carriedNotes ? { carriedNotes: state.carriedNotes } : {}),
152
+ ...(state.carriedNotice ? { carriedNotice: state.carriedNotice } : {}),
153
+ });
119
154
  this.save();
120
155
  return turn;
121
156
  }
@@ -134,6 +169,35 @@ class AcpTurnQueue {
134
169
  this.sessions.delete(session);
135
170
  this.save();
136
171
  }
172
+ /**
173
+ * Forget the briefs, notes deltas and notice kept for this session: it started fresh, so they
174
+ * describe a memory it no longer has (the server re-sends the whole set). Waiting messages stay.
175
+ */
176
+ forgetCarried(session) {
177
+ const state = this.sessions.get(session);
178
+ if (!state || (!state.carried && !state.carriedNotes && !state.carriedNotice))
179
+ return;
180
+ delete state.carried;
181
+ delete state.carriedNotes;
182
+ delete state.carriedNotice;
183
+ this.save();
184
+ }
185
+ /**
186
+ * Remove waiting messages this predicate picks (a fresh start's own note request, which the
187
+ * fresh session must never be asked again). Returns how many.
188
+ */
189
+ discard(session, pick) {
190
+ const state = this.sessions.get(session);
191
+ if (!state)
192
+ return 0;
193
+ const before = state.addressed.length + state.digest.length;
194
+ state.addressed = state.addressed.filter((m) => !pick(m));
195
+ state.digest = state.digest.filter((m) => !pick(m));
196
+ const removed = before - state.addressed.length - state.digest.length;
197
+ if (removed > 0)
198
+ this.save();
199
+ return removed;
200
+ }
137
201
  state(session) {
138
202
  let s = this.sessions.get(session);
139
203
  if (!s)
@@ -143,8 +207,28 @@ class AcpTurnQueue {
143
207
  trimDigest(state) {
144
208
  const size = () => state.digest.reduce((n, m) => n + m.content.length, 0);
145
209
  while (state.digest.length > this.caps.digestMessages || (state.digest.length > 1 && size() > this.caps.digestChars)) {
146
- state.digest.shift();
210
+ const dropped = state.digest.shift();
147
211
  state.omitted += 1;
212
+ // Its project brief is not dropped with it: the next message for that room carries it.
213
+ if (dropped.projectBrief && !(0, project_brief_1.carryBrief)(dropped.projectBrief, dropped.conversationId, [...state.digest, ...state.addressed])) {
214
+ const held = state.carried?.[dropped.conversationId];
215
+ if (!held || dropped.projectBrief.version >= held.version) {
216
+ state.carried = { ...(state.carried ?? {}), [dropped.conversationId]: dropped.projectBrief };
217
+ }
218
+ }
219
+ // …and neither is its notes delta (project-notes.ts) — carried only FORWARD, onto a message
220
+ // at least as new (an addressed message can be older than the digest's oldest).
221
+ if (dropped.projectNotes && !(0, project_notes_1.carryNotes)(dropped.projectNotes, dropped, [...state.digest, ...state.addressed])) {
222
+ const held = state.carriedNotes?.[dropped.conversationId];
223
+ state.carriedNotes = {
224
+ ...(state.carriedNotes ?? {}),
225
+ [dropped.conversationId]: held ? (0, project_notes_1.mergeNotesDeltas)(held, dropped.projectNotes) : dropped.projectNotes,
226
+ };
227
+ }
228
+ // …nor BayChat's notice: onto the next message still waiting, else kept for the next one.
229
+ if (dropped.notice && !(0, context_notice_1.carryNotice)(dropped.notice, [...state.digest, ...state.addressed])) {
230
+ state.carriedNotice = (0, context_notice_1.laterNotice)(state.carriedNotice, dropped.notice);
231
+ }
148
232
  }
149
233
  }
150
234
  save() {
@@ -161,3 +245,15 @@ class AcpTurnQueue {
161
245
  }
162
246
  }
163
247
  exports.AcpTurnQueue = AcpTurnQueue;
248
+ /** A persisted `carriedNotes` map, each delta re-parsed (a corrupt entry is dropped). */
249
+ function parseCarriedNotes(raw) {
250
+ if (!raw || typeof raw !== "object")
251
+ return undefined;
252
+ const out = {};
253
+ for (const [conversationId, value] of Object.entries(raw)) {
254
+ const delta = (0, project_notes_1.notesDeltaFrom)(value);
255
+ if (delta)
256
+ out[conversationId] = delta;
257
+ }
258
+ return Object.keys(out).length > 0 ? out : undefined;
259
+ }
@@ -0,0 +1,242 @@
1
+ "use strict";
2
+ // How full a Codex session's context is, read from the log Codex writes anyway.
3
+ //
4
+ // Codex records every model call in its rollout (`~/.codex/sessions/<y>/<m>/<d>/rollout-*.jsonl`)
5
+ // as an `event_msg` whose payload is `token_count`:
6
+ //
7
+ // {"type":"event_msg","payload":{"type":"token_count","info":{
8
+ // "total_token_usage":{…13,242,298…}, ← the whole thread: NEVER used
9
+ // "last_token_usage":{"input_tokens":59244,…,"total_tokens":59346}, ← what is in the window
10
+ // "model_context_window":258400}}}
11
+ //
12
+ // (shape from a real rollout, 2026-09-13), and a compaction as a top-level `{"type":"compacted"}`
13
+ // line. The relay already knows each Codex session's thread id — it is how `codex queue` reaches
14
+ // it — so it tails that thread's newest rollout, a few seconds behind, and reports.
15
+ //
16
+ // READ-ONLY, BOUNDED, AND NEVER IN THE WAY. The file is only ever read; a tick reads at most what
17
+ // was appended since the last one (capped); a missing or unreadable rollout is a quiet "nothing
18
+ // to report", said once in the log. Nothing here can delay or fail a delivery.
19
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ var desc = Object.getOwnPropertyDescriptor(m, k);
22
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
23
+ desc = { enumerable: true, get: function() { return m[k]; } };
24
+ }
25
+ Object.defineProperty(o, k2, desc);
26
+ }) : (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ o[k2] = m[k];
29
+ }));
30
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
31
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
32
+ }) : function(o, v) {
33
+ o["default"] = v;
34
+ });
35
+ var __importStar = (this && this.__importStar) || (function () {
36
+ var ownKeys = function(o) {
37
+ ownKeys = Object.getOwnPropertyNames || function (o) {
38
+ var ar = [];
39
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
40
+ return ar;
41
+ };
42
+ return ownKeys(o);
43
+ };
44
+ return function (mod) {
45
+ if (mod && mod.__esModule) return mod;
46
+ var result = {};
47
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
48
+ __setModuleDefault(result, mod);
49
+ return result;
50
+ };
51
+ })();
52
+ Object.defineProperty(exports, "__esModule", { value: true });
53
+ exports.CodexContextTailer = void 0;
54
+ const fs = __importStar(require("fs"));
55
+ const context_report_1 = require("../context-report");
56
+ const resume_1 = require("./resume");
57
+ /** On first sight of a rollout, how far back to look for the current number. */
58
+ const PRIME_BYTES = 512 * 1024;
59
+ /** Most bytes read in one tick; a burst beyond this is skipped to its last PRIME_BYTES. */
60
+ const MAX_TICK_BYTES = 4 * 1024 * 1024;
61
+ /** How often a session's rollout is looked up again (a resume writes a NEW rollout file). */
62
+ const RESOLVE_EVERY_MS = 2 * 60_000;
63
+ /** A compaction whose "after" number never arrives is still reported, without it. */
64
+ const COMPACTION_AFTER_WAIT_MS = 60_000;
65
+ const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
66
+ class CodexContextTailer {
67
+ deps;
68
+ tails = new Map();
69
+ timer;
70
+ running = false;
71
+ constructor(deps) {
72
+ this.deps = deps;
73
+ }
74
+ now() {
75
+ return (this.deps.now ?? Date.now)();
76
+ }
77
+ start(intervalMs = 5_000) {
78
+ if (this.timer)
79
+ return;
80
+ this.timer = setInterval(() => void this.tick(), intervalMs);
81
+ this.timer.unref?.();
82
+ }
83
+ stop() {
84
+ clearInterval(this.timer);
85
+ this.timer = undefined;
86
+ }
87
+ /** One pass over every Codex session. Never throws; never overlaps itself. */
88
+ async tick() {
89
+ if (this.running)
90
+ return;
91
+ this.running = true;
92
+ try {
93
+ const targets = this.deps.targets();
94
+ const live = new Set(targets.map((t) => t.session));
95
+ for (const session of [...this.tails.keys()])
96
+ if (!live.has(session))
97
+ this.tails.delete(session);
98
+ for (const target of targets) {
99
+ try {
100
+ await this.tickOne(target);
101
+ }
102
+ catch (err) {
103
+ this.deps.log(`context: could not read ${target.session}'s Codex log: ${err instanceof Error ? err.message : String(err)}`);
104
+ }
105
+ }
106
+ }
107
+ finally {
108
+ this.running = false;
109
+ }
110
+ }
111
+ emit(session, event) {
112
+ const conversationId = this.deps.conversationFor?.(session);
113
+ this.deps.report(session, { runtime: "codex", source: "exact", ...(conversationId ? { conversationId } : {}), ...event });
114
+ }
115
+ async tickOne(target) {
116
+ const now = this.now();
117
+ let tail = this.tails.get(target.session);
118
+ if (tail && tail.threadId !== target.threadId) {
119
+ // `/new` (or a fresh join) gave this BayChat session another Codex thread: a new context.
120
+ this.tails.delete(target.session);
121
+ tail = undefined;
122
+ this.emit(target.session, { event: "session_start" });
123
+ }
124
+ if (!tail) {
125
+ tail = { threadId: target.threadId, offset: 0, remainder: "", resolvedAt: 0, primed: false };
126
+ this.tails.set(target.session, tail);
127
+ }
128
+ if (!tail.file || now - tail.resolvedAt >= RESOLVE_EVERY_MS) {
129
+ tail.resolvedAt = now;
130
+ const file = await (this.deps.findRollout ?? defaultFindRollout)(target.threadId);
131
+ if (!file) {
132
+ if (!tail.missingSaid)
133
+ this.deps.log(`context: no Codex log found for ${target.session} (thread ${target.threadId}) — its context is not reported yet`);
134
+ tail.missingSaid = true;
135
+ }
136
+ else if (file !== tail.file) {
137
+ // A resumed thread writes a NEW rollout; follow it from its current end.
138
+ Object.assign(tail, { file, offset: 0, remainder: "", primed: false, missingSaid: false });
139
+ }
140
+ }
141
+ if (tail.compaction && now - tail.compaction.at >= COMPACTION_AFTER_WAIT_MS)
142
+ this.endCompaction(target.session, tail);
143
+ if (!tail.file)
144
+ return;
145
+ let size;
146
+ try {
147
+ size = (await fs.promises.stat(tail.file)).size;
148
+ }
149
+ catch {
150
+ tail.file = undefined; // gone: look it up again next tick
151
+ return;
152
+ }
153
+ if (size < tail.offset)
154
+ Object.assign(tail, { offset: 0, remainder: "", primed: false });
155
+ if (!tail.primed) {
156
+ // First sight: only the CURRENT number matters. History (old compactions) is not news.
157
+ const start = Math.max(0, size - PRIME_BYTES);
158
+ const text = await readRange(tail.file, start, size);
159
+ const lines = (start > 0 ? text.slice(text.indexOf("\n") + 1) : text).split("\n");
160
+ let latest = null;
161
+ for (const line of lines)
162
+ latest = parseTokenCount(line) ?? latest;
163
+ Object.assign(tail, { offset: size, remainder: "", primed: true });
164
+ if (latest) {
165
+ tail.lastUsed = latest.used;
166
+ this.emit(target.session, { event: "usage", used: latest.used, size: latest.size, autoCompactAt: (0, context_report_1.codexAutoCompactAt)(latest.size) });
167
+ }
168
+ return;
169
+ }
170
+ if (size === tail.offset)
171
+ return;
172
+ let from = tail.offset;
173
+ if (size - from > MAX_TICK_BYTES) {
174
+ from = size - PRIME_BYTES;
175
+ tail.remainder = "";
176
+ }
177
+ const text = tail.remainder + (await readRange(tail.file, from, size));
178
+ tail.offset = size;
179
+ const lines = text.split("\n");
180
+ tail.remainder = lines.pop() ?? ""; // an unfinished last line waits for the rest
181
+ for (const line of lines)
182
+ this.line(target.session, tail, line);
183
+ }
184
+ line(session, tail, line) {
185
+ if (!line.trim())
186
+ return;
187
+ if (line.includes('"compacted"') || line.includes("context_compacted")) {
188
+ const entry = safeParse(line);
189
+ const payloadType = isRecord(entry?.payload) ? entry.payload.type : undefined;
190
+ if (entry?.type === "compacted" || payloadType === "context_compacted") {
191
+ // One compaction may be written both ways; the first one wins.
192
+ if (!tail.compaction)
193
+ tail.compaction = { before: tail.lastUsed, at: this.now() };
194
+ return;
195
+ }
196
+ }
197
+ const sample = parseTokenCount(line);
198
+ if (!sample)
199
+ return;
200
+ this.endCompaction(session, tail, sample.used);
201
+ tail.lastUsed = sample.used;
202
+ this.emit(session, { event: "usage", used: sample.used, size: sample.size, autoCompactAt: (0, context_report_1.codexAutoCompactAt)(sample.size) });
203
+ }
204
+ endCompaction(session, tail, after) {
205
+ if (!tail.compaction)
206
+ return;
207
+ const { before } = tail.compaction;
208
+ tail.compaction = undefined;
209
+ // No `trigger`: the rollout does not say whether the person asked for it.
210
+ this.emit(session, { event: "compact_end", ...(before !== undefined ? { beforeTokens: before } : {}), ...(after !== undefined ? { afterTokens: after } : {}) });
211
+ }
212
+ }
213
+ exports.CodexContextTailer = CodexContextTailer;
214
+ function safeParse(line) {
215
+ try {
216
+ const v = JSON.parse(line);
217
+ return isRecord(v) ? v : undefined;
218
+ }
219
+ catch {
220
+ return undefined;
221
+ }
222
+ }
223
+ function parseTokenCount(line) {
224
+ if (!line.includes("token_count"))
225
+ return null;
226
+ const entry = safeParse(line);
227
+ return entry?.type === "event_msg" ? (0, context_report_1.codexUsageFromTokenCount)(entry.payload) : null;
228
+ }
229
+ async function readRange(file, start, end) {
230
+ const handle = await fs.promises.open(file, "r");
231
+ try {
232
+ const buf = Buffer.alloc(end - start);
233
+ await handle.read(buf, 0, buf.length, start);
234
+ return buf.toString("utf8");
235
+ }
236
+ finally {
237
+ await handle.close();
238
+ }
239
+ }
240
+ async function defaultFindRollout(threadId) {
241
+ return (await (0, resume_1.codexRolloutById)(threadId, {}))?.file;
242
+ }
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.COMPACT_COMMAND = exports.sanitizeCompactFocus = exports.COMPACT_FOCUS_MAX = exports.COMPACT_TRIGGER = void 0;
4
+ exports.parseCompactCommand = parseCompactCommand;
5
+ exports.compactKeys = compactKeys;
6
+ const compact_focus_1 = require("./compact-focus");
7
+ Object.defineProperty(exports, "COMPACT_FOCUS_MAX", { enumerable: true, get: function () { return compact_focus_1.COMPACT_FOCUS_MAX; } });
8
+ Object.defineProperty(exports, "sanitizeCompactFocus", { enumerable: true, get: function () { return compact_focus_1.sanitizeCompactFocus; } });
9
+ /**
10
+ * `/compact [focus]` — tidy a session's context now, optionally saying what to keep.
11
+ *
12
+ * ── 🔒 THE ONE COMMAND WITH AN ARGUMENT, AND WHY IT IS SAFE TO HAVE ONE ─────────────────
13
+ *
14
+ * Every other session command obeys "the wire carries a command NAME, never keystrokes"
15
+ * (session-commands.ts). This one carries one line of text, because Claude Code's own
16
+ * `/compact` takes instructions ("/compact keep the API decisions") and a tidy without them
17
+ * throws away what the person cared about. So the text is allowed in, and caged — the cage is
18
+ * `compact-focus.ts`, a twin the API applies too when the app asks for a tidy — and it is typed
19
+ * LITERALLY (`send-keys -l`, via `typeIntoPane`, which refuses `^`, `\` and control characters
20
+ * again on its own). Anything outside the cage is refused, by reason, and nothing is typed.
21
+ * Only Claude Code takes a focus; Codex's `/compact` takes none (checked against codex-cli
22
+ * 0.154.0's command list), so a focus sent to Codex is refused rather than silently dropped.
23
+ */
24
+ exports.COMPACT_TRIGGER = "/compact";
25
+ exports.COMPACT_COMMAND = {
26
+ id: "compact",
27
+ trigger: exports.COMPACT_TRIGGER,
28
+ summary: `Tidy this session's context now (Claude Code, Codex). Claude Code takes a one-line focus: /compact keep the API decisions — letters, numbers, spaces and ${compact_focus_1.COMPACT_FOCUS_PUNCTUATION}, up to ${compact_focus_1.COMPACT_FOCUS_MAX} characters.`,
29
+ };
30
+ /**
31
+ * `/compact` or `/compact <focus>` → the parse; anything else → undefined (it is chat).
32
+ *
33
+ * The command word is matched exactly and case-insensitively, as the other commands are:
34
+ * "can you /compact please" and "/compacting" are sentences, not commands.
35
+ */
36
+ function parseCompactCommand(text) {
37
+ const trimmed = text.trimStart();
38
+ if (trimmed.slice(0, exports.COMPACT_TRIGGER.length).toLowerCase() !== exports.COMPACT_TRIGGER)
39
+ return undefined;
40
+ // Trailing whitespace only: a SECOND line has non-space after its newline, so it survives
41
+ // this and is refused below.
42
+ const tail = trimmed.slice(exports.COMPACT_TRIGGER.length).trimEnd();
43
+ if (tail === "")
44
+ return { kind: "compact" };
45
+ // "/compacting" is a word, not the command: a separator must follow the trigger.
46
+ if (!/^\s/u.test(tail))
47
+ return undefined;
48
+ const checked = (0, compact_focus_1.sanitizeCompactFocus)(tail);
49
+ if (!checked.ok)
50
+ return { kind: "refused", reason: `/compact was not run: ${checked.reason}` };
51
+ return checked.focus ? { kind: "compact", focus: checked.focus } : { kind: "compact" };
52
+ }
53
+ /** What to type for this runtime, or why nothing can be. */
54
+ function compactKeys(runtime, focus) {
55
+ if (runtime === "claude")
56
+ return { ok: true, keys: focus ? `${exports.COMPACT_TRIGGER} ${focus}` : exports.COMPACT_TRIGGER };
57
+ if (runtime === "codex") {
58
+ return focus
59
+ ? { ok: false, reason: "Codex's /compact takes no focus. Send /compact on its own to tidy it." }
60
+ : { ok: true, keys: exports.COMPACT_TRIGGER };
61
+ }
62
+ return { ok: false, reason: `/compact is only for Claude Code and Codex sessions; this one runs ${runtime}.` };
63
+ }
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.COMPACT_FOCUS_ATTACKS = void 0;
4
+ /**
5
+ * Focus texts BOTH the relay and the API must refuse — the injection attempts the cage in
6
+ * `compact-focus.ts` exists for. Each is what follows `/compact` in a message (a leading space
7
+ * included where a person would type one). A twin, byte-identical in
8
+ * `packages/cli/src/relay/` and `apps/api/src/lib/`; each side runs every entry through its own
9
+ * copy of `sanitizeCompactFocus`, and the parity test holds the two files together.
10
+ *
11
+ * It imports nothing (the two packages resolve imports differently), so the refusal codes are
12
+ * spelled out here; they are `CompactFocusRefusal`'s, checked by the tests that use them.
13
+ */
14
+ exports.COMPACT_FOCUS_ATTACKS = [
15
+ { focus: " keep this\n/clear", code: "MULTILINE", reason: /single line/ },
16
+ { focus: " keep this\r\nrm -rf ~", code: "MULTILINE", reason: /single line/ },
17
+ { focus: "\nrm -rf ~", code: "MULTILINE", reason: /single line/ },
18
+ { focus: " keep\u2028this", code: "MULTILINE", reason: /single line/ },
19
+ { focus: " keep\u2029this", code: "MULTILINE", reason: /single line/ },
20
+ { focus: " \u001b[2J\u001b[31mred", code: "CONTROL", reason: /control or invisible/ },
21
+ { focus: " keep\tthis", code: "CONTROL", reason: /control or invisible/ },
22
+ { focus: " a\u0003b", code: "CONTROL", reason: /control or invisible/ },
23
+ { focus: " safe\u202Eesrever", code: "CONTROL", reason: /control or invisible/ },
24
+ { focus: " zero\u200Bwidth", code: "CONTROL", reason: /control or invisible/ },
25
+ { focus: " $(rm -rf ~)", code: "CHARACTER", reason: /"\$" is not allowed/ },
26
+ { focus: " `id`", code: "CHARACTER", reason: /"`" is not allowed/ },
27
+ { focus: " keep ^C", code: "CHARACTER", reason: /"\^" is not allowed/ },
28
+ { focus: " back\\slash", code: "CHARACTER", reason: /"\\" is not allowed/ },
29
+ { focus: " read @~/.ssh/id_rsa", code: "CHARACTER", reason: /"@" is not allowed/ },
30
+ { focus: " a | b", code: "CHARACTER", reason: /"\|" is not allowed/ },
31
+ { focus: " a > /etc/passwd", code: "CHARACTER", reason: /">" is not allowed/ },
32
+ { focus: " #memory", code: "CHARACTER", reason: /"#" is not allowed/ },
33
+ { focus: " {x}", code: "CHARACTER", reason: /"\{" is not allowed/ },
34
+ { focus: " /clear", code: "LEADING_SLASH", reason: /cannot start with \// },
35
+ { focus: ` ${"a".repeat(201)}`, code: "TOO_LONG", reason: /longer than 200/ },
36
+ ];