agent-yes 1.70.1 → 1.71.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/dist/{SUPPORTED_CLIS-Bq5hoKRN.js → SUPPORTED_CLIS-DtYo1wxO.js} +4 -248
- package/dist/{agent-yes.config-DcxG25Gv.js → agent-yes.config-CtQprJrA.js} +1 -1
- package/dist/cli.js +11 -1
- package/dist/index.js +1 -1
- package/dist/runningLock-BBI_URhR.js +263 -0
- package/dist/tray-BzSS0v-i.js +109 -0
- package/package.json +4 -1
- package/ts/cli.ts +7 -0
- package/ts/parseCliArgs.ts +6 -0
- package/ts/runningLock.ts +14 -0
- package/ts/tray.spec.ts +233 -0
- package/ts/tray.ts +118 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { t as logger } from "./logger-CX77vJDA.js";
|
|
2
|
+
import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-BBI_URhR.js";
|
|
2
3
|
import { arch, platform } from "process";
|
|
3
4
|
import { execSync } from "child_process";
|
|
4
5
|
import { execaCommandSync, parseCommandString } from "execa";
|
|
@@ -178,251 +179,6 @@ function removeControlCharacters(str) {
|
|
|
178
179
|
return str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, "");
|
|
179
180
|
}
|
|
180
181
|
|
|
181
|
-
//#endregion
|
|
182
|
-
//#region ts/runningLock.ts
|
|
183
|
-
const getLockDir = () => path.join(process.env.CLAUDE_YES_HOME || homedir(), ".claude-yes");
|
|
184
|
-
const getLockFile = () => path.join(getLockDir(), "running.lock.json");
|
|
185
|
-
const MAX_RETRIES = 5;
|
|
186
|
-
const RETRY_DELAYS = [
|
|
187
|
-
50,
|
|
188
|
-
100,
|
|
189
|
-
200,
|
|
190
|
-
400,
|
|
191
|
-
800
|
|
192
|
-
];
|
|
193
|
-
const POLL_INTERVAL = 2e3;
|
|
194
|
-
/**
|
|
195
|
-
* Check if a process is running
|
|
196
|
-
*/
|
|
197
|
-
function isProcessRunning(pid) {
|
|
198
|
-
try {
|
|
199
|
-
process.kill(pid, 0);
|
|
200
|
-
return true;
|
|
201
|
-
} catch {
|
|
202
|
-
return false;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
/**
|
|
206
|
-
* Get git repository root for a directory
|
|
207
|
-
*/
|
|
208
|
-
function getGitRoot(cwd) {
|
|
209
|
-
try {
|
|
210
|
-
return execSync("git rev-parse --show-toplevel", {
|
|
211
|
-
cwd,
|
|
212
|
-
encoding: "utf8",
|
|
213
|
-
stdio: [
|
|
214
|
-
"pipe",
|
|
215
|
-
"pipe",
|
|
216
|
-
"ignore"
|
|
217
|
-
]
|
|
218
|
-
}).trim();
|
|
219
|
-
} catch {
|
|
220
|
-
return null;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
/**
|
|
224
|
-
* Check if directory is in a git repository
|
|
225
|
-
*/
|
|
226
|
-
function isGitRepo(cwd) {
|
|
227
|
-
try {
|
|
228
|
-
return getGitRoot(cwd) !== null;
|
|
229
|
-
} catch {
|
|
230
|
-
return false;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
/**
|
|
234
|
-
* Resolve path to real path (handling symlinks)
|
|
235
|
-
*/
|
|
236
|
-
function resolveRealPath(p) {
|
|
237
|
-
try {
|
|
238
|
-
return path.resolve(p);
|
|
239
|
-
} catch {
|
|
240
|
-
return p;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
/**
|
|
244
|
-
* Sleep for a given number of milliseconds
|
|
245
|
-
*/
|
|
246
|
-
function sleep$1(ms) {
|
|
247
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
248
|
-
}
|
|
249
|
-
/**
|
|
250
|
-
* Read lock file with retry logic and stale lock cleanup
|
|
251
|
-
*/
|
|
252
|
-
async function readLockFile() {
|
|
253
|
-
try {
|
|
254
|
-
const lockDir = getLockDir();
|
|
255
|
-
const lockFilePath = getLockFile();
|
|
256
|
-
await mkdir(lockDir, { recursive: true });
|
|
257
|
-
if (!existsSync(lockFilePath)) return { tasks: [] };
|
|
258
|
-
const content = await readFile(lockFilePath, "utf8");
|
|
259
|
-
const lockFile = JSON.parse(content);
|
|
260
|
-
lockFile.tasks = lockFile.tasks.filter((task) => {
|
|
261
|
-
if (isProcessRunning(task.pid)) return true;
|
|
262
|
-
return false;
|
|
263
|
-
});
|
|
264
|
-
return lockFile;
|
|
265
|
-
} catch {
|
|
266
|
-
return { tasks: [] };
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
/**
|
|
270
|
-
* Write lock file atomically with retry logic
|
|
271
|
-
*/
|
|
272
|
-
async function writeLockFile(lockFile, retryCount = 0) {
|
|
273
|
-
try {
|
|
274
|
-
const lockDir = getLockDir();
|
|
275
|
-
const lockFilePath = getLockFile();
|
|
276
|
-
await mkdir(lockDir, { recursive: true });
|
|
277
|
-
const tempFile = `${lockFilePath}.tmp.${process.pid}`;
|
|
278
|
-
await writeFile(tempFile, JSON.stringify(lockFile, null, 2), "utf8");
|
|
279
|
-
await rename(tempFile, lockFilePath);
|
|
280
|
-
} catch (error) {
|
|
281
|
-
if (retryCount < MAX_RETRIES) {
|
|
282
|
-
await sleep$1(RETRY_DELAYS[retryCount] || 800);
|
|
283
|
-
return writeLockFile(lockFile, retryCount + 1);
|
|
284
|
-
}
|
|
285
|
-
throw error;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
/**
|
|
289
|
-
* Check if lock exists for the current working directory
|
|
290
|
-
*/
|
|
291
|
-
async function checkLock(cwd, _prompt) {
|
|
292
|
-
const resolvedCwd = resolveRealPath(cwd);
|
|
293
|
-
const gitRoot = isGitRepo(resolvedCwd) ? getGitRoot(resolvedCwd) : null;
|
|
294
|
-
const lockKey = gitRoot || resolvedCwd;
|
|
295
|
-
const blockingTasks = (await readLockFile()).tasks.filter((task) => {
|
|
296
|
-
if (!isProcessRunning(task.pid)) return false;
|
|
297
|
-
if (task.status !== "running") return false;
|
|
298
|
-
if (gitRoot && task.gitRoot) return task.gitRoot === gitRoot;
|
|
299
|
-
else return task.cwd === lockKey;
|
|
300
|
-
});
|
|
301
|
-
return {
|
|
302
|
-
isLocked: blockingTasks.length > 0,
|
|
303
|
-
blockingTasks,
|
|
304
|
-
lockKey
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
/**
|
|
308
|
-
* Add a task to the lock file
|
|
309
|
-
*/
|
|
310
|
-
async function addTask(task) {
|
|
311
|
-
const lockFile = await readLockFile();
|
|
312
|
-
lockFile.tasks = lockFile.tasks.filter((t) => t.pid !== task.pid);
|
|
313
|
-
lockFile.tasks.push(task);
|
|
314
|
-
await writeLockFile(lockFile);
|
|
315
|
-
}
|
|
316
|
-
/**
|
|
317
|
-
* Update task status
|
|
318
|
-
*/
|
|
319
|
-
async function updateTaskStatus(pid, status) {
|
|
320
|
-
const lockFile = await readLockFile();
|
|
321
|
-
const task = lockFile.tasks.find((t) => t.pid === pid);
|
|
322
|
-
if (task) {
|
|
323
|
-
task.status = status;
|
|
324
|
-
await writeLockFile(lockFile);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
/**
|
|
328
|
-
* Remove a task from the lock file
|
|
329
|
-
*/
|
|
330
|
-
async function removeTask(pid) {
|
|
331
|
-
const lockFile = await readLockFile();
|
|
332
|
-
lockFile.tasks = lockFile.tasks.filter((t) => t.pid !== pid);
|
|
333
|
-
await writeLockFile(lockFile);
|
|
334
|
-
}
|
|
335
|
-
/**
|
|
336
|
-
* Wait for lock to be released
|
|
337
|
-
*/
|
|
338
|
-
async function waitForUnlock(blockingTasks, currentTask) {
|
|
339
|
-
const blockingTask = blockingTasks[0];
|
|
340
|
-
if (!blockingTask) return;
|
|
341
|
-
console.log(`⏳ Queueing for unlock of: ${blockingTask.task}`);
|
|
342
|
-
console.log(` Press 'b' to bypass queue, 'k' to kill previous instance`);
|
|
343
|
-
await addTask({
|
|
344
|
-
...currentTask,
|
|
345
|
-
status: "queued"
|
|
346
|
-
});
|
|
347
|
-
const stdin = process.stdin;
|
|
348
|
-
const wasRaw = stdin.isRaw;
|
|
349
|
-
stdin.setRawMode?.(true);
|
|
350
|
-
stdin.resume();
|
|
351
|
-
let bypassed = false;
|
|
352
|
-
let killed = false;
|
|
353
|
-
const keyHandler = (key) => {
|
|
354
|
-
const char = key.toString();
|
|
355
|
-
if (char === "b" || char === "B") {
|
|
356
|
-
console.log("\n⚡ Bypassing queue...");
|
|
357
|
-
bypassed = true;
|
|
358
|
-
} else if (char === "k" || char === "K") {
|
|
359
|
-
console.log("\n🔪 Killing previous instance...");
|
|
360
|
-
killed = true;
|
|
361
|
-
}
|
|
362
|
-
};
|
|
363
|
-
stdin.on("data", keyHandler);
|
|
364
|
-
let dots = 0;
|
|
365
|
-
while (true) {
|
|
366
|
-
if (bypassed) {
|
|
367
|
-
await updateTaskStatus(currentTask.pid, "running");
|
|
368
|
-
console.log("✓ Queue bypassed, starting task...");
|
|
369
|
-
break;
|
|
370
|
-
}
|
|
371
|
-
if (killed && blockingTask) {
|
|
372
|
-
try {
|
|
373
|
-
process.kill(blockingTask.pid, "SIGTERM");
|
|
374
|
-
console.log(`✓ Killed process ${blockingTask.pid}`);
|
|
375
|
-
await sleep$1(1e3);
|
|
376
|
-
} catch (err) {
|
|
377
|
-
console.log(`⚠️ Could not kill process ${blockingTask.pid}: ${err}`);
|
|
378
|
-
}
|
|
379
|
-
killed = false;
|
|
380
|
-
}
|
|
381
|
-
await sleep$1(POLL_INTERVAL);
|
|
382
|
-
if (!(await checkLock(currentTask.cwd, currentTask.task)).isLocked) {
|
|
383
|
-
await updateTaskStatus(currentTask.pid, "running");
|
|
384
|
-
console.log(`\n✓ Lock released, starting task...`);
|
|
385
|
-
break;
|
|
386
|
-
}
|
|
387
|
-
dots = (dots + 1) % 4;
|
|
388
|
-
process.stdout.write(`\r⏳ Queueing${".".repeat(dots)}${" ".repeat(3 - dots)}`);
|
|
389
|
-
}
|
|
390
|
-
stdin.off("data", keyHandler);
|
|
391
|
-
stdin.setRawMode?.(wasRaw);
|
|
392
|
-
if (!wasRaw) stdin.pause();
|
|
393
|
-
}
|
|
394
|
-
/**
|
|
395
|
-
* Acquire lock or wait if locked
|
|
396
|
-
*/
|
|
397
|
-
async function acquireLock(cwd, prompt = "no prompt provided") {
|
|
398
|
-
const resolvedCwd = resolveRealPath(cwd);
|
|
399
|
-
const task = {
|
|
400
|
-
cwd: resolvedCwd,
|
|
401
|
-
gitRoot: (isGitRepo(resolvedCwd) ? getGitRoot(resolvedCwd) : null) || void 0,
|
|
402
|
-
task: prompt.substring(0, 100),
|
|
403
|
-
pid: process.pid,
|
|
404
|
-
status: "running",
|
|
405
|
-
startedAt: Date.now(),
|
|
406
|
-
lockedAt: Date.now()
|
|
407
|
-
};
|
|
408
|
-
const lockCheck = await checkLock(resolvedCwd, prompt);
|
|
409
|
-
if (lockCheck.isLocked) await waitForUnlock(lockCheck.blockingTasks, task);
|
|
410
|
-
else await addTask(task);
|
|
411
|
-
}
|
|
412
|
-
/**
|
|
413
|
-
* Release lock for current process
|
|
414
|
-
*/
|
|
415
|
-
async function releaseLock(pid = process.pid) {
|
|
416
|
-
await removeTask(pid);
|
|
417
|
-
}
|
|
418
|
-
/**
|
|
419
|
-
* Check if we should use locking for this directory
|
|
420
|
-
* Only use locking if we're in a git repository
|
|
421
|
-
*/
|
|
422
|
-
function shouldUseLock(_cwd) {
|
|
423
|
-
return true;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
182
|
//#endregion
|
|
427
183
|
//#region ts/beta/fifo.ts
|
|
428
184
|
/**
|
|
@@ -1059,7 +815,7 @@ function tryCatch(catchFn, fn) {
|
|
|
1059
815
|
//#endregion
|
|
1060
816
|
//#region package.json
|
|
1061
817
|
var name = "agent-yes";
|
|
1062
|
-
var version = "1.
|
|
818
|
+
var version = "1.71.0";
|
|
1063
819
|
|
|
1064
820
|
//#endregion
|
|
1065
821
|
//#region ts/pty-fix.ts
|
|
@@ -1486,7 +1242,7 @@ async function notifyWebhook(status, details, cwd = process.cwd()) {
|
|
|
1486
1242
|
|
|
1487
1243
|
//#endregion
|
|
1488
1244
|
//#region ts/index.ts
|
|
1489
|
-
const config = await import("./agent-yes.config-
|
|
1245
|
+
const config = await import("./agent-yes.config-CtQprJrA.js").then((mod) => mod.default || mod);
|
|
1490
1246
|
const CLIS_CONFIG = config.clis;
|
|
1491
1247
|
/**
|
|
1492
1248
|
* Main function to run agent-cli with automatic yes/no responses
|
|
@@ -2139,4 +1895,4 @@ const SUPPORTED_CLIS = Object.keys(CLIS_CONFIG);
|
|
|
2139
1895
|
|
|
2140
1896
|
//#endregion
|
|
2141
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 };
|
|
2142
|
-
//# sourceMappingURL=SUPPORTED_CLIS-
|
|
1898
|
+
//# sourceMappingURL=SUPPORTED_CLIS-DtYo1wxO.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-DtYo1wxO.js";
|
|
3
3
|
import { t as logger } from "./logger-CX77vJDA.js";
|
|
4
4
|
import { argv } from "process";
|
|
5
5
|
import { spawn } from "child_process";
|
|
@@ -91,6 +91,10 @@ function parseCliArgs(argv) {
|
|
|
91
91
|
description: "Pass --dangerously-skip-permissions to the CLI (claude shortcut)",
|
|
92
92
|
default: false,
|
|
93
93
|
alias: "y"
|
|
94
|
+
}).option("tray", {
|
|
95
|
+
type: "boolean",
|
|
96
|
+
description: "Show a system tray icon with running agent count (macOS/Windows only)",
|
|
97
|
+
default: false
|
|
94
98
|
}).option("rust", {
|
|
95
99
|
type: "boolean",
|
|
96
100
|
description: "Use the Rust implementation (enabled by default, use --no-rust for TypeScript)",
|
|
@@ -193,6 +197,7 @@ function parseCliArgs(argv) {
|
|
|
193
197
|
showVersion: parsedArgv.version,
|
|
194
198
|
autoYes: parsedArgv.auto !== "no",
|
|
195
199
|
idleAction: parsedArgv.idleAction,
|
|
200
|
+
tray: parsedArgv.tray,
|
|
196
201
|
useRust: parsedArgv.rust,
|
|
197
202
|
swarm: parsedArgv.swarm ?? (parsedArgv.experimentalSwarm ? parsedArgv.swarmTopic : void 0),
|
|
198
203
|
experimentalSwarm: parsedArgv.experimentalSwarm,
|
|
@@ -475,6 +480,11 @@ function buildRustArgs(argv, cliFromScript, supportedClis) {
|
|
|
475
480
|
//#region ts/cli.ts
|
|
476
481
|
const updateCheckPromise = checkAndAutoUpdate();
|
|
477
482
|
const config = parseCliArgs(process.argv);
|
|
483
|
+
if (config.tray) {
|
|
484
|
+
const { startTray } = await import("./tray-BzSS0v-i.js");
|
|
485
|
+
await startTray();
|
|
486
|
+
await new Promise(() => {});
|
|
487
|
+
}
|
|
478
488
|
if (config.useRust) {
|
|
479
489
|
let rustBinary;
|
|
480
490
|
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-DtYo1wxO.js";
|
|
2
2
|
import "./logger-CX77vJDA.js";
|
|
3
3
|
|
|
4
4
|
export { AgentContext, CLIS_CONFIG, config, agentYes as default, removeControlCharacters };
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { existsSync } from "fs";
|
|
6
|
+
|
|
7
|
+
//#region ts/runningLock.ts
|
|
8
|
+
const getLockDir = () => path.join(process.env.CLAUDE_YES_HOME || homedir(), ".claude-yes");
|
|
9
|
+
const getLockFile = () => path.join(getLockDir(), "running.lock.json");
|
|
10
|
+
const MAX_RETRIES = 5;
|
|
11
|
+
const RETRY_DELAYS = [
|
|
12
|
+
50,
|
|
13
|
+
100,
|
|
14
|
+
200,
|
|
15
|
+
400,
|
|
16
|
+
800
|
|
17
|
+
];
|
|
18
|
+
const POLL_INTERVAL = 2e3;
|
|
19
|
+
/**
|
|
20
|
+
* Check if a process is running
|
|
21
|
+
*/
|
|
22
|
+
function isProcessRunning(pid) {
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Get git repository root for a directory
|
|
32
|
+
*/
|
|
33
|
+
function getGitRoot(cwd) {
|
|
34
|
+
try {
|
|
35
|
+
return execSync("git rev-parse --show-toplevel", {
|
|
36
|
+
cwd,
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
stdio: [
|
|
39
|
+
"pipe",
|
|
40
|
+
"pipe",
|
|
41
|
+
"ignore"
|
|
42
|
+
]
|
|
43
|
+
}).trim();
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Check if directory is in a git repository
|
|
50
|
+
*/
|
|
51
|
+
function isGitRepo(cwd) {
|
|
52
|
+
try {
|
|
53
|
+
return getGitRoot(cwd) !== null;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolve path to real path (handling symlinks)
|
|
60
|
+
*/
|
|
61
|
+
function resolveRealPath(p) {
|
|
62
|
+
try {
|
|
63
|
+
return path.resolve(p);
|
|
64
|
+
} catch {
|
|
65
|
+
return p;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Sleep for a given number of milliseconds
|
|
70
|
+
*/
|
|
71
|
+
function sleep(ms) {
|
|
72
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Read lock file with retry logic and stale lock cleanup
|
|
76
|
+
*/
|
|
77
|
+
async function readLockFile() {
|
|
78
|
+
try {
|
|
79
|
+
const lockDir = getLockDir();
|
|
80
|
+
const lockFilePath = getLockFile();
|
|
81
|
+
await mkdir(lockDir, { recursive: true });
|
|
82
|
+
if (!existsSync(lockFilePath)) return { tasks: [] };
|
|
83
|
+
const content = await readFile(lockFilePath, "utf8");
|
|
84
|
+
const lockFile = JSON.parse(content);
|
|
85
|
+
lockFile.tasks = lockFile.tasks.filter((task) => {
|
|
86
|
+
if (isProcessRunning(task.pid)) return true;
|
|
87
|
+
return false;
|
|
88
|
+
});
|
|
89
|
+
return lockFile;
|
|
90
|
+
} catch {
|
|
91
|
+
return { tasks: [] };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Write lock file atomically with retry logic
|
|
96
|
+
*/
|
|
97
|
+
async function writeLockFile(lockFile, retryCount = 0) {
|
|
98
|
+
try {
|
|
99
|
+
const lockDir = getLockDir();
|
|
100
|
+
const lockFilePath = getLockFile();
|
|
101
|
+
await mkdir(lockDir, { recursive: true });
|
|
102
|
+
const tempFile = `${lockFilePath}.tmp.${process.pid}`;
|
|
103
|
+
await writeFile(tempFile, JSON.stringify(lockFile, null, 2), "utf8");
|
|
104
|
+
await rename(tempFile, lockFilePath);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (retryCount < MAX_RETRIES) {
|
|
107
|
+
await sleep(RETRY_DELAYS[retryCount] || 800);
|
|
108
|
+
return writeLockFile(lockFile, retryCount + 1);
|
|
109
|
+
}
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Check if lock exists for the current working directory
|
|
115
|
+
*/
|
|
116
|
+
async function checkLock(cwd, _prompt) {
|
|
117
|
+
const resolvedCwd = resolveRealPath(cwd);
|
|
118
|
+
const gitRoot = isGitRepo(resolvedCwd) ? getGitRoot(resolvedCwd) : null;
|
|
119
|
+
const lockKey = gitRoot || resolvedCwd;
|
|
120
|
+
const blockingTasks = (await readLockFile()).tasks.filter((task) => {
|
|
121
|
+
if (!isProcessRunning(task.pid)) return false;
|
|
122
|
+
if (task.status !== "running") return false;
|
|
123
|
+
if (gitRoot && task.gitRoot) return task.gitRoot === gitRoot;
|
|
124
|
+
else return task.cwd === lockKey;
|
|
125
|
+
});
|
|
126
|
+
return {
|
|
127
|
+
isLocked: blockingTasks.length > 0,
|
|
128
|
+
blockingTasks,
|
|
129
|
+
lockKey
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Add a task to the lock file
|
|
134
|
+
*/
|
|
135
|
+
async function addTask(task) {
|
|
136
|
+
const lockFile = await readLockFile();
|
|
137
|
+
lockFile.tasks = lockFile.tasks.filter((t) => t.pid !== task.pid);
|
|
138
|
+
lockFile.tasks.push(task);
|
|
139
|
+
await writeLockFile(lockFile);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Update task status
|
|
143
|
+
*/
|
|
144
|
+
async function updateTaskStatus(pid, status) {
|
|
145
|
+
const lockFile = await readLockFile();
|
|
146
|
+
const task = lockFile.tasks.find((t) => t.pid === pid);
|
|
147
|
+
if (task) {
|
|
148
|
+
task.status = status;
|
|
149
|
+
await writeLockFile(lockFile);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Remove a task from the lock file
|
|
154
|
+
*/
|
|
155
|
+
async function removeTask(pid) {
|
|
156
|
+
const lockFile = await readLockFile();
|
|
157
|
+
lockFile.tasks = lockFile.tasks.filter((t) => t.pid !== pid);
|
|
158
|
+
await writeLockFile(lockFile);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Wait for lock to be released
|
|
162
|
+
*/
|
|
163
|
+
async function waitForUnlock(blockingTasks, currentTask) {
|
|
164
|
+
const blockingTask = blockingTasks[0];
|
|
165
|
+
if (!blockingTask) return;
|
|
166
|
+
console.log(`⏳ Queueing for unlock of: ${blockingTask.task}`);
|
|
167
|
+
console.log(` Press 'b' to bypass queue, 'k' to kill previous instance`);
|
|
168
|
+
await addTask({
|
|
169
|
+
...currentTask,
|
|
170
|
+
status: "queued"
|
|
171
|
+
});
|
|
172
|
+
const stdin = process.stdin;
|
|
173
|
+
const wasRaw = stdin.isRaw;
|
|
174
|
+
stdin.setRawMode?.(true);
|
|
175
|
+
stdin.resume();
|
|
176
|
+
let bypassed = false;
|
|
177
|
+
let killed = false;
|
|
178
|
+
const keyHandler = (key) => {
|
|
179
|
+
const char = key.toString();
|
|
180
|
+
if (char === "b" || char === "B") {
|
|
181
|
+
console.log("\n⚡ Bypassing queue...");
|
|
182
|
+
bypassed = true;
|
|
183
|
+
} else if (char === "k" || char === "K") {
|
|
184
|
+
console.log("\n🔪 Killing previous instance...");
|
|
185
|
+
killed = true;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
stdin.on("data", keyHandler);
|
|
189
|
+
let dots = 0;
|
|
190
|
+
while (true) {
|
|
191
|
+
if (bypassed) {
|
|
192
|
+
await updateTaskStatus(currentTask.pid, "running");
|
|
193
|
+
console.log("✓ Queue bypassed, starting task...");
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
if (killed && blockingTask) {
|
|
197
|
+
try {
|
|
198
|
+
process.kill(blockingTask.pid, "SIGTERM");
|
|
199
|
+
console.log(`✓ Killed process ${blockingTask.pid}`);
|
|
200
|
+
await sleep(1e3);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
console.log(`⚠️ Could not kill process ${blockingTask.pid}: ${err}`);
|
|
203
|
+
}
|
|
204
|
+
killed = false;
|
|
205
|
+
}
|
|
206
|
+
await sleep(POLL_INTERVAL);
|
|
207
|
+
if (!(await checkLock(currentTask.cwd, currentTask.task)).isLocked) {
|
|
208
|
+
await updateTaskStatus(currentTask.pid, "running");
|
|
209
|
+
console.log(`\n✓ Lock released, starting task...`);
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
dots = (dots + 1) % 4;
|
|
213
|
+
process.stdout.write(`\r⏳ Queueing${".".repeat(dots)}${" ".repeat(3 - dots)}`);
|
|
214
|
+
}
|
|
215
|
+
stdin.off("data", keyHandler);
|
|
216
|
+
stdin.setRawMode?.(wasRaw);
|
|
217
|
+
if (!wasRaw) stdin.pause();
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Get the count of currently running agents
|
|
221
|
+
*/
|
|
222
|
+
async function getRunningAgentCount() {
|
|
223
|
+
const running = (await readLockFile()).tasks.filter((t) => t.status === "running");
|
|
224
|
+
return {
|
|
225
|
+
count: running.length,
|
|
226
|
+
tasks: running
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Acquire lock or wait if locked
|
|
231
|
+
*/
|
|
232
|
+
async function acquireLock(cwd, prompt = "no prompt provided") {
|
|
233
|
+
const resolvedCwd = resolveRealPath(cwd);
|
|
234
|
+
const task = {
|
|
235
|
+
cwd: resolvedCwd,
|
|
236
|
+
gitRoot: (isGitRepo(resolvedCwd) ? getGitRoot(resolvedCwd) : null) || void 0,
|
|
237
|
+
task: prompt.substring(0, 100),
|
|
238
|
+
pid: process.pid,
|
|
239
|
+
status: "running",
|
|
240
|
+
startedAt: Date.now(),
|
|
241
|
+
lockedAt: Date.now()
|
|
242
|
+
};
|
|
243
|
+
const lockCheck = await checkLock(resolvedCwd, prompt);
|
|
244
|
+
if (lockCheck.isLocked) await waitForUnlock(lockCheck.blockingTasks, task);
|
|
245
|
+
else await addTask(task);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Release lock for current process
|
|
249
|
+
*/
|
|
250
|
+
async function releaseLock(pid = process.pid) {
|
|
251
|
+
await removeTask(pid);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Check if we should use locking for this directory
|
|
255
|
+
* Only use locking if we're in a git repository
|
|
256
|
+
*/
|
|
257
|
+
function shouldUseLock(_cwd) {
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
//#endregion
|
|
262
|
+
export { shouldUseLock as i, getRunningAgentCount as n, releaseLock as r, acquireLock as t };
|
|
263
|
+
//# sourceMappingURL=runningLock-BBI_URhR.js.map
|
|
@@ -0,0 +1,109 @@
|
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-yes",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.71.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;
|
package/ts/parseCliArgs.ts
CHANGED
|
@@ -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
|
*/
|
package/ts/tray.spec.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } 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, opts: any) {
|
|
12
|
+
Object.assign(this, instance);
|
|
13
|
+
(MockClass as any).__lastOpts = opts;
|
|
14
|
+
return this;
|
|
15
|
+
}) as any;
|
|
16
|
+
MockClass.__lastOpts = null;
|
|
17
|
+
return {
|
|
18
|
+
instance,
|
|
19
|
+
MockClass,
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
vi.mock("systray2", () => ({
|
|
24
|
+
default: mockSysTray.MockClass,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
// Mock runningLock
|
|
28
|
+
const mockGetRunningAgentCount = vi.hoisted(() =>
|
|
29
|
+
vi.fn().mockResolvedValue({ count: 0, tasks: [] }),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
vi.mock("./runningLock.ts", () => ({
|
|
33
|
+
getRunningAgentCount: mockGetRunningAgentCount,
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
describe("tray", () => {
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
vi.clearAllMocks();
|
|
39
|
+
mockGetRunningAgentCount.mockResolvedValue({ count: 0, tasks: [] });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("startTray", () => {
|
|
43
|
+
it("should create a systray instance on macOS", async () => {
|
|
44
|
+
const originalPlatform = process.platform;
|
|
45
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
46
|
+
|
|
47
|
+
const { startTray } = await import("./tray.ts");
|
|
48
|
+
await startTray();
|
|
49
|
+
|
|
50
|
+
expect(mockSysTray.MockClass).toHaveBeenCalledWith(
|
|
51
|
+
expect.objectContaining({
|
|
52
|
+
menu: expect.objectContaining({
|
|
53
|
+
title: "AY: 0",
|
|
54
|
+
tooltip: "agent-yes: 0 running",
|
|
55
|
+
}),
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
expect(mockSysTray.instance.ready).toHaveBeenCalled();
|
|
59
|
+
expect(mockSysTray.instance.onClick).toHaveBeenCalled();
|
|
60
|
+
|
|
61
|
+
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should show running agent count and task details", async () => {
|
|
65
|
+
const originalPlatform = process.platform;
|
|
66
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
67
|
+
|
|
68
|
+
mockGetRunningAgentCount.mockResolvedValue({
|
|
69
|
+
count: 2,
|
|
70
|
+
tasks: [
|
|
71
|
+
{
|
|
72
|
+
pid: 1234,
|
|
73
|
+
cwd: "/home/user/project-a",
|
|
74
|
+
task: "fix bugs",
|
|
75
|
+
status: "running",
|
|
76
|
+
startedAt: Date.now(),
|
|
77
|
+
lockedAt: Date.now(),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
pid: 5678,
|
|
81
|
+
cwd: "/home/user/project-b",
|
|
82
|
+
task: "add tests",
|
|
83
|
+
status: "running",
|
|
84
|
+
startedAt: Date.now(),
|
|
85
|
+
lockedAt: Date.now(),
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const { startTray } = await import("./tray.ts");
|
|
91
|
+
await startTray();
|
|
92
|
+
|
|
93
|
+
const menuArg = mockSysTray.MockClass.mock.calls[0][0];
|
|
94
|
+
expect(menuArg.menu.title).toBe("AY: 2");
|
|
95
|
+
expect(menuArg.menu.tooltip).toBe("agent-yes: 2 running");
|
|
96
|
+
|
|
97
|
+
// Should have: header, separator, 2 agent items, separator, quit
|
|
98
|
+
const items = menuArg.menu.items;
|
|
99
|
+
expect(items[0].title).toBe("Running agents: 2");
|
|
100
|
+
expect(items[2].title).toContain("[1234]");
|
|
101
|
+
expect(items[2].title).toContain("project-a");
|
|
102
|
+
expect(items[3].title).toContain("[5678]");
|
|
103
|
+
expect(items[3].title).toContain("project-b");
|
|
104
|
+
|
|
105
|
+
// Last item should be "Quit Tray"
|
|
106
|
+
expect(items[items.length - 1].title).toBe("Quit Tray");
|
|
107
|
+
|
|
108
|
+
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("should handle quit menu click", async () => {
|
|
112
|
+
const originalPlatform = process.platform;
|
|
113
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
114
|
+
|
|
115
|
+
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
116
|
+
|
|
117
|
+
const { startTray } = await import("./tray.ts");
|
|
118
|
+
await startTray();
|
|
119
|
+
|
|
120
|
+
// Get the onClick callback
|
|
121
|
+
const onClickCb = mockSysTray.instance.onClick.mock.calls[0][0];
|
|
122
|
+
|
|
123
|
+
// Simulate "Quit Tray" click
|
|
124
|
+
onClickCb({ item: { title: "Quit Tray" } });
|
|
125
|
+
|
|
126
|
+
expect(mockSysTray.instance.kill).toHaveBeenCalledWith(false);
|
|
127
|
+
expect(mockExit).toHaveBeenCalledWith(0);
|
|
128
|
+
|
|
129
|
+
mockExit.mockRestore();
|
|
130
|
+
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("should update tray when agent count changes", async () => {
|
|
134
|
+
const originalPlatform = process.platform;
|
|
135
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
136
|
+
|
|
137
|
+
vi.useFakeTimers();
|
|
138
|
+
|
|
139
|
+
const { startTray } = await import("./tray.ts");
|
|
140
|
+
await startTray();
|
|
141
|
+
|
|
142
|
+
// Now simulate agent count change on next poll
|
|
143
|
+
mockGetRunningAgentCount.mockResolvedValue({
|
|
144
|
+
count: 3,
|
|
145
|
+
tasks: [
|
|
146
|
+
{
|
|
147
|
+
pid: 111,
|
|
148
|
+
cwd: "/a",
|
|
149
|
+
task: "t1",
|
|
150
|
+
status: "running" as const,
|
|
151
|
+
startedAt: 0,
|
|
152
|
+
lockedAt: 0,
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
pid: 222,
|
|
156
|
+
cwd: "/b",
|
|
157
|
+
task: "t2",
|
|
158
|
+
status: "running" as const,
|
|
159
|
+
startedAt: 0,
|
|
160
|
+
lockedAt: 0,
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
pid: 333,
|
|
164
|
+
cwd: "/c",
|
|
165
|
+
task: "t3",
|
|
166
|
+
status: "running" as const,
|
|
167
|
+
startedAt: 0,
|
|
168
|
+
lockedAt: 0,
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Advance timer past poll interval
|
|
174
|
+
await vi.advanceTimersByTimeAsync(2100);
|
|
175
|
+
|
|
176
|
+
expect(mockSysTray.instance.sendAction).toHaveBeenCalledWith(
|
|
177
|
+
expect.objectContaining({
|
|
178
|
+
type: "update-menu",
|
|
179
|
+
menu: expect.objectContaining({
|
|
180
|
+
title: "AY: 3",
|
|
181
|
+
}),
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
vi.useRealTimers();
|
|
186
|
+
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("should not update tray when agent count stays the same", async () => {
|
|
190
|
+
const originalPlatform = process.platform;
|
|
191
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
192
|
+
|
|
193
|
+
vi.useFakeTimers();
|
|
194
|
+
|
|
195
|
+
const { startTray } = await import("./tray.ts");
|
|
196
|
+
await startTray();
|
|
197
|
+
|
|
198
|
+
// Same count on next poll
|
|
199
|
+
mockGetRunningAgentCount.mockResolvedValue({ count: 0, tasks: [] });
|
|
200
|
+
|
|
201
|
+
await vi.advanceTimersByTimeAsync(2100);
|
|
202
|
+
|
|
203
|
+
expect(mockSysTray.instance.sendAction).not.toHaveBeenCalled();
|
|
204
|
+
|
|
205
|
+
vi.useRealTimers();
|
|
206
|
+
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("should work on Windows", async () => {
|
|
210
|
+
const originalPlatform = process.platform;
|
|
211
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
212
|
+
|
|
213
|
+
const { startTray } = await import("./tray.ts");
|
|
214
|
+
await startTray();
|
|
215
|
+
|
|
216
|
+
expect(mockSysTray.MockClass).toHaveBeenCalled();
|
|
217
|
+
|
|
218
|
+
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("should skip on Linux", async () => {
|
|
222
|
+
const originalPlatform = process.platform;
|
|
223
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
224
|
+
|
|
225
|
+
const { startTray } = await import("./tray.ts");
|
|
226
|
+
await startTray();
|
|
227
|
+
|
|
228
|
+
expect(mockSysTray.MockClass).not.toHaveBeenCalled();
|
|
229
|
+
|
|
230
|
+
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|
package/ts/tray.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { getRunningAgentCount, type Task } from "./runningLock.ts";
|
|
2
|
+
|
|
3
|
+
const POLL_INTERVAL = 2000;
|
|
4
|
+
|
|
5
|
+
// Minimal 16x16 white circle PNG as base64 (used as tray icon)
|
|
6
|
+
const ICON_BASE64 =
|
|
7
|
+
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAA" +
|
|
8
|
+
"jklEQVQ4T2NkoBAwUqifgWoGMDIyNjAyMv5nYGBYQMgVjMgC" +
|
|
9
|
+
"QM0LGBkZHYDYAY8BDUBxByB2wGcAyAUOQOwAxPYMDAyOeCzA" +
|
|
10
|
+
"bwBIMyMjowNQsz0ely8ACjng8wJeA0CaGRgY7IHYAZ8hQHEH" +
|
|
11
|
+
"fF7AawBYMwODPZABRHsBpwEgzUDN9kDsgM8lQHEHfC4gJhwA" +
|
|
12
|
+
"AM3hMBGq3cNNAAAAAElFTkSuQmCC";
|
|
13
|
+
|
|
14
|
+
function buildMenuItems(tasks: Task[]) {
|
|
15
|
+
const items = [];
|
|
16
|
+
|
|
17
|
+
if (tasks.length === 0) {
|
|
18
|
+
items.push({ title: "No running agents", tooltip: "", enabled: false });
|
|
19
|
+
} else {
|
|
20
|
+
items.push({
|
|
21
|
+
title: `Running agents: ${tasks.length}`,
|
|
22
|
+
tooltip: "",
|
|
23
|
+
enabled: false,
|
|
24
|
+
});
|
|
25
|
+
items.push({ title: "---", tooltip: "", enabled: false });
|
|
26
|
+
for (const task of tasks) {
|
|
27
|
+
const dir = task.cwd.replace(/^.*[/\\]/, "");
|
|
28
|
+
const desc = task.task ? ` - ${task.task.slice(0, 40)}` : "";
|
|
29
|
+
items.push({
|
|
30
|
+
title: `[${task.pid}] ${dir}${desc}`,
|
|
31
|
+
tooltip: task.cwd,
|
|
32
|
+
enabled: false,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
items.push({ title: "---", tooltip: "", enabled: false });
|
|
38
|
+
items.push({ title: "Quit Tray", tooltip: "Exit tray icon", enabled: true });
|
|
39
|
+
|
|
40
|
+
return items;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function startTray(): Promise<void> {
|
|
44
|
+
// Only macOS and Windows have proper tray support
|
|
45
|
+
if (process.platform !== "darwin" && process.platform !== "win32") {
|
|
46
|
+
console.error("Tray icon is only supported on macOS and Windows.");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let SysTray: typeof import("systray2").default;
|
|
51
|
+
try {
|
|
52
|
+
SysTray = (await import("systray2")).default;
|
|
53
|
+
} catch {
|
|
54
|
+
console.error("systray2 is not installed. Install it with: npm install systray2");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { count, tasks } = await getRunningAgentCount();
|
|
59
|
+
|
|
60
|
+
const systray = new SysTray({
|
|
61
|
+
menu: {
|
|
62
|
+
icon: ICON_BASE64,
|
|
63
|
+
title: `AY: ${count}`,
|
|
64
|
+
tooltip: `agent-yes: ${count} running`,
|
|
65
|
+
items: buildMenuItems(tasks),
|
|
66
|
+
},
|
|
67
|
+
debug: false,
|
|
68
|
+
copyDir: false,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await systray.ready();
|
|
72
|
+
console.log(`Tray started. Watching ${count} running agent(s).`);
|
|
73
|
+
|
|
74
|
+
// Handle quit
|
|
75
|
+
systray.onClick((action) => {
|
|
76
|
+
if (action.item.title === "Quit Tray") {
|
|
77
|
+
systray.kill(false);
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Poll and update
|
|
83
|
+
let lastCount = count;
|
|
84
|
+
const interval = setInterval(async () => {
|
|
85
|
+
try {
|
|
86
|
+
const { count: newCount, tasks: newTasks } = await getRunningAgentCount();
|
|
87
|
+
|
|
88
|
+
if (newCount !== lastCount) {
|
|
89
|
+
lastCount = newCount;
|
|
90
|
+
|
|
91
|
+
// Update title and tooltip
|
|
92
|
+
systray.sendAction({
|
|
93
|
+
type: "update-menu",
|
|
94
|
+
menu: {
|
|
95
|
+
icon: ICON_BASE64,
|
|
96
|
+
title: `AY: ${newCount}`,
|
|
97
|
+
tooltip: `agent-yes: ${newCount} running`,
|
|
98
|
+
items: buildMenuItems(newTasks),
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Ignore polling errors
|
|
104
|
+
}
|
|
105
|
+
}, POLL_INTERVAL);
|
|
106
|
+
|
|
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
|
+
});
|
|
118
|
+
}
|