@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.
@@ -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 = 'v11';
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) {
@@ -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;
@@ -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}
@@ -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
- export function getAgentsBinPath() {
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 absolute path of
667
- // the JS entrypoint the user actually invoked.
668
- const argv1 = process.argv[1];
669
- if (argv1 && fs.existsSync(argv1)) {
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(argv1);
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(argv1), compiledShim ? 'index.js' : 'agents');
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 argv1;
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
- return { running, pid, jobCount, logPath: getLogPath() };
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
@@ -34,7 +34,19 @@ export function localLoginUser() {
34
34
  catch {
35
35
  u = process.env.USER || process.env.USERNAME || undefined;
36
36
  }
37
- return u && /^[a-zA-Z0-9._-]+$/.test(u) ? u : undefined;
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 `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;
@@ -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
- // `--devices` / `--hosts` fan out to every registered device locally; don't
119
- // let a per-host passthrough turn it into a cascading remote fan-out.
120
- const fleetFlag = allArgs.includes('--devices') || allArgs.includes('--hosts');
121
- if (fleetFlag)
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)
@@ -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>;
@@ -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
@@ -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',