baychat 0.11.2 → 0.11.4
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 +18 -3
- package/dist/index.js +11 -4
- package/dist/relay/adapters.js +56 -1
- package/dist/relay/commands.js +55 -2
- package/dist/relay/daemon.js +83 -10
- package/dist/relay/resume.js +615 -0
- package/dist/relay/updates.js +28 -11
- package/dist/relay/watermarks.js +113 -0
- package/dist/runtime-install.js +1 -1
- package/dist/runtimes.js +76 -2
- package/package.json +1 -1
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Resume-id discovery.
|
|
4
|
+
*
|
|
5
|
+
* A detached session with no resume id is the relay's worst failure mode: the
|
|
6
|
+
* message reaches the box, `canResume` says no, and the delivery is recorded
|
|
7
|
+
* `pending` until a human re-attaches by hand. Every other delay in the system
|
|
8
|
+
* is measured in seconds; that one is unbounded.
|
|
9
|
+
*
|
|
10
|
+
* This module answers one question — *which runtime session is this BayChat
|
|
11
|
+
* session?* — and it is allowed to answer "I don't know". Resuming the wrong
|
|
12
|
+
* session is strictly worse than reporting pending: it answers a room from a
|
|
13
|
+
* process with no memory of it, or hijacks an unrelated piece of work. So
|
|
14
|
+
* every method here either **identifies** a session or refuses.
|
|
15
|
+
*
|
|
16
|
+
* Three methods, in descending order of trust:
|
|
17
|
+
*
|
|
18
|
+
* 1. `flag` — a human passed `--resume-id`. We take their word for it.
|
|
19
|
+
* 2. `session-env` — the attaching session read its OWN id out of the
|
|
20
|
+
* environment its runtime gave it. Self-identification, so
|
|
21
|
+
* it cannot name someone else's session.
|
|
22
|
+
* 3. `transcript` — the relay found, in the runtime's own on-disk state, a
|
|
23
|
+
* session whose transcript records *this BayChat session
|
|
24
|
+
* name being attached*. That is evidence of identity, not a
|
|
25
|
+
* heuristic about recency.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately NOT implemented: `claude --continue` and `codex exec resume
|
|
28
|
+
* --last`. Both mean "the most recent session in this directory", which is a
|
|
29
|
+
* statement about who ran last, not about who attached. On the machine this was
|
|
30
|
+
* written on, `/var/www/baychat` holds ten Claude transcripts and five Codex
|
|
31
|
+
* conversations, two of the latter started two seconds apart in the same
|
|
32
|
+
* directory — and Codex's newest rollout there is a *subagent* thread. A
|
|
33
|
+
* `--last` wake would have resumed a stranger. See ADR note in
|
|
34
|
+
* docs/features/AGENT_RELAY.md.
|
|
35
|
+
*/
|
|
36
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
37
|
+
if (k2 === undefined) k2 = k;
|
|
38
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
39
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
40
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
41
|
+
}
|
|
42
|
+
Object.defineProperty(o, k2, desc);
|
|
43
|
+
}) : (function(o, m, k, k2) {
|
|
44
|
+
if (k2 === undefined) k2 = k;
|
|
45
|
+
o[k2] = m[k];
|
|
46
|
+
}));
|
|
47
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
48
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
49
|
+
}) : function(o, v) {
|
|
50
|
+
o["default"] = v;
|
|
51
|
+
});
|
|
52
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
53
|
+
var ownKeys = function(o) {
|
|
54
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
55
|
+
var ar = [];
|
|
56
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
57
|
+
return ar;
|
|
58
|
+
};
|
|
59
|
+
return ownKeys(o);
|
|
60
|
+
};
|
|
61
|
+
return function (mod) {
|
|
62
|
+
if (mod && mod.__esModule) return mod;
|
|
63
|
+
var result = {};
|
|
64
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
65
|
+
__setModuleDefault(result, mod);
|
|
66
|
+
return result;
|
|
67
|
+
};
|
|
68
|
+
})();
|
|
69
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
70
|
+
exports.SESSION_ID_ENV = void 0;
|
|
71
|
+
exports.isUuid = isUuid;
|
|
72
|
+
exports.attachMarker = attachMarker;
|
|
73
|
+
exports.commandAttaches = commandAttaches;
|
|
74
|
+
exports.resumeIdFromSessionEnv = resumeIdFromSessionEnv;
|
|
75
|
+
exports.discoverClaudeResume = discoverClaudeResume;
|
|
76
|
+
exports.discoverCodexResume = discoverCodexResume;
|
|
77
|
+
exports.discoverResume = discoverResume;
|
|
78
|
+
const fs = __importStar(require("fs"));
|
|
79
|
+
const os = __importStar(require("os"));
|
|
80
|
+
const path = __importStar(require("path"));
|
|
81
|
+
const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60_000;
|
|
82
|
+
const DEFAULT_MAX_FILES = 200;
|
|
83
|
+
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
|
84
|
+
/**
|
|
85
|
+
* The env var each runtime exports to processes it spawns, if any.
|
|
86
|
+
*
|
|
87
|
+
* `CLAUDE_CODE_SESSION_ID` is verified: it is present in the environment of
|
|
88
|
+
* every Bash tool call and equals the id of the transcript the session is
|
|
89
|
+
* writing (a subagent inherits its parent's, which is the id you want — the
|
|
90
|
+
* parent is what `--resume` reopens).
|
|
91
|
+
*
|
|
92
|
+
* `CODEX_THREAD_ID` is a name found in the Codex binary next to its
|
|
93
|
+
* `exec_command` plumbing and is NOT verified to be exported. It is read
|
|
94
|
+
* opportunistically and then **corroborated against on-disk rollouts** before
|
|
95
|
+
* being trusted, because an unverified variable could name a subagent thread —
|
|
96
|
+
* exactly the kind of wrong session this module exists to refuse.
|
|
97
|
+
*
|
|
98
|
+
* Cursor and Hermes export nothing we can verify, and neither can be resumed
|
|
99
|
+
* headlessly anyway: for them "no session id" is the permanent, correct answer
|
|
100
|
+
* rather than a gap to be closed. They are woken while attached instead.
|
|
101
|
+
*/
|
|
102
|
+
exports.SESSION_ID_ENV = {
|
|
103
|
+
claude: "CLAUDE_CODE_SESSION_ID",
|
|
104
|
+
codex: "CODEX_THREAD_ID",
|
|
105
|
+
cursor: null,
|
|
106
|
+
hermes: null,
|
|
107
|
+
};
|
|
108
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
109
|
+
function isUuid(value) {
|
|
110
|
+
return UUID_RE.test(value);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Build the matcher for "this command attached THIS BayChat session".
|
|
114
|
+
*
|
|
115
|
+
* Three things have to be true at once, and each one closes a real false
|
|
116
|
+
* positive found while writing this:
|
|
117
|
+
*
|
|
118
|
+
* - the **BayChat CLI** is what runs, not any command containing the words.
|
|
119
|
+
* `grep -r 'relay attach --session codex-vps' src/` is a session reading
|
|
120
|
+
* about another session, and the machine this was written on is full of them.
|
|
121
|
+
* - the session name follows `--session` **directly**, so the usage line
|
|
122
|
+
* (`--session <name>`) matches nothing and `codex-vps` never claims
|
|
123
|
+
* `codex-vps-2`.
|
|
124
|
+
* - the invocation is not itself inside a quoted argument — see
|
|
125
|
+
* `commandAttaches`, which is the predicate you should actually call.
|
|
126
|
+
*/
|
|
127
|
+
function attachMarker(sessionName) {
|
|
128
|
+
const name = sessionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
129
|
+
return new RegExp(
|
|
130
|
+
// an argv-position CLI token: `baychat`, `npx baychat`, `node dist/index.js`
|
|
131
|
+
`(?:^|[\\s;&|(])(?:[^\\s'"]*[\\\\/])?(?:baychat|index\\.js)\\s+relay\\s+attach\\b` +
|
|
132
|
+
`[^\\n]{0,400}?--session[=\\s]+(?:["'])?${name}(?:["']|\\s|$)`);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Did this recorded command actually attach that session?
|
|
136
|
+
*
|
|
137
|
+
* The quoting check is the last gate. A session that greps for
|
|
138
|
+
* `'baychat relay attach --session codex-vps'` runs a command that contains a
|
|
139
|
+
* perfectly good invocation — inside single quotes, as data. Resuming on that
|
|
140
|
+
* evidence would wake the session that read about the room instead of the one
|
|
141
|
+
* that joined it.
|
|
142
|
+
*/
|
|
143
|
+
function commandAttaches(command, sessionName) {
|
|
144
|
+
for (const text of [command, ...unwrapShellC(command)]) {
|
|
145
|
+
const match = attachMarker(sessionName).exec(text);
|
|
146
|
+
if (match && !isQuotedAt(text, match.index))
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* `bash -lc "…"` one level down — there the quoted argument IS the command.
|
|
153
|
+
*
|
|
154
|
+
* Without this, an agent that wraps its attach in a login shell (a common habit)
|
|
155
|
+
* would look exactly like one grepping for the string, and its session would
|
|
156
|
+
* stay undiscoverable. Inside the unwrapped text the quoting rule still applies,
|
|
157
|
+
* so `bash -lc "grep 'baychat relay attach …' ."` is still refused.
|
|
158
|
+
*/
|
|
159
|
+
function unwrapShellC(command) {
|
|
160
|
+
const m = /^\s*(?:\S*\/)?(?:ba|z|da|k)?sh\s+(?:-\S+\s+)*?-\S*c\S*\s+(['"])([\s\S]*)\1\s*$/.exec(command);
|
|
161
|
+
if (!m)
|
|
162
|
+
return [];
|
|
163
|
+
return [m[2].replace(/\\(["'\\$`])/g, "$1")];
|
|
164
|
+
}
|
|
165
|
+
/** Is offset `index` inside a single- or double-quoted run of shell text? */
|
|
166
|
+
function isQuotedAt(text, index) {
|
|
167
|
+
let single = false;
|
|
168
|
+
let double = false;
|
|
169
|
+
for (let i = 0; i < index; i++) {
|
|
170
|
+
const ch = text[i];
|
|
171
|
+
if (ch === "\\") {
|
|
172
|
+
i += 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (ch === "'" && !double)
|
|
176
|
+
single = !single;
|
|
177
|
+
else if (ch === '"' && !single)
|
|
178
|
+
double = !double;
|
|
179
|
+
}
|
|
180
|
+
return single || double;
|
|
181
|
+
}
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// (2) self-identification from the runtime's environment
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
/**
|
|
186
|
+
* Read this session's own id out of the environment its runtime gave it.
|
|
187
|
+
*
|
|
188
|
+
* Called by `relay attach`, in the session's own process tree, so the answer
|
|
189
|
+
* can only ever be *this* session. That is what makes it the preferred method:
|
|
190
|
+
* no search, no ranking, nothing to mistake for a sibling.
|
|
191
|
+
*/
|
|
192
|
+
async function resumeIdFromSessionEnv(runtime, opts = {}) {
|
|
193
|
+
const env = opts.env ?? process.env;
|
|
194
|
+
const varName = exports.SESSION_ID_ENV[runtime];
|
|
195
|
+
if (!varName) {
|
|
196
|
+
return { ok: false, reason: `${runtime} exports no session id to the processes it spawns` };
|
|
197
|
+
}
|
|
198
|
+
const raw = (env[varName] ?? "").trim();
|
|
199
|
+
if (!raw) {
|
|
200
|
+
return { ok: false, reason: `$${varName} is not set — this process was not spawned by a ${runtime} session` };
|
|
201
|
+
}
|
|
202
|
+
if (!isUuid(raw)) {
|
|
203
|
+
// A non-uuid here means the variable does not mean what we think it means.
|
|
204
|
+
// Recording it would produce a resume that fails, or worse, one that hits
|
|
205
|
+
// something else entirely.
|
|
206
|
+
return { ok: false, reason: `$${varName} is not a session uuid (${truncate(raw, 40)}) — refusing to record it` };
|
|
207
|
+
}
|
|
208
|
+
if (runtime === "claude") {
|
|
209
|
+
// The transcript is not required — the variable is authoritative — but
|
|
210
|
+
// finding it gives us the session's own cwd for the headless spawn.
|
|
211
|
+
const found = await claudeTranscriptById(raw, opts);
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
resumeId: raw,
|
|
215
|
+
source: "session-env",
|
|
216
|
+
evidence: `recorded at attach from $${varName}`,
|
|
217
|
+
cwd: found?.cwd,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
// Codex: corroborate before trusting. `CODEX_THREAD_ID` is undocumented, and
|
|
221
|
+
// a value naming a subagent thread would resume a helper, not the session
|
|
222
|
+
// that joined the room.
|
|
223
|
+
const roll = await codexRolloutById(raw, opts);
|
|
224
|
+
if (!roll) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
reason: `$${varName} names ${truncate(raw, 40)} but no top-level Codex rollout under ${codexRoot(opts)} has that id — refusing to record an id we cannot corroborate`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
ok: true,
|
|
232
|
+
resumeId: roll.resumeId,
|
|
233
|
+
source: "session-env",
|
|
234
|
+
evidence: `recorded at attach from $${varName}, corroborated by ${short(roll.file)}`,
|
|
235
|
+
cwd: roll.cwd,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// (3) discovery from the runtime's on-disk state
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
/**
|
|
242
|
+
* Find the Claude Code session that attached under this BayChat session name.
|
|
243
|
+
*
|
|
244
|
+
* Transcripts live at `~/.claude/projects/<slug>/<session-uuid>.jsonl`, so the
|
|
245
|
+
* file name IS the id `--resume` wants. We do not rank by recency: we look for
|
|
246
|
+
* the transcript that records the attach command naming this session, which is
|
|
247
|
+
* proof of identity rather than a guess. Sub-agent transcripts
|
|
248
|
+
* (`<uuid>/subagents/agent-*.jsonl`) are skipped — they are not resumable and
|
|
249
|
+
* they mirror the parent's shell activity, so they would double every match.
|
|
250
|
+
*/
|
|
251
|
+
async function discoverClaudeResume(sessionName, opts = {}) {
|
|
252
|
+
const root = claudeRoot(opts);
|
|
253
|
+
const files = listClaudeTranscripts(root, opts);
|
|
254
|
+
if (files.length === 0) {
|
|
255
|
+
return { ok: false, reason: `no Claude Code transcripts under ${short(root)} to search` };
|
|
256
|
+
}
|
|
257
|
+
const hits = [];
|
|
258
|
+
for (const { file, id } of files) {
|
|
259
|
+
const scan = await scanTranscript(file, sessionName, opts, (line) => claudeEvidence(line, sessionName));
|
|
260
|
+
if (scan.evidence)
|
|
261
|
+
hits.push({ id, file, cwd: scan.cwd, command: scan.evidence });
|
|
262
|
+
}
|
|
263
|
+
return oneOrRefuse(hits.map((h) => ({ resumeId: h.id, file: h.file, cwd: h.cwd })), sessionName, "Claude Code", root);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Find the Codex conversation that attached under this BayChat session name.
|
|
267
|
+
*
|
|
268
|
+
* Codex writes `~/.codex/sessions/<y>/<m>/<d>/rollout-<ts>-<id>.jsonl`, whose
|
|
269
|
+
* first line is a `session_meta` carrying `session_id` (the conversation),
|
|
270
|
+
* `id` (this rollout), `cwd` and `source`.
|
|
271
|
+
*
|
|
272
|
+
* Two filters matter, and both were found by looking at real files:
|
|
273
|
+
* - `source` distinguishes a real session (`"cli"`) from a **subagent** thread
|
|
274
|
+
* (`{subagent:{…}}`). The subagent rollout mirrors the parent's shell calls,
|
|
275
|
+
* so it carries the same attach command — resuming it would resume a helper.
|
|
276
|
+
* - resuming a conversation appends a NEW rollout that keeps the original
|
|
277
|
+
* `session_id`, so candidates are collapsed on `session_id`. Without that a
|
|
278
|
+
* session resumed twice looks like three different sessions and discovery
|
|
279
|
+
* would refuse itself into permanent pending.
|
|
280
|
+
*/
|
|
281
|
+
async function discoverCodexResume(sessionName, opts = {}) {
|
|
282
|
+
const root = codexRoot(opts);
|
|
283
|
+
const files = listCodexRollouts(root, opts);
|
|
284
|
+
if (files.length === 0) {
|
|
285
|
+
return { ok: false, reason: `no Codex rollouts under ${short(root)} to search` };
|
|
286
|
+
}
|
|
287
|
+
const byConversation = new Map();
|
|
288
|
+
for (const file of files) {
|
|
289
|
+
const meta = await readCodexMeta(file);
|
|
290
|
+
if (!meta || !meta.resumeId)
|
|
291
|
+
continue;
|
|
292
|
+
if (meta.isSubagent)
|
|
293
|
+
continue; // a helper thread, not the session that joined the room
|
|
294
|
+
const scan = await scanTranscript(file, sessionName, opts, (line) => codexEvidence(line, sessionName));
|
|
295
|
+
if (!scan.evidence)
|
|
296
|
+
continue;
|
|
297
|
+
if (!byConversation.has(meta.resumeId)) {
|
|
298
|
+
byConversation.set(meta.resumeId, { resumeId: meta.resumeId, file, cwd: meta.cwd });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return oneOrRefuse([...byConversation.values()], sessionName, "Codex", root);
|
|
302
|
+
}
|
|
303
|
+
/** Route to the runtime's discovery, or say the runtime has none. */
|
|
304
|
+
async function discoverResume(target, opts = {}) {
|
|
305
|
+
if (target.runtime === "claude")
|
|
306
|
+
return discoverClaudeResume(target.name, opts);
|
|
307
|
+
if (target.runtime === "codex")
|
|
308
|
+
return discoverCodexResume(target.name, opts);
|
|
309
|
+
return { ok: false, reason: `${target.runtime} keeps no local session state to discover a resume id from` };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Exactly one candidate, or nothing.
|
|
313
|
+
*
|
|
314
|
+
* Two transcripts that both attached under one name means two terminals once
|
|
315
|
+
* claimed it and we cannot see which is live. Picking the newer one would be
|
|
316
|
+
* the recency guess this module exists to avoid, so it refuses and names both
|
|
317
|
+
* ids — a human reading `relay status` can then attach the right one.
|
|
318
|
+
*/
|
|
319
|
+
function oneOrRefuse(hits, sessionName, label, root) {
|
|
320
|
+
if (hits.length === 0) {
|
|
321
|
+
return {
|
|
322
|
+
ok: false,
|
|
323
|
+
reason: `no ${label} session under ${short(root)} records \`relay attach --session ${sessionName}\` — nothing on disk identifies which session this is`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (hits.length > 1) {
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
reason: `${hits.length} ${label} sessions record \`relay attach --session ${sessionName}\` (${hits
|
|
330
|
+
.map((h) => h.resumeId)
|
|
331
|
+
.join(", ")}) — refusing to guess which one is live`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const hit = hits[0];
|
|
335
|
+
return {
|
|
336
|
+
ok: true,
|
|
337
|
+
resumeId: hit.resumeId,
|
|
338
|
+
source: "transcript",
|
|
339
|
+
evidence: `discovered from ${short(hit.file)} (it ran \`relay attach --session ${sessionName}\`)`,
|
|
340
|
+
cwd: hit.cwd,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// evidence extractors
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
/**
|
|
347
|
+
* A Claude transcript line is evidence only when it is a recorded **tool call**
|
|
348
|
+
* whose command attached this session. Matching raw text would fire on the
|
|
349
|
+
* usage string, on this file's own source, and on any conversation that merely
|
|
350
|
+
* mentions the session name.
|
|
351
|
+
*/
|
|
352
|
+
function claudeEvidence(line, sessionName) {
|
|
353
|
+
const parsed = safeParse(line);
|
|
354
|
+
const content = parsed?.message?.content;
|
|
355
|
+
if (!Array.isArray(content))
|
|
356
|
+
return undefined;
|
|
357
|
+
for (const item of content) {
|
|
358
|
+
if (item?.type !== "tool_use")
|
|
359
|
+
continue;
|
|
360
|
+
const cmd = item?.input?.command;
|
|
361
|
+
if (typeof cmd === "string" && commandAttaches(cmd, sessionName))
|
|
362
|
+
return cmd;
|
|
363
|
+
}
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Codex records shell work as `custom_tool_call` (newer, `input` is a JS
|
|
368
|
+
* snippet calling `tools.exec_command({cmd:"…"})`) or `function_call` (older,
|
|
369
|
+
* `arguments` is JSON). Either way the *shell command* has to be dug out before
|
|
370
|
+
* the predicate can run: applied to the raw JS snippet, every command would
|
|
371
|
+
* look quoted and nothing would ever match.
|
|
372
|
+
*/
|
|
373
|
+
function codexEvidence(line, sessionName) {
|
|
374
|
+
const parsed = safeParse(line);
|
|
375
|
+
const payload = parsed?.payload;
|
|
376
|
+
if (!payload)
|
|
377
|
+
return undefined;
|
|
378
|
+
if (payload.type !== "custom_tool_call" && payload.type !== "function_call")
|
|
379
|
+
return undefined;
|
|
380
|
+
for (const raw of [payload.input, payload.arguments]) {
|
|
381
|
+
if (typeof raw !== "string")
|
|
382
|
+
continue;
|
|
383
|
+
for (const cmd of codexCommands(raw)) {
|
|
384
|
+
if (commandAttaches(cmd, sessionName))
|
|
385
|
+
return cmd;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
/** Every shell command embedded in a Codex tool call payload. */
|
|
391
|
+
function codexCommands(raw) {
|
|
392
|
+
const out = [];
|
|
393
|
+
// Newer: `tools.exec_command({cmd:"…", workdir:"…"})`.
|
|
394
|
+
for (const m of raw.matchAll(/\bcmd\s*:\s*("(?:[^"\\]|\\.)*")/g)) {
|
|
395
|
+
try {
|
|
396
|
+
const cmd = JSON.parse(m[1]);
|
|
397
|
+
if (typeof cmd === "string")
|
|
398
|
+
out.push(cmd);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
// Not a JSON string literal after all — not a command we can read.
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Older: `{"command":["bash","-lc","…"]}`.
|
|
405
|
+
const args = safeParse(raw);
|
|
406
|
+
if (typeof args?.command === "string")
|
|
407
|
+
out.push(args.command);
|
|
408
|
+
if (Array.isArray(args?.command))
|
|
409
|
+
out.push(args.command.filter((p) => typeof p === "string").join(" "));
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// file plumbing
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
function claudeRoot(opts) {
|
|
416
|
+
return opts.claudeProjectsDir ?? path.join(os.homedir(), ".claude", "projects");
|
|
417
|
+
}
|
|
418
|
+
function codexRoot(opts) {
|
|
419
|
+
return opts.codexSessionsDir ?? path.join(os.homedir(), ".codex", "sessions");
|
|
420
|
+
}
|
|
421
|
+
/** `~/.claude/projects/<slug>/<uuid>.jsonl`, newest first, bounded. */
|
|
422
|
+
function listClaudeTranscripts(root, opts) {
|
|
423
|
+
const cutoff = Date.now() - (opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS);
|
|
424
|
+
const out = [];
|
|
425
|
+
for (const project of readDirSafe(root)) {
|
|
426
|
+
if (!project.isDirectory())
|
|
427
|
+
continue;
|
|
428
|
+
const dir = path.join(root, project.name);
|
|
429
|
+
for (const entry of readDirSafe(dir)) {
|
|
430
|
+
// Only top-level transcripts: `<uuid>/subagents/agent-*.jsonl` are helper
|
|
431
|
+
// threads that mirror the parent's tool calls and cannot be resumed.
|
|
432
|
+
if (!entry.isFile() || !entry.name.endsWith(".jsonl"))
|
|
433
|
+
continue;
|
|
434
|
+
const id = entry.name.slice(0, -".jsonl".length);
|
|
435
|
+
if (!isUuid(id))
|
|
436
|
+
continue;
|
|
437
|
+
const file = path.join(dir, entry.name);
|
|
438
|
+
const mtimeMs = mtimeOf(file);
|
|
439
|
+
if (mtimeMs < cutoff)
|
|
440
|
+
continue;
|
|
441
|
+
out.push({ file, id, mtimeMs });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return out.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, opts.maxFiles ?? DEFAULT_MAX_FILES);
|
|
445
|
+
}
|
|
446
|
+
/** `~/.codex/sessions/**\/rollout-*.jsonl`, newest first, bounded. */
|
|
447
|
+
function listCodexRollouts(root, opts) {
|
|
448
|
+
const cutoff = Date.now() - (opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS);
|
|
449
|
+
const found = [];
|
|
450
|
+
const walk = (dir, depth) => {
|
|
451
|
+
if (depth > 6)
|
|
452
|
+
return;
|
|
453
|
+
for (const entry of readDirSafe(dir)) {
|
|
454
|
+
const p = path.join(dir, entry.name);
|
|
455
|
+
if (entry.isDirectory()) {
|
|
456
|
+
walk(p, depth + 1);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (!entry.name.startsWith("rollout-") || !entry.name.endsWith(".jsonl"))
|
|
460
|
+
continue;
|
|
461
|
+
const mtimeMs = mtimeOf(p);
|
|
462
|
+
if (mtimeMs < cutoff)
|
|
463
|
+
continue;
|
|
464
|
+
found.push({ file: p, mtimeMs });
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
walk(root, 0);
|
|
468
|
+
return found
|
|
469
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
470
|
+
.slice(0, opts.maxFiles ?? DEFAULT_MAX_FILES)
|
|
471
|
+
.map((f) => f.file);
|
|
472
|
+
}
|
|
473
|
+
/** Parse the `session_meta` first line of a rollout. */
|
|
474
|
+
async function readCodexMeta(file) {
|
|
475
|
+
const head = await readHead(file, 256 * 1024);
|
|
476
|
+
const firstLine = head.split("\n", 1)[0];
|
|
477
|
+
const parsed = safeParse(firstLine);
|
|
478
|
+
if (!parsed || parsed.type !== "session_meta" || !parsed.payload)
|
|
479
|
+
return undefined;
|
|
480
|
+
const p = parsed.payload;
|
|
481
|
+
// `session_id` is the conversation; `id` is this rollout. They are equal for a
|
|
482
|
+
// first run and diverge once the conversation is resumed or forked, so the
|
|
483
|
+
// conversation id is the stable handle to hand `codex exec resume`.
|
|
484
|
+
const resumeId = str(p.session_id) ?? str(p.id);
|
|
485
|
+
const source = p.source;
|
|
486
|
+
const isSubagent = (typeof source === "object" && source !== null && "subagent" in source) || p.parent_thread_id != null;
|
|
487
|
+
return { resumeId, cwd: str(p.cwd), isSubagent };
|
|
488
|
+
}
|
|
489
|
+
/** Locate a Codex rollout by conversation id, ignoring subagent threads. */
|
|
490
|
+
async function codexRolloutById(id, opts) {
|
|
491
|
+
for (const file of listCodexRollouts(codexRoot(opts), opts)) {
|
|
492
|
+
const meta = await readCodexMeta(file);
|
|
493
|
+
if (!meta || meta.isSubagent)
|
|
494
|
+
continue;
|
|
495
|
+
if (meta.resumeId === id)
|
|
496
|
+
return { resumeId: meta.resumeId, file, cwd: meta.cwd };
|
|
497
|
+
}
|
|
498
|
+
return undefined;
|
|
499
|
+
}
|
|
500
|
+
/** Locate a Claude transcript by session id and read the cwd it started in. */
|
|
501
|
+
async function claudeTranscriptById(id, opts) {
|
|
502
|
+
for (const { file, id: candidate } of listClaudeTranscripts(claudeRoot(opts), opts)) {
|
|
503
|
+
if (candidate !== id)
|
|
504
|
+
continue;
|
|
505
|
+
const head = await readHead(file, 512 * 1024);
|
|
506
|
+
for (const line of head.split("\n")) {
|
|
507
|
+
const cwd = str(safeParse(line)?.cwd);
|
|
508
|
+
if (cwd)
|
|
509
|
+
return { file, cwd };
|
|
510
|
+
}
|
|
511
|
+
return { file };
|
|
512
|
+
}
|
|
513
|
+
return undefined;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Stream one transcript looking for evidence, and pick up the session's own cwd
|
|
517
|
+
* on the way past.
|
|
518
|
+
*
|
|
519
|
+
* The cheap raw-substring pre-filter matters: these files run to several
|
|
520
|
+
* megabytes and `JSON.parse` on every line of every transcript would make a
|
|
521
|
+
* detached wake noticeably slow. A line that does not contain the session name
|
|
522
|
+
* at all cannot be evidence.
|
|
523
|
+
*/
|
|
524
|
+
async function scanTranscript(file, sessionName, opts, extract) {
|
|
525
|
+
let evidence;
|
|
526
|
+
let cwd;
|
|
527
|
+
const limit = opts.maxBytesPerFile ?? DEFAULT_MAX_BYTES;
|
|
528
|
+
let handle;
|
|
529
|
+
try {
|
|
530
|
+
handle = await fs.promises.open(file, "r");
|
|
531
|
+
const buf = Buffer.alloc(1 << 20);
|
|
532
|
+
let rest = "";
|
|
533
|
+
let read = 0;
|
|
534
|
+
for (;;) {
|
|
535
|
+
const { bytesRead } = await handle.read(buf, 0, buf.length, null);
|
|
536
|
+
if (bytesRead <= 0)
|
|
537
|
+
break;
|
|
538
|
+
read += bytesRead;
|
|
539
|
+
const parts = (rest + buf.toString("utf8", 0, bytesRead)).split("\n");
|
|
540
|
+
rest = parts.pop() ?? "";
|
|
541
|
+
for (const line of parts) {
|
|
542
|
+
if (!cwd && line.includes('"cwd"'))
|
|
543
|
+
cwd = str(safeParse(line)?.cwd);
|
|
544
|
+
if (!evidence && line.includes("relay") && line.includes(sessionName))
|
|
545
|
+
evidence = extract(line);
|
|
546
|
+
}
|
|
547
|
+
if (evidence && cwd)
|
|
548
|
+
break;
|
|
549
|
+
if (read >= limit)
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
if (!evidence && rest.includes("relay") && rest.includes(sessionName))
|
|
553
|
+
evidence = extract(rest);
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
// An unreadable transcript is not evidence. Refusing the whole search
|
|
557
|
+
// because one file is locked would turn a readable answer into pending.
|
|
558
|
+
}
|
|
559
|
+
finally {
|
|
560
|
+
await handle?.close().catch(() => undefined);
|
|
561
|
+
}
|
|
562
|
+
return { evidence, cwd };
|
|
563
|
+
}
|
|
564
|
+
async function readHead(file, bytes) {
|
|
565
|
+
let handle;
|
|
566
|
+
try {
|
|
567
|
+
handle = await fs.promises.open(file, "r");
|
|
568
|
+
const buf = Buffer.alloc(bytes);
|
|
569
|
+
const { bytesRead } = await handle.read(buf, 0, bytes, 0);
|
|
570
|
+
return buf.toString("utf8", 0, bytesRead);
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
return "";
|
|
574
|
+
}
|
|
575
|
+
finally {
|
|
576
|
+
await handle?.close().catch(() => undefined);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
function readDirSafe(dir) {
|
|
580
|
+
try {
|
|
581
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
return [];
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function mtimeOf(file) {
|
|
588
|
+
try {
|
|
589
|
+
return fs.statSync(file).mtimeMs;
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
return 0;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
function safeParse(line) {
|
|
596
|
+
if (!line || line[0] !== "{")
|
|
597
|
+
return undefined;
|
|
598
|
+
try {
|
|
599
|
+
return JSON.parse(line);
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
return undefined;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
function str(value) {
|
|
606
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
607
|
+
}
|
|
608
|
+
function truncate(value, max) {
|
|
609
|
+
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
610
|
+
}
|
|
611
|
+
/** Shorten a path for display: `$HOME` back to `~`. */
|
|
612
|
+
function short(file) {
|
|
613
|
+
const home = os.homedir();
|
|
614
|
+
return file.startsWith(home) ? `~${file.slice(home.length)}` : file;
|
|
615
|
+
}
|
package/dist/relay/updates.js
CHANGED
|
@@ -97,22 +97,36 @@ async function runUpdatesLoop(opts) {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
/**
|
|
100
|
-
* Re-read
|
|
101
|
-
* and
|
|
102
|
-
* form — one recovery, so the two transports cannot
|
|
103
|
-
*
|
|
104
|
-
* is cheap rather than duplicated into the room.
|
|
100
|
+
* Re-read what an expired cursor swallowed, for EVERY live session on this
|
|
101
|
+
* device. Used by 409 recovery and by the WebSocket's `{"t":"reset"}`, which is
|
|
102
|
+
* the same condition in frame form — one recovery, so the two transports cannot
|
|
103
|
+
* drift apart.
|
|
105
104
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
105
|
+
* **One read per (session, conversation), naming the session.** The endpoint
|
|
106
|
+
* answers as *a* session that participates, so a read with no `session=` is
|
|
107
|
+
* answered by whichever of this device's sessions happens to be tried first.
|
|
108
|
+
* That is correct for that session and silently nothing for its siblings: two
|
|
109
|
+
* sessions in one shared room, one recovered, one re-baselined at the moment of
|
|
110
|
+
* the 409 with the missed window unreachable. Naming the session is what makes
|
|
111
|
+
* recovery cover the device rather than one arbitrary member of it.
|
|
112
|
+
*
|
|
113
|
+
* Messages already delivered come back here; the caller de-dupes on (session,
|
|
114
|
+
* message id), so a replay is free and a sibling's copy of one message is never
|
|
115
|
+
* mistaken for a duplicate.
|
|
116
|
+
*
|
|
117
|
+
* A 404 for one pair is skipped rather than fatal — that session is simply not
|
|
118
|
+
* in that room, which is the expected answer for most pairs the plan contains,
|
|
119
|
+
* and a room that has gone away must not strand the catch-up for every other.
|
|
120
|
+
* Anything else throws: a partial recovery reported as a complete one is the
|
|
121
|
+
* silent gap this endpoint exists to prevent.
|
|
109
122
|
*/
|
|
110
123
|
async function catchUpFromWatermarks(auth, watermarks, request) {
|
|
111
124
|
const events = [];
|
|
112
|
-
for (const
|
|
125
|
+
for (const { sessionName, conversationId, since } of watermarks.catchUpPlan()) {
|
|
126
|
+
const query = new URLSearchParams({ since, session: sessionName });
|
|
113
127
|
let res;
|
|
114
128
|
try {
|
|
115
|
-
res = await request(auth, "GET", `/api/device-api/conversations/${conversationId}/messages
|
|
129
|
+
res = await request(auth, "GET", `/api/device-api/conversations/${encodeURIComponent(conversationId)}/messages?${query.toString()}`);
|
|
116
130
|
}
|
|
117
131
|
catch (err) {
|
|
118
132
|
if (err instanceof api_1.ApiError && err.status === 404)
|
|
@@ -123,8 +137,11 @@ async function catchUpFromWatermarks(auth, watermarks, request) {
|
|
|
123
137
|
if (m.deletedAt)
|
|
124
138
|
continue;
|
|
125
139
|
events.push({
|
|
140
|
+
// The server's answer, not the name we asked with: a server too old to
|
|
141
|
+
// honour `session=` answers as someone else, and tagging its messages
|
|
142
|
+
// with the session we wanted would wake that session with another's view.
|
|
126
143
|
conversationId,
|
|
127
|
-
sessionName: res.sessionName,
|
|
144
|
+
sessionName: res.sessionName ?? sessionName,
|
|
128
145
|
message: { ...m, conversationId },
|
|
129
146
|
});
|
|
130
147
|
}
|