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.
Files changed (57) hide show
  1. package/dist/{SUPPORTED_CLIS-DD6bhkyI.js → SUPPORTED_CLIS-BBeIFAZD.js} +2 -2
  2. package/dist/SUPPORTED_CLIS-BRgPb6Pz.js +11 -0
  3. package/dist/{agentShare-CoI7IHeq.js → agentShare-90Y_SFqK.js} +4 -4
  4. package/dist/{callback-CbdkyTRw.js → callback-CLzRxjEn.js} +3 -3
  5. package/dist/{callback-DNI__ASS.js → callback-DYQ2jQi2.js} +4 -4
  6. package/dist/cli.js +11 -11
  7. package/dist/{cwdConflictWarn-DZTxTGXq.js → cwdConflictWarn-B_047KCF.js} +2 -2
  8. package/dist/{forkNested-CgJ1rTnT.js → forkNested-CBdhC852.js} +2 -2
  9. package/dist/globalPidIndex-CHA__9Xf.js +4 -0
  10. package/dist/{globalPidIndex-Dq4KY2IV.js → globalPidIndex-pwp7Vb7o.js} +8 -2
  11. package/dist/index.js +4 -4
  12. package/dist/{notifyDaemon-BQ6_UbJS.js → notifyDaemon-D5RtFwMq.js} +4 -4
  13. package/dist/pidStore-1PQ4ARnh.js +6 -0
  14. package/dist/{pidStore-Cmcc8Oej.js → pidStore-DfSm1jog.js} +7 -4
  15. package/dist/{remotes-Dy4Rg8Yp.js → remotes-Cn-wszbK.js} +2 -2
  16. package/dist/{remotes-CNe85Icx.js → remotes-lOhHSoSS.js} +1 -1
  17. package/dist/{rustBinary-orhBmTNo.js → rustBinary-BEscpLLP.js} +2 -2
  18. package/dist/{schedule-BQy6gXYk.js → schedule-Cw5nW_di.js} +6 -6
  19. package/dist/{serve-C2mQMjUR.js → serve-DctJ7tY-.js} +19 -19
  20. package/dist/{setup-EEF1vfhi.js → setup-BA9D1ExO.js} +10 -2
  21. package/dist/{spawnGate-D0E1uRP7.js → spawnGate-D-68B907.js} +2 -2
  22. package/dist/spawnGate-DbSHnfqA.js +5 -0
  23. package/dist/{subcommands-CiX7J7hA.js → subcommands-D_cPHm34.js} +93 -24
  24. package/dist/{subcommands-S7uULM_d.js → subcommands-Dg7KkEi6.js} +3 -3
  25. package/dist/systemPathLink-DTAhFXFx.js +91 -0
  26. package/dist/{terminal-i7IimD_R.js → terminal-CB5K4Jjh.js} +2 -2
  27. package/dist/{todoCli-B2kqgqPh.js → todoCli-DNOk38lq.js} +2 -2
  28. package/dist/{tray-CgkwxtG6.js → tray-wWMvGWeY.js} +1 -1
  29. package/dist/trayApp-B1OhYfj3.js +5 -0
  30. package/dist/{trayApp-4sZgxxP-.js → trayApp-Ck3kWM-7.js} +2 -2
  31. package/dist/{ts-D-E4xqpA.js → ts-Bd_7sgVu.js} +5 -4
  32. package/dist/{versionChecker-YDl8a9il.js → versionChecker-DRijpksb.js} +2 -2
  33. package/dist/{webrtcRemote-C3OlHYSt.js → webrtcRemote-BkZcA5yq.js} +1 -1
  34. package/dist/{widget-7j9RrYZR.js → widget-DiRKbtng.js} +3 -3
  35. package/dist/{ws-DjyQ20AD.js → ws-Bp8eOEoV.js} +2 -2
  36. package/dist/{ws-Clem1WQ-.js → ws-Cqn8mxsS.js} +4 -4
  37. package/package.json +1 -1
  38. package/ts/globalPidIndex.spec.ts +36 -0
  39. package/ts/globalPidIndex.ts +19 -1
  40. package/ts/hist.spec.ts +219 -0
  41. package/ts/hist.ts +136 -0
  42. package/ts/histStore.spec.ts +351 -0
  43. package/ts/histStore.ts +494 -0
  44. package/ts/index.ts +4 -0
  45. package/ts/messageLog.ts +2 -0
  46. package/ts/pidStore.spec.ts +26 -0
  47. package/ts/pidStore.ts +10 -0
  48. package/ts/setup.ts +17 -0
  49. package/ts/subcommands.spec.ts +180 -0
  50. package/ts/subcommands.ts +103 -3
  51. package/ts/systemPathLink.spec.ts +69 -0
  52. package/ts/systemPathLink.ts +134 -0
  53. package/dist/SUPPORTED_CLIS-D1BoydrZ.js +0 -11
  54. package/dist/globalPidIndex-CQX_0JSB.js +0 -4
  55. package/dist/pidStore-BbKqQOS8.js +0 -6
  56. package/dist/spawnGate-DqKBA07k.js +0 -5
  57. package/dist/trayApp-C35IkXnz.js +0 -5
@@ -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. */
@@ -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`