@phnx-labs/agents-cli 1.20.54 → 1.20.56
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 +17 -0
- package/README.md +9 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/message.js +7 -5
- package/dist/commands/packages.js +113 -2
- package/dist/commands/profiles.js +4 -3
- package/dist/commands/routines.js +185 -15
- package/dist/commands/ssh.js +2 -9
- package/dist/commands/tmux.js +11 -3
- package/dist/index.js +1 -1
- package/dist/lib/daemon.d.ts +33 -1
- package/dist/lib/daemon.js +131 -14
- package/dist/lib/devices/sync.d.ts +7 -0
- package/dist/lib/devices/sync.js +13 -1
- package/dist/lib/exec.js +10 -8
- package/dist/lib/hosts/passthrough.js +8 -4
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +9 -0
- package/dist/lib/migrate.js +48 -0
- package/dist/lib/overdue.js +6 -1
- package/dist/lib/profiles-presets.js +52 -0
- package/dist/lib/registry.d.ts +55 -0
- package/dist/lib/registry.js +82 -1
- package/dist/lib/routines.d.ts +42 -1
- package/dist/lib/routines.js +72 -5
- package/dist/lib/runner.js +62 -9
- package/dist/lib/scheduler.js +4 -3
- package/dist/lib/teams/agents.js +6 -6
- package/dist/lib/tmux/binary.d.ts +4 -0
- package/dist/lib/tmux/binary.js +26 -3
- package/dist/lib/tmux/index.d.ts +1 -1
- package/dist/lib/tmux/index.js +1 -1
- package/dist/lib/tmux/session.d.ts +23 -7
- package/dist/lib/tmux/session.js +32 -9
- package/dist/lib/triggers/webhook.js +2 -2
- package/dist/lib/types.d.ts +6 -0
- package/package.json +1 -1
package/dist/commands/tmux.js
CHANGED
|
@@ -24,7 +24,7 @@ import chalk from 'chalk';
|
|
|
24
24
|
import { truncate } from '../lib/format.js';
|
|
25
25
|
import * as path from 'path';
|
|
26
26
|
import { setHelpSections } from '../lib/help.js';
|
|
27
|
-
import { assertTmuxAvailable, attachTmux, capturePane, createSession, getDefaultSocketPath, getTmuxVersion, hasSession, isTmuxInstalled, killAll, killSession, listSessions, readSessionMeta, sendKeys, splitPane, TmuxCommandError, TmuxSessionError, TmuxUnavailableError, } from '../lib/tmux/index.js';
|
|
27
|
+
import { assertTmuxAvailable, attachTmux, capturePane, createSession, getDefaultSocketPath, getTmuxVersion, hasSession, isTmuxInstalled, isTmuxVersionSupported, killAll, killSession, listSessions, MIN_TMUX_VERSION, readSessionMeta, sendKeys, splitPane, TmuxCommandError, TmuxSessionError, TmuxUnavailableError, } from '../lib/tmux/index.js';
|
|
28
28
|
/** Register the `agents tmux` command tree. */
|
|
29
29
|
export function registerTmuxCommands(program) {
|
|
30
30
|
const tmux = program
|
|
@@ -70,14 +70,22 @@ export function registerTmuxCommands(program) {
|
|
|
70
70
|
checkCmd.action((opts) => {
|
|
71
71
|
const installed = isTmuxInstalled();
|
|
72
72
|
const version = installed ? getTmuxVersion() : null;
|
|
73
|
+
const supported = isTmuxVersionSupported(version);
|
|
73
74
|
if (opts.json) {
|
|
74
|
-
console.log(JSON.stringify({ installed, version, socket: getDefaultSocketPath() }));
|
|
75
|
+
console.log(JSON.stringify({ installed, supported, version, minimumVersion: MIN_TMUX_VERSION, socket: getDefaultSocketPath() }));
|
|
76
|
+
if (installed && !supported)
|
|
77
|
+
process.exitCode = 1;
|
|
75
78
|
return;
|
|
76
79
|
}
|
|
77
|
-
if (installed) {
|
|
80
|
+
if (installed && supported) {
|
|
78
81
|
console.log(chalk.green('tmux:'), version ?? '(version unknown)');
|
|
79
82
|
console.log(chalk.gray(`socket: ${getDefaultSocketPath()}`));
|
|
80
83
|
}
|
|
84
|
+
else if (installed) {
|
|
85
|
+
console.log(chalk.yellow('tmux:'), `${version ?? '(version unknown)'} — unsupported`);
|
|
86
|
+
console.log(chalk.gray(` agents requires tmux ${MIN_TMUX_VERSION} or newer.`));
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
}
|
|
81
89
|
else {
|
|
82
90
|
console.log(chalk.yellow('tmux is not installed.'));
|
|
83
91
|
console.log(chalk.gray(process.platform === 'darwin'
|
package/dist/index.js
CHANGED
|
@@ -874,7 +874,7 @@ if (process.env.AGENTS_SKIP_MIGRATION !== '1') {
|
|
|
874
874
|
// Bumping the suffix re-runs migrations for every user; binary releases that
|
|
875
875
|
// don't change the schema must NOT re-run (they would destroy user content
|
|
876
876
|
// when migration steps overlap with user-authored paths). See issue #20.
|
|
877
|
-
const sentinelValue = '
|
|
877
|
+
const sentinelValue = 'v12';
|
|
878
878
|
let needRun = true;
|
|
879
879
|
try {
|
|
880
880
|
if (fs.existsSync(sentinel) && fs.readFileSync(sentinel, 'utf-8').trim() === sentinelValue) {
|
package/dist/lib/daemon.d.ts
CHANGED
|
@@ -12,6 +12,14 @@ export declare function readDaemonPid(): number | null;
|
|
|
12
12
|
export declare function writeDaemonPid(pid: number): void;
|
|
13
13
|
/** Remove the daemon PID file. */
|
|
14
14
|
export declare function removeDaemonPid(): void;
|
|
15
|
+
export interface DaemonHeartbeat {
|
|
16
|
+
lastTick: string;
|
|
17
|
+
pid: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function writeHeartbeat(pid?: number): void;
|
|
20
|
+
export declare function readHeartbeat(): DaemonHeartbeat | null;
|
|
21
|
+
export declare function removeHeartbeat(): void;
|
|
22
|
+
export declare function isDaemonWedged(): boolean;
|
|
15
23
|
/** Check if the daemon process is alive by sending signal 0 to the stored PID. */
|
|
16
24
|
export declare function isDaemonRunning(): boolean;
|
|
17
25
|
/**
|
|
@@ -77,7 +85,7 @@ export declare function writeOwnerOnlyServiceManifest(filePath: string, content:
|
|
|
77
85
|
export declare function generateLaunchdPlist(oauthToken?: string | null): string;
|
|
78
86
|
/** Generate a Linux systemd user unit for auto-starting the daemon. */
|
|
79
87
|
export declare function generateSystemdUnit(oauthToken?: string | null): string;
|
|
80
|
-
export declare function getAgentsBinPath(): string;
|
|
88
|
+
export declare function getAgentsBinPath(argv1?: string | undefined, execPath?: string): string;
|
|
81
89
|
/** Start the daemon via launchd, systemd, or as a detached process. */
|
|
82
90
|
export declare function startDaemon(): {
|
|
83
91
|
pid: number | null;
|
|
@@ -128,6 +136,27 @@ export declare function getDaemonLaunch(agentsBin?: string): {
|
|
|
128
136
|
command: string;
|
|
129
137
|
args: string[];
|
|
130
138
|
};
|
|
139
|
+
/**
|
|
140
|
+
* Build the argv to relaunch the `agents` CLI with the given subcommand args.
|
|
141
|
+
*
|
|
142
|
+
* Resolves the real on-disk binary via getAgentsBinPath(), then dispatches: a
|
|
143
|
+
* `.js` entry runs under node (`node <entry> …`), a native/compiled binary runs
|
|
144
|
+
* directly (`<bin> …`).
|
|
145
|
+
*
|
|
146
|
+
* Callers MUST route self-spawns through this rather than hand-rolling
|
|
147
|
+
* `[process.execPath, process.argv[1], …]`: under the compiled standalone binary
|
|
148
|
+
* (#315) `process.argv[1]` is the bun virtual entry `/$bunfs/root/agents`, so the
|
|
149
|
+
* hand-rolled form becomes `agents /$bunfs/root/agents …` → the CLI receives the
|
|
150
|
+
* bunfs path as a subcommand and dies with "unknown command '/$bunfs/root/agents'".
|
|
151
|
+
* getAgentsBinPath() resolves that virtual entry to the physical process.execPath.
|
|
152
|
+
*/
|
|
153
|
+
export declare function getAgentsInvocation(subArgs: string[], agentsBin?: string): {
|
|
154
|
+
command: string;
|
|
155
|
+
args: string[];
|
|
156
|
+
};
|
|
157
|
+
export declare function validateDaemonBinary(binPath: string): {
|
|
158
|
+
warnings: string[];
|
|
159
|
+
};
|
|
131
160
|
interface StartDetachedOptions {
|
|
132
161
|
/** CLI entry to launch (defaults to the running binary). Injectable for tests. */
|
|
133
162
|
agentsBin?: string;
|
|
@@ -144,10 +173,13 @@ export declare function startDetached(opts?: StartDetachedOptions): {
|
|
|
144
173
|
export declare function stopDaemon(): boolean;
|
|
145
174
|
/** Get current daemon status including running state, PID, and enabled job count. */
|
|
146
175
|
export declare function getDaemonStatus(): {
|
|
176
|
+
state: 'running' | 'wedged' | 'stopped';
|
|
147
177
|
running: boolean;
|
|
148
178
|
pid: number | null;
|
|
149
179
|
jobCount: number;
|
|
150
180
|
logPath: string;
|
|
181
|
+
binaryPath: string | null;
|
|
182
|
+
heartbeat: DaemonHeartbeat | null;
|
|
151
183
|
};
|
|
152
184
|
/** Read the daemon log, optionally limited to the last N lines. */
|
|
153
185
|
export declare function readDaemonLog(lines?: number): string;
|
package/dist/lib/daemon.js
CHANGED
|
@@ -23,10 +23,13 @@ import { redactSecrets } from './redact.js';
|
|
|
23
23
|
const PID_FILE = 'daemon.pid';
|
|
24
24
|
const LOCK_FILE = 'daemon.lock';
|
|
25
25
|
const LOG_FILE = 'logs.jsonl';
|
|
26
|
+
const HEARTBEAT_FILE = 'heartbeat.json';
|
|
26
27
|
const LOG_MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
27
28
|
const LOG_ROTATE_COUNT = 3;
|
|
28
29
|
const PLIST_NAME = 'com.phnx-labs.agents-daemon';
|
|
29
30
|
const SYSTEMD_UNIT = 'agents-daemon.service';
|
|
31
|
+
const MONITOR_TICK_MS = 60_000;
|
|
32
|
+
const WEDGE_THRESHOLD_TICKS = 3;
|
|
30
33
|
// A long-lived `claude setup-token` value stored in this secrets bundle/key is
|
|
31
34
|
// baked into the daemon's service-manager environment so headless routine runs
|
|
32
35
|
// authenticate without depending on the short-lived interactive Keychain OAuth
|
|
@@ -118,6 +121,48 @@ export function removeDaemonPid() {
|
|
|
118
121
|
fs.unlinkSync(pidPath);
|
|
119
122
|
}
|
|
120
123
|
}
|
|
124
|
+
function getHeartbeatPath() {
|
|
125
|
+
return path.join(getDaemonDir(), HEARTBEAT_FILE);
|
|
126
|
+
}
|
|
127
|
+
export function writeHeartbeat(pid = process.pid) {
|
|
128
|
+
const hb = { lastTick: new Date().toISOString(), pid };
|
|
129
|
+
try {
|
|
130
|
+
fs.writeFileSync(getHeartbeatPath(), JSON.stringify(hb), 'utf-8');
|
|
131
|
+
}
|
|
132
|
+
catch { /* best effort */ }
|
|
133
|
+
}
|
|
134
|
+
export function readHeartbeat() {
|
|
135
|
+
try {
|
|
136
|
+
const raw = fs.readFileSync(getHeartbeatPath(), 'utf-8');
|
|
137
|
+
const hb = JSON.parse(raw);
|
|
138
|
+
if (!hb.lastTick || !hb.pid)
|
|
139
|
+
return null;
|
|
140
|
+
return hb;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export function removeHeartbeat() {
|
|
147
|
+
try {
|
|
148
|
+
fs.unlinkSync(getHeartbeatPath());
|
|
149
|
+
}
|
|
150
|
+
catch { /* already removed */ }
|
|
151
|
+
}
|
|
152
|
+
export function isDaemonWedged() {
|
|
153
|
+
const pid = readDaemonPid();
|
|
154
|
+
if (!pid)
|
|
155
|
+
return false;
|
|
156
|
+
if (!isAlive(pid))
|
|
157
|
+
return false;
|
|
158
|
+
const hb = readHeartbeat();
|
|
159
|
+
if (!hb)
|
|
160
|
+
return false;
|
|
161
|
+
if (hb.pid !== pid)
|
|
162
|
+
return false;
|
|
163
|
+
const elapsed = Date.now() - Date.parse(hb.lastTick);
|
|
164
|
+
return elapsed > WEDGE_THRESHOLD_TICKS * MONITOR_TICK_MS;
|
|
165
|
+
}
|
|
121
166
|
/** Check if the daemon process is alive by sending signal 0 to the stored PID. */
|
|
122
167
|
export function isDaemonRunning() {
|
|
123
168
|
const pid = readDaemonPid();
|
|
@@ -323,9 +368,11 @@ export async function runDaemon() {
|
|
|
323
368
|
catch (err) {
|
|
324
369
|
log('ERROR', `Browser IPC failed to start: ${err.message}`);
|
|
325
370
|
}
|
|
371
|
+
writeHeartbeat();
|
|
326
372
|
const monitorInterval = setInterval(() => {
|
|
373
|
+
writeHeartbeat();
|
|
327
374
|
monitorRunningJobs();
|
|
328
|
-
},
|
|
375
|
+
}, MONITOR_TICK_MS);
|
|
329
376
|
// Cross-machine session sync: push this machine's transcripts to R2 and pull
|
|
330
377
|
// every other machine's, ~every 90s. Skipped silently when the r2.backups
|
|
331
378
|
// bundle is absent. An overlap guard prevents a slow cycle from stacking.
|
|
@@ -548,6 +595,7 @@ export async function runDaemon() {
|
|
|
548
595
|
clearInterval(launchHealthInterval);
|
|
549
596
|
clearTimeout(launchHealthKickoff);
|
|
550
597
|
removeDaemonPid();
|
|
598
|
+
removeHeartbeat();
|
|
551
599
|
process.exit(0);
|
|
552
600
|
};
|
|
553
601
|
process.on('SIGHUP', handleReload);
|
|
@@ -605,6 +653,7 @@ export function writeOwnerOnlyServiceManifest(filePath, content) {
|
|
|
605
653
|
/** Generate a macOS launchd plist for auto-starting the daemon. */
|
|
606
654
|
export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken()) {
|
|
607
655
|
const agentsBin = getAgentsBinPath();
|
|
656
|
+
const launch = getDaemonLaunch(agentsBin);
|
|
608
657
|
const logPath = getLogPath();
|
|
609
658
|
const oauthEntry = oauthToken
|
|
610
659
|
? `
|
|
@@ -619,9 +668,7 @@ export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken())
|
|
|
619
668
|
<string>${PLIST_NAME}</string>
|
|
620
669
|
<key>ProgramArguments</key>
|
|
621
670
|
<array>
|
|
622
|
-
<string>${
|
|
623
|
-
<string>daemon</string>
|
|
624
|
-
<string>_run</string>
|
|
671
|
+
${[launch.command, ...launch.args].map((arg) => ` <string>${xmlEscape(arg)}</string>`).join('\n')}
|
|
625
672
|
</array>
|
|
626
673
|
<key>RunAtLoad</key>
|
|
627
674
|
<true/>
|
|
@@ -639,9 +686,15 @@ export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken())
|
|
|
639
686
|
</dict>
|
|
640
687
|
</plist>`;
|
|
641
688
|
}
|
|
689
|
+
/** Quote one systemd ExecStart argument without delegating parsing to a shell. */
|
|
690
|
+
function systemdExecArg(value) {
|
|
691
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
692
|
+
}
|
|
642
693
|
/** Generate a Linux systemd user unit for auto-starting the daemon. */
|
|
643
694
|
export function generateSystemdUnit(oauthToken = readDaemonClaudeOAuthToken()) {
|
|
644
695
|
const agentsBin = getAgentsBinPath();
|
|
696
|
+
const launch = getDaemonLaunch(agentsBin);
|
|
697
|
+
const execStart = [launch.command, ...launch.args].map(systemdExecArg).join(' ');
|
|
645
698
|
const oauthLine = oauthToken
|
|
646
699
|
? `\nEnvironment=${DAEMON_OAUTH_KEY}=${oauthToken}`
|
|
647
700
|
: '';
|
|
@@ -651,7 +704,7 @@ After=network.target
|
|
|
651
704
|
|
|
652
705
|
[Service]
|
|
653
706
|
Type=simple
|
|
654
|
-
ExecStart=${
|
|
707
|
+
ExecStart=${execStart}
|
|
655
708
|
Restart=always
|
|
656
709
|
RestartSec=10
|
|
657
710
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin${oauthLine}
|
|
@@ -659,28 +712,39 @@ Environment=PATH=/usr/local/bin:/usr/bin:/bin:${os.homedir()}/.nvm/versions/node
|
|
|
659
712
|
[Install]
|
|
660
713
|
WantedBy=default.target`;
|
|
661
714
|
}
|
|
662
|
-
|
|
715
|
+
const BUN_VIRTUAL_ROOT = /[/\\]\$bunfs[/\\]root[/\\]/;
|
|
716
|
+
function resolveBunStandaloneEntry(entry, execPath) {
|
|
717
|
+
if (!BUN_VIRTUAL_ROOT.test(entry))
|
|
718
|
+
return entry;
|
|
719
|
+
if (!execPath || BUN_VIRTUAL_ROOT.test(execPath) || !fs.existsSync(execPath)) {
|
|
720
|
+
throw new Error(`Cannot resolve agents CLI: Bun standalone executable not found at ${execPath || '(empty path)'}`);
|
|
721
|
+
}
|
|
722
|
+
return execPath;
|
|
723
|
+
}
|
|
724
|
+
export function getAgentsBinPath(argv1 = process.argv[1], execPath = process.execPath) {
|
|
663
725
|
// Prefer the binary actively executing this code. `which agents` returns
|
|
664
726
|
// whatever happens to be first on PATH, which means a side-by-side dev
|
|
665
727
|
// build at ~/.local/bin would silently spawn the registry-installed
|
|
666
|
-
// daemon and run stale code. process.argv[1] is the
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
728
|
+
// daemon and run stale code. For a JS install, process.argv[1] is the
|
|
729
|
+
// absolute entrypoint the user actually invoked. A Bun standalone instead
|
|
730
|
+
// exposes its embedded /$bunfs/root entry at argv[1] and its physical signed
|
|
731
|
+
// executable at process.execPath; Bun reports both as existing paths.
|
|
732
|
+
const runningEntry = argv1 ? resolveBunStandaloneEntry(argv1, execPath) : undefined;
|
|
733
|
+
if (runningEntry && fs.existsSync(runningEntry)) {
|
|
670
734
|
// The package's browser/computer entrypoints are sibling shims without a
|
|
671
735
|
// `daemon` command. A daemon started as their IPC side effect must launch
|
|
672
736
|
// through the main agents entrypoint instead of replaying the shim path.
|
|
673
|
-
const entryName = path.basename(
|
|
737
|
+
const entryName = path.basename(runningEntry);
|
|
674
738
|
const compiledShim = /^(browser|computer)\.(c|m)?js$/.test(entryName);
|
|
675
739
|
const installedShim = /^(browser|computer)$/.test(entryName);
|
|
676
740
|
if (compiledShim || installedShim) {
|
|
677
|
-
const agentsEntry = path.join(path.dirname(
|
|
741
|
+
const agentsEntry = path.join(path.dirname(runningEntry), compiledShim ? 'index.js' : 'agents');
|
|
678
742
|
if (!fs.existsSync(agentsEntry)) {
|
|
679
743
|
throw new Error(`Cannot start agents daemon: main CLI entry not found at ${agentsEntry}`);
|
|
680
744
|
}
|
|
681
745
|
return agentsEntry;
|
|
682
746
|
}
|
|
683
|
-
return
|
|
747
|
+
return runningEntry;
|
|
684
748
|
}
|
|
685
749
|
try {
|
|
686
750
|
return execFileSync('which', ['agents'], { encoding: 'utf-8' }).trim();
|
|
@@ -842,11 +906,50 @@ export function buildDetachedDaemonEnv(baseEnv = process.env, oauthToken = readD
|
|
|
842
906
|
* `which agents`), run it directly — it owns its own runtime resolution.
|
|
843
907
|
*/
|
|
844
908
|
export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
|
|
909
|
+
const { warnings } = validateDaemonBinary(agentsBin);
|
|
910
|
+
for (const w of warnings)
|
|
911
|
+
process.stderr.write(`[agents] ${w}\n`);
|
|
845
912
|
if (/\.(c|m)?js$/.test(agentsBin)) {
|
|
846
913
|
return { command: process.execPath, args: [agentsBin, 'daemon', '_run'] };
|
|
847
914
|
}
|
|
848
915
|
return { command: agentsBin, args: ['daemon', '_run'] };
|
|
849
916
|
}
|
|
917
|
+
/**
|
|
918
|
+
* Build the argv to relaunch the `agents` CLI with the given subcommand args.
|
|
919
|
+
*
|
|
920
|
+
* Resolves the real on-disk binary via getAgentsBinPath(), then dispatches: a
|
|
921
|
+
* `.js` entry runs under node (`node <entry> …`), a native/compiled binary runs
|
|
922
|
+
* directly (`<bin> …`).
|
|
923
|
+
*
|
|
924
|
+
* Callers MUST route self-spawns through this rather than hand-rolling
|
|
925
|
+
* `[process.execPath, process.argv[1], …]`: under the compiled standalone binary
|
|
926
|
+
* (#315) `process.argv[1]` is the bun virtual entry `/$bunfs/root/agents`, so the
|
|
927
|
+
* hand-rolled form becomes `agents /$bunfs/root/agents …` → the CLI receives the
|
|
928
|
+
* bunfs path as a subcommand and dies with "unknown command '/$bunfs/root/agents'".
|
|
929
|
+
* getAgentsBinPath() resolves that virtual entry to the physical process.execPath.
|
|
930
|
+
*/
|
|
931
|
+
export function getAgentsInvocation(subArgs, agentsBin = getAgentsBinPath()) {
|
|
932
|
+
const resolvedBin = resolveBunStandaloneEntry(agentsBin, process.execPath);
|
|
933
|
+
if (/\.(c|m)?js$/.test(resolvedBin)) {
|
|
934
|
+
return { command: process.execPath, args: [resolvedBin, ...subArgs] };
|
|
935
|
+
}
|
|
936
|
+
return { command: resolvedBin, args: subArgs };
|
|
937
|
+
}
|
|
938
|
+
export function validateDaemonBinary(binPath) {
|
|
939
|
+
const warnings = [];
|
|
940
|
+
if (BUN_VIRTUAL_ROOT.test(binPath)) {
|
|
941
|
+
throw new Error(`Refusing to supervise daemon: resolved binary is a bun virtual path (${binPath}). ` +
|
|
942
|
+
`Install agents globally (npm i -g @phnx-labs/agents-cli) and restart.`);
|
|
943
|
+
}
|
|
944
|
+
if (/[/\\]\.agents[/\\]worktrees[/\\]/.test(binPath)) {
|
|
945
|
+
warnings.push(`Warning: daemon binary is inside a git worktree (${binPath}). ` +
|
|
946
|
+
`A worktree deletion will wedge the daemon. Use the globally installed binary instead.`);
|
|
947
|
+
}
|
|
948
|
+
if (!fs.existsSync(binPath) && !/\.(c|m)?js$/.test(binPath)) {
|
|
949
|
+
warnings.push(`Warning: daemon binary does not exist on disk (${binPath}).`);
|
|
950
|
+
}
|
|
951
|
+
return { warnings };
|
|
952
|
+
}
|
|
850
953
|
export function startDetached(opts = {}) {
|
|
851
954
|
const agentsBin = opts.agentsBin ?? getAgentsBinPath();
|
|
852
955
|
const logPath = opts.logPath ?? getLogPath();
|
|
@@ -948,13 +1051,27 @@ export function stopDaemon() {
|
|
|
948
1051
|
/** Get current daemon status including running state, PID, and enabled job count. */
|
|
949
1052
|
export function getDaemonStatus() {
|
|
950
1053
|
const running = isDaemonRunning();
|
|
1054
|
+
const wedged = running && isDaemonWedged();
|
|
951
1055
|
const pid = readDaemonPid();
|
|
952
1056
|
let jobCount = 0;
|
|
953
1057
|
try {
|
|
954
1058
|
jobCount = listAllJobs().filter((j) => j.enabled).length;
|
|
955
1059
|
}
|
|
956
1060
|
catch { /* job listing failed */ }
|
|
957
|
-
|
|
1061
|
+
let binaryPath = null;
|
|
1062
|
+
try {
|
|
1063
|
+
binaryPath = getAgentsBinPath();
|
|
1064
|
+
}
|
|
1065
|
+
catch { /* resolution failed */ }
|
|
1066
|
+
return {
|
|
1067
|
+
state: wedged ? 'wedged' : running ? 'running' : 'stopped',
|
|
1068
|
+
running,
|
|
1069
|
+
pid,
|
|
1070
|
+
jobCount,
|
|
1071
|
+
logPath: getLogPath(),
|
|
1072
|
+
binaryPath,
|
|
1073
|
+
heartbeat: readHeartbeat(),
|
|
1074
|
+
};
|
|
958
1075
|
}
|
|
959
1076
|
/** Read the daemon log, optionally limited to the last N lines. */
|
|
960
1077
|
export function readDaemonLog(lines) {
|
|
@@ -11,6 +11,13 @@ import type { PendingDevice } from './pending.js';
|
|
|
11
11
|
* the wrong account). Returns undefined when the username isn't a safe ssh
|
|
12
12
|
* identifier, so a weird value never lands in the registry. */
|
|
13
13
|
export declare function localLoginUser(): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Reduce a raw OS username to a safe ssh account, or undefined. Windows reports
|
|
16
|
+
* the login as `COMPUTER\user` / `DOMAIN\user`; the ssh account is the bare name
|
|
17
|
+
* after the backslash — without this strip the `\` fails the charset guard and
|
|
18
|
+
* Windows boxes never pin a user. Pure, so the platform-specific munging is
|
|
19
|
+
* unit-tested without reading the real OS user. */
|
|
20
|
+
export declare function sanitizeLoginUser(raw: string | undefined): string | undefined;
|
|
14
21
|
/**
|
|
15
22
|
* Fill in a device's login user during sync WITHOUT ever clobbering an account
|
|
16
23
|
* the user pinned. Precedence: an existing registered user wins; else the local
|
package/dist/lib/devices/sync.js
CHANGED
|
@@ -34,7 +34,19 @@ export function localLoginUser() {
|
|
|
34
34
|
catch {
|
|
35
35
|
u = process.env.USER || process.env.USERNAME || undefined;
|
|
36
36
|
}
|
|
37
|
-
return
|
|
37
|
+
return sanitizeLoginUser(u);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Reduce a raw OS username to a safe ssh account, or undefined. Windows reports
|
|
41
|
+
* the login as `COMPUTER\user` / `DOMAIN\user`; the ssh account is the bare name
|
|
42
|
+
* after the backslash — without this strip the `\` fails the charset guard and
|
|
43
|
+
* Windows boxes never pin a user. Pure, so the platform-specific munging is
|
|
44
|
+
* unit-tested without reading the real OS user. */
|
|
45
|
+
export function sanitizeLoginUser(raw) {
|
|
46
|
+
if (!raw)
|
|
47
|
+
return undefined;
|
|
48
|
+
const bare = raw.includes('\\') ? raw.slice(raw.lastIndexOf('\\') + 1) : raw;
|
|
49
|
+
return /^[a-zA-Z0-9._-]+$/.test(bare) ? bare : undefined;
|
|
38
50
|
}
|
|
39
51
|
/**
|
|
40
52
|
* Fill in a device's login user during sync WITHOUT ever clobbering an account
|
package/dist/lib/exec.js
CHANGED
|
@@ -927,14 +927,16 @@ async function runInTmux(options, executable, args) {
|
|
|
927
927
|
// When the AGENT pane dies, detach the client (don't kill) so the session
|
|
928
928
|
// survives just long enough to read the dead pane's exit status below. The
|
|
929
929
|
// `#{hook_pane}` guard scopes this to the agent pane only: if the user splits
|
|
930
|
-
// the window and exits one of THEIR panes, the else-branch
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
//
|
|
937
|
-
|
|
930
|
+
// the window and exits one of THEIR panes, the else-branch closes that split
|
|
931
|
+
// in place instead of detaching everyone (`run-shell -C` executes the
|
|
932
|
+
// targeted kill inside tmux's server command queue, avoiding a second
|
|
933
|
+
// client racing the same socket under load, #965). Without the guard,
|
|
934
|
+
// exiting any split kicked the user clean out of tmux.
|
|
935
|
+
const hookInstalled = await setSessionHook(name, 'pane-died', agentPaneDiedHook(name, pane), socket);
|
|
936
|
+
// Stamp the schema marker only after tmux accepted the hook. A failed
|
|
937
|
+
// install stays unmarked so daemon reconciliation retries it later.
|
|
938
|
+
if (hookInstalled)
|
|
939
|
+
await markSessionHookSchema(name, socket);
|
|
938
940
|
// Record the agent's OS pid (the pane leaf, thanks to `exec`) WITH its tmux
|
|
939
941
|
// pane so the active-scan attributes it exactly and shows the %pane.
|
|
940
942
|
let panePid = 0;
|
|
@@ -34,6 +34,7 @@ const REMOTE_PASSTHROUGH = {
|
|
|
34
34
|
sync: { nonInteractive: ['--yes'] },
|
|
35
35
|
teams: {},
|
|
36
36
|
message: {},
|
|
37
|
+
routines: {},
|
|
37
38
|
};
|
|
38
39
|
/** `--no-tty` is stripped like the routing flags but carries no value. */
|
|
39
40
|
const STRIP_SPECS = [...HOST_ROUTING_SPECS, { long: 'no-tty', takesValue: false }];
|
|
@@ -115,10 +116,13 @@ export async function maybeRunOnHost(command, allArgs) {
|
|
|
115
116
|
process.exitCode = 1;
|
|
116
117
|
return true;
|
|
117
118
|
}
|
|
118
|
-
// `--
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
// `--hosts` is always a generic fleet flag — bail for every command so the
|
|
120
|
+
// local aggregator handles it. `--devices` is fan-out on most commands but
|
|
121
|
+
// a placement flag on `routines` (which devices may run the routine), so
|
|
122
|
+
// only exempt routines from the bail.
|
|
123
|
+
if (allArgs.includes('--hosts'))
|
|
124
|
+
return false;
|
|
125
|
+
if (allArgs.includes('--devices') && command !== 'routines')
|
|
122
126
|
return false;
|
|
123
127
|
const hostName = hostFlag ?? deviceFlag;
|
|
124
128
|
if (!hostName)
|
|
Binary file
|
package/dist/lib/migrate.d.ts
CHANGED
|
@@ -96,5 +96,14 @@ export declare function repairSelfReferentialBinShims(versionsRoot?: string, shi
|
|
|
96
96
|
* the new name.
|
|
97
97
|
*/
|
|
98
98
|
export declare function migrateExtrasExtrasToAgentsExtras(historyDir?: string): void;
|
|
99
|
+
/**
|
|
100
|
+
* Rewrite every routine YAML that carries the legacy singular `device: <value>`
|
|
101
|
+
* field to the new plural `devices: [<value>]` format. Preserves all other
|
|
102
|
+
* fields. Idempotent: a routine that already has `devices:` (or neither field)
|
|
103
|
+
* is left untouched.
|
|
104
|
+
*
|
|
105
|
+
* Params default to the real routines dir; injectable for tests.
|
|
106
|
+
*/
|
|
107
|
+
export declare function migrateRoutineDeviceToDevices(routinesDir?: string): void;
|
|
99
108
|
/** Run all idempotent migrations. Safe to call multiple times. */
|
|
100
109
|
export declare function runMigration(): Promise<void>;
|
package/dist/lib/migrate.js
CHANGED
|
@@ -1885,6 +1885,52 @@ export function migrateExtrasExtrasToAgentsExtras(historyDir = HISTORY_DIR) {
|
|
|
1885
1885
|
console.error(`Renamed extras-extras → agents-extras (dirs: ${renamedDirs}, known_marketplaces: ${rewroteKnown}, settings: ${rewroteSettings})`);
|
|
1886
1886
|
}
|
|
1887
1887
|
}
|
|
1888
|
+
/**
|
|
1889
|
+
* Rewrite every routine YAML that carries the legacy singular `device: <value>`
|
|
1890
|
+
* field to the new plural `devices: [<value>]` format. Preserves all other
|
|
1891
|
+
* fields. Idempotent: a routine that already has `devices:` (or neither field)
|
|
1892
|
+
* is left untouched.
|
|
1893
|
+
*
|
|
1894
|
+
* Params default to the real routines dir; injectable for tests.
|
|
1895
|
+
*/
|
|
1896
|
+
export function migrateRoutineDeviceToDevices(routinesDir) {
|
|
1897
|
+
const dir = routinesDir ?? path.join(USER_DIR, 'routines');
|
|
1898
|
+
if (!fs.existsSync(dir))
|
|
1899
|
+
return;
|
|
1900
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
1901
|
+
let migrated = 0;
|
|
1902
|
+
for (const file of files) {
|
|
1903
|
+
const filePath = path.join(dir, file);
|
|
1904
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
1905
|
+
let doc;
|
|
1906
|
+
try {
|
|
1907
|
+
doc = yaml.parse(raw);
|
|
1908
|
+
if (!doc || typeof doc !== 'object')
|
|
1909
|
+
continue;
|
|
1910
|
+
}
|
|
1911
|
+
catch {
|
|
1912
|
+
continue;
|
|
1913
|
+
}
|
|
1914
|
+
if (!('device' in doc))
|
|
1915
|
+
continue;
|
|
1916
|
+
if ('devices' in doc) {
|
|
1917
|
+
delete doc.device;
|
|
1918
|
+
atomicWriteFileSync(filePath, yaml.stringify(doc));
|
|
1919
|
+
continue;
|
|
1920
|
+
}
|
|
1921
|
+
const val = doc.device;
|
|
1922
|
+
if (typeof val !== 'string' || !val.trim()) {
|
|
1923
|
+
throw new Error(`${file}: legacy 'device' field is not a valid device name — repair the file and retry`);
|
|
1924
|
+
}
|
|
1925
|
+
delete doc.device;
|
|
1926
|
+
doc.devices = [val.trim()];
|
|
1927
|
+
atomicWriteFileSync(filePath, yaml.stringify(doc));
|
|
1928
|
+
migrated++;
|
|
1929
|
+
}
|
|
1930
|
+
if (migrated > 0) {
|
|
1931
|
+
console.error(`Migrated ${migrated} routine${migrated === 1 ? '' : 's'}: device → devices`);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1888
1934
|
/** Run all idempotent migrations. Safe to call multiple times. */
|
|
1889
1935
|
export async function runMigration() {
|
|
1890
1936
|
// MUST run first: every other migrator reads SYSTEM_DIR (the new path).
|
|
@@ -1937,6 +1983,8 @@ export async function runMigration() {
|
|
|
1937
1983
|
// installed version-home. Runs after migrateRuntimeToHistory so the version
|
|
1938
1984
|
// homes are at their canonical HISTORY_DIR location.
|
|
1939
1985
|
migrateExtrasExtrasToAgentsExtras();
|
|
1986
|
+
// Rewrite routine YAML files: singular `device:` -> plural `devices: []`.
|
|
1987
|
+
migrateRoutineDeviceToDevices();
|
|
1940
1988
|
// Symlink repair runs LAST so it can find the post-move version homes.
|
|
1941
1989
|
repairAgentConfigSymlinks();
|
|
1942
1990
|
// Repair self-referential node_modules/.bin/<cli> symlinks (the droid
|
package/dist/lib/overdue.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { Cron } from 'croner';
|
|
15
15
|
import * as os from 'os';
|
|
16
16
|
import { spawn } from 'child_process';
|
|
17
|
-
import { listJobs, getLatestRun } from './routines.js';
|
|
17
|
+
import { listJobs, getLatestRun, jobRunsOnThisDevice } from './routines.js';
|
|
18
18
|
// Tolerance between "expected fire" and "recorded run start" — accounts for
|
|
19
19
|
// the small gap between the cron tick and when the runner writes meta.json.
|
|
20
20
|
const GRACE_MS = 60_000;
|
|
@@ -47,6 +47,11 @@ export function detectOverdueJobs(now = new Date()) {
|
|
|
47
47
|
// Trigger-only jobs (no cron schedule) never have an expected fire time.
|
|
48
48
|
if (!job.schedule)
|
|
49
49
|
continue;
|
|
50
|
+
// A job pinned to another device is that device's to run, notify, and
|
|
51
|
+
// catch up — flagging it here would make every machine in the fleet nag
|
|
52
|
+
// (and `catchup` fire) for a job that must not run locally.
|
|
53
|
+
if (!jobRunsOnThisDevice(job))
|
|
54
|
+
continue;
|
|
50
55
|
let expected = null;
|
|
51
56
|
try {
|
|
52
57
|
const cronOptions = { paused: true };
|
|
@@ -88,6 +88,58 @@ export const PRESETS = [
|
|
|
88
88
|
ANTHROPIC_SMALL_FAST_MODEL: 'deepseek/deepseek-chat-v3-0324',
|
|
89
89
|
},
|
|
90
90
|
},
|
|
91
|
+
{
|
|
92
|
+
name: 'open-claude',
|
|
93
|
+
description: 'Open-weight coding via OpenRouter inside Claude Code (Qwen3 Coder Next, 256K ctx, $0.15/$0.80 per 1M). HEADLESS-SAFE — best general preset for open-claude usage with Claude Code harness.',
|
|
94
|
+
...OPENROUTER_AUTH,
|
|
95
|
+
env: {
|
|
96
|
+
ANTHROPIC_BASE_URL: OPENROUTER_BASE,
|
|
97
|
+
ANTHROPIC_MODEL: 'qwen/qwen3-coder-next',
|
|
98
|
+
ANTHROPIC_SMALL_FAST_MODEL: 'qwen/qwen3-coder-next',
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'claude-spark',
|
|
103
|
+
description: 'Meta Claude Spark 1.1 via OpenRouter inside Claude Code (open alternative). Model: meta/claude-spark-1.1 — free via opencode, now usable in Claude Code UI. HEADLESS-SAFE. For open-claude spark usage.',
|
|
104
|
+
...OPENROUTER_AUTH,
|
|
105
|
+
env: {
|
|
106
|
+
ANTHROPIC_BASE_URL: OPENROUTER_BASE,
|
|
107
|
+
ANTHROPIC_MODEL: 'meta/claude-spark-1.1',
|
|
108
|
+
ANTHROPIC_SMALL_FAST_MODEL: 'meta/claude-spark-1.1',
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
// ----- OpenCode CLI (open-claude harness) -----
|
|
112
|
+
{
|
|
113
|
+
name: 'opencode',
|
|
114
|
+
description: 'OpenCode default — uses your configured model via opencode auth. Run `opencode auth` to login, then `agents run opencode --model meta/claude-spark-1.1 "prompt"` for spark usage.',
|
|
115
|
+
provider: 'opencode',
|
|
116
|
+
host: 'opencode',
|
|
117
|
+
authEnvVar: 'OPENCODE_API_KEY',
|
|
118
|
+
authOptional: true,
|
|
119
|
+
env: {},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'opencode-spark',
|
|
123
|
+
description: 'Meta Claude Spark 1.1 via OpenCode (free, headless-safe). Pinned to meta/claude-spark-1.1 — best for open-claude usage with opencode harness.',
|
|
124
|
+
provider: 'opencode',
|
|
125
|
+
host: 'opencode',
|
|
126
|
+
authEnvVar: 'OPENCODE_API_KEY',
|
|
127
|
+
authOptional: true,
|
|
128
|
+
env: {
|
|
129
|
+
OPENCODE_MODEL: 'meta/claude-spark-1.1',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: 'opencode-qwen',
|
|
134
|
+
description: 'Qwen3 Coder Next via OpenCode (open-claude path). Use `agents run opencode-qwen "prompt"` — free via opencode provider.',
|
|
135
|
+
provider: 'opencode',
|
|
136
|
+
host: 'opencode',
|
|
137
|
+
authEnvVar: 'OPENCODE_API_KEY',
|
|
138
|
+
authOptional: true,
|
|
139
|
+
env: {
|
|
140
|
+
OPENCODE_MODEL: 'qwen/qwen3-coder-next',
|
|
141
|
+
},
|
|
142
|
+
},
|
|
91
143
|
// ----- xAI Grok Build CLI (native host) -----
|
|
92
144
|
{
|
|
93
145
|
name: 'grok-fast',
|