agent-yes 1.258.3 → 1.259.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/dist/{SUPPORTED_CLIS-DD6bhkyI.js → SUPPORTED_CLIS-BBeIFAZD.js} +2 -2
- package/dist/SUPPORTED_CLIS-BRgPb6Pz.js +11 -0
- package/dist/{agentShare-CoI7IHeq.js → agentShare-90Y_SFqK.js} +4 -4
- package/dist/{callback-CbdkyTRw.js → callback-CLzRxjEn.js} +3 -3
- package/dist/{callback-DNI__ASS.js → callback-DYQ2jQi2.js} +4 -4
- package/dist/cli.js +11 -11
- package/dist/{cwdConflictWarn-DZTxTGXq.js → cwdConflictWarn-B_047KCF.js} +2 -2
- package/dist/{forkNested-CgJ1rTnT.js → forkNested-CBdhC852.js} +2 -2
- package/dist/globalPidIndex-CHA__9Xf.js +4 -0
- package/dist/{globalPidIndex-Dq4KY2IV.js → globalPidIndex-pwp7Vb7o.js} +8 -2
- package/dist/index.js +4 -4
- package/dist/{notifyDaemon-BQ6_UbJS.js → notifyDaemon-D5RtFwMq.js} +4 -4
- package/dist/pidStore-1PQ4ARnh.js +6 -0
- package/dist/{pidStore-Cmcc8Oej.js → pidStore-DfSm1jog.js} +7 -4
- package/dist/{remotes-Dy4Rg8Yp.js → remotes-Cn-wszbK.js} +2 -2
- package/dist/{remotes-CNe85Icx.js → remotes-lOhHSoSS.js} +1 -1
- package/dist/{rustBinary-orhBmTNo.js → rustBinary-BEscpLLP.js} +2 -2
- package/dist/{schedule-BQy6gXYk.js → schedule-Cw5nW_di.js} +6 -6
- package/dist/{serve-C2mQMjUR.js → serve-DctJ7tY-.js} +19 -19
- package/dist/{setup-EEF1vfhi.js → setup-BA9D1ExO.js} +10 -2
- package/dist/{spawnGate-D0E1uRP7.js → spawnGate-D-68B907.js} +2 -2
- package/dist/spawnGate-DbSHnfqA.js +5 -0
- package/dist/{subcommands-CiX7J7hA.js → subcommands-D_cPHm34.js} +93 -24
- package/dist/{subcommands-S7uULM_d.js → subcommands-Dg7KkEi6.js} +3 -3
- package/dist/systemPathLink-DTAhFXFx.js +91 -0
- package/dist/{terminal-i7IimD_R.js → terminal-CB5K4Jjh.js} +2 -2
- package/dist/{todoCli-B2kqgqPh.js → todoCli-DNOk38lq.js} +2 -2
- package/dist/{tray-CgkwxtG6.js → tray-wWMvGWeY.js} +1 -1
- package/dist/trayApp-B1OhYfj3.js +5 -0
- package/dist/{trayApp-4sZgxxP-.js → trayApp-Ck3kWM-7.js} +2 -2
- package/dist/{ts-D-E4xqpA.js → ts-Bd_7sgVu.js} +5 -4
- package/dist/{versionChecker-YDl8a9il.js → versionChecker-DRijpksb.js} +2 -2
- package/dist/{webrtcRemote-C3OlHYSt.js → webrtcRemote-BkZcA5yq.js} +1 -1
- package/dist/{widget-7j9RrYZR.js → widget-DiRKbtng.js} +3 -3
- package/dist/{ws-DjyQ20AD.js → ws-Bp8eOEoV.js} +2 -2
- package/dist/{ws-Clem1WQ-.js → ws-Cqn8mxsS.js} +4 -4
- package/package.json +1 -1
- package/ts/globalPidIndex.spec.ts +36 -0
- package/ts/globalPidIndex.ts +19 -1
- package/ts/hist.spec.ts +219 -0
- package/ts/hist.ts +136 -0
- package/ts/histStore.spec.ts +351 -0
- package/ts/histStore.ts +494 -0
- package/ts/index.ts +4 -0
- package/ts/messageLog.ts +2 -0
- package/ts/pidStore.spec.ts +26 -0
- package/ts/pidStore.ts +10 -0
- package/ts/setup.ts +17 -0
- package/ts/subcommands.spec.ts +180 -0
- package/ts/subcommands.ts +103 -3
- package/ts/systemPathLink.spec.ts +69 -0
- package/ts/systemPathLink.ts +134 -0
- package/dist/SUPPORTED_CLIS-D1BoydrZ.js +0 -11
- package/dist/globalPidIndex-CQX_0JSB.js +0 -4
- package/dist/pidStore-BbKqQOS8.js +0 -6
- package/dist/spawnGate-DqKBA07k.js +0 -5
- package/dist/trayApp-C35IkXnz.js +0 -5
package/ts/histStore.ts
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ay hist` — read the tail of *coding-agent conversation transcripts* that
|
|
3
|
+
* already exist on this machine (Claude Code, Codex), newest-last.
|
|
4
|
+
*
|
|
5
|
+
* This is deliberately NOT `ay tail`. `ay tail` follows the live PTY log of an
|
|
6
|
+
* agent process agent-yes spawned; `ay hist` reads the durable JSONL transcript
|
|
7
|
+
* the CLI itself writes, including sessions from before agent-yes was involved
|
|
8
|
+
* and sessions whose process already exited.
|
|
9
|
+
*
|
|
10
|
+
* Design notes (the two that actually matter):
|
|
11
|
+
*
|
|
12
|
+
* 1. **Read backwards.** Transcript lines are whole JSON records and routinely
|
|
13
|
+
* reach 1.3MB (tool outputs, base64 images). Reading a file forward — or
|
|
14
|
+
* `readFile().split("\n")` — costs megabytes to show six records. We seek to
|
|
15
|
+
* EOF and walk back in chunks, stopping as soon as the caller has enough, so
|
|
16
|
+
* cost is proportional to what's displayed, not to session length.
|
|
17
|
+
*
|
|
18
|
+
* 2. **No index.** These files are append-only, so a sidecar index storing
|
|
19
|
+
* `indexed_up_to_bytes` would let a future `ay hist search` re-scan only the
|
|
20
|
+
* tail delta. Tailing needs none of that, and the 90% case is tailing — so
|
|
21
|
+
* v1 ships with zero index, zero daemon, zero cache invalidation.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readdir, open, stat } from "fs/promises";
|
|
25
|
+
import type { FileHandle } from "fs/promises";
|
|
26
|
+
import { homedir } from "os";
|
|
27
|
+
import path from "path";
|
|
28
|
+
|
|
29
|
+
const NEWLINE = 0x0a;
|
|
30
|
+
|
|
31
|
+
/** Which coding agent produced a transcript. */
|
|
32
|
+
export type HistSource = "claude" | "codex";
|
|
33
|
+
|
|
34
|
+
export interface TranscriptFile {
|
|
35
|
+
path: string;
|
|
36
|
+
source: HistSource;
|
|
37
|
+
/** Session identifier — the file's basename for both supported sources. */
|
|
38
|
+
sessionId: string;
|
|
39
|
+
mtimeMs: number;
|
|
40
|
+
size: number;
|
|
41
|
+
/**
|
|
42
|
+
* Working directory the session ran in, when known without reading the file.
|
|
43
|
+
* Claude encodes it in the parent directory name; Codex only stores it inside
|
|
44
|
+
* the file's `session_meta` record, so it stays undefined until resolved.
|
|
45
|
+
*/
|
|
46
|
+
cwd?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface HistRecord {
|
|
50
|
+
/** ISO timestamp, or null for records that carry none. */
|
|
51
|
+
ts: string | null;
|
|
52
|
+
role: "user" | "assistant";
|
|
53
|
+
text: string;
|
|
54
|
+
source: HistSource;
|
|
55
|
+
sessionId: string;
|
|
56
|
+
file: string;
|
|
57
|
+
/** Byte offset of the record's first byte, usable as a stable cursor. */
|
|
58
|
+
offset: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Claude Code names each project directory after its cwd with every
|
|
63
|
+
* non-alphanumeric byte replaced by `-`:
|
|
64
|
+
* `/code/snomiao/cv.snomiao.com` → `-code-snomiao-cv-snomiao-com`.
|
|
65
|
+
*
|
|
66
|
+
* Encoding the cwd and comparing is exact and costs no file reads — decoding
|
|
67
|
+
* the slug back to a path is not possible, since `-` is ambiguous.
|
|
68
|
+
*/
|
|
69
|
+
export function encodeProjectSlug(cwd: string): string {
|
|
70
|
+
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function claudeProjectsDir(home = homedir()): string {
|
|
74
|
+
return path.join(home, ".claude", "projects");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function codexSessionsDir(home = homedir()): string {
|
|
78
|
+
return path.join(home, ".codex", "sessions");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function listJsonlDeep(dir: string, out: string[] = [], depth = 0): Promise<string[]> {
|
|
82
|
+
// Codex nests transcripts under YYYY/MM/DD; Claude is flat under the project
|
|
83
|
+
// slug. A depth cap keeps a stray symlink from turning discovery into a walk
|
|
84
|
+
// of the whole home directory.
|
|
85
|
+
if (depth > 4) return out;
|
|
86
|
+
let entries;
|
|
87
|
+
try {
|
|
88
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
89
|
+
} catch {
|
|
90
|
+
return out; // absent source (e.g. Codex never installed) is not an error
|
|
91
|
+
}
|
|
92
|
+
for (const e of entries) {
|
|
93
|
+
const full = path.join(dir, e.name);
|
|
94
|
+
if (e.isDirectory()) await listJsonlDeep(full, out, depth + 1);
|
|
95
|
+
else if (e.isFile() && e.name.endsWith(".jsonl")) out.push(full);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface DiscoverOpts {
|
|
101
|
+
home?: string;
|
|
102
|
+
/** Restrict to sessions whose cwd matches. Omit for every project. */
|
|
103
|
+
cwd?: string;
|
|
104
|
+
sources?: readonly HistSource[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Locate transcripts, newest first. Only stats files — never opens them — so
|
|
109
|
+
* this stays cheap even with thousands of sessions on disk.
|
|
110
|
+
*/
|
|
111
|
+
export async function discoverTranscripts(opts: DiscoverOpts = {}): Promise<TranscriptFile[]> {
|
|
112
|
+
const home = opts.home ?? homedir();
|
|
113
|
+
const sources = opts.sources ?? (["claude", "codex"] as const);
|
|
114
|
+
const found: TranscriptFile[] = [];
|
|
115
|
+
|
|
116
|
+
if (sources.includes("claude")) {
|
|
117
|
+
const root = claudeProjectsDir(home);
|
|
118
|
+
const wanted = opts.cwd ? encodeProjectSlug(opts.cwd) : null;
|
|
119
|
+
let projects: string[] = [];
|
|
120
|
+
try {
|
|
121
|
+
projects = (await readdir(root, { withFileTypes: true }))
|
|
122
|
+
.filter((e) => e.isDirectory())
|
|
123
|
+
.map((e) => e.name);
|
|
124
|
+
} catch {
|
|
125
|
+
projects = [];
|
|
126
|
+
}
|
|
127
|
+
for (const slug of projects) {
|
|
128
|
+
if (wanted && slug !== wanted) continue;
|
|
129
|
+
for (const file of await listJsonlDeep(path.join(root, slug))) {
|
|
130
|
+
const st = await stat(file).catch(() => null);
|
|
131
|
+
if (!st?.isFile() || st.size === 0) continue;
|
|
132
|
+
found.push({
|
|
133
|
+
path: file,
|
|
134
|
+
source: "claude",
|
|
135
|
+
sessionId: path.basename(file, ".jsonl"),
|
|
136
|
+
mtimeMs: st.mtimeMs,
|
|
137
|
+
size: st.size,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (sources.includes("codex")) {
|
|
144
|
+
for (const file of await listJsonlDeep(codexSessionsDir(home))) {
|
|
145
|
+
if (!path.basename(file).startsWith("rollout-")) continue; // skip history.jsonl
|
|
146
|
+
const st = await stat(file).catch(() => null);
|
|
147
|
+
if (!st?.isFile() || st.size === 0) continue;
|
|
148
|
+
found.push({
|
|
149
|
+
path: file,
|
|
150
|
+
source: "codex",
|
|
151
|
+
sessionId: path.basename(file, ".jsonl"),
|
|
152
|
+
mtimeMs: st.mtimeMs,
|
|
153
|
+
size: st.size,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
found.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
159
|
+
|
|
160
|
+
if (opts.cwd && sources.includes("codex")) {
|
|
161
|
+
// Codex hides cwd inside the file, so it can only be filtered by peeking at
|
|
162
|
+
// the head record. Done after sorting and only for Codex files, so the
|
|
163
|
+
// common `--cwd` case reads at most a few KB per candidate session.
|
|
164
|
+
const kept: TranscriptFile[] = [];
|
|
165
|
+
for (const f of found) {
|
|
166
|
+
if (f.source !== "codex") {
|
|
167
|
+
kept.push(f);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const cwd = await readCodexCwd(f.path);
|
|
171
|
+
if (cwd === opts.cwd) kept.push({ ...f, cwd });
|
|
172
|
+
}
|
|
173
|
+
return kept;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return found;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Read the leading `session_meta` record of a Codex rollout to learn its cwd. */
|
|
180
|
+
export async function readCodexCwd(file: string): Promise<string | undefined> {
|
|
181
|
+
let fh: FileHandle | undefined;
|
|
182
|
+
try {
|
|
183
|
+
fh = await open(file, "r");
|
|
184
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
185
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
|
|
186
|
+
const nl = buf.indexOf(NEWLINE);
|
|
187
|
+
const end = nl === -1 ? bytesRead : nl;
|
|
188
|
+
const rec = JSON.parse(buf.subarray(0, end).toString("utf8")) as {
|
|
189
|
+
payload?: { cwd?: string };
|
|
190
|
+
};
|
|
191
|
+
return rec.payload?.cwd;
|
|
192
|
+
} catch {
|
|
193
|
+
return undefined;
|
|
194
|
+
} finally {
|
|
195
|
+
await fh?.close();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface BackwardOpts {
|
|
200
|
+
chunkSize?: number;
|
|
201
|
+
maxRecordBytes?: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Yield whole lines from EOF toward the start, newest first, as
|
|
206
|
+
* `{ offset, text }`. Lazy: the generator only reads another chunk when the
|
|
207
|
+
* consumer asks for another line, so `take(6)` reads ~one chunk regardless of
|
|
208
|
+
* whether the file is 4KB or 400MB.
|
|
209
|
+
*
|
|
210
|
+
* A trailing partial line (a session being appended to right now) is dropped —
|
|
211
|
+
* we stop at the last complete newline rather than hand back half a record.
|
|
212
|
+
*/
|
|
213
|
+
export async function* readLinesBackward(
|
|
214
|
+
file: string,
|
|
215
|
+
opts: BackwardOpts = {},
|
|
216
|
+
): AsyncGenerator<{ offset: number; text: string }> {
|
|
217
|
+
const chunkSize = opts.chunkSize ?? 64 * 1024;
|
|
218
|
+
const maxRecordBytes = opts.maxRecordBytes ?? 16 * 1024 * 1024;
|
|
219
|
+
|
|
220
|
+
const fh = await open(file, "r");
|
|
221
|
+
try {
|
|
222
|
+
const st = await fh.stat();
|
|
223
|
+
let pos = st.size;
|
|
224
|
+
if (pos === 0) return;
|
|
225
|
+
|
|
226
|
+
// `pending` holds bytes to the LEFT of everything already yielded, whose
|
|
227
|
+
// owning line has not been closed by a newline yet. `base` is its offset.
|
|
228
|
+
let pending = Buffer.alloc(0);
|
|
229
|
+
let base = pos;
|
|
230
|
+
let first = true;
|
|
231
|
+
|
|
232
|
+
while (pos > 0) {
|
|
233
|
+
const len = Math.min(chunkSize, pos);
|
|
234
|
+
pos -= len;
|
|
235
|
+
const buf = Buffer.alloc(len);
|
|
236
|
+
await fh.read(buf, 0, len, pos);
|
|
237
|
+
|
|
238
|
+
const combined = pending.length ? Buffer.concat([buf, pending]) : buf;
|
|
239
|
+
base = pos;
|
|
240
|
+
|
|
241
|
+
let end = combined.length;
|
|
242
|
+
if (first) {
|
|
243
|
+
first = false;
|
|
244
|
+
// Drop anything after the final newline: either the empty string from a
|
|
245
|
+
// well-formed trailing "\n", or a half-written record.
|
|
246
|
+
const last = combined.lastIndexOf(NEWLINE);
|
|
247
|
+
end = last === -1 ? 0 : last;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
while (end > 0) {
|
|
251
|
+
const nl = combined.lastIndexOf(NEWLINE, end - 1);
|
|
252
|
+
if (nl === -1) break; // line starts before this chunk — carry it over
|
|
253
|
+
const text = combined.subarray(nl + 1, end).toString("utf8");
|
|
254
|
+
if (text.trim()) yield { offset: base + nl + 1, text };
|
|
255
|
+
end = nl;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
pending = combined.subarray(0, end);
|
|
259
|
+
if (pending.length > maxRecordBytes) {
|
|
260
|
+
// A single record larger than the cap means a corrupt or pathological
|
|
261
|
+
// transcript (the largest legitimate record seen in practice is ~1.3MB).
|
|
262
|
+
// Drop the partial buffer and resync at this chunk edge rather than
|
|
263
|
+
// buffering without bound — losing one unreadable record is preferable
|
|
264
|
+
// to holding the whole file in memory.
|
|
265
|
+
pending = Buffer.alloc(0);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Whatever remains starts at byte 0 — the file's first line, unterminated
|
|
270
|
+
// on its left by definition.
|
|
271
|
+
const head = pending.toString("utf8");
|
|
272
|
+
if (head.trim()) yield { offset: 0, text: head };
|
|
273
|
+
} finally {
|
|
274
|
+
await fh.close();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
interface ClaudeBlock {
|
|
279
|
+
type?: string;
|
|
280
|
+
text?: string;
|
|
281
|
+
name?: string;
|
|
282
|
+
thinking?: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function claudeText(content: unknown, includeTools: boolean): string {
|
|
286
|
+
if (typeof content === "string") return content;
|
|
287
|
+
if (!Array.isArray(content)) return "";
|
|
288
|
+
const parts: string[] = [];
|
|
289
|
+
for (const raw of content as ClaudeBlock[]) {
|
|
290
|
+
if (!raw || typeof raw !== "object") continue;
|
|
291
|
+
if (raw.type === "text" && typeof raw.text === "string") parts.push(raw.text);
|
|
292
|
+
else if (includeTools && raw.type === "thinking" && typeof raw.thinking === "string")
|
|
293
|
+
parts.push(`(thinking) ${raw.thinking}`);
|
|
294
|
+
else if (includeTools && raw.type === "tool_use") parts.push(`[tool: ${raw.name ?? "?"}]`);
|
|
295
|
+
else if (includeTools && raw.type === "tool_result") parts.push(`[tool result]`);
|
|
296
|
+
}
|
|
297
|
+
return parts.join("\n").trim();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export interface ProjectOpts {
|
|
301
|
+
/** Render tool calls/results/thinking as markers instead of dropping them. */
|
|
302
|
+
includeTools?: boolean;
|
|
303
|
+
/** Include Claude sub-agent (sidechain) turns. Off by default: high volume. */
|
|
304
|
+
includeSidechains?: boolean;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Reduce one raw transcript line to a displayable turn, or null when the record
|
|
309
|
+
* is not conversational (metadata, token counts, tool plumbing, …).
|
|
310
|
+
*
|
|
311
|
+
* Returning null for the great majority of records is the point: it is what
|
|
312
|
+
* makes six records of *conversation* cost six records of output rather than
|
|
313
|
+
* six records of JSON noise.
|
|
314
|
+
*/
|
|
315
|
+
export function projectRecord(
|
|
316
|
+
line: string,
|
|
317
|
+
source: HistSource,
|
|
318
|
+
opts: ProjectOpts = {},
|
|
319
|
+
): Pick<HistRecord, "ts" | "role" | "text"> | null {
|
|
320
|
+
let rec: Record<string, any>;
|
|
321
|
+
try {
|
|
322
|
+
rec = JSON.parse(line);
|
|
323
|
+
} catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
if (!rec || typeof rec !== "object") return null;
|
|
327
|
+
|
|
328
|
+
if (source === "claude") {
|
|
329
|
+
if (rec.type !== "user" && rec.type !== "assistant") return null;
|
|
330
|
+
if (rec.isSidechain && !opts.includeSidechains) return null;
|
|
331
|
+
const role = rec.message?.role === "assistant" ? "assistant" : "user";
|
|
332
|
+
const text = claudeText(rec.message?.content, opts.includeTools ?? false);
|
|
333
|
+
if (!text) return null;
|
|
334
|
+
return { ts: typeof rec.timestamp === "string" ? rec.timestamp : null, role, text };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Codex writes each turn twice: as a raw `response_item` and as a rendered
|
|
338
|
+
// `event_msg`. The event stream is the clean one, so we read only that and
|
|
339
|
+
// avoid emitting every turn twice.
|
|
340
|
+
if (rec.type !== "event_msg") return null;
|
|
341
|
+
const kind = rec.payload?.type;
|
|
342
|
+
if (kind !== "user_message" && kind !== "agent_message") return null;
|
|
343
|
+
const text = typeof rec.payload?.message === "string" ? rec.payload.message.trim() : "";
|
|
344
|
+
if (!text) return null;
|
|
345
|
+
return {
|
|
346
|
+
ts: typeof rec.timestamp === "string" ? rec.timestamp : null,
|
|
347
|
+
role: kind === "user_message" ? "user" : "assistant",
|
|
348
|
+
text,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Position of a single record in the global ordering.
|
|
354
|
+
*
|
|
355
|
+
* Paging on a bare timestamp loses data: transcripts routinely contain several
|
|
356
|
+
* records sharing one millisecond, so `ts < cursor` drops every record that
|
|
357
|
+
* ties with the page boundary. `(ts, sessionId, offset)` is a total order —
|
|
358
|
+
* `(sessionId, offset)` is unique by construction — so paging on it can neither
|
|
359
|
+
* skip nor repeat a record.
|
|
360
|
+
*/
|
|
361
|
+
export interface Cursor {
|
|
362
|
+
ts: string;
|
|
363
|
+
sessionId: string;
|
|
364
|
+
offset: number;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function encodeCursor(r: HistRecord): string {
|
|
368
|
+
return `${r.ts ?? ""}|${r.sessionId}|${r.offset}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Parse an encoded cursor; returns null for a bare timestamp or garbage. */
|
|
372
|
+
export function decodeCursor(raw: string): Cursor | null {
|
|
373
|
+
const parts = raw.split("|");
|
|
374
|
+
if (parts.length !== 3) return null;
|
|
375
|
+
const offset = Number(parts[2]);
|
|
376
|
+
if (!Number.isFinite(offset)) return null;
|
|
377
|
+
return { ts: parts[0]!, sessionId: parts[1]!, offset };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Total order over records: timestamp, then session, then byte offset. */
|
|
381
|
+
export function compareRecords(
|
|
382
|
+
a: Pick<HistRecord, "ts" | "sessionId" | "offset">,
|
|
383
|
+
b: Pick<HistRecord, "ts" | "sessionId" | "offset">,
|
|
384
|
+
): number {
|
|
385
|
+
return (
|
|
386
|
+
(a.ts ?? "").localeCompare(b.ts ?? "") ||
|
|
387
|
+
a.sessionId.localeCompare(b.sessionId) ||
|
|
388
|
+
a.offset - b.offset
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export interface TailOpts extends ProjectOpts, BackwardOpts {
|
|
393
|
+
/** Max records to return. */
|
|
394
|
+
limit: number;
|
|
395
|
+
/** Only records strictly older than this ISO timestamp. */
|
|
396
|
+
before?: string;
|
|
397
|
+
/** Only records strictly before this position. Takes precedence over `before`. */
|
|
398
|
+
cursor?: Cursor;
|
|
399
|
+
role?: "user" | "assistant";
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Newest `limit` conversational records of one transcript, oldest-first.
|
|
404
|
+
*
|
|
405
|
+
* Stops reading as soon as `limit` records are collected — the whole reason the
|
|
406
|
+
* backward reader is a generator.
|
|
407
|
+
*/
|
|
408
|
+
export async function tailTranscript(
|
|
409
|
+
file: TranscriptFile,
|
|
410
|
+
opts: TailOpts,
|
|
411
|
+
): Promise<HistRecord[]> {
|
|
412
|
+
const out: HistRecord[] = [];
|
|
413
|
+
for await (const { offset, text } of readLinesBackward(file.path, opts)) {
|
|
414
|
+
const projected = projectRecord(text, file.source, opts);
|
|
415
|
+
if (!projected) continue;
|
|
416
|
+
if (opts.role && projected.role !== opts.role) continue;
|
|
417
|
+
const record: HistRecord = {
|
|
418
|
+
...projected,
|
|
419
|
+
source: file.source,
|
|
420
|
+
sessionId: file.sessionId,
|
|
421
|
+
file: file.path,
|
|
422
|
+
offset,
|
|
423
|
+
};
|
|
424
|
+
if (opts.cursor) {
|
|
425
|
+
if (compareRecords(record, opts.cursor) >= 0) continue;
|
|
426
|
+
} else if (opts.before && projected.ts && projected.ts >= opts.before) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
out.push(record);
|
|
430
|
+
if (out.length >= opts.limit) break;
|
|
431
|
+
}
|
|
432
|
+
return out.reverse();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export interface HistPage {
|
|
436
|
+
records: HistRecord[];
|
|
437
|
+
/**
|
|
438
|
+
* Opaque cursor to pass as `--before` for the next (older) page, or null when
|
|
439
|
+
* the history is exhausted. Opaque rather than a numeric offset: sessions are
|
|
440
|
+
* appended to constantly, so `--skip N` would shift under the caller between
|
|
441
|
+
* pages.
|
|
442
|
+
*/
|
|
443
|
+
cursor: string | null;
|
|
444
|
+
/** Transcripts consulted, for reporting when nothing matched. */
|
|
445
|
+
scanned: number;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export interface HistQuery extends TailOpts {
|
|
449
|
+
home?: string;
|
|
450
|
+
cwd?: string;
|
|
451
|
+
sources?: readonly HistSource[];
|
|
452
|
+
/**
|
|
453
|
+
* Cap on transcripts to tail before merging. Sessions are sorted newest-first,
|
|
454
|
+
* so a low cap only excludes sessions too old to appear in the page anyway.
|
|
455
|
+
*/
|
|
456
|
+
maxFiles?: number;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Merge the tails of the most recently touched transcripts into one
|
|
461
|
+
* chronological page — "what have my agents been saying", across sessions.
|
|
462
|
+
*/
|
|
463
|
+
export async function histPage(q: HistQuery): Promise<HistPage> {
|
|
464
|
+
const files = await discoverTranscripts({ home: q.home, cwd: q.cwd, sources: q.sources });
|
|
465
|
+
const considered = files.slice(0, q.maxFiles ?? 40);
|
|
466
|
+
|
|
467
|
+
// Over-read by one per transcript. Without it, a page filled entirely by a
|
|
468
|
+
// single session would look exhausted — we would have stopped at exactly
|
|
469
|
+
// `limit` and had no evidence that older records remained.
|
|
470
|
+
const perFile = q.limit > 0 ? q.limit + 1 : Number.MAX_SAFE_INTEGER;
|
|
471
|
+
// `--before` accepts either an encoded cursor or a bare ISO timestamp typed by
|
|
472
|
+
// hand; only the former can page without losing timestamp ties.
|
|
473
|
+
const cursor = q.cursor ?? (q.before ? decodeCursor(q.before) : null);
|
|
474
|
+
const tails = await Promise.all(
|
|
475
|
+
considered.map((f) =>
|
|
476
|
+
tailTranscript(f, { ...q, limit: perFile, cursor: cursor ?? undefined }),
|
|
477
|
+
),
|
|
478
|
+
);
|
|
479
|
+
const merged = tails.flat();
|
|
480
|
+
|
|
481
|
+
// Records without a timestamp sort oldest, so a well-timestamped page is
|
|
482
|
+
// never displaced by metadata-poor records.
|
|
483
|
+
merged.sort(compareRecords);
|
|
484
|
+
|
|
485
|
+
const records = q.limit > 0 ? merged.slice(-q.limit) : merged;
|
|
486
|
+
const oldest = records[0];
|
|
487
|
+
const more = merged.length > records.length;
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
records,
|
|
491
|
+
cursor: more && oldest ? encodeCursor(oldest) : null,
|
|
492
|
+
scanned: considered.length,
|
|
493
|
+
};
|
|
494
|
+
}
|
package/ts/index.ts
CHANGED
|
@@ -460,6 +460,10 @@ export default async function agentYes({
|
|
|
460
460
|
// `ay`) don't inherit it and register under the same id (which would make that
|
|
461
461
|
// id ambiguous). The wrapper's own process.env still carries it for pidStore.
|
|
462
462
|
delete ptyEnv.AGENT_YES_AGENT_ID;
|
|
463
|
+
// Same reasoning for AGENT_YES_ROLE: it names THIS agent's lane (pidStore
|
|
464
|
+
// reads it from our own env at registration). A subagent is its own lane, so
|
|
465
|
+
// don't let it inherit the parent's role.
|
|
466
|
+
delete ptyEnv.AGENT_YES_ROLE;
|
|
463
467
|
// Strip the parent Claude Code session markers so the wrapped CLI is a CLEAN
|
|
464
468
|
// top-level session. Without this, an `ay claude` launched from inside another
|
|
465
469
|
// Claude Code session (or this claude's Bash tool) inherits
|
package/ts/messageLog.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface MailParty {
|
|
|
25
25
|
cli: string;
|
|
26
26
|
cwd: string;
|
|
27
27
|
agent_id?: string | null;
|
|
28
|
+
/** Self-declared lane/role name at send time, when the party had one set. */
|
|
29
|
+
role?: string;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
/** A single delivered inter-agent message, stored verbatim in both mailboxes. */
|
package/ts/pidStore.spec.ts
CHANGED
|
@@ -63,6 +63,32 @@ describe("PidStore", () => {
|
|
|
63
63
|
expect(rec._id).toBeTypeOf("string");
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
it("picks up a sane AGENT_YES_ROLE and ignores a forgeable one", async () => {
|
|
67
|
+
const saved = process.env.AGENT_YES_ROLE;
|
|
68
|
+
try {
|
|
69
|
+
process.env.AGENT_YES_ROLE = "pm";
|
|
70
|
+
const rec = await store.registerProcess({
|
|
71
|
+
pid: 12346,
|
|
72
|
+
cli: "claude",
|
|
73
|
+
args: [],
|
|
74
|
+
cwd: "/tmp",
|
|
75
|
+
});
|
|
76
|
+
expect(rec.role).toBe("pm");
|
|
77
|
+
|
|
78
|
+
process.env.AGENT_YES_ROLE = "a b > forged";
|
|
79
|
+
const rec2 = await store.registerProcess({
|
|
80
|
+
pid: 12347,
|
|
81
|
+
cli: "claude",
|
|
82
|
+
args: [],
|
|
83
|
+
cwd: "/tmp",
|
|
84
|
+
});
|
|
85
|
+
expect(rec2.role).toBeUndefined();
|
|
86
|
+
} finally {
|
|
87
|
+
if (saved === undefined) delete process.env.AGENT_YES_ROLE;
|
|
88
|
+
else process.env.AGENT_YES_ROLE = saved;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
66
92
|
it("should upsert when registering same pid", async () => {
|
|
67
93
|
await store.registerProcess({
|
|
68
94
|
pid: 12345,
|
package/ts/pidStore.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
appendGlobalPid,
|
|
10
10
|
maybeCompactGlobalPids,
|
|
11
11
|
pruneOldLogs,
|
|
12
|
+
sanitizeRole,
|
|
12
13
|
updateGlobalPidStatus,
|
|
13
14
|
} from "./globalPidIndex.ts";
|
|
14
15
|
|
|
@@ -29,6 +30,8 @@ export interface PidRecord {
|
|
|
29
30
|
agentId?: string;
|
|
30
31
|
/** Permission posture at spawn time; mirrored to the global index. */
|
|
31
32
|
permissions?: AgentPermissions;
|
|
33
|
+
/** Self-declared lane/role name; mirrored to the global index as `role`. */
|
|
34
|
+
role?: string;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
export class PidStore {
|
|
@@ -92,6 +95,11 @@ export class PidStore {
|
|
|
92
95
|
existing?.agentId ??
|
|
93
96
|
(injected && /^[0-9a-f]{6,32}$/i.test(injected) ? injected : randomBytes(6).toString("hex"));
|
|
94
97
|
|
|
98
|
+
// A caller-set AGENT_YES_ROLE names the lane this agent plays ("pm", "crm",
|
|
99
|
+
// …) for the `ay send` envelope. Picked up once at registration; index.ts
|
|
100
|
+
// strips it from the wrapped CLI's env so subagents don't inherit it.
|
|
101
|
+
const role = sanitizeRole(process.env.AGENT_YES_ROLE) ?? existing?.role;
|
|
102
|
+
|
|
95
103
|
const record: Omit<PidRecord, "_id"> = {
|
|
96
104
|
pid,
|
|
97
105
|
cli,
|
|
@@ -105,6 +113,7 @@ export class PidStore {
|
|
|
105
113
|
exitReason: "",
|
|
106
114
|
startedAt: now,
|
|
107
115
|
agentId,
|
|
116
|
+
...(role ? { role } : {}),
|
|
108
117
|
};
|
|
109
118
|
|
|
110
119
|
if (existing) {
|
|
@@ -140,6 +149,7 @@ export class PidStore {
|
|
|
140
149
|
parent_pid: parentPid ?? null,
|
|
141
150
|
agent_id: agentId,
|
|
142
151
|
permissions: permissions ?? null,
|
|
152
|
+
role: role ?? null,
|
|
143
153
|
})
|
|
144
154
|
.then(() => maybeCompactGlobalPids())
|
|
145
155
|
.catch(() => null);
|
package/ts/setup.ts
CHANGED
|
@@ -57,6 +57,23 @@ export async function cmdSetup(rest: string[]): Promise<number> {
|
|
|
57
57
|
);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// 1b. Make the bun-global CLIs resolvable in EVERY shell — including the
|
|
61
|
+
// non-interactive non-login shells Claude Code's Bash tool runs, whose
|
|
62
|
+
// PATH snapshot may lack ~/.bun/bin. Symlinks them into /usr/local/bin
|
|
63
|
+
// (on PATH in every context). Best-effort; never blocks setup.
|
|
64
|
+
try {
|
|
65
|
+
const { linkBunBinsToSystemPath } = await import("./systemPathLink.ts");
|
|
66
|
+
const r = linkBunBinsToSystemPath();
|
|
67
|
+
if (r && (r.linked.length || r.refreshed.length)) {
|
|
68
|
+
const n = r.linked.length + r.refreshed.length;
|
|
69
|
+
process.stdout.write(
|
|
70
|
+
`linked ${n} CLI${n === 1 ? "" : "s"} into ${r.target} (resolvable in non-login shells)\n`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
/* non-fatal */
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
if (noShare) return 0;
|
|
61
78
|
|
|
62
79
|
// 2. Share to agent-yes.com as a boot-persistent daemon. `ay serve install`
|