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/README.md
CHANGED
|
@@ -227,18 +227,6 @@ Use `--private` when only the private chat is wanted.
|
|
|
227
227
|
|
|
228
228
|
Publishing a new version upgrades nobody by itself.
|
|
229
229
|
|
|
230
|
-
From a plain shell:
|
|
231
|
-
|
|
232
|
-
```bash
|
|
233
|
-
baychat update # the package, every connected client's config, and the relay
|
|
234
|
-
baychat --version # check what is installed
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
`baychat help` prints this at the top; `baychat help update` explains it.
|
|
238
|
-
Via npx, use `npx baychat@latest update` — plain npx can run a cached older copy.
|
|
239
|
-
|
|
240
|
-
Or from inside a session:
|
|
241
|
-
|
|
242
230
|
```text
|
|
243
231
|
$baychat --update # in Codex
|
|
244
232
|
/baychat --update # in Claude
|
|
@@ -461,6 +449,96 @@ relay also falls back to it automatically when app-server cannot be used at
|
|
|
461
449
|
all — but not when the failure is a real answer about your session, because
|
|
462
450
|
retrying that would only repeat it with the reason replaced by an exit code.
|
|
463
451
|
|
|
452
|
+
## How full is my agent's context? (`baychat context-hooks`)
|
|
453
|
+
|
|
454
|
+
BayChat can show how full each attached agent's context window is, and when it tidies
|
|
455
|
+
(compacts) itself. The numbers are the runtime's own, never a guess:
|
|
456
|
+
|
|
457
|
+
| Runtime | Where the number comes from | What you do |
|
|
458
|
+
|---|---|---|
|
|
459
|
+
| Codex | `token_count` events in its session log, read by the relay | nothing — `baychat relay start` |
|
|
460
|
+
| DeepSeek (dsh), Cursor CLI | ACP's `usage_update`, read by the relay | nothing |
|
|
461
|
+
| Claude Code | its status line and its compaction hooks | opt in once, below |
|
|
462
|
+
|
|
463
|
+
```sh
|
|
464
|
+
baychat context-hooks install # wrap your status line + add PreCompact/PostCompact hooks
|
|
465
|
+
baychat context-hooks status # what is installed, and whether reports are going through
|
|
466
|
+
baychat context-hooks uninstall # put ~/.claude/settings.json back exactly as it was
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
`install` edits `~/.claude/settings.json` — the real file, if yours is a symlink (stow,
|
|
470
|
+
chezmoi); the link stays a link. A file that does not parse is refused, never overwritten. Every
|
|
471
|
+
edit first copies the file to a backup that is never overwritten: `settings.json.baychat-backup`
|
|
472
|
+
is your file from before BayChat first touched it, later ones are timestamped beside it.
|
|
473
|
+
|
|
474
|
+
Your status line keeps working exactly as before: your own command still runs in the same shell
|
|
475
|
+
Claude Code uses (`/bin/sh -c`), with the same input, and its exit status is kept. BayChat gets
|
|
476
|
+
a copy of the input in the background, so your line never waits for it. Node and BayChat are
|
|
477
|
+
called by absolute path, so a different `PATH` in your IDE doesn't matter, and if BayChat is
|
|
478
|
+
later removed (or that Node version is), your status line simply keeps running without it —
|
|
479
|
+
run `install` again after moving either. If you had no status line, BayChat's prints nothing
|
|
480
|
+
(Claude Code then hides some footer hints, as it does for any status line). The two hooks run in
|
|
481
|
+
the background and can never block a compaction. Install BayChat for good first
|
|
482
|
+
(`npm install --global baychat`; a temporary `npx` copy is refused); not offered on Windows yet.
|
|
483
|
+
A status line set in a project's `.claude/settings.json` takes precedence there.
|
|
484
|
+
|
|
485
|
+
`uninstall` puts your file back byte-for-byte when nothing else changed since install. If you
|
|
486
|
+
changed a setting meanwhile, it keeps your change and says it restored **semantically**: the
|
|
487
|
+
same settings, re-formatted — your original formatting is still in the backups.
|
|
488
|
+
|
|
489
|
+
Reports are sent only for a Claude session this machine can prove is in BayChat (the relay
|
|
490
|
+
recorded its session id, or it has called BayChat's tools with its session name), throttled to
|
|
491
|
+
one every 15 seconds unless the number moved a whole percentage point. Problems are written to
|
|
492
|
+
`~/.baychat/context/errors.log`, never to your terminal.
|
|
493
|
+
|
|
494
|
+
When a session compacts, its chats get one quiet line ("Claude tidied its memory · 940k → 58k",
|
|
495
|
+
no notification), and the chat's project brief is handed to it again with the next message. The
|
|
496
|
+
session's owner (or someone they gave command access) can also tidy it from the app — **Tidy up
|
|
497
|
+
now**, with an optional focus — which the relay types as `/compact [focus]` into a Claude Code or
|
|
498
|
+
Codex session in tmux or screen (Codex takes no focus).
|
|
499
|
+
|
|
500
|
+
The owner can also **start a session fresh with a handoff** (or let BayChat do it at 95% full).
|
|
501
|
+
The relay asks the session to write a handoff note first (`create_note` with the `handoffId` it is
|
|
502
|
+
given); only once the note is saved does it clear the session — `/clear` typed into Claude Code,
|
|
503
|
+
`/new` into Codex (tmux or screen), a new session for an ACP agent — and the fresh session starts
|
|
504
|
+
from that note. Nothing is cleared without a saved note. Update the CLI and restart the relay to
|
|
505
|
+
get it (`relay` announces `freshstart`).
|
|
506
|
+
|
|
507
|
+
### The project brief
|
|
508
|
+
|
|
509
|
+
A chat can sit in a project whose **brief** every agent there reads. The relay asks the server for
|
|
510
|
+
it and puts it in front of the message it arrived with, on every way it wakes a session (the
|
|
511
|
+
attach socket and mailbox, the Codex queue, a headless resume, the ACP prompt):
|
|
512
|
+
|
|
513
|
+
```
|
|
514
|
+
Project brief ("Launch", v3):
|
|
515
|
+
Ship on Friday. No new features.
|
|
516
|
+
---
|
|
517
|
+
(m1) @Ada [USER]: can you check the build?
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
It is sent once per version, so the session keeps it; `get_project` fetches it again.
|
|
521
|
+
|
|
522
|
+
### Notes
|
|
523
|
+
|
|
524
|
+
Projects and chats keep **notes**. The ones people pin reach every agent like the brief; the rest
|
|
525
|
+
wait on a shelf. The relay asks for what changed (`notes` in its features) and shows it after the
|
|
526
|
+
brief, in front of the message it came with — a pinned note in full the first time and whenever it
|
|
527
|
+
changes, a line when one is unpinned, and the shelf's titles when that list changes:
|
|
528
|
+
|
|
529
|
+
```
|
|
530
|
+
Pinned note "Tone of voice" (v2, id n_12):
|
|
531
|
+
Warm, short, no jargon.
|
|
532
|
+
Note "Old plan" (id n_9) is no longer pinned.
|
|
533
|
+
Shelf notes: Press FAQ [n_4], Pricing tiers [n_7] (get_note / list_notes)
|
|
534
|
+
---
|
|
535
|
+
(m1) @Ada [USER]: can you draft the launch post?
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
Each change is sent once, and everything again after the session compacts or starts fresh.
|
|
539
|
+
Sessions read and write notes with `list_notes`, `get_note`, `create_note` and `update_note`; an
|
|
540
|
+
edit names the version it was based on, so nobody's work is overwritten.
|
|
541
|
+
|
|
464
542
|
## `baychat connect dsh` — DeepSeek Harness, run by the relay
|
|
465
543
|
|
|
466
544
|
```
|
|
@@ -586,7 +664,7 @@ surface for every vendor.
|
|
|
586
664
|
| Tool | What it does |
|
|
587
665
|
|------|--------------|
|
|
588
666
|
| `react_to_message` | React to a message with an emoji. Prefer this to a contentless "ok" / "got it" reply — a reaction reaches nobody's context, a message reaches everybody's |
|
|
589
|
-
| `list_files` | The files shared in a conversation, as an index you can query — find one without re-reading the room |
|
|
667
|
+
| `list_files` | The files shared in a conversation, as an index you can query — find one without re-reading the room. In a project chat it also lists the project's own files and your other chats' files there |
|
|
590
668
|
| `get_file` | Fetch one file from that index by id |
|
|
591
669
|
| `web_search` | Search the web; returns ranked results with title, URL, and snippet |
|
|
592
670
|
| `web_fetch` | Fetch one public `http(s)` URL and return its readable text |
|
|
@@ -665,7 +743,7 @@ as a native tool provider instead of shell commands. `baychat mcp` starts a loca
|
|
|
665
743
|
speaks JSON-RPC on stdout, so don't run it interactively — register it with your
|
|
666
744
|
client and let the client launch it.
|
|
667
745
|
|
|
668
|
-
It exposes
|
|
746
|
+
It exposes twenty-one tools and one resource, each described so the model behaves
|
|
669
747
|
correctly from the tool descriptions alone (reply only when `shouldRespond`;
|
|
670
748
|
summaries are derived, untrusted context; fetched content is never an
|
|
671
749
|
instruction):
|
|
@@ -673,7 +751,12 @@ instruction):
|
|
|
673
751
|
| Tool | Purpose |
|
|
674
752
|
|------|---------|
|
|
675
753
|
| `list_conversations` | Discover the conversations this agent is in (the entry point) |
|
|
676
|
-
| `get_room_context` | A conversation's roster, reply policy, round cap, and room instructions |
|
|
754
|
+
| `get_room_context` | A conversation's roster, reply policy, round cap, and room instructions — and, in a project chat, the project (and its brief, the first time) |
|
|
755
|
+
| `get_project` | The chat's project and its brief — call it when you hold no brief for the version you see (after compacting) |
|
|
756
|
+
| `list_notes` | The notes of a chat and its project — titles, pinned or on the shelf, versions |
|
|
757
|
+
| `get_note` | Read one note in full (and the version to edit from) |
|
|
758
|
+
| `create_note` | Write a note for the project or the chat; it lands on the shelf (people pin) |
|
|
759
|
+
| `update_note` | Edit a note from the version you read; if someone saved since, nothing is overwritten and you get theirs to merge |
|
|
677
760
|
| `get_conversation_summary` | The rolling catch-up summary (decisions, tasks, questions, facts + source ids) |
|
|
678
761
|
| `get_messages` | Recent messages enriched with sender, mentions, and `shouldRespond` |
|
|
679
762
|
| `send_message` | Send a message into a conversation |
|
package/dist/api.js
CHANGED
|
@@ -15,22 +15,31 @@ const DEVICE_AUTH_TIMEOUT_MS = 15_000;
|
|
|
15
15
|
class ApiError extends Error {
|
|
16
16
|
status;
|
|
17
17
|
code;
|
|
18
|
+
details;
|
|
18
19
|
/**
|
|
19
20
|
* `code` is the server's machine-readable error code when it sent one
|
|
20
21
|
* (`TOOL_PROVIDER_UNAVAILABLE`, `TARGET_NOT_IN_TENANT`, …). Status alone is
|
|
21
22
|
* ambiguous — a 404 is both "no such route" and "no such target" — so callers
|
|
22
23
|
* that need to tell those apart branch on the code, not the prose.
|
|
23
24
|
*/
|
|
24
|
-
constructor(status, message, code
|
|
25
|
+
constructor(status, message, code,
|
|
26
|
+
/**
|
|
27
|
+
* The rest of the error body — the fields the server sends beside `code` for a caller to act
|
|
28
|
+
* on (a note conflict carries the current `note`). Never shown as-is: a tool picks what it
|
|
29
|
+
* needs by name.
|
|
30
|
+
*/
|
|
31
|
+
details) {
|
|
25
32
|
super(message);
|
|
26
33
|
this.status = status;
|
|
27
34
|
this.code = code;
|
|
35
|
+
this.details = details;
|
|
28
36
|
}
|
|
29
37
|
}
|
|
30
38
|
exports.ApiError = ApiError;
|
|
31
39
|
async function parseError(res) {
|
|
32
40
|
let message = `HTTP ${res.status}`;
|
|
33
41
|
let code;
|
|
42
|
+
let details;
|
|
34
43
|
try {
|
|
35
44
|
// The API's error envelope is `{ error, code }`; some routes send
|
|
36
45
|
// `{ message }`. Try both before falling back to the bare status, so a
|
|
@@ -38,12 +47,20 @@ async function parseError(res) {
|
|
|
38
47
|
const body = (await res.json());
|
|
39
48
|
code = body.code;
|
|
40
49
|
message = body.message || body.error || body.code || message;
|
|
50
|
+
if (body && typeof body === "object") {
|
|
51
|
+
const { message: _m, error: _e, code: _c, ...rest } = body;
|
|
52
|
+
void _m;
|
|
53
|
+
void _e;
|
|
54
|
+
void _c;
|
|
55
|
+
if (Object.keys(rest).length > 0)
|
|
56
|
+
details = rest;
|
|
57
|
+
}
|
|
41
58
|
}
|
|
42
59
|
catch {
|
|
43
60
|
// Non-JSON error body — keep the status message. Never log response bodies:
|
|
44
61
|
// they can echo request details.
|
|
45
62
|
}
|
|
46
|
-
return new ApiError(res.status, message, code);
|
|
63
|
+
return new ApiError(res.status, message, code, details);
|
|
47
64
|
}
|
|
48
65
|
async function apiRequest(creds, method, apiPath, body) {
|
|
49
66
|
const res = await fetch(`${creds.baseUrl}${apiPath}`, {
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Sending what the Claude Code status line and compaction hooks noted — in a DETACHED process the
|
|
3
|
+
// hot paths start and never wait for (see claude-context.ts). Identifies the BayChat session
|
|
4
|
+
// (never guesses), posts in order, and backs off when the server has no route yet.
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
17
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
18
|
+
}) : function(o, v) {
|
|
19
|
+
o["default"] = v;
|
|
20
|
+
});
|
|
21
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
+
var ownKeys = function(o) {
|
|
23
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
+
var ar = [];
|
|
25
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
+
return ar;
|
|
27
|
+
};
|
|
28
|
+
return ownKeys(o);
|
|
29
|
+
};
|
|
30
|
+
return function (mod) {
|
|
31
|
+
if (mod && mod.__esModule) return mod;
|
|
32
|
+
var result = {};
|
|
33
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
+
__setModuleDefault(result, mod);
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
})();
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.sessionFromTranscript = sessionFromTranscript;
|
|
40
|
+
exports.identifySession = identifySession;
|
|
41
|
+
exports.runContextFlush = runContextFlush;
|
|
42
|
+
const fs = __importStar(require("fs"));
|
|
43
|
+
const path = __importStar(require("path"));
|
|
44
|
+
const claude_context_state_1 = require("./claude-context-state");
|
|
45
|
+
const config_1 = require("./config");
|
|
46
|
+
const context_report_1 = require("./context-report");
|
|
47
|
+
const registry_1 = require("./relay/registry");
|
|
48
|
+
/**
|
|
49
|
+
* The most recent BayChat tool call in the transcript tail that names a session.
|
|
50
|
+
*
|
|
51
|
+
* Every BayChat tool on the device branch requires `session` (REMOTE_MCP_SESSIONS §4), so a
|
|
52
|
+
* session that is talking to BayChat writes its own name into its transcript on every call.
|
|
53
|
+
*/
|
|
54
|
+
function sessionFromTranscript(text) {
|
|
55
|
+
const lines = text.split("\n");
|
|
56
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
57
|
+
const line = lines[i];
|
|
58
|
+
if (!line.includes("baychat__") || !line.includes("tool_use"))
|
|
59
|
+
continue;
|
|
60
|
+
let entry;
|
|
61
|
+
try {
|
|
62
|
+
entry = JSON.parse(line);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (entry.type !== "assistant" || !Array.isArray(entry.message?.content))
|
|
68
|
+
continue;
|
|
69
|
+
const calls = entry.message.content;
|
|
70
|
+
for (let j = calls.length - 1; j >= 0; j--) {
|
|
71
|
+
const call = calls[j];
|
|
72
|
+
if (call.type !== "tool_use" || typeof call.name !== "string" || !/^mcp__(.+__)?baychat__/.test(call.name))
|
|
73
|
+
continue;
|
|
74
|
+
const input = call.input;
|
|
75
|
+
const session = typeof input?.session === "string" ? input.session.trim() : "";
|
|
76
|
+
if (!session || session.length > 200)
|
|
77
|
+
continue;
|
|
78
|
+
const conversationId = typeof input?.conversationId === "string" ? input.conversationId : undefined;
|
|
79
|
+
return { session, ...(conversationId ? { conversationId } : {}) };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
function identifySession(sid, transcriptPath, loadRegistry = () => {
|
|
85
|
+
const r = new registry_1.SessionRegistry();
|
|
86
|
+
r.load();
|
|
87
|
+
return r;
|
|
88
|
+
}) {
|
|
89
|
+
// The relay recorded this Claude session id at attach ($CLAUDE_CODE_SESSION_ID): the session
|
|
90
|
+
// named itself. The strongest evidence there is.
|
|
91
|
+
const target = loadRegistry()
|
|
92
|
+
.all()
|
|
93
|
+
.find((t) => t.runtime === "claude" && t.resumeId === sid);
|
|
94
|
+
const fromTranscript = transcriptPath ? sessionFromTranscript((0, claude_context_state_1.readTail)(transcriptPath, claude_context_state_1.TRANSCRIPT_TAIL_BYTES)) : undefined;
|
|
95
|
+
if (target) {
|
|
96
|
+
return {
|
|
97
|
+
session: target.name,
|
|
98
|
+
from: "relay",
|
|
99
|
+
...(fromTranscript?.session === target.name && fromTranscript.conversationId ? { conversationId: fromTranscript.conversationId } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return fromTranscript ? { ...fromTranscript, from: "transcript" } : undefined;
|
|
103
|
+
}
|
|
104
|
+
function acquireLock(file, now) {
|
|
105
|
+
(0, claude_context_state_1.ensureDir)();
|
|
106
|
+
try {
|
|
107
|
+
fs.writeFileSync(file, String(process.pid), { flag: "wx", mode: 0o600 });
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
try {
|
|
112
|
+
if (now - fs.statSync(file).mtimeMs > claude_context_state_1.LOCK_STALE_MS) {
|
|
113
|
+
fs.unlinkSync(file);
|
|
114
|
+
fs.writeFileSync(file, String(process.pid), { flag: "wx", mode: 0o600 });
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* somebody else took it */
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Remove what was handled, re-reading first so a sample noted meanwhile is not lost. */
|
|
125
|
+
function settle(sid, handled, patch) {
|
|
126
|
+
const state = (0, claude_context_state_1.readState)(sid);
|
|
127
|
+
state.queue = state.queue.filter((q) => !handled.has(q.id));
|
|
128
|
+
(0, claude_context_state_1.writeState)({ ...state, ...patch, queue: state.queue });
|
|
129
|
+
}
|
|
130
|
+
/** `baychat context-flush --claude <id>`: send what the status line and hooks noted. */
|
|
131
|
+
async function runContextFlush(argv, deps = {}) {
|
|
132
|
+
const d = { now: Date.now, post: context_report_1.postContextEvent, loadDevice: config_1.loadDeviceCredentials, identify: identifySession, ...deps };
|
|
133
|
+
const i = argv.indexOf("--claude");
|
|
134
|
+
const sid = (0, claude_context_state_1.safeId)(i >= 0 ? argv[i + 1] : undefined);
|
|
135
|
+
if (!sid)
|
|
136
|
+
return 0;
|
|
137
|
+
const lock = path.join((0, claude_context_state_1.contextDir)(), `claude-${sid}.lock`);
|
|
138
|
+
if (!acquireLock(lock, d.now()))
|
|
139
|
+
return 0;
|
|
140
|
+
try {
|
|
141
|
+
for (let round = 0; round < 5; round++) {
|
|
142
|
+
const state = (0, claude_context_state_1.readState)(sid);
|
|
143
|
+
if (state.queue.length === 0)
|
|
144
|
+
return 0;
|
|
145
|
+
if ((0, claude_context_state_1.backoffUntil)() > d.now())
|
|
146
|
+
return 0;
|
|
147
|
+
const all = new Set(state.queue.map((q) => q.id));
|
|
148
|
+
let identity;
|
|
149
|
+
if (state.baychatSession && state.identifiedAt && d.now() - state.identifiedAt < claude_context_state_1.IDENTITY_TTL_MS) {
|
|
150
|
+
identity = { session: state.baychatSession, conversationId: state.conversationId, from: "relay" };
|
|
151
|
+
}
|
|
152
|
+
else if (!state.unidentifiedAt || d.now() - state.unidentifiedAt >= claude_context_state_1.UNIDENTIFIED_RETRY_MS) {
|
|
153
|
+
identity = d.identify(sid, state.transcriptPath);
|
|
154
|
+
}
|
|
155
|
+
if (!identity) {
|
|
156
|
+
// Not a BayChat session (or not provably one): drop what was noted rather than let it
|
|
157
|
+
// grow. Said once per Claude session, in the log.
|
|
158
|
+
if (!state.unidentifiedAt)
|
|
159
|
+
(0, claude_context_state_1.logContextError)(`Claude session ${sid} is not joined to BayChat as far as this machine can prove — not reporting its context`);
|
|
160
|
+
settle(sid, all, { unidentifiedAt: d.now(), baychatSession: undefined, identifiedAt: undefined });
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
const identityPatch = { baychatSession: identity.session, conversationId: identity.conversationId, identifiedAt: d.now(), unidentifiedAt: undefined };
|
|
164
|
+
const device = d.loadDevice();
|
|
165
|
+
if (!device) {
|
|
166
|
+
(0, claude_context_state_1.logContextError)("not logged in to BayChat (`baychat login`) — context not reported");
|
|
167
|
+
settle(sid, all, identityPatch);
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
const handled = new Set();
|
|
171
|
+
for (const q of state.queue) {
|
|
172
|
+
const event = identity.conversationId && !q.event.conversationId ? { ...q.event, conversationId: identity.conversationId } : q.event;
|
|
173
|
+
const outcome = await d.post({ baseUrl: device.baseUrl, token: device.token }, identity.session, event);
|
|
174
|
+
if (outcome.kind === "sent") {
|
|
175
|
+
handled.add(q.id);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (outcome.kind === "not-deployed") {
|
|
179
|
+
(0, claude_context_state_1.writeJsonAtomic)((0, claude_context_state_1.backoffPath)(), { until: d.now() + context_report_1.NOT_DEPLOYED_BACKOFF_MS });
|
|
180
|
+
(0, claude_context_state_1.logContextError)("this BayChat server does not accept context reports yet — trying again in an hour");
|
|
181
|
+
// Keep only the newest usage for then; a compaction an hour old is history.
|
|
182
|
+
for (const other of state.queue)
|
|
183
|
+
if (other.event.event !== "usage")
|
|
184
|
+
handled.add(other.id);
|
|
185
|
+
settle(sid, handled, identityPatch);
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
if (outcome.kind === "refused") {
|
|
189
|
+
(0, claude_context_state_1.logContextError)(`BayChat refused a ${q.event.event} report for "${identity.session}": ${outcome.message}${outcome.code ? ` (${outcome.code})` : ""}`);
|
|
190
|
+
handled.add(q.id);
|
|
191
|
+
// A refusal about the session (ended, renamed) makes the cached identity suspect.
|
|
192
|
+
if (outcome.code === "SESSION_NOT_FOUND")
|
|
193
|
+
Object.assign(identityPatch, { identifiedAt: undefined });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
(0, claude_context_state_1.logContextError)(`could not report context for "${identity.session}": ${outcome.message}`);
|
|
197
|
+
// A failed usage is superseded by the next one; a compaction is kept for the next try.
|
|
198
|
+
if (q.event.event === "usage")
|
|
199
|
+
handled.add(q.id);
|
|
200
|
+
settle(sid, handled, identityPatch);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
settle(sid, handled, identityPatch);
|
|
204
|
+
}
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
(0, claude_context_state_1.logContextError)(`context-flush: ${(0, claude_context_state_1.errText)(err)}`);
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
try {
|
|
213
|
+
fs.unlinkSync(lock);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
/* already gone */
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Local state for Claude Code context reporting: what the status line and the compaction hooks
|
|
3
|
+
// noted, per Claude session, under ~/.baychat/context/ (0700; files 0600). No secrets live here —
|
|
4
|
+
// numbers, a session name, a transcript path. Shared by `claude-context.ts` (the hot paths) and
|
|
5
|
+
// `claude-context-flush.ts` (the sender), and deliberately light: fs, path, crypto only.
|
|
6
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
7
|
+
if (k2 === undefined) k2 = k;
|
|
8
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
9
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
10
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
11
|
+
}
|
|
12
|
+
Object.defineProperty(o, k2, desc);
|
|
13
|
+
}) : (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
o[k2] = m[k];
|
|
16
|
+
}));
|
|
17
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
18
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
19
|
+
}) : function(o, v) {
|
|
20
|
+
o["default"] = v;
|
|
21
|
+
});
|
|
22
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
23
|
+
var ownKeys = function(o) {
|
|
24
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
25
|
+
var ar = [];
|
|
26
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
27
|
+
return ar;
|
|
28
|
+
};
|
|
29
|
+
return ownKeys(o);
|
|
30
|
+
};
|
|
31
|
+
return function (mod) {
|
|
32
|
+
if (mod && mod.__esModule) return mod;
|
|
33
|
+
var result = {};
|
|
34
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
35
|
+
__setModuleDefault(result, mod);
|
|
36
|
+
return result;
|
|
37
|
+
};
|
|
38
|
+
})();
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
exports.backoffPath = exports.statePath = exports.BOUNDARY_FRESH_MS = exports.LOCK_STALE_MS = exports.BOUNDARY_TAIL_BYTES = exports.TRANSCRIPT_TAIL_BYTES = exports.UNIDENTIFIED_RETRY_MS = exports.IDENTITY_TTL_MS = exports.MAX_QUEUE = void 0;
|
|
41
|
+
exports.contextDir = contextDir;
|
|
42
|
+
exports.safeId = safeId;
|
|
43
|
+
exports.ensureDir = ensureDir;
|
|
44
|
+
exports.writeJsonAtomic = writeJsonAtomic;
|
|
45
|
+
exports.readState = readState;
|
|
46
|
+
exports.writeState = writeState;
|
|
47
|
+
exports.backoffUntil = backoffUntil;
|
|
48
|
+
exports.logContextError = logContextError;
|
|
49
|
+
exports.errText = errText;
|
|
50
|
+
exports.enqueue = enqueue;
|
|
51
|
+
exports.readAll = readAll;
|
|
52
|
+
exports.readTail = readTail;
|
|
53
|
+
const crypto = __importStar(require("crypto"));
|
|
54
|
+
const fs = __importStar(require("fs"));
|
|
55
|
+
const path = __importStar(require("path"));
|
|
56
|
+
const config_1 = require("./config");
|
|
57
|
+
exports.MAX_QUEUE = 20;
|
|
58
|
+
exports.IDENTITY_TTL_MS = 10 * 60_000;
|
|
59
|
+
/**
|
|
60
|
+
* Most Claude sessions on a machine are NOT in BayChat. Once one is found not to be, its status
|
|
61
|
+
* line stops starting flushers for this long, so an ordinary session costs one tiny file read.
|
|
62
|
+
*/
|
|
63
|
+
exports.UNIDENTIFIED_RETRY_MS = 5 * 60_000;
|
|
64
|
+
exports.TRANSCRIPT_TAIL_BYTES = 2 * 1024 * 1024;
|
|
65
|
+
exports.BOUNDARY_TAIL_BYTES = 1024 * 1024;
|
|
66
|
+
exports.LOCK_STALE_MS = 60_000;
|
|
67
|
+
const ERROR_LOG_MAX_BYTES = 256 * 1024;
|
|
68
|
+
/** A compact_boundary older than this is an earlier compaction, not the one that just ended. */
|
|
69
|
+
exports.BOUNDARY_FRESH_MS = 15 * 60_000;
|
|
70
|
+
function contextDir() {
|
|
71
|
+
return path.join((0, config_1.configDir)(), "context");
|
|
72
|
+
}
|
|
73
|
+
/** Claude's session id becomes a file name, so only its own alphabet is accepted. */
|
|
74
|
+
function safeId(value) {
|
|
75
|
+
return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value) ? value : undefined;
|
|
76
|
+
}
|
|
77
|
+
const statePath = (sid) => path.join(contextDir(), `claude-${sid}.json`);
|
|
78
|
+
exports.statePath = statePath;
|
|
79
|
+
const backoffPath = () => path.join(contextDir(), "backoff.json");
|
|
80
|
+
exports.backoffPath = backoffPath;
|
|
81
|
+
function ensureDir() {
|
|
82
|
+
fs.mkdirSync(contextDir(), { recursive: true, mode: 0o700 });
|
|
83
|
+
}
|
|
84
|
+
function writeJsonAtomic(file, value) {
|
|
85
|
+
ensureDir();
|
|
86
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
87
|
+
fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
|
|
88
|
+
fs.renameSync(tmp, file);
|
|
89
|
+
}
|
|
90
|
+
function readState(sid) {
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(fs.readFileSync((0, exports.statePath)(sid), "utf8"));
|
|
93
|
+
if (parsed && parsed.v === 1 && Array.isArray(parsed.queue))
|
|
94
|
+
return parsed;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Missing (first report) or unreadable: a fresh state is the correct reading — the worst it
|
|
98
|
+
// costs is one report the throttle would have held back.
|
|
99
|
+
}
|
|
100
|
+
return { v: 1, claudeSessionId: sid, queue: [] };
|
|
101
|
+
}
|
|
102
|
+
function writeState(state) {
|
|
103
|
+
writeJsonAtomic((0, exports.statePath)(state.claudeSessionId), state);
|
|
104
|
+
}
|
|
105
|
+
function backoffUntil() {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(fs.readFileSync((0, exports.backoffPath)(), "utf8"));
|
|
108
|
+
return typeof parsed.until === "number" ? parsed.until : 0;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Never the terminal: the status line's stdout is the person's, and its stderr may be shown. */
|
|
115
|
+
function logContextError(line) {
|
|
116
|
+
try {
|
|
117
|
+
ensureDir();
|
|
118
|
+
const file = path.join(contextDir(), "errors.log");
|
|
119
|
+
try {
|
|
120
|
+
if (fs.statSync(file).size > ERROR_LOG_MAX_BYTES)
|
|
121
|
+
fs.renameSync(file, `${file}.1`);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* no log yet */
|
|
125
|
+
}
|
|
126
|
+
fs.appendFileSync(file, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Nowhere left to say it. The status line must still render.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function errText(err) {
|
|
133
|
+
return err instanceof Error ? err.message : String(err);
|
|
134
|
+
}
|
|
135
|
+
function enqueue(state, event) {
|
|
136
|
+
// Only the NEWEST usage is worth sending; compaction events are all kept, in order.
|
|
137
|
+
if (event.event === "usage")
|
|
138
|
+
state.queue = state.queue.filter((q) => q.event.event !== "usage");
|
|
139
|
+
state.queue.push({ id: crypto.randomBytes(6).toString("hex"), event });
|
|
140
|
+
if (state.queue.length > exports.MAX_QUEUE)
|
|
141
|
+
state.queue = state.queue.slice(-exports.MAX_QUEUE);
|
|
142
|
+
}
|
|
143
|
+
// ─── stdin ───────────────────────────────────────────────────────────────────
|
|
144
|
+
/** All of stdin, bounded in time and size so a stuck pipe cannot wedge a status line. */
|
|
145
|
+
function readAll(stream, timeoutMs = 3_000, maxBytes = 1024 * 1024) {
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
const chunks = [];
|
|
148
|
+
let size = 0;
|
|
149
|
+
let done = false;
|
|
150
|
+
const finish = () => {
|
|
151
|
+
if (done)
|
|
152
|
+
return;
|
|
153
|
+
done = true;
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
// Let go of stdin, or an input that never closes would keep this process alive after its
|
|
156
|
+
// answer — and a status line Claude Code has to cancel is a status line that got slower.
|
|
157
|
+
stream.destroy?.();
|
|
158
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
159
|
+
};
|
|
160
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
161
|
+
timer.unref?.();
|
|
162
|
+
stream.on("data", (chunk) => {
|
|
163
|
+
const b = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
164
|
+
size += b.length;
|
|
165
|
+
if (size <= maxBytes)
|
|
166
|
+
chunks.push(b);
|
|
167
|
+
});
|
|
168
|
+
stream.on("end", finish);
|
|
169
|
+
stream.on("error", finish);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function readTail(file, bytes) {
|
|
173
|
+
let fd;
|
|
174
|
+
try {
|
|
175
|
+
fd = fs.openSync(file, "r");
|
|
176
|
+
const size = fs.fstatSync(fd).size;
|
|
177
|
+
const start = Math.max(0, size - bytes);
|
|
178
|
+
const buf = Buffer.alloc(size - start);
|
|
179
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
180
|
+
const text = buf.toString("utf8");
|
|
181
|
+
// Drop a partial first line when we started mid-file.
|
|
182
|
+
return start > 0 ? text.slice(text.indexOf("\n") + 1) : text;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return "";
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
if (fd !== undefined)
|
|
189
|
+
fs.closeSync(fd);
|
|
190
|
+
}
|
|
191
|
+
}
|