@phnx-labs/agents-cli 1.20.53 → 1.20.55
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 +18 -0
- package/README.md +6 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/packages.js +113 -2
- package/dist/commands/routines.js +55 -15
- package/dist/commands/sessions.js +5 -3
- package/dist/commands/ssh.js +19 -14
- package/dist/commands/tmux.js +11 -3
- package/dist/lib/daemon.d.ts +14 -0
- package/dist/lib/daemon.js +91 -6
- package/dist/lib/devices/resolve-target.d.ts +21 -4
- package/dist/lib/devices/resolve-target.js +82 -14
- package/dist/lib/devices/sync.d.ts +25 -0
- package/dist/lib/devices/sync.js +46 -1
- package/dist/lib/exec.js +10 -8
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -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 +18 -0
- package/dist/lib/routines.js +19 -2
- package/dist/lib/runner.js +52 -8
- package/dist/lib/scheduler.js +4 -3
- package/dist/lib/session/remote.d.ts +13 -0
- package/dist/lib/session/remote.js +27 -4
- 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/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}
|
|
@@ -842,11 +895,29 @@ export function buildDetachedDaemonEnv(baseEnv = process.env, oauthToken = readD
|
|
|
842
895
|
* `which agents`), run it directly — it owns its own runtime resolution.
|
|
843
896
|
*/
|
|
844
897
|
export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
|
|
898
|
+
const { warnings } = validateDaemonBinary(agentsBin);
|
|
899
|
+
for (const w of warnings)
|
|
900
|
+
process.stderr.write(`[agents] ${w}\n`);
|
|
845
901
|
if (/\.(c|m)?js$/.test(agentsBin)) {
|
|
846
902
|
return { command: process.execPath, args: [agentsBin, 'daemon', '_run'] };
|
|
847
903
|
}
|
|
848
904
|
return { command: agentsBin, args: ['daemon', '_run'] };
|
|
849
905
|
}
|
|
906
|
+
export function validateDaemonBinary(binPath) {
|
|
907
|
+
const warnings = [];
|
|
908
|
+
if (/\/\$bunfs\/root\//.test(binPath)) {
|
|
909
|
+
throw new Error(`Refusing to supervise daemon: resolved binary is a bun virtual path (${binPath}). ` +
|
|
910
|
+
`Install agents globally (npm i -g @phnx-labs/agents-cli) and restart.`);
|
|
911
|
+
}
|
|
912
|
+
if (/[/\\]\.agents[/\\]worktrees[/\\]/.test(binPath)) {
|
|
913
|
+
warnings.push(`Warning: daemon binary is inside a git worktree (${binPath}). ` +
|
|
914
|
+
`A worktree deletion will wedge the daemon. Use the globally installed binary instead.`);
|
|
915
|
+
}
|
|
916
|
+
if (!fs.existsSync(binPath) && !/\.(c|m)?js$/.test(binPath)) {
|
|
917
|
+
warnings.push(`Warning: daemon binary does not exist on disk (${binPath}).`);
|
|
918
|
+
}
|
|
919
|
+
return { warnings };
|
|
920
|
+
}
|
|
850
921
|
export function startDetached(opts = {}) {
|
|
851
922
|
const agentsBin = opts.agentsBin ?? getAgentsBinPath();
|
|
852
923
|
const logPath = opts.logPath ?? getLogPath();
|
|
@@ -948,13 +1019,27 @@ export function stopDaemon() {
|
|
|
948
1019
|
/** Get current daemon status including running state, PID, and enabled job count. */
|
|
949
1020
|
export function getDaemonStatus() {
|
|
950
1021
|
const running = isDaemonRunning();
|
|
1022
|
+
const wedged = running && isDaemonWedged();
|
|
951
1023
|
const pid = readDaemonPid();
|
|
952
1024
|
let jobCount = 0;
|
|
953
1025
|
try {
|
|
954
1026
|
jobCount = listAllJobs().filter((j) => j.enabled).length;
|
|
955
1027
|
}
|
|
956
1028
|
catch { /* job listing failed */ }
|
|
957
|
-
|
|
1029
|
+
let binaryPath = null;
|
|
1030
|
+
try {
|
|
1031
|
+
binaryPath = getAgentsBinPath();
|
|
1032
|
+
}
|
|
1033
|
+
catch { /* resolution failed */ }
|
|
1034
|
+
return {
|
|
1035
|
+
state: wedged ? 'wedged' : running ? 'running' : 'stopped',
|
|
1036
|
+
running,
|
|
1037
|
+
pid,
|
|
1038
|
+
jobCount,
|
|
1039
|
+
logPath: getLogPath(),
|
|
1040
|
+
binaryPath,
|
|
1041
|
+
heartbeat: readHeartbeat(),
|
|
1042
|
+
};
|
|
958
1043
|
}
|
|
959
1044
|
/** Read the daemon log, optionally limited to the last N lines. */
|
|
960
1045
|
export function readDaemonLog(lines) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type DeviceRegistry } from './registry.js';
|
|
1
|
+
import { type DeviceProfile, type DeviceRegistry } from './registry.js';
|
|
2
2
|
/** A dialable peer: the ssh target, the machine id used to tag its rows, a
|
|
3
3
|
* display name, and the OS family that picks the remote shell dialect. */
|
|
4
4
|
export interface ResolvedSshTarget {
|
|
@@ -7,14 +7,31 @@ export interface ResolvedSshTarget {
|
|
|
7
7
|
name: string;
|
|
8
8
|
os?: string;
|
|
9
9
|
}
|
|
10
|
+
/** Split a `user@host` / `host` token into its login user (if any) and host part. */
|
|
11
|
+
export declare function splitUserHost(token: string): {
|
|
12
|
+
user?: string;
|
|
13
|
+
host: string;
|
|
14
|
+
};
|
|
10
15
|
/**
|
|
11
16
|
* Resolve one `--host`/`--device` token to a concrete ssh target through the
|
|
12
17
|
* registry. Registry hit → the device's real address + platform (so the machine
|
|
13
|
-
* id, route, and OS all match the auto-discovery sweep)
|
|
14
|
-
* `user@host` fallback, its OS
|
|
15
|
-
*
|
|
18
|
+
* id, route, and OS all match the auto-discovery sweep), with any `user@`
|
|
19
|
+
* overriding the login account. Miss → a literal `user@host` fallback, its OS
|
|
20
|
+
* taken from the host overlay if enrolled. Returns undefined only when the token
|
|
21
|
+
* fails the shared ssh-target injection guard.
|
|
16
22
|
*/
|
|
17
23
|
export declare function resolveSshTarget(token: string, reg: DeviceRegistry): ResolvedSshTarget | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a target token to a full {@link DeviceProfile} for `agents ssh`. Same
|
|
26
|
+
* grammar as {@link resolveSshTarget}, but returns the whole profile (auth,
|
|
27
|
+
* shell, tailscale metadata) `buildSshInvocation` needs — not just a target
|
|
28
|
+
* string. A registered `name` or `user@device` yields that profile (with the
|
|
29
|
+
* login user overridden by any `user@`); an ad-hoc `user@host`/`host` literal
|
|
30
|
+
* yields a synthesized key-auth profile. A bare unregistered alias (no `@`/dot)
|
|
31
|
+
* returns undefined so the caller reports "Unknown device" rather than dialing a
|
|
32
|
+
* literal — the strict behaviour the interactive wrapper has always had.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveDeviceTarget(token: string, reg: DeviceRegistry): DeviceProfile | undefined;
|
|
18
35
|
/**
|
|
19
36
|
* Resolve an explicit `--host`/`--device` list to dialable targets, reading the
|
|
20
37
|
* registry once. A token that fails the injection guard is skipped with a
|
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
* `%C` hash → a cold dial every time) and could read a perfectly reachable box
|
|
14
14
|
* as "unreachable" when only the non-Tailscale route was down.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* The grammar is uniform across the fleet: `mac-mini` (device name), and
|
|
17
|
+
* `muqsit@mac-mini` (same device, login user overridden) both resolve through
|
|
18
|
+
* the registry to the device's Tailscale route — the `user@` form no longer
|
|
19
|
+
* short-circuits to a bare `ssh muqsit@mac-mini` (LAN DNS). A `user@host` that
|
|
20
|
+
* matches no registered device falls back to a literal target so ad-hoc boxes
|
|
21
|
+
* still work.
|
|
18
22
|
*/
|
|
19
23
|
import chalk from 'chalk';
|
|
20
24
|
import { assertValidSshTarget } from '../ssh-exec.js';
|
|
@@ -22,12 +26,28 @@ import { normalizeHost } from '../machine-id.js';
|
|
|
22
26
|
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
23
27
|
import { sshTargetFor } from './connect.js';
|
|
24
28
|
import { loadDevices } from './registry.js';
|
|
29
|
+
/** Split a `user@host` / `host` token into its login user (if any) and host part. */
|
|
30
|
+
export function splitUserHost(token) {
|
|
31
|
+
const at = token.indexOf('@');
|
|
32
|
+
return at === -1 ? { host: token } : { user: token.slice(0, at), host: token.slice(at + 1) };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Match a host part (the piece after any `user@`) to a registered device: exact
|
|
36
|
+
* registry key first, then a normalized-host match so `yosemite-s0` and
|
|
37
|
+
* `yosemite-s0.<tailnet>.ts.net` land on the same profile. The single source of
|
|
38
|
+
* truth both `resolveSshTarget` (fan-out) and `resolveDeviceTarget` (`agents
|
|
39
|
+
* ssh`) share, so a `user@device` can never resolve two different routes.
|
|
40
|
+
*/
|
|
41
|
+
function matchDevice(host, reg) {
|
|
42
|
+
return reg[host] ?? Object.values(reg).find((d) => normalizeHost(d.name) === normalizeHost(host));
|
|
43
|
+
}
|
|
25
44
|
/**
|
|
26
45
|
* Resolve one `--host`/`--device` token to a concrete ssh target through the
|
|
27
46
|
* registry. Registry hit → the device's real address + platform (so the machine
|
|
28
|
-
* id, route, and OS all match the auto-discovery sweep)
|
|
29
|
-
* `user@host` fallback, its OS
|
|
30
|
-
*
|
|
47
|
+
* id, route, and OS all match the auto-discovery sweep), with any `user@`
|
|
48
|
+
* overriding the login account. Miss → a literal `user@host` fallback, its OS
|
|
49
|
+
* taken from the host overlay if enrolled. Returns undefined only when the token
|
|
50
|
+
* fails the shared ssh-target injection guard.
|
|
31
51
|
*/
|
|
32
52
|
export function resolveSshTarget(token, reg) {
|
|
33
53
|
try {
|
|
@@ -36,22 +56,70 @@ export function resolveSshTarget(token, reg) {
|
|
|
36
56
|
catch {
|
|
37
57
|
return undefined;
|
|
38
58
|
}
|
|
39
|
-
const
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
const device =
|
|
44
|
-
? undefined
|
|
45
|
-
: reg[token] ?? Object.values(reg).find((d) => normalizeHost(d.name) === normalizeHost(bare));
|
|
59
|
+
const { user, host } = splitUserHost(token);
|
|
60
|
+
// A device and a `user@device` are the same box; resolve the host part through
|
|
61
|
+
// the registry so both dial the Tailscale route, and let an explicit `user@`
|
|
62
|
+
// override only the login account.
|
|
63
|
+
const device = matchDevice(host, reg);
|
|
46
64
|
if (device) {
|
|
47
65
|
try {
|
|
48
|
-
|
|
66
|
+
const effective = user ? { ...device, user } : device;
|
|
67
|
+
return { target: sshTargetFor(effective), machine: normalizeHost(device.name), name: device.name, os: device.platform };
|
|
49
68
|
}
|
|
50
69
|
catch {
|
|
51
70
|
// Registered but has no address to dial — fall through to the literal token.
|
|
52
71
|
}
|
|
53
72
|
}
|
|
54
|
-
return { target: token, machine: normalizeHost(
|
|
73
|
+
return { target: token, machine: normalizeHost(host), name: token, os: resolveRemoteOsSync(token) };
|
|
74
|
+
}
|
|
75
|
+
/** Timestamps for a synthesized ad-hoc profile — never persisted, so a constant
|
|
76
|
+
* keeps the value deterministic (and side-effect free) without reading the clock. */
|
|
77
|
+
const SYNTH_TS = '1970-01-01T00:00:00.000Z';
|
|
78
|
+
/** True when a token is clearly a network target (a `user@`, or a dotted/IPv6
|
|
79
|
+
* host / IP) rather than a bare alias. A bare unknown word is a typo, so `agents
|
|
80
|
+
* ssh foo` still says "Unknown device" instead of dialing a literal `foo`. */
|
|
81
|
+
function looksLikeHostLiteral(token) {
|
|
82
|
+
return token.includes('@') || token.includes('.') || token.includes(':');
|
|
83
|
+
}
|
|
84
|
+
/** Synthesize a throwaway device profile for an ad-hoc `user@host` / `host`
|
|
85
|
+
* literal so `agents ssh` can dial a box that was never registered. */
|
|
86
|
+
function adHocDevice(token, host, user) {
|
|
87
|
+
const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
|
|
88
|
+
return {
|
|
89
|
+
name: token,
|
|
90
|
+
platform: 'unknown',
|
|
91
|
+
shell: 'posix',
|
|
92
|
+
user,
|
|
93
|
+
address: { via: 'manual', dnsName: isIp ? undefined : host, ip: isIp ? host : undefined },
|
|
94
|
+
auth: { method: 'key' },
|
|
95
|
+
createdAt: SYNTH_TS,
|
|
96
|
+
updatedAt: SYNTH_TS,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Resolve a target token to a full {@link DeviceProfile} for `agents ssh`. Same
|
|
101
|
+
* grammar as {@link resolveSshTarget}, but returns the whole profile (auth,
|
|
102
|
+
* shell, tailscale metadata) `buildSshInvocation` needs — not just a target
|
|
103
|
+
* string. A registered `name` or `user@device` yields that profile (with the
|
|
104
|
+
* login user overridden by any `user@`); an ad-hoc `user@host`/`host` literal
|
|
105
|
+
* yields a synthesized key-auth profile. A bare unregistered alias (no `@`/dot)
|
|
106
|
+
* returns undefined so the caller reports "Unknown device" rather than dialing a
|
|
107
|
+
* literal — the strict behaviour the interactive wrapper has always had.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveDeviceTarget(token, reg) {
|
|
110
|
+
try {
|
|
111
|
+
assertValidSshTarget(token);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
const { user, host } = splitUserHost(token);
|
|
117
|
+
const device = matchDevice(host, reg);
|
|
118
|
+
if (device)
|
|
119
|
+
return user ? { ...device, user } : device;
|
|
120
|
+
if (looksLikeHostLiteral(token))
|
|
121
|
+
return adHocDevice(token, host, user);
|
|
122
|
+
return undefined;
|
|
55
123
|
}
|
|
56
124
|
/**
|
|
57
125
|
* Resolve an explicit `--host`/`--device` list to dialable targets, reading the
|
|
@@ -1,5 +1,30 @@
|
|
|
1
|
+
import { type DeviceInput } from './registry.js';
|
|
1
2
|
import { type TailscaleNode } from './tailscale.js';
|
|
2
3
|
import type { PendingDevice } from './pending.js';
|
|
4
|
+
/**
|
|
5
|
+
* The login user to stamp onto newly-synced devices. Tailscale status carries a
|
|
6
|
+
* node's OS and address but NOT the account you ssh in as, so we materialize the
|
|
7
|
+
* local operator's username — tailnet devices are overwhelmingly one person's
|
|
8
|
+
* boxes, and this is exactly the account ssh would already fall back to. Pinning
|
|
9
|
+
* it in the registry makes `--host <device>` dial that account no matter which
|
|
10
|
+
* machine launches the fan-out (a peer whose local user differs otherwise dials
|
|
11
|
+
* the wrong account). Returns undefined when the username isn't a safe ssh
|
|
12
|
+
* identifier, so a weird value never lands in the registry. */
|
|
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;
|
|
21
|
+
/**
|
|
22
|
+
* Fill in a device's login user during sync WITHOUT ever clobbering an account
|
|
23
|
+
* the user pinned. Precedence: an existing registered user wins; else the local
|
|
24
|
+
* operator's username; else leave it unset (ssh's implicit local default still
|
|
25
|
+
* applies). Pure so the "never overwrite an explicit user" guard is unit-tested
|
|
26
|
+
* without a tailnet. */
|
|
27
|
+
export declare function withDefaultUser(input: DeviceInput, prevUser: string | undefined, localUser: string | undefined): DeviceInput;
|
|
3
28
|
/**
|
|
4
29
|
* bootstrap — register every non-ignored node (opt-out). First-run `agents
|
|
5
30
|
* setup` and manual `agents devices sync`, so the fleet is usable out of box.
|
package/dist/lib/devices/sync.js
CHANGED
|
@@ -14,8 +14,51 @@
|
|
|
14
14
|
* - soft (`soft: true`): auto-callers must never abort setup/sync because a
|
|
15
15
|
* machine has no tailscale — they get a result with `ok: false` instead.
|
|
16
16
|
*/
|
|
17
|
+
import * as os from 'os';
|
|
17
18
|
import { loadDevices, loadIgnored, upsertDevice, } from './registry.js';
|
|
18
19
|
import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from './tailscale.js';
|
|
20
|
+
/**
|
|
21
|
+
* The login user to stamp onto newly-synced devices. Tailscale status carries a
|
|
22
|
+
* node's OS and address but NOT the account you ssh in as, so we materialize the
|
|
23
|
+
* local operator's username — tailnet devices are overwhelmingly one person's
|
|
24
|
+
* boxes, and this is exactly the account ssh would already fall back to. Pinning
|
|
25
|
+
* it in the registry makes `--host <device>` dial that account no matter which
|
|
26
|
+
* machine launches the fan-out (a peer whose local user differs otherwise dials
|
|
27
|
+
* the wrong account). Returns undefined when the username isn't a safe ssh
|
|
28
|
+
* identifier, so a weird value never lands in the registry. */
|
|
29
|
+
export function localLoginUser() {
|
|
30
|
+
let u;
|
|
31
|
+
try {
|
|
32
|
+
u = os.userInfo().username;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
u = process.env.USER || process.env.USERNAME || undefined;
|
|
36
|
+
}
|
|
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;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Fill in a device's login user during sync WITHOUT ever clobbering an account
|
|
53
|
+
* the user pinned. Precedence: an existing registered user wins; else the local
|
|
54
|
+
* operator's username; else leave it unset (ssh's implicit local default still
|
|
55
|
+
* applies). Pure so the "never overwrite an explicit user" guard is unit-tested
|
|
56
|
+
* without a tailnet. */
|
|
57
|
+
export function withDefaultUser(input, prevUser, localUser) {
|
|
58
|
+
if (input.user || prevUser || !localUser)
|
|
59
|
+
return input;
|
|
60
|
+
return { ...input, user: localUser };
|
|
61
|
+
}
|
|
19
62
|
/**
|
|
20
63
|
* Node names present on the tailnet but neither already in the registry nor on
|
|
21
64
|
* the ignore-list — i.e. genuinely new devices worth surfacing. Pure so the
|
|
@@ -68,8 +111,10 @@ export async function runDeviceSync(opts = {}) {
|
|
|
68
111
|
platform: byName.get(name)?.platform ?? 'unknown',
|
|
69
112
|
}));
|
|
70
113
|
const toUpsert = selectNodesToUpsert(nodes, registered, ignored, mode);
|
|
114
|
+
const localUser = localLoginUser();
|
|
71
115
|
for (const node of toUpsert) {
|
|
72
|
-
|
|
116
|
+
const input = withDefaultUser(nodeToDeviceInput(node), registeredBefore[node.name]?.user, localUser);
|
|
117
|
+
await upsertDevice(node.name, input);
|
|
73
118
|
}
|
|
74
119
|
return { ok: true, synced: toUpsert.length, pending };
|
|
75
120
|
}
|
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;
|
|
Binary file
|
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',
|
package/dist/lib/registry.d.ts
CHANGED
|
@@ -44,6 +44,30 @@ export declare function mcpEntryToInstallSpec(entry: McpServerEntry): {
|
|
|
44
44
|
} | null;
|
|
45
45
|
/** Look up detailed info for an MCP server by exact name. */
|
|
46
46
|
export declare function getMcpServerInfo(serverName: string, registryName?: string): Promise<McpServerEntry | null>;
|
|
47
|
+
/** One row of a skill index document. */
|
|
48
|
+
export interface SkillIndexEntry {
|
|
49
|
+
name: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
source?: string;
|
|
52
|
+
identifier?: string;
|
|
53
|
+
trust_level?: string;
|
|
54
|
+
repo?: string;
|
|
55
|
+
path?: string;
|
|
56
|
+
tags?: string[];
|
|
57
|
+
author?: string;
|
|
58
|
+
installs?: number;
|
|
59
|
+
/** Lowercase hex sha256 of the skill's SKILL.md — written by `agents publish`. */
|
|
60
|
+
sha256?: string;
|
|
61
|
+
}
|
|
62
|
+
/** Raw shape of the skill index document served by Hermes and compatible registries. */
|
|
63
|
+
export interface SkillIndexDocument {
|
|
64
|
+
version?: number;
|
|
65
|
+
generated_at?: string;
|
|
66
|
+
skill_count?: number;
|
|
67
|
+
skills: SkillIndexEntry[];
|
|
68
|
+
}
|
|
69
|
+
/** Map a raw skill-index row into the canonical SkillEntry shape. */
|
|
70
|
+
export declare function normalizeSkillEntry(raw: SkillIndexEntry): SkillEntry;
|
|
47
71
|
/** Search skill registries for entries matching a query string. */
|
|
48
72
|
export declare function searchSkillRegistries(query: string, options?: {
|
|
49
73
|
registry?: string;
|
|
@@ -66,3 +90,34 @@ export declare function parsePackageIdentifier(identifier: string): {
|
|
|
66
90
|
};
|
|
67
91
|
/** Resolve a package identifier to an installable package with source metadata. */
|
|
68
92
|
export declare function resolvePackage(identifier: string): Promise<ResolvedPackage | null>;
|
|
93
|
+
/** Lowercase hex sha256 of a file's bytes. Small files only (SKILL.md). */
|
|
94
|
+
export declare function sha256OfFile(file: string): string;
|
|
95
|
+
/**
|
|
96
|
+
* Parse an 'owner/repo' slug from a git remote URL (https or scp-style ssh).
|
|
97
|
+
* Returns null if the URL is not a recognizable GitHub-style remote.
|
|
98
|
+
*/
|
|
99
|
+
export declare function parseOwnerRepoFromRemote(remoteUrl: string): string | null;
|
|
100
|
+
/**
|
|
101
|
+
* Walk a repo's skills/ and build a flat {@link SkillIndexDocument}. Each entry
|
|
102
|
+
* carries the sha256 of its SKILL.md so install can verify integrity after
|
|
103
|
+
* cloning — this is the artifact `agents publish` commits + pushes.
|
|
104
|
+
*
|
|
105
|
+
* `repoSlug` is the 'owner/repo' the skills are published under, written into
|
|
106
|
+
* each entry's `repo` field so {@link skillEntryToGitSource} resolves it to
|
|
107
|
+
* `gh:owner/repo`. `identifier` is set to the skill's directory name so
|
|
108
|
+
* `agents install skill:<name>` resolves against this index.
|
|
109
|
+
*/
|
|
110
|
+
export declare function buildSkillIndex(repoPath: string, repoSlug: string, opts?: {
|
|
111
|
+
generatedAt?: string;
|
|
112
|
+
}): SkillIndexDocument;
|
|
113
|
+
/**
|
|
114
|
+
* Verify a cloned skill's SKILL.md against the sha256 recorded in its registry
|
|
115
|
+
* entry. Returns ok when the entry carries no sha256 — indexes published before
|
|
116
|
+
* integrity hashes (or by third parties) simply skip the check. Returns an
|
|
117
|
+
* error when the file is missing or its hash differs, so install can abort
|
|
118
|
+
* rather than silently trusting a tampered artifact.
|
|
119
|
+
*/
|
|
120
|
+
export declare function verifySkillIntegrity(repoPath: string, entry: Pick<SkillEntry, 'name' | 'path' | 'sha256'>): {
|
|
121
|
+
ok: boolean;
|
|
122
|
+
error?: string;
|
|
123
|
+
};
|