agent-yes 1.70.1 → 1.72.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.
@@ -0,0 +1,180 @@
1
+ import { n as getRunningAgentCount } from "./runningLock-BBI_URhR.js";
2
+ import { mkdir, readFile, unlink, writeFile } from "fs/promises";
3
+ import path from "path";
4
+ import { homedir } from "os";
5
+ import { existsSync } from "fs";
6
+
7
+ //#region ts/tray.ts
8
+ const POLL_INTERVAL = 2e3;
9
+ const getTrayDir = () => path.join(process.env.CLAUDE_YES_HOME || homedir(), ".claude-yes");
10
+ const getTrayPidFile = () => path.join(getTrayDir(), "tray.pid");
11
+ const ICON_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAjklEQVQ4T2NkoBAwUqifgWoGMDIyNjAyMv5nYGBYQMgVjMgCQM0LGBkZHYDYAY8BDUBxByB2wGcAyAUOQOwAxPYMDAyOeCzAbwBIMyMjowNQsz0ely8ACjng8wJeA0CaGRgY7IHYAZ8hQHEHfF7AawBYMwODPZABRHsBpwEgzUDN9kDsgM8lQHEHfC4gJhwAAM3hMBGq3cNNAAAAAElFTkSuQmCC";
12
+ function buildMenuItems(tasks) {
13
+ const items = [];
14
+ if (tasks.length === 0) items.push({
15
+ title: "No running agents",
16
+ tooltip: "",
17
+ enabled: false
18
+ });
19
+ else {
20
+ items.push({
21
+ title: `Running agents: ${tasks.length}`,
22
+ tooltip: "",
23
+ enabled: false
24
+ });
25
+ items.push({
26
+ title: "---",
27
+ tooltip: "",
28
+ enabled: false
29
+ });
30
+ for (const task of tasks) {
31
+ const dir = task.cwd.replace(/^.*[/\\]/, "");
32
+ const desc = task.task ? ` - ${task.task.slice(0, 40)}` : "";
33
+ items.push({
34
+ title: `[${task.pid}] ${dir}${desc}`,
35
+ tooltip: task.cwd,
36
+ enabled: false
37
+ });
38
+ }
39
+ }
40
+ items.push({
41
+ title: "---",
42
+ tooltip: "",
43
+ enabled: false
44
+ });
45
+ items.push({
46
+ title: "Quit Tray",
47
+ tooltip: "Exit tray icon",
48
+ enabled: true
49
+ });
50
+ return items;
51
+ }
52
+ function isDesktopOS() {
53
+ return process.platform === "darwin" || process.platform === "win32";
54
+ }
55
+ function isTrayProcessRunning(pid) {
56
+ try {
57
+ process.kill(pid, 0);
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+ /**
64
+ * Write the current process PID to the tray PID file
65
+ */
66
+ async function writeTrayPid() {
67
+ await mkdir(getTrayDir(), { recursive: true });
68
+ await writeFile(getTrayPidFile(), String(process.pid), "utf8");
69
+ }
70
+ /**
71
+ * Remove the tray PID file
72
+ */
73
+ async function removeTrayPid() {
74
+ try {
75
+ await unlink(getTrayPidFile());
76
+ } catch {}
77
+ }
78
+ /**
79
+ * Check if a tray process is already running
80
+ */
81
+ async function isTrayRunning() {
82
+ try {
83
+ const pidFile = getTrayPidFile();
84
+ if (!existsSync(pidFile)) return false;
85
+ const pid = parseInt(await readFile(pidFile, "utf8"), 10);
86
+ if (isNaN(pid)) return false;
87
+ return isTrayProcessRunning(pid);
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+ /**
93
+ * Auto-spawn a tray process in the background if not already running.
94
+ * Only spawns on desktop OS (macOS/Windows).
95
+ * Silently does nothing if systray2 is not installed or on non-desktop OS.
96
+ */
97
+ async function ensureTray() {
98
+ if (!isDesktopOS()) return;
99
+ if (await isTrayRunning()) return;
100
+ try {
101
+ const cliPath = new URL("./cli.ts", import.meta.url).pathname;
102
+ const { spawn } = await import("child_process");
103
+ spawn(process.execPath, [
104
+ cliPath,
105
+ "--tray",
106
+ "--no-rust"
107
+ ], {
108
+ detached: true,
109
+ stdio: "ignore",
110
+ env: { ...process.env }
111
+ }).unref();
112
+ } catch {}
113
+ }
114
+ async function startTray() {
115
+ if (!isDesktopOS()) {
116
+ console.error("Tray icon is only supported on macOS and Windows.");
117
+ return;
118
+ }
119
+ if (await isTrayRunning()) {
120
+ console.error("Tray is already running.");
121
+ return;
122
+ }
123
+ await writeTrayPid();
124
+ let SysTray;
125
+ try {
126
+ SysTray = (await import("systray2")).default;
127
+ } catch {
128
+ await removeTrayPid();
129
+ console.error("systray2 is not installed. Install it with: npm install systray2");
130
+ return;
131
+ }
132
+ const { count, tasks } = await getRunningAgentCount();
133
+ const systray = new SysTray({
134
+ menu: {
135
+ icon: ICON_BASE64,
136
+ title: `AY: ${count}`,
137
+ tooltip: `agent-yes: ${count} running`,
138
+ items: buildMenuItems(tasks)
139
+ },
140
+ debug: false,
141
+ copyDir: false
142
+ });
143
+ await systray.ready();
144
+ console.log(`Tray started. Watching ${count} running agent(s).`);
145
+ systray.onClick((action) => {
146
+ if (action.item.title === "Quit Tray") {
147
+ systray.kill(false);
148
+ removeTrayPid().finally(() => process.exit(0));
149
+ }
150
+ });
151
+ let lastCount = count;
152
+ const interval = setInterval(async () => {
153
+ try {
154
+ const { count: newCount, tasks: newTasks } = await getRunningAgentCount();
155
+ if (newCount !== lastCount) {
156
+ lastCount = newCount;
157
+ systray.sendAction({
158
+ type: "update-menu",
159
+ menu: {
160
+ icon: ICON_BASE64,
161
+ title: `AY: ${newCount}`,
162
+ tooltip: `agent-yes: ${newCount} running`,
163
+ items: buildMenuItems(newTasks)
164
+ }
165
+ });
166
+ }
167
+ } catch {}
168
+ }, POLL_INTERVAL);
169
+ const cleanup = () => {
170
+ clearInterval(interval);
171
+ systray.kill(false);
172
+ removeTrayPid().finally(() => process.exit(0));
173
+ };
174
+ process.on("SIGINT", cleanup);
175
+ process.on("SIGTERM", cleanup);
176
+ }
177
+
178
+ //#endregion
179
+ export { ensureTray, startTray };
180
+ //# sourceMappingURL=tray-Dyiihcrq.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.70.1",
3
+ "version": "1.72.0",
4
4
  "description": "A wrapper tool that automates interactions with various AI CLI tools by automatically handling common prompts and responses.",
5
5
  "keywords": [
6
6
  "ai",
@@ -130,6 +130,9 @@
130
130
  "optional": true
131
131
  }
132
132
  },
133
+ "optionalDependencies": {
134
+ "systray2": "^2.1.4"
135
+ },
133
136
  "engines": {
134
137
  "node": ">=22.0.0"
135
138
  },
package/ts/cli.ts CHANGED
@@ -15,6 +15,13 @@ const updateCheckPromise = checkAndAutoUpdate();
15
15
  // Parse CLI arguments
16
16
  const config = parseCliArgs(process.argv);
17
17
 
18
+ // Handle --tray: show system tray icon and block
19
+ if (config.tray) {
20
+ const { startTray } = await import("./tray.ts");
21
+ await startTray();
22
+ await new Promise(() => {}); // Block forever, exit via tray quit or signal
23
+ }
24
+
18
25
  // Handle --rust: spawn the Rust binary instead, fall back to TypeScript if unavailable
19
26
  if (config.useRust) {
20
27
  let rustBinary: string | undefined;
@@ -135,6 +142,12 @@ if (config.verbose) {
135
142
  console.log(argv);
136
143
  }
137
144
 
145
+ // Auto-spawn tray icon in background on desktop OS (best-effort, silent failure)
146
+ {
147
+ const { ensureTray } = await import("./tray.ts");
148
+ ensureTray(); // fire-and-forget, don't await
149
+ }
150
+
138
151
  const { default: cliYes } = await import("./index.ts");
139
152
  const { exitCode } = await cliYes({ ...config, autoYes: config.autoYes });
140
153
 
@@ -126,6 +126,11 @@ export function parseCliArgs(argv: string[]) {
126
126
  default: false,
127
127
  alias: "y",
128
128
  })
129
+ .option("tray", {
130
+ type: "boolean",
131
+ description: "Show a system tray icon with running agent count (macOS/Windows only)",
132
+ default: false,
133
+ })
129
134
  .option("rust", {
130
135
  type: "boolean",
131
136
  description: "Use the Rust implementation (enabled by default, use --no-rust for TypeScript)",
@@ -275,6 +280,7 @@ export function parseCliArgs(argv: string[]) {
275
280
  showVersion: parsedArgv.version,
276
281
  autoYes: parsedArgv.auto !== "no", // auto-yes enabled by default, disabled with --auto=no
277
282
  idleAction: parsedArgv.idleAction as string | undefined,
283
+ tray: parsedArgv.tray,
278
284
  useRust: parsedArgv.rust,
279
285
  // New unified --swarm flag (takes precedence over deprecated flags)
280
286
  swarm: parsedArgv.swarm ?? (parsedArgv.experimentalSwarm ? parsedArgv.swarmTopic : undefined),
package/ts/runningLock.ts CHANGED
@@ -286,6 +286,20 @@ async function waitForUnlock(blockingTasks: Task[], currentTask: Task): Promise<
286
286
  if (!wasRaw) stdin.pause();
287
287
  }
288
288
 
289
+ /**
290
+ * Read the current lock file (exported for tray and other consumers)
291
+ */
292
+ export { readLockFile };
293
+
294
+ /**
295
+ * Get the count of currently running agents
296
+ */
297
+ export async function getRunningAgentCount(): Promise<{ count: number; tasks: Task[] }> {
298
+ const lockFile = await readLockFile();
299
+ const running = lockFile.tasks.filter((t) => t.status === "running");
300
+ return { count: running.length, tasks: running };
301
+ }
302
+
289
303
  /**
290
304
  * Clean stale locks from the lock file
291
305
  */
@@ -0,0 +1,340 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Mock systray2 before imports
4
+ const mockSysTray = vi.hoisted(() => {
5
+ const instance = {
6
+ ready: vi.fn().mockResolvedValue(undefined),
7
+ onClick: vi.fn(),
8
+ sendAction: vi.fn(),
9
+ kill: vi.fn(),
10
+ };
11
+ const MockClass = vi.fn().mockImplementation(function (this: any) {
12
+ Object.assign(this, instance);
13
+ return this;
14
+ }) as any;
15
+ return {
16
+ instance,
17
+ MockClass,
18
+ };
19
+ });
20
+
21
+ vi.mock("systray2", () => ({
22
+ default: mockSysTray.MockClass,
23
+ }));
24
+
25
+ // Mock runningLock
26
+ const mockGetRunningAgentCount = vi.hoisted(() =>
27
+ vi.fn().mockResolvedValue({ count: 0, tasks: [] }),
28
+ );
29
+
30
+ vi.mock("./runningLock.ts", () => ({
31
+ getRunningAgentCount: mockGetRunningAgentCount,
32
+ }));
33
+
34
+ // Mock fs for PID file operations
35
+ const mockFs = vi.hoisted(() => ({
36
+ existsSync: vi.fn().mockReturnValue(false),
37
+ }));
38
+ const mockFsPromises = vi.hoisted(() => ({
39
+ mkdir: vi.fn().mockResolvedValue(undefined),
40
+ readFile: vi.fn().mockResolvedValue(""),
41
+ writeFile: vi.fn().mockResolvedValue(undefined),
42
+ unlink: vi.fn().mockResolvedValue(undefined),
43
+ }));
44
+
45
+ vi.mock("fs", () => ({ existsSync: mockFs.existsSync }));
46
+ vi.mock("fs/promises", () => mockFsPromises);
47
+
48
+ // Mock child_process for ensureTray
49
+ const mockSpawn = vi.hoisted(() => {
50
+ const child = { unref: vi.fn() };
51
+ return { spawn: vi.fn().mockReturnValue(child), child };
52
+ });
53
+
54
+ vi.mock("child_process", () => ({ spawn: mockSpawn.spawn }));
55
+
56
+ describe("tray", () => {
57
+ beforeEach(() => {
58
+ vi.clearAllMocks();
59
+ mockGetRunningAgentCount.mockResolvedValue({ count: 0, tasks: [] });
60
+ mockFs.existsSync.mockReturnValue(false);
61
+ });
62
+
63
+ describe("startTray", () => {
64
+ it("should create a systray instance on macOS", async () => {
65
+ const originalPlatform = process.platform;
66
+ Object.defineProperty(process, "platform", { value: "darwin" });
67
+
68
+ const { startTray } = await import("./tray.ts");
69
+ await startTray();
70
+
71
+ expect(mockSysTray.MockClass).toHaveBeenCalledWith(
72
+ expect.objectContaining({
73
+ menu: expect.objectContaining({
74
+ title: "AY: 0",
75
+ tooltip: "agent-yes: 0 running",
76
+ }),
77
+ }),
78
+ );
79
+ expect(mockSysTray.instance.ready).toHaveBeenCalled();
80
+ expect(mockSysTray.instance.onClick).toHaveBeenCalled();
81
+ // Should write PID file
82
+ expect(mockFsPromises.writeFile).toHaveBeenCalled();
83
+
84
+ Object.defineProperty(process, "platform", { value: originalPlatform });
85
+ });
86
+
87
+ it("should show running agent count and task details", async () => {
88
+ const originalPlatform = process.platform;
89
+ Object.defineProperty(process, "platform", { value: "darwin" });
90
+
91
+ mockGetRunningAgentCount.mockResolvedValue({
92
+ count: 2,
93
+ tasks: [
94
+ {
95
+ pid: 1234,
96
+ cwd: "/home/user/project-a",
97
+ task: "fix bugs",
98
+ status: "running",
99
+ startedAt: Date.now(),
100
+ lockedAt: Date.now(),
101
+ },
102
+ {
103
+ pid: 5678,
104
+ cwd: "/home/user/project-b",
105
+ task: "add tests",
106
+ status: "running",
107
+ startedAt: Date.now(),
108
+ lockedAt: Date.now(),
109
+ },
110
+ ],
111
+ });
112
+
113
+ const { startTray } = await import("./tray.ts");
114
+ await startTray();
115
+
116
+ const menuArg = mockSysTray.MockClass.mock.calls[0][0];
117
+ expect(menuArg.menu.title).toBe("AY: 2");
118
+ expect(menuArg.menu.tooltip).toBe("agent-yes: 2 running");
119
+
120
+ const items = menuArg.menu.items;
121
+ expect(items[0].title).toBe("Running agents: 2");
122
+ expect(items[2].title).toContain("[1234]");
123
+ expect(items[2].title).toContain("project-a");
124
+ expect(items[3].title).toContain("[5678]");
125
+ expect(items[3].title).toContain("project-b");
126
+ expect(items[items.length - 1].title).toBe("Quit Tray");
127
+
128
+ Object.defineProperty(process, "platform", { value: originalPlatform });
129
+ });
130
+
131
+ it("should handle quit menu click", async () => {
132
+ const originalPlatform = process.platform;
133
+ Object.defineProperty(process, "platform", { value: "darwin" });
134
+ const mockExit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
135
+
136
+ const { startTray } = await import("./tray.ts");
137
+ await startTray();
138
+
139
+ const onClickCb = mockSysTray.instance.onClick.mock.calls[0][0];
140
+ onClickCb({ item: { title: "Quit Tray" } });
141
+
142
+ expect(mockSysTray.instance.kill).toHaveBeenCalledWith(false);
143
+ // removeTrayPid is called (unlink)
144
+ await vi.waitFor(() => expect(mockExit).toHaveBeenCalledWith(0));
145
+
146
+ mockExit.mockRestore();
147
+ Object.defineProperty(process, "platform", { value: originalPlatform });
148
+ });
149
+
150
+ it("should update tray when agent count changes", async () => {
151
+ const originalPlatform = process.platform;
152
+ Object.defineProperty(process, "platform", { value: "darwin" });
153
+ vi.useFakeTimers();
154
+
155
+ const { startTray } = await import("./tray.ts");
156
+ await startTray();
157
+
158
+ mockGetRunningAgentCount.mockResolvedValue({
159
+ count: 3,
160
+ tasks: [
161
+ {
162
+ pid: 111,
163
+ cwd: "/a",
164
+ task: "t1",
165
+ status: "running" as const,
166
+ startedAt: 0,
167
+ lockedAt: 0,
168
+ },
169
+ {
170
+ pid: 222,
171
+ cwd: "/b",
172
+ task: "t2",
173
+ status: "running" as const,
174
+ startedAt: 0,
175
+ lockedAt: 0,
176
+ },
177
+ {
178
+ pid: 333,
179
+ cwd: "/c",
180
+ task: "t3",
181
+ status: "running" as const,
182
+ startedAt: 0,
183
+ lockedAt: 0,
184
+ },
185
+ ],
186
+ });
187
+
188
+ await vi.advanceTimersByTimeAsync(2100);
189
+
190
+ expect(mockSysTray.instance.sendAction).toHaveBeenCalledWith(
191
+ expect.objectContaining({
192
+ type: "update-menu",
193
+ menu: expect.objectContaining({ title: "AY: 3" }),
194
+ }),
195
+ );
196
+
197
+ vi.useRealTimers();
198
+ Object.defineProperty(process, "platform", { value: originalPlatform });
199
+ });
200
+
201
+ it("should not update tray when agent count stays the same", async () => {
202
+ const originalPlatform = process.platform;
203
+ Object.defineProperty(process, "platform", { value: "darwin" });
204
+ vi.useFakeTimers();
205
+
206
+ const { startTray } = await import("./tray.ts");
207
+ await startTray();
208
+
209
+ mockGetRunningAgentCount.mockResolvedValue({ count: 0, tasks: [] });
210
+ await vi.advanceTimersByTimeAsync(2100);
211
+
212
+ expect(mockSysTray.instance.sendAction).not.toHaveBeenCalled();
213
+
214
+ vi.useRealTimers();
215
+ Object.defineProperty(process, "platform", { value: originalPlatform });
216
+ });
217
+
218
+ it("should work on Windows", async () => {
219
+ const originalPlatform = process.platform;
220
+ Object.defineProperty(process, "platform", { value: "win32" });
221
+
222
+ const { startTray } = await import("./tray.ts");
223
+ await startTray();
224
+
225
+ expect(mockSysTray.MockClass).toHaveBeenCalled();
226
+
227
+ Object.defineProperty(process, "platform", { value: originalPlatform });
228
+ });
229
+
230
+ it("should skip on Linux", async () => {
231
+ const originalPlatform = process.platform;
232
+ Object.defineProperty(process, "platform", { value: "linux" });
233
+
234
+ const { startTray } = await import("./tray.ts");
235
+ await startTray();
236
+
237
+ expect(mockSysTray.MockClass).not.toHaveBeenCalled();
238
+
239
+ Object.defineProperty(process, "platform", { value: originalPlatform });
240
+ });
241
+
242
+ it("should skip if tray already running", async () => {
243
+ const originalPlatform = process.platform;
244
+ Object.defineProperty(process, "platform", { value: "darwin" });
245
+
246
+ // Simulate existing tray PID file with a live process (our own PID)
247
+ mockFs.existsSync.mockReturnValue(true);
248
+ mockFsPromises.readFile.mockResolvedValue(String(process.pid));
249
+
250
+ const { startTray } = await import("./tray.ts");
251
+ await startTray();
252
+
253
+ // Should NOT create systray because one is already running
254
+ expect(mockSysTray.MockClass).not.toHaveBeenCalled();
255
+
256
+ Object.defineProperty(process, "platform", { value: originalPlatform });
257
+ });
258
+ });
259
+
260
+ describe("isTrayRunning", () => {
261
+ it("should return false when no PID file exists", async () => {
262
+ mockFs.existsSync.mockReturnValue(false);
263
+
264
+ const { isTrayRunning } = await import("./tray.ts");
265
+ expect(await isTrayRunning()).toBe(false);
266
+ });
267
+
268
+ it("should return false when PID file has invalid content", async () => {
269
+ mockFs.existsSync.mockReturnValue(true);
270
+ mockFsPromises.readFile.mockResolvedValue("not-a-number");
271
+
272
+ const { isTrayRunning } = await import("./tray.ts");
273
+ expect(await isTrayRunning()).toBe(false);
274
+ });
275
+
276
+ it("should return true when PID file points to a running process", async () => {
277
+ mockFs.existsSync.mockReturnValue(true);
278
+ mockFsPromises.readFile.mockResolvedValue(String(process.pid));
279
+
280
+ const { isTrayRunning } = await import("./tray.ts");
281
+ expect(await isTrayRunning()).toBe(true);
282
+ });
283
+
284
+ it("should return false when PID file points to a dead process", async () => {
285
+ mockFs.existsSync.mockReturnValue(true);
286
+ mockFsPromises.readFile.mockResolvedValue("999999999");
287
+
288
+ const { isTrayRunning } = await import("./tray.ts");
289
+ expect(await isTrayRunning()).toBe(false);
290
+ });
291
+ });
292
+
293
+ describe("ensureTray", () => {
294
+ it("should spawn tray on macOS when not running", async () => {
295
+ const originalPlatform = process.platform;
296
+ Object.defineProperty(process, "platform", { value: "darwin" });
297
+ mockFs.existsSync.mockReturnValue(false);
298
+
299
+ const { ensureTray } = await import("./tray.ts");
300
+ await ensureTray();
301
+
302
+ expect(mockSpawn.spawn).toHaveBeenCalledWith(
303
+ process.execPath,
304
+ expect.arrayContaining(["--tray", "--no-rust"]),
305
+ expect.objectContaining({ detached: true, stdio: "ignore" }),
306
+ );
307
+ expect(mockSpawn.child.unref).toHaveBeenCalled();
308
+
309
+ Object.defineProperty(process, "platform", { value: originalPlatform });
310
+ });
311
+
312
+ it("should not spawn on Linux", async () => {
313
+ const originalPlatform = process.platform;
314
+ Object.defineProperty(process, "platform", { value: "linux" });
315
+
316
+ const { ensureTray } = await import("./tray.ts");
317
+ await ensureTray();
318
+
319
+ expect(mockSpawn.spawn).not.toHaveBeenCalled();
320
+
321
+ Object.defineProperty(process, "platform", { value: originalPlatform });
322
+ });
323
+
324
+ it("should not spawn if tray already running", async () => {
325
+ const originalPlatform = process.platform;
326
+ Object.defineProperty(process, "platform", { value: "darwin" });
327
+
328
+ // Simulate existing tray
329
+ mockFs.existsSync.mockReturnValue(true);
330
+ mockFsPromises.readFile.mockResolvedValue(String(process.pid));
331
+
332
+ const { ensureTray } = await import("./tray.ts");
333
+ await ensureTray();
334
+
335
+ expect(mockSpawn.spawn).not.toHaveBeenCalled();
336
+
337
+ Object.defineProperty(process, "platform", { value: originalPlatform });
338
+ });
339
+ });
340
+ });