agent-yes 1.73.2 → 1.74.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/ts/cli.ts CHANGED
@@ -16,6 +16,17 @@ import { buildRustArgs } from "./buildRustArgs.ts";
16
16
  }
17
17
  }
18
18
 
19
+ // Subcommand fast path: `cy ls / read / cat / tail / head / send` bypass the
20
+ // agent-spawn machinery (and the --rust dispatch) and operate on the global
21
+ // pid index instead. Must run before checkAndAutoUpdate / yargs / Rust spawn.
22
+ {
23
+ const { isSubcommand, runSubcommand } = await import("./subcommands.ts");
24
+ if (isSubcommand(process.argv[2])) {
25
+ const code = await runSubcommand(process.argv);
26
+ process.exit(code ?? 0);
27
+ }
28
+ }
29
+
19
30
  // Check for updates before starting — installs & re-execs if a newer version exists.
20
31
  // Fast path: cached result (no network), so this adds near-zero latency most of the time.
21
32
  await checkAndAutoUpdate();
@@ -0,0 +1,166 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mkdtemp, rm } from "fs/promises";
3
+ import { tmpdir, homedir } from "os";
4
+ import path from "path";
5
+
6
+ // homedir() is what the module derives `~/.agent-yes/pids.jsonl` from.
7
+ // Stub it to a fresh tempdir per test so we don't touch the user's real
8
+ // global index file.
9
+ let testHome: string;
10
+
11
+ vi.mock("os", async () => {
12
+ const actual = await vi.importActual<typeof import("os")>("os");
13
+ return {
14
+ ...actual,
15
+ homedir: () => testHome,
16
+ };
17
+ });
18
+
19
+ beforeEach(async () => {
20
+ testHome = await mkdtemp(path.join(tmpdir(), "agent-yes-test-"));
21
+ // Reset module cache so the import below picks up the new home.
22
+ vi.resetModules();
23
+ });
24
+
25
+ afterEach(async () => {
26
+ await rm(testHome, { recursive: true, force: true }).catch(() => null);
27
+ });
28
+
29
+ async function loadModule() {
30
+ return await import("./globalPidIndex.ts");
31
+ }
32
+
33
+ describe("globalPidIndex", () => {
34
+ it("appends a record and reads it back with last-line-wins merge", async () => {
35
+ const mod = await loadModule();
36
+ await mod.appendGlobalPid({
37
+ pid: 11111,
38
+ cli: "claude",
39
+ prompt: "hello",
40
+ cwd: "/tmp/x",
41
+ log_file: "/tmp/x/.agent-yes/11111.raw.log",
42
+ fifo_file: "/tmp/x/.agent-yes/fifo/11111.stdin",
43
+ status: "active",
44
+ exit_code: null,
45
+ exit_reason: null,
46
+ started_at: 1000,
47
+ });
48
+
49
+ const records = await mod.readGlobalPids();
50
+ expect(records).toHaveLength(1);
51
+ expect(records[0]).toMatchObject({
52
+ pid: 11111,
53
+ cli: "claude",
54
+ status: "active",
55
+ });
56
+ });
57
+
58
+ it("merges multiple appends for the same pid (last write wins)", async () => {
59
+ const mod = await loadModule();
60
+ await mod.appendGlobalPid({
61
+ pid: 22222,
62
+ cli: "codex",
63
+ prompt: null,
64
+ cwd: "/a",
65
+ log_file: null,
66
+ status: "active",
67
+ exit_code: null,
68
+ exit_reason: null,
69
+ started_at: 1,
70
+ });
71
+ await mod.updateGlobalPidStatus(22222, {
72
+ status: "exited",
73
+ exit_code: 0,
74
+ exit_reason: "completed",
75
+ });
76
+
77
+ const records = await mod.readGlobalPids();
78
+ expect(records).toHaveLength(1);
79
+ expect(records[0]).toMatchObject({
80
+ pid: 22222,
81
+ status: "exited",
82
+ exit_code: 0,
83
+ exit_reason: "completed",
84
+ });
85
+ });
86
+
87
+ it("liveOnly filter drops records with status=exited even if pid is alive", async () => {
88
+ const mod = await loadModule();
89
+ // Use this very process's pid — guaranteed alive.
90
+ const livePid = process.pid;
91
+ await mod.appendGlobalPid({
92
+ pid: livePid,
93
+ cli: "claude",
94
+ prompt: null,
95
+ cwd: "/a",
96
+ log_file: null,
97
+ status: "exited",
98
+ exit_code: 0,
99
+ exit_reason: null,
100
+ started_at: 1,
101
+ });
102
+ const live = await mod.readGlobalPids({ liveOnly: true });
103
+ expect(live).toHaveLength(0);
104
+ const all = await mod.readGlobalPids();
105
+ expect(all).toHaveLength(1);
106
+ });
107
+
108
+ it("liveOnly filter drops dead pids", async () => {
109
+ const mod = await loadModule();
110
+ // PID 999999 is virtually never live in CI.
111
+ await mod.appendGlobalPid({
112
+ pid: 999999,
113
+ cli: "claude",
114
+ prompt: null,
115
+ cwd: "/a",
116
+ log_file: null,
117
+ status: "active",
118
+ exit_code: null,
119
+ exit_reason: null,
120
+ started_at: 1,
121
+ });
122
+ const live = await mod.readGlobalPids({ liveOnly: true });
123
+ expect(live).toHaveLength(0);
124
+ });
125
+
126
+ it("getGlobalPidIndexPath returns a stable path under homedir", async () => {
127
+ const mod = await loadModule();
128
+ const p = mod.getGlobalPidIndexPath();
129
+ expect(p).toBe(path.join(testHome, ".agent-yes", "pids.jsonl"));
130
+ });
131
+
132
+ it("readGlobalPids returns [] when the file does not exist", async () => {
133
+ const mod = await loadModule();
134
+ const records = await mod.readGlobalPids();
135
+ expect(records).toEqual([]);
136
+ });
137
+
138
+ it("updateGlobalPidStatus is a no-op for unknown pids", async () => {
139
+ const mod = await loadModule();
140
+ await mod.updateGlobalPidStatus(7777, { status: "exited" });
141
+ const records = await mod.readGlobalPids();
142
+ expect(records).toEqual([]);
143
+ });
144
+
145
+ it("skips corrupt lines without throwing", async () => {
146
+ const mod = await loadModule();
147
+ await mod.appendGlobalPid({
148
+ pid: 5555,
149
+ cli: "claude",
150
+ prompt: null,
151
+ cwd: "/a",
152
+ log_file: null,
153
+ status: "active",
154
+ exit_code: null,
155
+ exit_reason: null,
156
+ started_at: 1,
157
+ });
158
+ // Inject a corrupt line directly.
159
+ const { appendFile } = await import("fs/promises");
160
+ await appendFile(mod.getGlobalPidIndexPath(), "not-json-at-all\n");
161
+
162
+ const records = await mod.readGlobalPids();
163
+ expect(records).toHaveLength(1);
164
+ expect(records[0]?.pid).toBe(5555);
165
+ });
166
+ });
@@ -0,0 +1,143 @@
1
+ import { appendFile, mkdir, readFile } from "fs/promises";
2
+ import { homedir } from "os";
3
+ import path from "path";
4
+ import { lock } from "proper-lockfile";
5
+ import { logger } from "./logger.ts";
6
+
7
+ /**
8
+ * Global, cross-runtime pid registry at `~/.agent-yes/pids.jsonl`.
9
+ *
10
+ * Schema mirrors Rust's `PidRecord` exactly (snake_case) so the Rust binary
11
+ * and the TS implementation can both read and write the same file. Rust
12
+ * uses serde's default (deny-unknown = false), so TS-only extras like
13
+ * `fifo_file` are silently dropped on Rust rewrites — fine, we re-add
14
+ * them on the next TS status update.
15
+ *
16
+ * Wire format (one JSON object per line, JSONL):
17
+ *
18
+ * {"pid":1234,"cli":"claude","prompt":null,"cwd":"/foo",
19
+ * "log_file":"/foo/.agent-yes/1234.raw.log",
20
+ * "fifo_file":"/foo/.agent-yes/fifo/1234.stdin",
21
+ * "status":"active","exit_code":null,"exit_reason":null,
22
+ * "started_at":1735689600000}
23
+ *
24
+ * Append semantics (TS) + rewrite-on-update (Rust) coexist because the
25
+ * reader always merges by `pid`, last-line wins.
26
+ */
27
+
28
+ export interface GlobalPidRecord {
29
+ pid: number;
30
+ cli: string;
31
+ prompt: string | null;
32
+ cwd: string;
33
+ log_file: string | null;
34
+ fifo_file?: string | null;
35
+ status: "active" | "idle" | "exited";
36
+ exit_code: number | null;
37
+ exit_reason: string | null;
38
+ started_at: number;
39
+ }
40
+
41
+ const GLOBAL_DIR = path.join(homedir(), ".agent-yes");
42
+ const GLOBAL_FILE = path.join(GLOBAL_DIR, "pids.jsonl");
43
+
44
+ export function getGlobalPidIndexPath(): string {
45
+ return GLOBAL_FILE;
46
+ }
47
+
48
+ async function ensureDir() {
49
+ await mkdir(GLOBAL_DIR, { recursive: true });
50
+ }
51
+
52
+ async function withLock<R>(fn: () => Promise<R>): Promise<R> {
53
+ await ensureDir();
54
+ let release: (() => Promise<void>) | undefined;
55
+ try {
56
+ release = await lock(GLOBAL_DIR, {
57
+ lockfilePath: GLOBAL_FILE + ".lock",
58
+ retries: { retries: 5, minTimeout: 50, maxTimeout: 500 },
59
+ });
60
+ return await fn();
61
+ } finally {
62
+ await release?.();
63
+ }
64
+ }
65
+
66
+ /** Append one full record line. Caller must provide all required fields. */
67
+ export async function appendGlobalPid(record: GlobalPidRecord): Promise<void> {
68
+ try {
69
+ await withLock(async () => {
70
+ await appendFile(GLOBAL_FILE, JSON.stringify(record) + "\n");
71
+ });
72
+ } catch (error) {
73
+ logger.debug("[globalPidIndex] append failed:", error);
74
+ }
75
+ }
76
+
77
+ /** Append a partial status update by pid (status, exit_code, exit_reason). */
78
+ export async function updateGlobalPidStatus(
79
+ pid: number,
80
+ patch: Partial<Pick<GlobalPidRecord, "status" | "exit_code" | "exit_reason">>,
81
+ ): Promise<void> {
82
+ try {
83
+ await withLock(async () => {
84
+ const current = await readGlobalPidsRaw();
85
+ const existing = current.find((r) => r.pid === pid);
86
+ if (!existing) return; // unknown pid — nothing to update
87
+ const merged: GlobalPidRecord = { ...existing, ...patch };
88
+ await appendFile(GLOBAL_FILE, JSON.stringify(merged) + "\n");
89
+ });
90
+ } catch (error) {
91
+ logger.debug("[globalPidIndex] updateStatus failed:", error);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Read the file once without merge logic — internal helper for status updates.
97
+ */
98
+ async function readGlobalPidsRaw(): Promise<GlobalPidRecord[]> {
99
+ let raw: string;
100
+ try {
101
+ raw = await readFile(GLOBAL_FILE, "utf-8");
102
+ } catch (err: any) {
103
+ if (err.code === "ENOENT") return [];
104
+ throw err;
105
+ }
106
+ const merged = new Map<number, GlobalPidRecord>();
107
+ for (const line of raw.split("\n")) {
108
+ const trimmed = line.trim();
109
+ if (!trimmed) continue;
110
+ try {
111
+ const doc = JSON.parse(trimmed) as GlobalPidRecord;
112
+ if (typeof doc.pid !== "number") continue;
113
+ const prev = merged.get(doc.pid);
114
+ merged.set(doc.pid, prev ? { ...prev, ...doc } : doc);
115
+ } catch {
116
+ // skip corrupt
117
+ }
118
+ }
119
+ return Array.from(merged.values());
120
+ }
121
+
122
+ /**
123
+ * Read all records, last-line-per-pid wins (events get merged).
124
+ * Optionally filter to live processes only.
125
+ */
126
+ export async function readGlobalPids(
127
+ opts: {
128
+ liveOnly?: boolean;
129
+ } = {},
130
+ ): Promise<GlobalPidRecord[]> {
131
+ const records = await readGlobalPidsRaw();
132
+ if (!opts.liveOnly) return records;
133
+ return records.filter((r) => r.status !== "exited" && isProcessAlive(r.pid));
134
+ }
135
+
136
+ function isProcessAlive(pid: number): boolean {
137
+ try {
138
+ process.kill(pid, 0);
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
package/ts/pidStore.ts CHANGED
@@ -2,6 +2,7 @@ import { mkdir, writeFile } from "fs/promises";
2
2
  import path from "path";
3
3
  import { logger } from "./logger.ts";
4
4
  import { JsonlStore } from "./JsonlStore.ts";
5
+ import { appendGlobalPid, updateGlobalPidStatus } from "./globalPidIndex.ts";
5
6
 
6
7
  export interface PidRecord {
7
8
  _id?: string;
@@ -85,6 +86,22 @@ export class PidStore {
85
86
  }
86
87
 
87
88
  logger.debug(`[pidStore] Registered process ${pid}`);
89
+
90
+ // Mirror to the cross-runtime global index (~/.agent-yes/pids.jsonl).
91
+ // Fire-and-forget — failures must not block agent startup.
92
+ appendGlobalPid({
93
+ pid,
94
+ cli,
95
+ prompt: prompt ?? null,
96
+ cwd,
97
+ log_file: logFile,
98
+ fifo_file: fifoFile,
99
+ status: "active",
100
+ exit_code: null,
101
+ exit_reason: null,
102
+ started_at: now,
103
+ }).catch(() => null);
104
+
88
105
  return result;
89
106
  }
90
107
 
@@ -102,6 +119,13 @@ export class PidStore {
102
119
 
103
120
  await this.store.updateById(existing._id!, patch);
104
121
  logger.debug(`[pidStore] Updated process ${pid} status=${status}`);
122
+
123
+ // Mirror to global index. Same fire-and-forget policy.
124
+ updateGlobalPidStatus(pid, {
125
+ status,
126
+ exit_code: extra?.exitCode ?? null,
127
+ exit_reason: extra?.exitReason ?? null,
128
+ }).catch(() => null);
105
129
  }
106
130
 
107
131
  getAllRecords(): PidRecord[] {