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.
- package/dist/{SUPPORTED_CLIS-DtYo1wxO.js → SUPPORTED_CLIS-DBk8E5_6.js} +2 -2
- package/dist/cli.js +6 -2
- package/dist/index.js +1 -1
- package/dist/tray-BQkynk6r.js +187 -0
- package/package.json +1 -1
- package/ts/cli.ts +7 -0
- package/ts/tray.spec.ts +183 -20
- package/ts/tray.ts +117 -20
- package/dist/tray-BzSS0v-i.js +0 -109
|
@@ -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.
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
process.on("
|
|
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
|
}
|
package/dist/tray-BzSS0v-i.js
DELETED
|
@@ -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
|