@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.
@@ -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
- }, 60_000);
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>${agentsBin}</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=${agentsBin} daemon _run
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
- return { running, pid, jobCount, logPath: getLogPath() };
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). Miss a literal
14
- * `user@host` fallback, its OS taken from the host overlay if enrolled. Returns
15
- * undefined only when the token fails the shared ssh-target injection guard.
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
- * A raw `user@host` that matches no registered device falls back to a literal
17
- * target so ad-hoc boxes still work.
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). Miss a literal
29
- * `user@host` fallback, its OS taken from the host overlay if enrolled. Returns
30
- * undefined only when the token fails the shared ssh-target injection guard.
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 bare = token.split('@').pop() || token;
40
- // An explicit `user@host` names an exact account/target honour it literally.
41
- // A bare alias (`yosemite-s0`) resolves through the registry to the device's
42
- // real address, so it never diverges from the auto-discovery sweep.
43
- const device = token.includes('@')
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
- return { target: sshTargetFor(device), machine: normalizeHost(device.name), name: device.name, os: device.platform };
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(bare), name: token, os: resolveRemoteOsSync(token) };
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.
@@ -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
- await upsertDevice(node.name, nodeToDeviceInput(node));
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 `kill-pane` closes
931
- // that split in place instead of detaching everyone (the pane-died hook runs
932
- // in the dead pane's context, so bare `kill-pane` targets it). Without the
933
- // guard, exiting any split kicked the user clean out of tmux.
934
- await setSessionHook(name, 'pane-died', agentPaneDiedHook(name, pane), socket);
935
- // Stamp the schema marker so the daemon reconcile (which retrofits older
936
- // sessions) recognizes this one as already current and skips it.
937
- await markSessionHookSchema(name, socket);
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;
@@ -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',
@@ -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
+ };