@phnx-labs/agents-cli 1.20.88 → 1.20.89
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/CHANGELOG.md +263 -0
- package/README.md +9 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/commands.js +7 -7
- package/dist/commands/factory.js +26 -2
- package/dist/commands/funnel.js +16 -1
- package/dist/commands/menubar.js +117 -34
- package/dist/commands/routines.js +23 -1
- package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
- package/dist/commands/secrets-rotate-passphrase.js +96 -0
- package/dist/commands/secrets.js +2 -0
- package/dist/commands/sessions.d.ts +7 -1
- package/dist/commands/sessions.js +39 -12
- package/dist/commands/webhook.js +7 -2
- package/dist/lib/commands.js +9 -1
- package/dist/lib/daemon.d.ts +29 -0
- package/dist/lib/daemon.js +58 -4
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/factory/snapshot.d.ts +78 -0
- package/dist/lib/factory/snapshot.js +209 -0
- package/dist/lib/fs-atomic.d.ts +14 -1
- package/dist/lib/fs-atomic.js +35 -3
- package/dist/lib/funnel.d.ts +1 -0
- package/dist/lib/funnel.js +8 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
- package/dist/lib/menubar/install-menubar.d.ts +53 -2
- package/dist/lib/menubar/install-menubar.js +183 -28
- package/dist/lib/platform/process.d.ts +2 -0
- package/dist/lib/platform/process.js +5 -3
- package/dist/lib/resources.d.ts +8 -0
- package/dist/lib/resources.js +34 -1
- package/dist/lib/routines-placement.d.ts +2 -1
- package/dist/lib/routines-placement.js +8 -4
- package/dist/lib/routines.d.ts +57 -1
- package/dist/lib/routines.js +74 -1
- package/dist/lib/runner.d.ts +2 -0
- package/dist/lib/runner.js +21 -8
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.js +9 -34
- package/dist/lib/secrets/filestore.d.ts +152 -34
- package/dist/lib/secrets/filestore.js +676 -123
- package/dist/lib/session/remote-active.d.ts +4 -1
- package/dist/lib/session/remote-active.js +8 -2
- package/dist/lib/session/viewing-in.d.ts +31 -0
- package/dist/lib/session/viewing-in.js +47 -0
- package/dist/lib/state.d.ts +17 -0
- package/dist/lib/state.js +30 -2
- package/dist/lib/triggers/handlers.d.ts +95 -0
- package/dist/lib/triggers/handlers.js +384 -0
- package/dist/lib/triggers/webhook.d.ts +10 -2
- package/dist/lib/triggers/webhook.js +65 -11
- package/package.json +1 -1
|
@@ -19,6 +19,7 @@ import { execFileSync, spawnSync } from 'child_process';
|
|
|
19
19
|
import * as fs from 'fs';
|
|
20
20
|
import * as os from 'os';
|
|
21
21
|
import * as path from 'path';
|
|
22
|
+
import { sleepSync } from '../fs-atomic.js';
|
|
22
23
|
import { getRuntimeStateDir, getHelpersDir } from '../state.js';
|
|
23
24
|
import { getCliVersion, resolveAgentsBin, resolveInstalledLayout } from '../version.js';
|
|
24
25
|
const APP_BUNDLE_NAME = 'MenubarHelper.app';
|
|
@@ -149,6 +150,27 @@ function copyAppBundle(src, dest) {
|
|
|
149
150
|
throw new Error(`Failed to copy ${src} -> ${dest}: ${msg || 'unknown error'}`);
|
|
150
151
|
}
|
|
151
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Register the freshly-installed bundle with LaunchServices.
|
|
155
|
+
*
|
|
156
|
+
* The helper is copied to ~/Library/Application Support (not /Applications) and
|
|
157
|
+
* launched only via launchd, so LaunchServices may never discover it on its own.
|
|
158
|
+
* A daemon notification posted by the one-shot `MenubarHelper --notify` process is
|
|
159
|
+
* attributed to this bundle, and macOS resolves the notification's LEFT-hand app
|
|
160
|
+
* icon from the bundle's LaunchServices record — so a bundle LS doesn't know about
|
|
161
|
+
* shows a blank app icon there (the right-hand contentImage is unaffected;
|
|
162
|
+
* appIconImage reads the `.icns` directly). `lsregister -f` registers the bundle at
|
|
163
|
+
* its current path so the OS can resolve its AppIcon for that slot. Best-effort:
|
|
164
|
+
* LaunchServices is advisory, and a failure here must never block install.
|
|
165
|
+
*/
|
|
166
|
+
function refreshBundleIconRegistration(appPath) {
|
|
167
|
+
const lsregister = '/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister';
|
|
168
|
+
const bin = fs.existsSync(lsregister) ? lsregister : 'lsregister';
|
|
169
|
+
const r = spawnSync(bin, ['-f', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
170
|
+
if (r.error) {
|
|
171
|
+
/* lsregister missing / moved — advisory only, ignore. */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
152
174
|
/** True when the bundle carries a signature the kernel will accept at launch. */
|
|
153
175
|
export function codesignVerifies(appPath) {
|
|
154
176
|
const r = spawnSync('codesign', ['--verify', '--strict', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
@@ -200,6 +222,10 @@ export function ensureMenubarAppInstalled(opts = {}) {
|
|
|
200
222
|
}
|
|
201
223
|
copyAppBundle(src, dest);
|
|
202
224
|
ensureValidSignature(dest);
|
|
225
|
+
// A fresh copy is exactly when the bundle's icon can be new (first install) or
|
|
226
|
+
// superseded (upgrade) — register it so LaunchServices knows the bundle and can
|
|
227
|
+
// resolve its AppIcon for the left-hand slot of daemon notifications.
|
|
228
|
+
refreshBundleIconRegistration(dest);
|
|
203
229
|
return installedExecutablePath();
|
|
204
230
|
}
|
|
205
231
|
function xmlEscape(s) {
|
|
@@ -299,24 +325,34 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
|
|
|
299
325
|
process.stderr.write('agents: menu-bar helper has no valid code signature; skipping launch to avoid a crash loop.\n');
|
|
300
326
|
return false;
|
|
301
327
|
}
|
|
302
|
-
if (opts.clearOptOut)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
328
|
+
if (opts.clearOptOut)
|
|
329
|
+
clearMenubarOptOut();
|
|
330
|
+
installAndStartService(exec);
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
/** Drop the sticky `agents menubar disable` sentinel. */
|
|
334
|
+
function clearMenubarOptOut() {
|
|
335
|
+
try {
|
|
336
|
+
fs.rmSync(disabledSentinelPath(), { force: true });
|
|
307
337
|
}
|
|
338
|
+
catch { /* already gone */ }
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Write the launchd plist for `exec`, restart the job, and stamp the installed
|
|
342
|
+
* version. Shared by `enableMenubarService` and `runMenubarSetup` so the two
|
|
343
|
+
* cannot drift on what "installed and started" means — the version stamp in
|
|
344
|
+
* particular is what the upgrade self-heal reads to decide staleness, and a
|
|
345
|
+
* path that skipped it would make every later `agents` invocation reinstall.
|
|
346
|
+
*/
|
|
347
|
+
function installAndStartService(exec) {
|
|
308
348
|
const plist = servicePlistPath();
|
|
309
349
|
fs.mkdirSync(path.dirname(plist), { recursive: true });
|
|
310
350
|
fs.writeFileSync(plist, generateServicePlist(exec));
|
|
311
|
-
|
|
312
|
-
restartMenubarLaunchAgent(uid, plist);
|
|
313
|
-
// Stamp the version we just installed so the upgrade self-heal can tell when
|
|
314
|
-
// a later release ships a newer helper that needs reinstalling.
|
|
351
|
+
restartMenubarLaunchAgent(process.getuid?.() ?? 0, plist);
|
|
315
352
|
try {
|
|
316
353
|
fs.writeFileSync(installedVersionMarkerPath(), getCliVersion());
|
|
317
354
|
}
|
|
318
355
|
catch { /* best effort */ }
|
|
319
|
-
return true;
|
|
320
356
|
}
|
|
321
357
|
/**
|
|
322
358
|
* Pure staleness decision (no I/O) so the truth table is unit-testable. The
|
|
@@ -441,6 +477,117 @@ export function installMenubarLaunchAgentOnUpgrade() {
|
|
|
441
477
|
/* never block startup on the menu bar */
|
|
442
478
|
}
|
|
443
479
|
}
|
|
480
|
+
/**
|
|
481
|
+
* Decide which live helper processes must be ended so exactly one status item
|
|
482
|
+
* survives. Pure so the choice is unit-testable without a live menu bar.
|
|
483
|
+
*
|
|
484
|
+
* EVERY current process is ended, including the wanted one: the caller
|
|
485
|
+
* re-kickstarts the launchd service straight after, so the survivor is the one
|
|
486
|
+
* launchd owns (RunAtLoad + KeepAlive), not whichever copy happened to win a
|
|
487
|
+
* race. Picking a survivor from a `ps` listing cannot do this — the list says
|
|
488
|
+
* nothing about which pid launchd will keep alive, so leaving one alive risks
|
|
489
|
+
* keeping the un-managed copy and re-creating the duplicate on next login.
|
|
490
|
+
*/
|
|
491
|
+
export function processesToEnd(status) {
|
|
492
|
+
return [...status.instances, ...status.foreignInstances];
|
|
493
|
+
}
|
|
494
|
+
function endProcess(pid) {
|
|
495
|
+
try {
|
|
496
|
+
process.kill(pid, 'SIGTERM');
|
|
497
|
+
}
|
|
498
|
+
catch { /* already gone */ }
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* `agents menubar setup` — configure the menu bar end-to-end, idempotently.
|
|
502
|
+
*
|
|
503
|
+
* The one command that gets a machine to the intended state: exactly one status
|
|
504
|
+
* item, owned by a launchd service that starts it at login and restarts it if it
|
|
505
|
+
* dies. Each concern is a reported step, so a partial failure names itself
|
|
506
|
+
* instead of hiding behind "enabled".
|
|
507
|
+
*
|
|
508
|
+
* 1. bundle — install/refresh the .app at the stable App Support path
|
|
509
|
+
* 2. signature — a valid code identity (macOS 26+ SIGKILLs an invalid one)
|
|
510
|
+
* 3. duplicates — end every live helper, so the only survivor is launchd's
|
|
511
|
+
* 4. login item — write the plist (RunAtLoad + KeepAlive) and bootstrap it
|
|
512
|
+
* 5. single — verify exactly one helper came back up
|
|
513
|
+
*/
|
|
514
|
+
export function runMenubarSetup() {
|
|
515
|
+
const steps = [];
|
|
516
|
+
const step = (name, outcome, detail) => {
|
|
517
|
+
steps.push({ name, outcome, detail });
|
|
518
|
+
};
|
|
519
|
+
if (!onDarwin()) {
|
|
520
|
+
step('platform', 'failed', `the menu bar helper is macOS only (this is ${process.platform})`);
|
|
521
|
+
return { steps, configured: false, status: getMenubarStatus() };
|
|
522
|
+
}
|
|
523
|
+
const before = getMenubarStatus();
|
|
524
|
+
if (!sourceAppPath()) {
|
|
525
|
+
step('bundle', 'failed', 'no menu-bar helper bundle ships with this install');
|
|
526
|
+
return { steps, configured: false, status: before };
|
|
527
|
+
}
|
|
528
|
+
// 3 before 1: end the running copies BEFORE swapping the bundle underneath
|
|
529
|
+
// them, so no helper keeps a status item alive on a binary that no longer
|
|
530
|
+
// exists on disk.
|
|
531
|
+
const doomed = processesToEnd(before);
|
|
532
|
+
for (const p of doomed)
|
|
533
|
+
endProcess(p.pid);
|
|
534
|
+
if (doomed.length > 1) {
|
|
535
|
+
step('duplicates', 'changed', `ended ${doomed.length} running helpers (${doomed.map((p) => p.pid).join(', ')}) — launchd restarts exactly one`);
|
|
536
|
+
}
|
|
537
|
+
else if (doomed.length === 1) {
|
|
538
|
+
step('duplicates', 'ok', 'one helper was running; restarting it under launchd');
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
step('duplicates', 'ok', 'no helper was running');
|
|
542
|
+
}
|
|
543
|
+
const exec = ensureMenubarAppInstalled({ forceReinstall: true });
|
|
544
|
+
if (!exec) {
|
|
545
|
+
step('bundle', 'failed', 'could not install the helper bundle');
|
|
546
|
+
return { steps, configured: false, status: getMenubarStatus() };
|
|
547
|
+
}
|
|
548
|
+
step('bundle', before.installedVersion === getCliVersion() ? 'ok' : 'changed', `${installedAppPath()} (${getCliVersion()})`);
|
|
549
|
+
if (!codesignVerifies(installedAppPath())) {
|
|
550
|
+
step('signature', 'failed', 'no valid code signature — refusing to start it (launchd KeepAlive would crash-loop)');
|
|
551
|
+
return { steps, configured: false, status: getMenubarStatus() };
|
|
552
|
+
}
|
|
553
|
+
step('signature', 'ok', 'valid');
|
|
554
|
+
// Clear the sticky opt-out: running `setup` is an explicit request for the
|
|
555
|
+
// menu bar, so a stale `menubar disable` must not silently win.
|
|
556
|
+
clearMenubarOptOut();
|
|
557
|
+
installAndStartService(exec);
|
|
558
|
+
step('login item', before.serviceInstalled ? 'ok' : 'changed', `${SERVICE_LABEL} — starts at login, restarts if it dies`);
|
|
559
|
+
// launchd's bootstrap+kickstart is asynchronous; give the status item a beat
|
|
560
|
+
// to claim the lock before counting instances, or `setup` reports zero on a
|
|
561
|
+
// machine that is in fact coming up correctly.
|
|
562
|
+
const after = waitForSingleInstance();
|
|
563
|
+
if (after.instances.length === 1 && after.foreignInstances.length === 0) {
|
|
564
|
+
step('single instance', 'ok', `pid ${after.instances[0].pid}`);
|
|
565
|
+
}
|
|
566
|
+
else if (after.instances.length === 0) {
|
|
567
|
+
step('single instance', 'failed', 'the helper did not come back up — see `agents menubar status`');
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
const extra = [...after.instances.slice(1), ...after.foreignInstances];
|
|
571
|
+
step('single instance', 'failed', `${after.instances.length + after.foreignInstances.length} helpers running (${extra.map((p) => p.pid).join(', ')} are extra)`);
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
steps,
|
|
575
|
+
configured: steps.every((s) => s.outcome !== 'failed'),
|
|
576
|
+
status: after,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Poll (up to ~3s) for launchd to bring the single helper back. Returns the
|
|
581
|
+
* last status read either way — the caller decides what a miss means.
|
|
582
|
+
*/
|
|
583
|
+
function waitForSingleInstance() {
|
|
584
|
+
let status = getMenubarStatus();
|
|
585
|
+
for (let i = 0; i < 15 && status.instances.length !== 1; i++) {
|
|
586
|
+
sleepSync(200);
|
|
587
|
+
status = getMenubarStatus();
|
|
588
|
+
}
|
|
589
|
+
return status;
|
|
590
|
+
}
|
|
444
591
|
/** Parse `ps -axo pid=,<field>=` into pid -> field. The field is the rest of the
|
|
445
592
|
* line, so a path containing spaces (App Support does) survives intact. */
|
|
446
593
|
function parsePsLines(psOutput) {
|
|
@@ -454,21 +601,28 @@ function parsePsLines(psOutput) {
|
|
|
454
601
|
}
|
|
455
602
|
/**
|
|
456
603
|
* Split the live MenubarHelper processes into the installed bundle's own
|
|
457
|
-
* (`
|
|
604
|
+
* (`own`) and every other copy (`foreign`).
|
|
458
605
|
*
|
|
459
606
|
* `pgrep -f MenubarHelper` conflated the two, so a stray dev build could hold
|
|
460
607
|
* the global Cmd-Shift-V chord (RegisterEventHotKey is first-come) while status
|
|
461
608
|
* still reported a healthy `running: yes` — the paste was dead and nothing said
|
|
462
609
|
* so. A foreign copy is the thing to look for, so name it.
|
|
463
610
|
*
|
|
611
|
+
* `own` is a LIST, not a boolean: two copies of the INSTALLED bundle can run at
|
|
612
|
+
* once (launchd's KeepAlive service plus a LaunchServices/`open` launch of the
|
|
613
|
+
* same .app), which is the duplicate the user actually sees — two agents marks
|
|
614
|
+
* in the menu bar. Collapsing them to `running: true` reported that state as
|
|
615
|
+
* healthy. The helper now refuses to be the second (SingleInstance.swift), and
|
|
616
|
+
* `agents menubar setup` ends any duplicate a pre-fix helper left behind.
|
|
617
|
+
*
|
|
464
618
|
* Identity comes from `comm` (the resolved executable), never from a substring
|
|
465
619
|
* of the command line: matching the latter flags any shell that merely mentions
|
|
466
620
|
* MenubarHelper. `command` is consulted only to drop `--notify` one-shots.
|
|
467
621
|
*/
|
|
468
622
|
export function classifyMenubarProcesses(commOutput, commandOutput, installedExec) {
|
|
469
623
|
const commands = parsePsLines(commandOutput);
|
|
624
|
+
const own = [];
|
|
470
625
|
const foreign = [];
|
|
471
|
-
let running = false;
|
|
472
626
|
for (const [pid, executable] of parsePsLines(commOutput)) {
|
|
473
627
|
if (path.basename(executable) !== 'MenubarHelper')
|
|
474
628
|
continue;
|
|
@@ -477,26 +631,26 @@ export function classifyMenubarProcesses(commOutput, commandOutput, installedExe
|
|
|
477
631
|
if ((commands.get(pid) || '').includes('--notify'))
|
|
478
632
|
continue;
|
|
479
633
|
if (executable === installedExec)
|
|
480
|
-
|
|
634
|
+
own.push({ pid, executable });
|
|
481
635
|
else
|
|
482
636
|
foreign.push({ pid, executable });
|
|
483
637
|
}
|
|
484
|
-
return {
|
|
638
|
+
return { own, foreign };
|
|
639
|
+
}
|
|
640
|
+
/** Live MenubarHelper processes, split by whether they are the installed bundle. */
|
|
641
|
+
function liveMenubarProcesses() {
|
|
642
|
+
if (!onDarwin())
|
|
643
|
+
return { own: [], foreign: [] };
|
|
644
|
+
const ps = (format) => spawnSync('ps', ['-axo', format], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
|
|
645
|
+
const comm = ps('pid=,comm=');
|
|
646
|
+
const command = ps('pid=,command=');
|
|
647
|
+
if (comm.status !== 0 || command.status !== 0)
|
|
648
|
+
return { own: [], foreign: [] };
|
|
649
|
+
return classifyMenubarProcesses(comm.stdout || '', command.stdout || '', installedExecutablePath());
|
|
485
650
|
}
|
|
486
651
|
export function getMenubarStatus() {
|
|
487
652
|
const dest = installedAppPath();
|
|
488
|
-
|
|
489
|
-
let foreignInstances = [];
|
|
490
|
-
if (onDarwin()) {
|
|
491
|
-
const ps = (format) => spawnSync('ps', ['-axo', format], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
|
|
492
|
-
const comm = ps('pid=,comm=');
|
|
493
|
-
const command = ps('pid=,command=');
|
|
494
|
-
if (comm.status === 0 && command.status === 0) {
|
|
495
|
-
const c = classifyMenubarProcesses(comm.stdout || '', command.stdout || '', installedExecutablePath());
|
|
496
|
-
running = c.running;
|
|
497
|
-
foreignInstances = c.foreign;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
653
|
+
const { own, foreign } = liveMenubarProcesses();
|
|
500
654
|
const serviceInstalled = menubarServiceInstalled();
|
|
501
655
|
return {
|
|
502
656
|
platform: process.platform,
|
|
@@ -506,8 +660,9 @@ export function getMenubarStatus() {
|
|
|
506
660
|
currentVersion: getCliVersion(),
|
|
507
661
|
stale: onDarwin() && serviceInstalled && menubarSetupStale(),
|
|
508
662
|
serviceInstalled,
|
|
509
|
-
running,
|
|
510
|
-
|
|
663
|
+
running: own.length > 0,
|
|
664
|
+
instances: own,
|
|
665
|
+
foreignInstances: foreign,
|
|
511
666
|
disabledByUser: menubarDisabledByUser(),
|
|
512
667
|
};
|
|
513
668
|
}
|
|
@@ -34,9 +34,11 @@ export declare function killTree(pid: number): void;
|
|
|
34
34
|
* sites pass their own `windowsHide` with piped stdio.
|
|
35
35
|
*/
|
|
36
36
|
export declare function backgroundSpawnOptions(opts?: {
|
|
37
|
+
cwd?: string;
|
|
37
38
|
fdStdio?: boolean;
|
|
38
39
|
platform?: NodeJS.Platform;
|
|
39
40
|
}): {
|
|
41
|
+
cwd: string;
|
|
40
42
|
detached: boolean;
|
|
41
43
|
windowsHide: boolean;
|
|
42
44
|
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { execFileSync } from 'child_process';
|
|
5
5
|
import { readFileSync } from 'fs';
|
|
6
|
+
import * as os from 'os';
|
|
6
7
|
import { sleepSync } from '../fs-atomic.js';
|
|
7
8
|
/**
|
|
8
9
|
* Forcefully terminate a process AND its descendant tree.
|
|
@@ -56,12 +57,13 @@ export function killTree(pid) {
|
|
|
56
57
|
*/
|
|
57
58
|
export function backgroundSpawnOptions(opts = {}) {
|
|
58
59
|
const platform = opts.platform ?? process.platform;
|
|
60
|
+
const cwd = opts.cwd ?? os.homedir();
|
|
59
61
|
if (platform === 'win32') {
|
|
60
62
|
return opts.fdStdio
|
|
61
|
-
? { detached: true, windowsHide: true }
|
|
62
|
-
: { detached: false, windowsHide: true };
|
|
63
|
+
? { cwd, detached: true, windowsHide: true }
|
|
64
|
+
: { cwd, detached: false, windowsHide: true };
|
|
63
65
|
}
|
|
64
|
-
return { detached: true, windowsHide: false };
|
|
66
|
+
return { cwd, detached: true, windowsHide: false };
|
|
65
67
|
}
|
|
66
68
|
/**
|
|
67
69
|
* Is a process with this PID currently alive?
|
package/dist/lib/resources.d.ts
CHANGED
|
@@ -17,6 +17,14 @@ export interface ResolvedResource {
|
|
|
17
17
|
*/
|
|
18
18
|
source: string;
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* True when `rawName` (a filename with its extension already stripped) names a
|
|
22
|
+
* directory doc rather than a resource of `kind`. Exported so every enumerator
|
|
23
|
+
* shares one definition — `listCentralCommands` and `discoverCommands` in
|
|
24
|
+
* `commands.ts` do their own `readdirSync` scans, and without this they would
|
|
25
|
+
* list a `README` that `resolveResource` then refuses to open.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isDirectoryDoc(kind: ResourceKind, rawName: string): boolean;
|
|
20
28
|
/**
|
|
21
29
|
* Resolve a single resource by kind + name using project > user > system precedence.
|
|
22
30
|
* For file-based resources the path ends in `.md`, `.yaml`, or `.yml` as appropriate.
|
package/dist/lib/resources.js
CHANGED
|
@@ -35,6 +35,30 @@ function resourceIsActive(kind, name, source) {
|
|
|
35
35
|
const activeKind = profiledKind(kind);
|
|
36
36
|
return activeKind ? isNameActiveInResourceProfile(activeKind, name, source) : true;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Documentation filenames that live *beside* resources, describing the directory
|
|
40
|
+
* rather than being a resource in it. A DotAgents repo keeps a `README.md` (for
|
|
41
|
+
* humans) and an `AGENTS.md` (for agents) in each resource dir, with
|
|
42
|
+
* `CLAUDE.md`/`GEMINI.md` symlinked to the latter. Without this filter every one
|
|
43
|
+
* of them materializes as a resource — `commands/README.md` installs a bogus
|
|
44
|
+
* `/README` slash command into every agent home.
|
|
45
|
+
*
|
|
46
|
+
* `rules` is exempt: there `AGENTS.md` IS the resource (the composed ruleset that
|
|
47
|
+
* syncs as each agent's memory file), not documentation about the directory.
|
|
48
|
+
*/
|
|
49
|
+
const DOC_BASENAMES = new Set(['readme', 'agents', 'claude', 'gemini']);
|
|
50
|
+
/**
|
|
51
|
+
* True when `rawName` (a filename with its extension already stripped) names a
|
|
52
|
+
* directory doc rather than a resource of `kind`. Exported so every enumerator
|
|
53
|
+
* shares one definition — `listCentralCommands` and `discoverCommands` in
|
|
54
|
+
* `commands.ts` do their own `readdirSync` scans, and without this they would
|
|
55
|
+
* list a `README` that `resolveResource` then refuses to open.
|
|
56
|
+
*/
|
|
57
|
+
export function isDirectoryDoc(kind, rawName) {
|
|
58
|
+
if (kind === 'rules')
|
|
59
|
+
return false;
|
|
60
|
+
return DOC_BASENAMES.has(rawName.toLowerCase());
|
|
61
|
+
}
|
|
38
62
|
/**
|
|
39
63
|
* Resolve a single resource by kind + name using project > user > system precedence.
|
|
40
64
|
* For file-based resources the path ends in `.md`, `.yaml`, or `.yml` as appropriate.
|
|
@@ -62,7 +86,10 @@ export function resolveResource(kind, name, cwd) {
|
|
|
62
86
|
}
|
|
63
87
|
continue;
|
|
64
88
|
}
|
|
65
|
-
// Try with common file extensions
|
|
89
|
+
// Try with common file extensions. A directory doc (README/AGENTS/CLAUDE/
|
|
90
|
+
// GEMINI) describes the directory and is never itself a resource.
|
|
91
|
+
if (isDirectoryDoc(kind, name))
|
|
92
|
+
continue;
|
|
66
93
|
for (const ext of ['.md', '.yaml', '.yml']) {
|
|
67
94
|
const withExt = exactPath + ext;
|
|
68
95
|
if (fs.existsSync(withExt)) {
|
|
@@ -105,6 +132,12 @@ export function listResources(kind, cwd) {
|
|
|
105
132
|
if (entry.name.startsWith('.'))
|
|
106
133
|
continue;
|
|
107
134
|
const rawName = entry.name.replace(/\.(md|yaml|yml)$/, '');
|
|
135
|
+
// Not isFile(): a Dirent for a symlink reports isFile() === false, and
|
|
136
|
+
// CLAUDE.md/GEMINI.md are symlinks to AGENTS.md by convention. Anything
|
|
137
|
+
// that is not a directory is a candidate doc; a resource directory that
|
|
138
|
+
// happens to be named `agents/` is still a real resource.
|
|
139
|
+
if (!entry.isDirectory() && isDirectoryDoc(kind, rawName))
|
|
140
|
+
continue;
|
|
108
141
|
if (seen.has(rawName))
|
|
109
142
|
continue;
|
|
110
143
|
if (!resourceIsActive(kind, rawName, source))
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* pin for fleet/host/cloud strategies (applied at add/sync time).
|
|
11
11
|
*/
|
|
12
12
|
import type { JobConfig } from './routines.js';
|
|
13
|
+
import { type DevicePlatform } from './devices/registry.js';
|
|
13
14
|
export type PlacementTarget = {
|
|
14
15
|
mode: 'local';
|
|
15
16
|
} | {
|
|
@@ -30,7 +31,7 @@ export type PlacementTarget = {
|
|
|
30
31
|
* the double-fire pin (`devices: [self]`) would collapse fleet placement to
|
|
31
32
|
* always-local. Control / offline / no-address devices are never chosen.
|
|
32
33
|
*/
|
|
33
|
-
export declare function pickFleetDevice(_config?: Pick<JobConfig, 'devices'
|
|
34
|
+
export declare function pickFleetDevice(_config?: Pick<JobConfig, 'devices'>, platform?: DevicePlatform): string | null;
|
|
34
35
|
/**
|
|
35
36
|
* Resolve where a fired job's body should execute.
|
|
36
37
|
* Throws a human-readable Error when placement cannot be satisfied.
|
|
@@ -25,7 +25,7 @@ import { planFleetTargets } from './devices/fleet.js';
|
|
|
25
25
|
* the double-fire pin (`devices: [self]`) would collapse fleet placement to
|
|
26
26
|
* always-local. Control / offline / no-address devices are never chosen.
|
|
27
27
|
*/
|
|
28
|
-
export function pickFleetDevice(_config) {
|
|
28
|
+
export function pickFleetDevice(_config, platform) {
|
|
29
29
|
let reg;
|
|
30
30
|
try {
|
|
31
31
|
reg = loadDevicesSync();
|
|
@@ -34,11 +34,15 @@ export function pickFleetDevice(_config) {
|
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
36
36
|
const planned = planFleetTargets(reg);
|
|
37
|
-
const candidates = planned
|
|
37
|
+
const candidates = planned
|
|
38
|
+
.filter((t) => !t.skip && (!platform || t.device.platform === platform))
|
|
39
|
+
.map((t) => t.device.name);
|
|
38
40
|
if (candidates.length === 0) {
|
|
39
41
|
// No registry / nothing online: fall back to self so a single-box fleet
|
|
40
|
-
// without a registry entry still runs locally.
|
|
41
|
-
|
|
42
|
+
// without a registry entry still runs locally. Only when no platform filter
|
|
43
|
+
// was requested — an unmet filter must fail loud so e.g. `fleet/linux` never
|
|
44
|
+
// silently lands on a macOS box.
|
|
45
|
+
return platform ? null : machineId();
|
|
42
46
|
}
|
|
43
47
|
const self = machineId();
|
|
44
48
|
const selfMatch = candidates.find((n) => normalizeHost(n) === self);
|
package/dist/lib/routines.d.ts
CHANGED
|
@@ -78,6 +78,10 @@ export interface LinearJobTrigger {
|
|
|
78
78
|
teamKey?: string;
|
|
79
79
|
/** Required issue label name. */
|
|
80
80
|
label?: string;
|
|
81
|
+
/** Current Linear state name that must match (e.g. `Plan`). */
|
|
82
|
+
stateTo?: string;
|
|
83
|
+
/** Previous Linear state name that must match (e.g. `Triage`). */
|
|
84
|
+
stateFrom?: string;
|
|
81
85
|
}
|
|
82
86
|
export type JobTrigger = GithubJobTrigger | LinearJobTrigger;
|
|
83
87
|
/**
|
|
@@ -118,6 +122,12 @@ export interface JobConfig {
|
|
|
118
122
|
* overdue; everywhere else it is inert and `run` refuses with a pointer.
|
|
119
123
|
*/
|
|
120
124
|
devices?: string[];
|
|
125
|
+
/**
|
|
126
|
+
* Environment variables injected into the spawned run, on top of the sandbox
|
|
127
|
+
* overlay's own. Merged by `buildSpawnEnv`, so it applies to both the
|
|
128
|
+
* foreground and detached execution paths.
|
|
129
|
+
*/
|
|
130
|
+
env?: Record<string, string>;
|
|
121
131
|
/**
|
|
122
132
|
* Execution placement — run the job body on this machine over SSH (a
|
|
123
133
|
* registered host, device, capability tag, or user@host) instead of locally.
|
|
@@ -360,8 +370,54 @@ export declare function oneShotScheduleFireDate(schedule: string | undefined | n
|
|
|
360
370
|
export declare function isPastOneShotRoutine(config: Pick<JobConfig, 'schedule' | 'runOnce' | 'timezone'>, now?: Date): boolean;
|
|
361
371
|
export declare function hasCompletedOneShotRun(config: Pick<JobConfig, 'name' | 'schedule' | 'runOnce' | 'timezone'>, now?: Date): boolean;
|
|
362
372
|
export declare function shouldPurgeCompletedOneShotRoutine(config: Pick<JobConfig, 'name' | 'schedule' | 'runOnce' | 'timezone'>, now?: Date): boolean;
|
|
373
|
+
/**
|
|
374
|
+
* Context passed to `resolveJobPrompt` when a job is fired by a webhook. Lets
|
|
375
|
+
* prompts use `{{issue.identifier}}`, `{{updatedFrom.state.name}}`, etc.
|
|
376
|
+
*/
|
|
377
|
+
export interface WebhookContext {
|
|
378
|
+
source: string;
|
|
379
|
+
event: string;
|
|
380
|
+
action?: string;
|
|
381
|
+
issue?: unknown;
|
|
382
|
+
updatedFrom?: unknown;
|
|
383
|
+
pull_request?: unknown;
|
|
384
|
+
repository?: unknown;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Substitute `{{dotted.path}}` placeholders in a string using a webhook context.
|
|
388
|
+
* Missing values are replaced with an empty string.
|
|
389
|
+
*/
|
|
390
|
+
export declare function substituteWebhookPrompt(prompt: string, context: WebhookContext): string;
|
|
391
|
+
/**
|
|
392
|
+
* Substitute `{{dotted.path}}` placeholders in a string destined for a SHELL,
|
|
393
|
+
* quoting every substituted value so payload content cannot break out of it.
|
|
394
|
+
*
|
|
395
|
+
* `run.command` is executed through a shell, and its context is built from an
|
|
396
|
+
* external webhook payload — `issue.title`, `issue.description`, and the GitHub
|
|
397
|
+
* `pull_request` fields are free text any outside contributor can set. Pasting
|
|
398
|
+
* those in raw (as {@link substituteWebhookPrompt} does, correctly, for prompts)
|
|
399
|
+
* turns an operator's `echo {{issue.title}}` into a command-injection sink.
|
|
400
|
+
*
|
|
401
|
+
* The template itself is operator-authored and stays unquoted, so pipes,
|
|
402
|
+
* redirects, and `&&` in the configured command keep working. Only the
|
|
403
|
+
* interpolated values are quoted.
|
|
404
|
+
*
|
|
405
|
+
* POSIX `sh` quoting: wrap in single quotes and close/escape/reopen for any
|
|
406
|
+
* embedded single quote. `exec` uses `cmd.exe` on Windows, which does not
|
|
407
|
+
* honour these rules — see `assertShellSubstitutionSupported`.
|
|
408
|
+
*/
|
|
409
|
+
export declare function substituteWebhookCommand(command: string, context: WebhookContext): string;
|
|
410
|
+
/**
|
|
411
|
+
* Refuse a `run.command` carrying placeholders on a platform whose shell we
|
|
412
|
+
* cannot safely quote for. `child_process.exec` runs through `cmd.exe` on
|
|
413
|
+
* Windows, where POSIX single-quoting is not a quoting mechanism at all, so
|
|
414
|
+
* {@link substituteWebhookCommand} would not contain a hostile value.
|
|
415
|
+
*
|
|
416
|
+
* Fail loud rather than execute something we cannot prove is safe.
|
|
417
|
+
*/
|
|
418
|
+
export declare function assertShellSubstitutionSupported(command: string, platform?: NodeJS.Platform): void;
|
|
363
419
|
/** Expand built-in and user-defined template variables in a job's prompt string. */
|
|
364
|
-
export declare function resolveJobPrompt(config: JobConfig): string;
|
|
420
|
+
export declare function resolveJobPrompt(config: JobConfig, context?: WebhookContext): string;
|
|
365
421
|
/** Parse a human-readable timeout string (e.g. "10m", "2h", "1h30m", "3d", "1w") into milliseconds.
|
|
366
422
|
* Accepts combinations of w (weeks), d (days), h (hours), m (minutes).
|
|
367
423
|
* Returns null if the string is empty, matches nothing, totals zero, or exceeds 1 week.
|
package/dist/lib/routines.js
CHANGED
|
@@ -569,6 +569,12 @@ export function validateTrigger(trigger) {
|
|
|
569
569
|
if (linear.label !== undefined && typeof linear.label !== 'string') {
|
|
570
570
|
errors.push('trigger.label must be a string');
|
|
571
571
|
}
|
|
572
|
+
if (linear.stateTo !== undefined && typeof linear.stateTo !== 'string') {
|
|
573
|
+
errors.push('trigger.stateTo must be a string');
|
|
574
|
+
}
|
|
575
|
+
if (linear.stateFrom !== undefined && typeof linear.stateFrom !== 'string') {
|
|
576
|
+
errors.push('trigger.stateFrom must be a string');
|
|
577
|
+
}
|
|
572
578
|
return errors;
|
|
573
579
|
}
|
|
574
580
|
function isParseableDate(value) {
|
|
@@ -702,8 +708,71 @@ export function hasCompletedOneShotRun(config, now = new Date()) {
|
|
|
702
708
|
export function shouldPurgeCompletedOneShotRoutine(config, now = new Date()) {
|
|
703
709
|
return hasCompletedOneShotRun(config, now);
|
|
704
710
|
}
|
|
711
|
+
function getPath(obj, path) {
|
|
712
|
+
const parts = path.split('.');
|
|
713
|
+
let current = obj;
|
|
714
|
+
for (const part of parts) {
|
|
715
|
+
if (current === null || current === undefined)
|
|
716
|
+
return undefined;
|
|
717
|
+
current = current[part];
|
|
718
|
+
}
|
|
719
|
+
return current;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Substitute `{{dotted.path}}` placeholders in a string using a webhook context.
|
|
723
|
+
* Missing values are replaced with an empty string.
|
|
724
|
+
*/
|
|
725
|
+
export function substituteWebhookPrompt(prompt, context) {
|
|
726
|
+
return prompt.replace(/\{\{([^{}]+)\}\}/g, (_, rawPath) => {
|
|
727
|
+
const value = getPath(context, rawPath.trim());
|
|
728
|
+
if (value === undefined || value === null)
|
|
729
|
+
return '';
|
|
730
|
+
return String(value);
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Substitute `{{dotted.path}}` placeholders in a string destined for a SHELL,
|
|
735
|
+
* quoting every substituted value so payload content cannot break out of it.
|
|
736
|
+
*
|
|
737
|
+
* `run.command` is executed through a shell, and its context is built from an
|
|
738
|
+
* external webhook payload — `issue.title`, `issue.description`, and the GitHub
|
|
739
|
+
* `pull_request` fields are free text any outside contributor can set. Pasting
|
|
740
|
+
* those in raw (as {@link substituteWebhookPrompt} does, correctly, for prompts)
|
|
741
|
+
* turns an operator's `echo {{issue.title}}` into a command-injection sink.
|
|
742
|
+
*
|
|
743
|
+
* The template itself is operator-authored and stays unquoted, so pipes,
|
|
744
|
+
* redirects, and `&&` in the configured command keep working. Only the
|
|
745
|
+
* interpolated values are quoted.
|
|
746
|
+
*
|
|
747
|
+
* POSIX `sh` quoting: wrap in single quotes and close/escape/reopen for any
|
|
748
|
+
* embedded single quote. `exec` uses `cmd.exe` on Windows, which does not
|
|
749
|
+
* honour these rules — see `assertShellSubstitutionSupported`.
|
|
750
|
+
*/
|
|
751
|
+
export function substituteWebhookCommand(command, context) {
|
|
752
|
+
return command.replace(/\{\{([^{}]+)\}\}/g, (_, rawPath) => {
|
|
753
|
+
const value = getPath(context, rawPath.trim());
|
|
754
|
+
if (value === undefined || value === null)
|
|
755
|
+
return "''";
|
|
756
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Refuse a `run.command` carrying placeholders on a platform whose shell we
|
|
761
|
+
* cannot safely quote for. `child_process.exec` runs through `cmd.exe` on
|
|
762
|
+
* Windows, where POSIX single-quoting is not a quoting mechanism at all, so
|
|
763
|
+
* {@link substituteWebhookCommand} would not contain a hostile value.
|
|
764
|
+
*
|
|
765
|
+
* Fail loud rather than execute something we cannot prove is safe.
|
|
766
|
+
*/
|
|
767
|
+
export function assertShellSubstitutionSupported(command, platform = process.platform) {
|
|
768
|
+
if (platform === 'win32' && /\{\{[^{}]+\}\}/.test(command)) {
|
|
769
|
+
throw new Error('run.command with {{…}} placeholders is not supported on Windows: the values come from an ' +
|
|
770
|
+
'untrusted webhook payload and cmd.exe cannot be quoted safely. Use run.prompt, or a ' +
|
|
771
|
+
'command with no placeholders.');
|
|
772
|
+
}
|
|
773
|
+
}
|
|
705
774
|
/** Expand built-in and user-defined template variables in a job's prompt string. */
|
|
706
|
-
export function resolveJobPrompt(config) {
|
|
775
|
+
export function resolveJobPrompt(config, context) {
|
|
707
776
|
const now = new Date();
|
|
708
777
|
const tz = config.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
709
778
|
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
@@ -725,6 +794,10 @@ export function resolveJobPrompt(config) {
|
|
|
725
794
|
prompt = prompt.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
|
|
726
795
|
}
|
|
727
796
|
}
|
|
797
|
+
// Webhook-driven variables ({{issue.identifier}}, {{updatedFrom.state.name}}, ...)
|
|
798
|
+
if (context) {
|
|
799
|
+
prompt = substituteWebhookPrompt(prompt, context);
|
|
800
|
+
}
|
|
728
801
|
// Last report (special handling). Only a COMPLETED run's report is injected —
|
|
729
802
|
// a failed run's report.md is the agent's error text (e.g. a login prompt on
|
|
730
803
|
// an auth failure), and feeding that into the next prompt poisons every
|
package/dist/lib/runner.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface RunResult {
|
|
|
26
26
|
/** Agents the daemon can actually run, derived from the command table above
|
|
27
27
|
* so the `--agent` help and any validation can never drift from it. */
|
|
28
28
|
export declare const ROUTINE_AGENT_IDS: readonly string[];
|
|
29
|
+
/** Stable working directory for routine children, independent of the daemon's launch cwd. */
|
|
30
|
+
export declare function routineSpawnCwd(config: Pick<JobConfig, 'repo'>, configuredRoot?: string | undefined): string;
|
|
29
31
|
/** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
|
|
30
32
|
export declare function buildJobCommand(config: JobConfig, resolvedPrompt: string): string[];
|
|
31
33
|
export declare function archiveRoutineTranscripts(meta: Pick<RunMeta, 'jobName' | 'runId' | 'agent'>, runDir: string, overlayHome?: string): void;
|