claude-yes 1.71.0 → 1.72.1

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.
@@ -815,7 +815,7 @@ function tryCatch(catchFn, fn) {
815
815
  //#endregion
816
816
  //#region package.json
817
817
  var name = "agent-yes";
818
- var version = "1.71.0";
818
+ var version = "1.72.1";
819
819
 
820
820
  //#endregion
821
821
  //#region ts/pty-fix.ts
@@ -1895,4 +1895,4 @@ const SUPPORTED_CLIS = Object.keys(CLIS_CONFIG);
1895
1895
 
1896
1896
  //#endregion
1897
1897
  export { AgentContext as a, PidStore as c, config as i, removeControlCharacters as l, CLIS_CONFIG as n, name as o, agentYes as r, version as s, SUPPORTED_CLIS as t };
1898
- //# sourceMappingURL=SUPPORTED_CLIS-DtYo1wxO.js.map
1898
+ //# sourceMappingURL=SUPPORTED_CLIS-DBk8E5_6.js.map
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { c as PidStore, o as name, s as version, t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-DtYo1wxO.js";
2
+ import { c as PidStore, o as name, s as version, t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-DBk8E5_6.js";
3
3
  import { t as logger } from "./logger-CX77vJDA.js";
4
4
  import { argv } from "process";
5
5
  import { spawn } from "child_process";
@@ -481,10 +481,14 @@ function buildRustArgs(argv, cliFromScript, supportedClis) {
481
481
  const updateCheckPromise = checkAndAutoUpdate();
482
482
  const config = parseCliArgs(process.argv);
483
483
  if (config.tray) {
484
- const { startTray } = await import("./tray-BzSS0v-i.js");
484
+ const { startTray } = await import("./tray-BQkynk6r.js");
485
485
  await startTray();
486
486
  await new Promise(() => {});
487
487
  }
488
+ {
489
+ const { ensureTray } = await import("./tray-BQkynk6r.js");
490
+ ensureTray();
491
+ }
488
492
  if (config.useRust) {
489
493
  let rustBinary;
490
494
  try {
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as AgentContext, i as config, l as removeControlCharacters, n as CLIS_CONFIG, r as agentYes } from "./SUPPORTED_CLIS-DtYo1wxO.js";
1
+ import { a as AgentContext, i as config, l as removeControlCharacters, n as CLIS_CONFIG, r as agentYes } from "./SUPPORTED_CLIS-DBk8E5_6.js";
2
2
  import "./logger-CX77vJDA.js";
3
3
 
4
4
  export { AgentContext, CLIS_CONFIG, config, agentYes as default, removeControlCharacters };
@@ -0,0 +1,187 @@
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 IDLE_EXIT_POLLS = 15;
10
+ const getTrayDir = () => path.join(process.env.CLAUDE_YES_HOME || homedir(), ".claude-yes");
11
+ const getTrayPidFile = () => path.join(getTrayDir(), "tray.pid");
12
+ const ICON_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAjklEQVQ4T2NkoBAwUqifgWoGMDIyNjAyMv5nYGBYQMgVjMgCQM0LGBkZHYDYAY8BDUBxByB2wGcAyAUOQOwAxPYMDAyOeCzAbwBIMyMjowNQsz0ely8ACjng8wJeA0CaGRgY7IHYAZ8hQHEHfF7AawBYMwODPZABRHsBpwEgzUDN9kDsgM8lQHEHfC4gJhwAAM3hMBGq3cNNAAAAAElFTkSuQmCC";
13
+ function buildMenuItems(tasks) {
14
+ const items = [];
15
+ if (tasks.length === 0) items.push({
16
+ title: "No running agents",
17
+ tooltip: "",
18
+ enabled: false
19
+ });
20
+ else {
21
+ items.push({
22
+ title: `Running agents: ${tasks.length}`,
23
+ tooltip: "",
24
+ enabled: false
25
+ });
26
+ items.push({
27
+ title: "---",
28
+ tooltip: "",
29
+ enabled: false
30
+ });
31
+ for (const task of tasks) {
32
+ const dir = task.cwd.replace(/^.*[/\\]/, "");
33
+ const desc = task.task ? ` - ${task.task.slice(0, 40)}` : "";
34
+ items.push({
35
+ title: `[${task.pid}] ${dir}${desc}`,
36
+ tooltip: task.cwd,
37
+ enabled: false
38
+ });
39
+ }
40
+ }
41
+ items.push({
42
+ title: "---",
43
+ tooltip: "",
44
+ enabled: false
45
+ });
46
+ items.push({
47
+ title: "Quit Tray",
48
+ tooltip: "Exit tray icon",
49
+ enabled: true
50
+ });
51
+ return items;
52
+ }
53
+ function isDesktopOS() {
54
+ return process.platform === "darwin" || process.platform === "win32";
55
+ }
56
+ function isTrayProcessRunning(pid) {
57
+ try {
58
+ process.kill(pid, 0);
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+ /**
65
+ * Write the current process PID to the tray PID file
66
+ */
67
+ async function writeTrayPid() {
68
+ await mkdir(getTrayDir(), { recursive: true });
69
+ await writeFile(getTrayPidFile(), String(process.pid), "utf8");
70
+ }
71
+ /**
72
+ * Remove the tray PID file
73
+ */
74
+ async function removeTrayPid() {
75
+ try {
76
+ await unlink(getTrayPidFile());
77
+ } catch {}
78
+ }
79
+ /**
80
+ * Check if a tray process is already running
81
+ */
82
+ async function isTrayRunning() {
83
+ try {
84
+ const pidFile = getTrayPidFile();
85
+ if (!existsSync(pidFile)) return false;
86
+ const pid = parseInt(await readFile(pidFile, "utf8"), 10);
87
+ if (isNaN(pid)) return false;
88
+ return isTrayProcessRunning(pid);
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+ /**
94
+ * Auto-spawn a tray process in the background if not already running.
95
+ * Only spawns on desktop OS (macOS/Windows).
96
+ * Silently does nothing if systray2 is not installed or on non-desktop OS.
97
+ */
98
+ async function ensureTray() {
99
+ if (!isDesktopOS()) return;
100
+ if (await isTrayRunning()) return;
101
+ try {
102
+ const cliPath = new URL("./cli.ts", import.meta.url).pathname;
103
+ const { spawn } = await import("child_process");
104
+ spawn(process.execPath, [
105
+ cliPath,
106
+ "--tray",
107
+ "--no-rust"
108
+ ], {
109
+ detached: true,
110
+ stdio: "ignore",
111
+ env: { ...process.env }
112
+ }).unref();
113
+ } catch {}
114
+ }
115
+ async function startTray() {
116
+ if (!isDesktopOS()) {
117
+ console.error("Tray icon is only supported on macOS and Windows.");
118
+ return;
119
+ }
120
+ if (await isTrayRunning()) {
121
+ console.error("Tray is already running.");
122
+ return;
123
+ }
124
+ await writeTrayPid();
125
+ let SysTray;
126
+ try {
127
+ SysTray = (await import("systray2")).default;
128
+ } catch {
129
+ await removeTrayPid();
130
+ console.error("systray2 is not installed. Install it with: npm install systray2");
131
+ return;
132
+ }
133
+ const { count, tasks } = await getRunningAgentCount();
134
+ const systray = new SysTray({
135
+ menu: {
136
+ icon: ICON_BASE64,
137
+ title: `AY: ${count}`,
138
+ tooltip: `agent-yes: ${count} running`,
139
+ items: buildMenuItems(tasks)
140
+ },
141
+ debug: false,
142
+ copyDir: false
143
+ });
144
+ await systray.ready();
145
+ console.log(`Tray started. Watching ${count} running agent(s).`);
146
+ let intervalId;
147
+ const cleanup = () => {
148
+ if (intervalId) clearInterval(intervalId);
149
+ systray.kill(false);
150
+ removeTrayPid().finally(() => process.exit(0));
151
+ };
152
+ systray.onClick((action) => {
153
+ if (action.item.title === "Quit Tray") cleanup();
154
+ });
155
+ let lastCount = count;
156
+ let idlePolls = count === 0 ? 1 : 0;
157
+ intervalId = setInterval(async () => {
158
+ try {
159
+ const { count: newCount, tasks: newTasks } = await getRunningAgentCount();
160
+ if (newCount === 0) {
161
+ idlePolls++;
162
+ if (idlePolls >= IDLE_EXIT_POLLS) {
163
+ cleanup();
164
+ return;
165
+ }
166
+ } else idlePolls = 0;
167
+ if (newCount !== lastCount) {
168
+ lastCount = newCount;
169
+ systray.sendAction({
170
+ type: "update-menu",
171
+ menu: {
172
+ icon: ICON_BASE64,
173
+ title: `AY: ${newCount}`,
174
+ tooltip: `agent-yes: ${newCount} running`,
175
+ items: buildMenuItems(newTasks)
176
+ }
177
+ });
178
+ }
179
+ } catch {}
180
+ }, POLL_INTERVAL);
181
+ process.on("SIGINT", cleanup);
182
+ process.on("SIGTERM", cleanup);
183
+ }
184
+
185
+ //#endregion
186
+ export { ensureTray, startTray };
187
+ //# sourceMappingURL=tray-BQkynk6r.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-yes",
3
- "version": "1.71.0",
3
+ "version": "1.72.1",
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",
package/ts/cli.ts CHANGED
@@ -22,6 +22,13 @@ if (config.tray) {
22
22
  await new Promise(() => {}); // Block forever, exit via tray quit or signal
23
23
  }
24
24
 
25
+ // Auto-spawn tray icon in background on desktop OS (best-effort, silent failure)
26
+ // Must run before --rust spawn since that blocks forever
27
+ {
28
+ const { ensureTray } = await import("./tray.ts");
29
+ ensureTray(); // fire-and-forget, don't await
30
+ }
31
+
25
32
  // Handle --rust: spawn the Rust binary instead, fall back to TypeScript if unavailable
26
33
  if (config.useRust) {
27
34
  let rustBinary: string | undefined;
package/ts/tray.spec.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
 
3
3
  // Mock systray2 before imports
4
4
  const mockSysTray = vi.hoisted(() => {
@@ -8,12 +8,10 @@ const mockSysTray = vi.hoisted(() => {
8
8
  sendAction: vi.fn(),
9
9
  kill: vi.fn(),
10
10
  };
11
- const MockClass = vi.fn().mockImplementation(function (this: any, opts: any) {
11
+ const MockClass = vi.fn().mockImplementation(function (this: any) {
12
12
  Object.assign(this, instance);
13
- (MockClass as any).__lastOpts = opts;
14
13
  return this;
15
14
  }) as any;
16
- MockClass.__lastOpts = null;
17
15
  return {
18
16
  instance,
19
17
  MockClass,
@@ -33,10 +31,33 @@ vi.mock("./runningLock.ts", () => ({
33
31
  getRunningAgentCount: mockGetRunningAgentCount,
34
32
  }));
35
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
+
36
56
  describe("tray", () => {
37
57
  beforeEach(() => {
38
58
  vi.clearAllMocks();
39
59
  mockGetRunningAgentCount.mockResolvedValue({ count: 0, tasks: [] });
60
+ mockFs.existsSync.mockReturnValue(false);
40
61
  });
41
62
 
42
63
  describe("startTray", () => {
@@ -57,6 +78,8 @@ describe("tray", () => {
57
78
  );
58
79
  expect(mockSysTray.instance.ready).toHaveBeenCalled();
59
80
  expect(mockSysTray.instance.onClick).toHaveBeenCalled();
81
+ // Should write PID file
82
+ expect(mockFsPromises.writeFile).toHaveBeenCalled();
60
83
 
61
84
  Object.defineProperty(process, "platform", { value: originalPlatform });
62
85
  });
@@ -94,15 +117,12 @@ describe("tray", () => {
94
117
  expect(menuArg.menu.title).toBe("AY: 2");
95
118
  expect(menuArg.menu.tooltip).toBe("agent-yes: 2 running");
96
119
 
97
- // Should have: header, separator, 2 agent items, separator, quit
98
120
  const items = menuArg.menu.items;
99
121
  expect(items[0].title).toBe("Running agents: 2");
100
122
  expect(items[2].title).toContain("[1234]");
101
123
  expect(items[2].title).toContain("project-a");
102
124
  expect(items[3].title).toContain("[5678]");
103
125
  expect(items[3].title).toContain("project-b");
104
-
105
- // Last item should be "Quit Tray"
106
126
  expect(items[items.length - 1].title).toBe("Quit Tray");
107
127
 
108
128
  Object.defineProperty(process, "platform", { value: originalPlatform });
@@ -111,20 +131,17 @@ describe("tray", () => {
111
131
  it("should handle quit menu click", async () => {
112
132
  const originalPlatform = process.platform;
113
133
  Object.defineProperty(process, "platform", { value: "darwin" });
114
-
115
134
  const mockExit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
116
135
 
117
136
  const { startTray } = await import("./tray.ts");
118
137
  await startTray();
119
138
 
120
- // Get the onClick callback
121
139
  const onClickCb = mockSysTray.instance.onClick.mock.calls[0][0];
122
-
123
- // Simulate "Quit Tray" click
124
140
  onClickCb({ item: { title: "Quit Tray" } });
125
141
 
126
142
  expect(mockSysTray.instance.kill).toHaveBeenCalledWith(false);
127
- expect(mockExit).toHaveBeenCalledWith(0);
143
+ // removeTrayPid is called (unlink)
144
+ await vi.waitFor(() => expect(mockExit).toHaveBeenCalledWith(0));
128
145
 
129
146
  mockExit.mockRestore();
130
147
  Object.defineProperty(process, "platform", { value: originalPlatform });
@@ -133,13 +150,11 @@ describe("tray", () => {
133
150
  it("should update tray when agent count changes", async () => {
134
151
  const originalPlatform = process.platform;
135
152
  Object.defineProperty(process, "platform", { value: "darwin" });
136
-
137
153
  vi.useFakeTimers();
138
154
 
139
155
  const { startTray } = await import("./tray.ts");
140
156
  await startTray();
141
157
 
142
- // Now simulate agent count change on next poll
143
158
  mockGetRunningAgentCount.mockResolvedValue({
144
159
  count: 3,
145
160
  tasks: [
@@ -170,15 +185,12 @@ describe("tray", () => {
170
185
  ],
171
186
  });
172
187
 
173
- // Advance timer past poll interval
174
188
  await vi.advanceTimersByTimeAsync(2100);
175
189
 
176
190
  expect(mockSysTray.instance.sendAction).toHaveBeenCalledWith(
177
191
  expect.objectContaining({
178
192
  type: "update-menu",
179
- menu: expect.objectContaining({
180
- title: "AY: 3",
181
- }),
193
+ menu: expect.objectContaining({ title: "AY: 3" }),
182
194
  }),
183
195
  );
184
196
 
@@ -189,19 +201,72 @@ describe("tray", () => {
189
201
  it("should not update tray when agent count stays the same", async () => {
190
202
  const originalPlatform = process.platform;
191
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 auto-exit after ~30s with 0 agents", async () => {
219
+ const originalPlatform = process.platform;
220
+ Object.defineProperty(process, "platform", { value: "darwin" });
221
+ vi.useFakeTimers();
222
+ const mockExit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
223
+
224
+ const { startTray } = await import("./tray.ts");
225
+ await startTray();
192
226
 
227
+ // Keep returning 0 agents for 15 polls (IDLE_EXIT_POLLS)
228
+ mockGetRunningAgentCount.mockResolvedValue({ count: 0, tasks: [] });
229
+
230
+ // Advance 15 * 2s = 30s
231
+ await vi.advanceTimersByTimeAsync(15 * 2100);
232
+
233
+ expect(mockSysTray.instance.kill).toHaveBeenCalledWith(false);
234
+ await vi.waitFor(() => expect(mockExit).toHaveBeenCalledWith(0));
235
+
236
+ mockExit.mockRestore();
237
+ vi.useRealTimers();
238
+ Object.defineProperty(process, "platform", { value: originalPlatform });
239
+ });
240
+
241
+ it("should reset idle counter when agents appear", async () => {
242
+ const originalPlatform = process.platform;
243
+ Object.defineProperty(process, "platform", { value: "darwin" });
193
244
  vi.useFakeTimers();
245
+ const mockExit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
194
246
 
195
247
  const { startTray } = await import("./tray.ts");
196
248
  await startTray();
197
249
 
198
- // Same count on next poll
250
+ // 10 polls at 0 agents
199
251
  mockGetRunningAgentCount.mockResolvedValue({ count: 0, tasks: [] });
252
+ await vi.advanceTimersByTimeAsync(10 * 2100);
200
253
 
254
+ // Then an agent appears — resets idle counter
255
+ mockGetRunningAgentCount.mockResolvedValue({
256
+ count: 1,
257
+ tasks: [
258
+ { pid: 1, cwd: "/a", task: "t", status: "running" as const, startedAt: 0, lockedAt: 0 },
259
+ ],
260
+ });
201
261
  await vi.advanceTimersByTimeAsync(2100);
202
262
 
203
- expect(mockSysTray.instance.sendAction).not.toHaveBeenCalled();
263
+ // Then 10 more polls at 0 — should NOT exit yet (need 15 consecutive)
264
+ mockGetRunningAgentCount.mockResolvedValue({ count: 0, tasks: [] });
265
+ await vi.advanceTimersByTimeAsync(10 * 2100);
266
+
267
+ expect(mockExit).not.toHaveBeenCalled();
204
268
 
269
+ mockExit.mockRestore();
205
270
  vi.useRealTimers();
206
271
  Object.defineProperty(process, "platform", { value: originalPlatform });
207
272
  });
@@ -229,5 +294,103 @@ describe("tray", () => {
229
294
 
230
295
  Object.defineProperty(process, "platform", { value: originalPlatform });
231
296
  });
297
+
298
+ it("should skip if tray already running", async () => {
299
+ const originalPlatform = process.platform;
300
+ Object.defineProperty(process, "platform", { value: "darwin" });
301
+
302
+ // Simulate existing tray PID file with a live process (our own PID)
303
+ mockFs.existsSync.mockReturnValue(true);
304
+ mockFsPromises.readFile.mockResolvedValue(String(process.pid));
305
+
306
+ const { startTray } = await import("./tray.ts");
307
+ await startTray();
308
+
309
+ // Should NOT create systray because one is already running
310
+ expect(mockSysTray.MockClass).not.toHaveBeenCalled();
311
+
312
+ Object.defineProperty(process, "platform", { value: originalPlatform });
313
+ });
314
+ });
315
+
316
+ describe("isTrayRunning", () => {
317
+ it("should return false when no PID file exists", async () => {
318
+ mockFs.existsSync.mockReturnValue(false);
319
+
320
+ const { isTrayRunning } = await import("./tray.ts");
321
+ expect(await isTrayRunning()).toBe(false);
322
+ });
323
+
324
+ it("should return false when PID file has invalid content", async () => {
325
+ mockFs.existsSync.mockReturnValue(true);
326
+ mockFsPromises.readFile.mockResolvedValue("not-a-number");
327
+
328
+ const { isTrayRunning } = await import("./tray.ts");
329
+ expect(await isTrayRunning()).toBe(false);
330
+ });
331
+
332
+ it("should return true when PID file points to a running process", async () => {
333
+ mockFs.existsSync.mockReturnValue(true);
334
+ mockFsPromises.readFile.mockResolvedValue(String(process.pid));
335
+
336
+ const { isTrayRunning } = await import("./tray.ts");
337
+ expect(await isTrayRunning()).toBe(true);
338
+ });
339
+
340
+ it("should return false when PID file points to a dead process", async () => {
341
+ mockFs.existsSync.mockReturnValue(true);
342
+ mockFsPromises.readFile.mockResolvedValue("999999999");
343
+
344
+ const { isTrayRunning } = await import("./tray.ts");
345
+ expect(await isTrayRunning()).toBe(false);
346
+ });
347
+ });
348
+
349
+ describe("ensureTray", () => {
350
+ it("should spawn tray on macOS when not running", async () => {
351
+ const originalPlatform = process.platform;
352
+ Object.defineProperty(process, "platform", { value: "darwin" });
353
+ mockFs.existsSync.mockReturnValue(false);
354
+
355
+ const { ensureTray } = await import("./tray.ts");
356
+ await ensureTray();
357
+
358
+ expect(mockSpawn.spawn).toHaveBeenCalledWith(
359
+ process.execPath,
360
+ expect.arrayContaining(["--tray", "--no-rust"]),
361
+ expect.objectContaining({ detached: true, stdio: "ignore" }),
362
+ );
363
+ expect(mockSpawn.child.unref).toHaveBeenCalled();
364
+
365
+ Object.defineProperty(process, "platform", { value: originalPlatform });
366
+ });
367
+
368
+ it("should not spawn on Linux", async () => {
369
+ const originalPlatform = process.platform;
370
+ Object.defineProperty(process, "platform", { value: "linux" });
371
+
372
+ const { ensureTray } = await import("./tray.ts");
373
+ await ensureTray();
374
+
375
+ expect(mockSpawn.spawn).not.toHaveBeenCalled();
376
+
377
+ Object.defineProperty(process, "platform", { value: originalPlatform });
378
+ });
379
+
380
+ it("should not spawn if tray already running", async () => {
381
+ const originalPlatform = process.platform;
382
+ Object.defineProperty(process, "platform", { value: "darwin" });
383
+
384
+ // Simulate existing tray
385
+ mockFs.existsSync.mockReturnValue(true);
386
+ mockFsPromises.readFile.mockResolvedValue(String(process.pid));
387
+
388
+ const { ensureTray } = await import("./tray.ts");
389
+ await ensureTray();
390
+
391
+ expect(mockSpawn.spawn).not.toHaveBeenCalled();
392
+
393
+ Object.defineProperty(process, "platform", { value: originalPlatform });
394
+ });
232
395
  });
233
396
  });
package/ts/tray.ts CHANGED
@@ -1,6 +1,14 @@
1
+ import { existsSync } from "fs";
2
+ import { mkdir, readFile, writeFile, unlink } from "fs/promises";
3
+ import { homedir } from "os";
4
+ import path from "path";
1
5
  import { getRunningAgentCount, type Task } from "./runningLock.ts";
2
6
 
3
7
  const POLL_INTERVAL = 2000;
8
+ const IDLE_EXIT_POLLS = 15; // Exit after 15 polls (~30s) with 0 agents
9
+
10
+ const getTrayDir = () => path.join(process.env.CLAUDE_YES_HOME || homedir(), ".claude-yes");
11
+ const getTrayPidFile = () => path.join(getTrayDir(), "tray.pid");
4
12
 
5
13
  // Minimal 16x16 white circle PNG as base64 (used as tray icon)
6
14
  const ICON_BASE64 =
@@ -40,17 +48,100 @@ function buildMenuItems(tasks: Task[]) {
40
48
  return items;
41
49
  }
42
50
 
51
+ function isDesktopOS(): boolean {
52
+ return process.platform === "darwin" || process.platform === "win32";
53
+ }
54
+
55
+ function isTrayProcessRunning(pid: number): boolean {
56
+ try {
57
+ process.kill(pid, 0);
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Write the current process PID to the tray PID file
66
+ */
67
+ async function writeTrayPid(): Promise<void> {
68
+ const dir = getTrayDir();
69
+ await mkdir(dir, { recursive: true });
70
+ await writeFile(getTrayPidFile(), String(process.pid), "utf8");
71
+ }
72
+
73
+ /**
74
+ * Remove the tray PID file
75
+ */
76
+ async function removeTrayPid(): Promise<void> {
77
+ try {
78
+ await unlink(getTrayPidFile());
79
+ } catch {
80
+ // ignore
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Check if a tray process is already running
86
+ */
87
+ export async function isTrayRunning(): Promise<boolean> {
88
+ try {
89
+ const pidFile = getTrayPidFile();
90
+ if (!existsSync(pidFile)) return false;
91
+ const pid = parseInt(await readFile(pidFile, "utf8"), 10);
92
+ if (isNaN(pid)) return false;
93
+ return isTrayProcessRunning(pid);
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Auto-spawn a tray process in the background if not already running.
101
+ * Only spawns on desktop OS (macOS/Windows).
102
+ * Silently does nothing if systray2 is not installed or on non-desktop OS.
103
+ */
104
+ export async function ensureTray(): Promise<void> {
105
+ if (!isDesktopOS()) return;
106
+ if (await isTrayRunning()) return;
107
+
108
+ try {
109
+ // Resolve the CLI entry point (dist/cli.js or ts/cli.ts)
110
+ const cliPath = new URL("./cli.ts", import.meta.url).pathname;
111
+ const { spawn } = await import("child_process");
112
+
113
+ // Spawn detached tray process
114
+ const child = spawn(process.execPath, [cliPath, "--tray", "--no-rust"], {
115
+ detached: true,
116
+ stdio: "ignore",
117
+ env: { ...process.env },
118
+ });
119
+ child.unref();
120
+ } catch {
121
+ // Silently fail — tray is best-effort
122
+ }
123
+ }
124
+
43
125
  export async function startTray(): Promise<void> {
44
- // Only macOS and Windows have proper tray support
45
- if (process.platform !== "darwin" && process.platform !== "win32") {
126
+ if (!isDesktopOS()) {
46
127
  console.error("Tray icon is only supported on macOS and Windows.");
47
128
  return;
48
129
  }
49
130
 
131
+ // Check if another tray is already running
132
+ if (await isTrayRunning()) {
133
+ console.error("Tray is already running.");
134
+ return;
135
+ }
136
+
137
+ // Register our PID
138
+ await writeTrayPid();
139
+
50
140
  let SysTray: typeof import("systray2").default;
51
141
  try {
52
142
  SysTray = (await import("systray2")).default;
53
143
  } catch {
144
+ await removeTrayPid();
54
145
  console.error("systray2 is not installed. Install it with: npm install systray2");
55
146
  return;
56
147
  }
@@ -71,24 +162,39 @@ export async function startTray(): Promise<void> {
71
162
  await systray.ready();
72
163
  console.log(`Tray started. Watching ${count} running agent(s).`);
73
164
 
165
+ // Cleanup helper
166
+ let intervalId: ReturnType<typeof setInterval> | undefined;
167
+ const cleanup = () => {
168
+ if (intervalId) clearInterval(intervalId);
169
+ systray.kill(false);
170
+ removeTrayPid().finally(() => process.exit(0));
171
+ };
172
+
74
173
  // Handle quit
75
174
  systray.onClick((action) => {
76
- if (action.item.title === "Quit Tray") {
77
- systray.kill(false);
78
- process.exit(0);
79
- }
175
+ if (action.item.title === "Quit Tray") cleanup();
80
176
  });
81
177
 
82
- // Poll and update
178
+ // Poll and update, auto-exit after ~30s idle (0 agents)
83
179
  let lastCount = count;
84
- const interval = setInterval(async () => {
180
+ let idlePolls = count === 0 ? 1 : 0;
181
+ intervalId = setInterval(async () => {
85
182
  try {
86
183
  const { count: newCount, tasks: newTasks } = await getRunningAgentCount();
87
184
 
185
+ if (newCount === 0) {
186
+ idlePolls++;
187
+ if (idlePolls >= IDLE_EXIT_POLLS) {
188
+ cleanup();
189
+ return;
190
+ }
191
+ } else {
192
+ idlePolls = 0;
193
+ }
194
+
88
195
  if (newCount !== lastCount) {
89
196
  lastCount = newCount;
90
197
 
91
- // Update title and tooltip
92
198
  systray.sendAction({
93
199
  type: "update-menu",
94
200
  menu: {
@@ -104,15 +210,6 @@ export async function startTray(): Promise<void> {
104
210
  }
105
211
  }, POLL_INTERVAL);
106
212
 
107
- // Cleanup on exit
108
- process.on("SIGINT", () => {
109
- clearInterval(interval);
110
- systray.kill(false);
111
- process.exit(0);
112
- });
113
- process.on("SIGTERM", () => {
114
- clearInterval(interval);
115
- systray.kill(false);
116
- process.exit(0);
117
- });
213
+ process.on("SIGINT", cleanup);
214
+ process.on("SIGTERM", cleanup);
118
215
  }
@@ -1,109 +0,0 @@
1
- import { n as getRunningAgentCount } from "./runningLock-BBI_URhR.js";
2
-
3
- //#region ts/tray.ts
4
- const POLL_INTERVAL = 2e3;
5
- const ICON_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAjklEQVQ4T2NkoBAwUqifgWoGMDIyNjAyMv5nYGBYQMgVjMgCQM0LGBkZHYDYAY8BDUBxByB2wGcAyAUOQOwAxPYMDAyOeCzAbwBIMyMjowNQsz0ely8ACjng8wJeA0CaGRgY7IHYAZ8hQHEHfF7AawBYMwODPZABRHsBpwEgzUDN9kDsgM8lQHEHfC4gJhwAAM3hMBGq3cNNAAAAAElFTkSuQmCC";
6
- function buildMenuItems(tasks) {
7
- const items = [];
8
- if (tasks.length === 0) items.push({
9
- title: "No running agents",
10
- tooltip: "",
11
- enabled: false
12
- });
13
- else {
14
- items.push({
15
- title: `Running agents: ${tasks.length}`,
16
- tooltip: "",
17
- enabled: false
18
- });
19
- items.push({
20
- title: "---",
21
- tooltip: "",
22
- enabled: false
23
- });
24
- for (const task of tasks) {
25
- const dir = task.cwd.replace(/^.*[/\\]/, "");
26
- const desc = task.task ? ` - ${task.task.slice(0, 40)}` : "";
27
- items.push({
28
- title: `[${task.pid}] ${dir}${desc}`,
29
- tooltip: task.cwd,
30
- enabled: false
31
- });
32
- }
33
- }
34
- items.push({
35
- title: "---",
36
- tooltip: "",
37
- enabled: false
38
- });
39
- items.push({
40
- title: "Quit Tray",
41
- tooltip: "Exit tray icon",
42
- enabled: true
43
- });
44
- return items;
45
- }
46
- async function startTray() {
47
- if (process.platform !== "darwin" && process.platform !== "win32") {
48
- console.error("Tray icon is only supported on macOS and Windows.");
49
- return;
50
- }
51
- let SysTray;
52
- try {
53
- SysTray = (await import("systray2")).default;
54
- } catch {
55
- console.error("systray2 is not installed. Install it with: npm install systray2");
56
- return;
57
- }
58
- const { count, tasks } = await getRunningAgentCount();
59
- const systray = new SysTray({
60
- menu: {
61
- icon: ICON_BASE64,
62
- title: `AY: ${count}`,
63
- tooltip: `agent-yes: ${count} running`,
64
- items: buildMenuItems(tasks)
65
- },
66
- debug: false,
67
- copyDir: false
68
- });
69
- await systray.ready();
70
- console.log(`Tray started. Watching ${count} running agent(s).`);
71
- systray.onClick((action) => {
72
- if (action.item.title === "Quit Tray") {
73
- systray.kill(false);
74
- process.exit(0);
75
- }
76
- });
77
- let lastCount = count;
78
- const interval = setInterval(async () => {
79
- try {
80
- const { count: newCount, tasks: newTasks } = await getRunningAgentCount();
81
- if (newCount !== lastCount) {
82
- lastCount = newCount;
83
- systray.sendAction({
84
- type: "update-menu",
85
- menu: {
86
- icon: ICON_BASE64,
87
- title: `AY: ${newCount}`,
88
- tooltip: `agent-yes: ${newCount} running`,
89
- items: buildMenuItems(newTasks)
90
- }
91
- });
92
- }
93
- } catch {}
94
- }, POLL_INTERVAL);
95
- process.on("SIGINT", () => {
96
- clearInterval(interval);
97
- systray.kill(false);
98
- process.exit(0);
99
- });
100
- process.on("SIGTERM", () => {
101
- clearInterval(interval);
102
- systray.kill(false);
103
- process.exit(0);
104
- });
105
- }
106
-
107
- //#endregion
108
- export { startTray };
109
- //# sourceMappingURL=tray-BzSS0v-i.js.map