@phnx-labs/agents-cli 1.20.55 → 1.20.57

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.
@@ -306,6 +306,28 @@ export async function runDaemon() {
306
306
  catch (err) {
307
307
  log('ERROR', `Stray daemon reaper failed: ${err.message}`);
308
308
  }
309
+ // #416: host the secrets broker socket-first — before the scheduler and the
310
+ // heavy browser/session-sync services — so `agents secrets` resolves within
311
+ // ms of daemon start. Only host when no broker is already reachable, so we
312
+ // never orphan a live standalone broker's clients (that broker stays the
313
+ // server until it idle-exits or the daemon restarts). Best-effort: a failure
314
+ // here must not stop the daemon. Retiring the standalone launchd service is
315
+ // the follow-on (#416 step 2 / #417).
316
+ let hostedBroker = null;
317
+ try {
318
+ const { agentPing, startHostedBroker } = await import('./secrets/agent.js');
319
+ if ((await agentPing()).reachable) {
320
+ log('INFO', 'Secrets broker already running (standalone); daemon not hosting it');
321
+ }
322
+ else {
323
+ hostedBroker = await startHostedBroker();
324
+ if (hostedBroker)
325
+ log('INFO', 'Secrets broker hosted in daemon (socket-first)');
326
+ }
327
+ }
328
+ catch (err) {
329
+ log('WARN', `Secrets broker host skipped: ${err.message}`);
330
+ }
309
331
  const scheduler = new JobScheduler(async (config) => {
310
332
  log('INFO', `Triggering job '${config.name}' (agent: ${config.agent})`);
311
333
  try {
@@ -594,6 +616,7 @@ export async function runDaemon() {
594
616
  clearTimeout(tmuxReconcileKickoff);
595
617
  clearInterval(launchHealthInterval);
596
618
  clearTimeout(launchHealthKickoff);
619
+ hostedBroker?.close();
597
620
  removeDaemonPid();
598
621
  removeHeartbeat();
599
622
  process.exit(0);
@@ -712,28 +735,39 @@ Environment=PATH=/usr/local/bin:/usr/bin:/bin:${os.homedir()}/.nvm/versions/node
712
735
  [Install]
713
736
  WantedBy=default.target`;
714
737
  }
715
- export function getAgentsBinPath() {
738
+ const BUN_VIRTUAL_ROOT = /[/\\]\$bunfs[/\\]root[/\\]/;
739
+ function resolveBunStandaloneEntry(entry, execPath) {
740
+ if (!BUN_VIRTUAL_ROOT.test(entry))
741
+ return entry;
742
+ if (!execPath || BUN_VIRTUAL_ROOT.test(execPath) || !fs.existsSync(execPath)) {
743
+ throw new Error(`Cannot resolve agents CLI: Bun standalone executable not found at ${execPath || '(empty path)'}`);
744
+ }
745
+ return execPath;
746
+ }
747
+ export function getAgentsBinPath(argv1 = process.argv[1], execPath = process.execPath) {
716
748
  // Prefer the binary actively executing this code. `which agents` returns
717
749
  // whatever happens to be first on PATH, which means a side-by-side dev
718
750
  // build at ~/.local/bin would silently spawn the registry-installed
719
- // daemon and run stale code. process.argv[1] is the absolute path of
720
- // the JS entrypoint the user actually invoked.
721
- const argv1 = process.argv[1];
722
- if (argv1 && fs.existsSync(argv1)) {
751
+ // daemon and run stale code. For a JS install, process.argv[1] is the
752
+ // absolute entrypoint the user actually invoked. A Bun standalone instead
753
+ // exposes its embedded /$bunfs/root entry at argv[1] and its physical signed
754
+ // executable at process.execPath; Bun reports both as existing paths.
755
+ const runningEntry = argv1 ? resolveBunStandaloneEntry(argv1, execPath) : undefined;
756
+ if (runningEntry && fs.existsSync(runningEntry)) {
723
757
  // The package's browser/computer entrypoints are sibling shims without a
724
758
  // `daemon` command. A daemon started as their IPC side effect must launch
725
759
  // through the main agents entrypoint instead of replaying the shim path.
726
- const entryName = path.basename(argv1);
760
+ const entryName = path.basename(runningEntry);
727
761
  const compiledShim = /^(browser|computer)\.(c|m)?js$/.test(entryName);
728
762
  const installedShim = /^(browser|computer)$/.test(entryName);
729
763
  if (compiledShim || installedShim) {
730
- const agentsEntry = path.join(path.dirname(argv1), compiledShim ? 'index.js' : 'agents');
764
+ const agentsEntry = path.join(path.dirname(runningEntry), compiledShim ? 'index.js' : 'agents');
731
765
  if (!fs.existsSync(agentsEntry)) {
732
766
  throw new Error(`Cannot start agents daemon: main CLI entry not found at ${agentsEntry}`);
733
767
  }
734
768
  return agentsEntry;
735
769
  }
736
- return argv1;
770
+ return runningEntry;
737
771
  }
738
772
  try {
739
773
  return execFileSync('which', ['agents'], { encoding: 'utf-8' }).trim();
@@ -903,9 +937,30 @@ export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
903
937
  }
904
938
  return { command: agentsBin, args: ['daemon', '_run'] };
905
939
  }
940
+ /**
941
+ * Build the argv to relaunch the `agents` CLI with the given subcommand args.
942
+ *
943
+ * Resolves the real on-disk binary via getAgentsBinPath(), then dispatches: a
944
+ * `.js` entry runs under node (`node <entry> …`), a native/compiled binary runs
945
+ * directly (`<bin> …`).
946
+ *
947
+ * Callers MUST route self-spawns through this rather than hand-rolling
948
+ * `[process.execPath, process.argv[1], …]`: under the compiled standalone binary
949
+ * (#315) `process.argv[1]` is the bun virtual entry `/$bunfs/root/agents`, so the
950
+ * hand-rolled form becomes `agents /$bunfs/root/agents …` → the CLI receives the
951
+ * bunfs path as a subcommand and dies with "unknown command '/$bunfs/root/agents'".
952
+ * getAgentsBinPath() resolves that virtual entry to the physical process.execPath.
953
+ */
954
+ export function getAgentsInvocation(subArgs, agentsBin = getAgentsBinPath()) {
955
+ const resolvedBin = resolveBunStandaloneEntry(agentsBin, process.execPath);
956
+ if (/\.(c|m)?js$/.test(resolvedBin)) {
957
+ return { command: process.execPath, args: [resolvedBin, ...subArgs] };
958
+ }
959
+ return { command: resolvedBin, args: subArgs };
960
+ }
906
961
  export function validateDaemonBinary(binPath) {
907
962
  const warnings = [];
908
- if (/\/\$bunfs\/root\//.test(binPath)) {
963
+ if (BUN_VIRTUAL_ROOT.test(binPath)) {
909
964
  throw new Error(`Refusing to supervise daemon: resolved binary is a bun virtual path (${binPath}). ` +
910
965
  `Install agents globally (npm i -g @phnx-labs/agents-cli) and restart.`);
911
966
  }
@@ -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
@@ -61,14 +61,13 @@ export interface JobConfig {
61
61
  timezone?: string;
62
62
  repo?: string;
63
63
  /**
64
- * Pin this routine to one machine. `~/.agents/routines/` is synced to every
65
- * device via the user repo, so without a pin an enabled routine fires on
66
- * EVERY machine running the scheduler. When set, only the device whose
67
- * `machineId()` matches (normalized hostname, e.g. `yosemite-s0`) schedules,
68
- * fires, catches up, or counts this job as overdue; everywhere else it is
69
- * inert and `run` refuses with an `agents ssh` pointer.
64
+ * Fleet allowlist — restrict this routine to specific devices. When omitted
65
+ * or empty, the routine is unrestricted and fires on every device running the
66
+ * scheduler. When set, only devices whose `machineId()` matches any entry
67
+ * (via `normalizeHost`) schedule, fire, catch up, or count this job as
68
+ * overdue; everywhere else it is inert and `run` refuses with a pointer.
70
69
  */
71
- device?: string;
70
+ devices?: string[];
72
71
  variables?: Record<string, string>;
73
72
  sandbox?: boolean;
74
73
  allow?: JobAllowConfig;
@@ -94,12 +93,31 @@ export interface RunMeta {
94
93
  exitCode: number | null;
95
94
  }
96
95
  /**
97
- * True when the job may execute on this machine: no `device` pin, or the pin
98
- * names this device. Both sides go through `normalizeHost` so `Yosemite-S0`,
99
- * `yosemite-s0.tailnet.ts.net`, and `yosemite-s0` all agree. Every fire path
100
- * (cron scheduler, webhook, catchup/overdue, manual run) gates on this.
96
+ * True when the job may execute on this machine: no `devices` allowlist (or
97
+ * empty), or the allowlist includes this device. Both sides go through
98
+ * `normalizeHost` so `Yosemite-S0`, `yosemite-s0.tailnet.ts.net`, and
99
+ * `yosemite-s0` all agree. Every fire path (cron scheduler, webhook,
100
+ * catchup/overdue, manual run) gates on this.
101
101
  */
102
- export declare function jobRunsOnThisDevice(config: Pick<JobConfig, 'device'>): boolean;
102
+ export declare function jobRunsOnThisDevice(config: Pick<JobConfig, 'devices'>): boolean;
103
+ /** Human presentation of a device-affinity mismatch for commands and runner. */
104
+ export interface JobEligibilityResult {
105
+ /** Full human message, e.g. "Job 'NAME' can only run on: a, b". */
106
+ message: string;
107
+ /** One-line copy-paste suggestion, e.g. "agents routines run NAME --host a". */
108
+ suggestion: string;
109
+ /** Comma-separated allowed devices label, e.g. "a, b". */
110
+ allowedLabel: string;
111
+ /** First allowed device (normalized), useful for the suggested host. */
112
+ firstHost: string;
113
+ }
114
+ /**
115
+ * Return null when the job may run here; otherwise return a structured,
116
+ * human-friendly eligibility failure. Centralizes the message/suggestion
117
+ * construction so manual run, executeJob, and executeJobDetached stay in
118
+ * sync. Scheduler/webhook/overdue paths continue to use jobRunsOnThisDevice.
119
+ */
120
+ export declare function checkJobDeviceEligibility(config: Pick<JobConfig, 'name' | 'devices'>): JobEligibilityResult | null;
103
121
  /**
104
122
  * List all job configs, scanning project > user routine dirs.
105
123
  * Project routines (`<project>/.agents/routines/`) shadow user routines of the
@@ -113,7 +131,12 @@ export declare function listJobs(cwd?: string): JobConfig[];
113
131
  * only resolve user routines.
114
132
  */
115
133
  export declare function readJob(name: string, cwd?: string): JobConfig | null;
116
- /** Write a job config to disk, omitting fields that match defaults. */
134
+ /** Write a job config to disk, omitting fields that match defaults.
135
+ *
136
+ * Updates the one existing supported extension (.yml or .yaml) atomically.
137
+ * New routines are written as .yml. If both extensions exist for the same
138
+ * name, the write fails explicitly so we never choose or drop a sibling.
139
+ */
117
140
  export declare function writeJob(config: JobConfig): void;
118
141
  /** Delete a job config file by name. Returns true if the file existed. */
119
142
  export declare function deleteJob(name: string): boolean;
@@ -12,6 +12,7 @@ import * as yaml from 'yaml';
12
12
  import { Cron } from 'croner';
13
13
  import { getRoutinesDir, getRunsDir, ensureAgentsDir, getProjectRoutinesDir } from './state.js';
14
14
  import { safeJoin } from './paths.js';
15
+ import { atomicWriteFileSync } from './fs-atomic.js';
15
16
  import { ALL_AGENT_IDS } from './agents.js';
16
17
  import { machineId, normalizeHost } from './machine-id.js';
17
18
  /** Canonical set of accepted GitHub trigger events — single source for validation. */
@@ -42,15 +43,33 @@ export function normalizeTriggerEvent(input) {
42
43
  return aliases[key] ?? null;
43
44
  }
44
45
  /**
45
- * True when the job may execute on this machine: no `device` pin, or the pin
46
- * names this device. Both sides go through `normalizeHost` so `Yosemite-S0`,
47
- * `yosemite-s0.tailnet.ts.net`, and `yosemite-s0` all agree. Every fire path
48
- * (cron scheduler, webhook, catchup/overdue, manual run) gates on this.
46
+ * True when the job may execute on this machine: no `devices` allowlist (or
47
+ * empty), or the allowlist includes this device. Both sides go through
48
+ * `normalizeHost` so `Yosemite-S0`, `yosemite-s0.tailnet.ts.net`, and
49
+ * `yosemite-s0` all agree. Every fire path (cron scheduler, webhook,
50
+ * catchup/overdue, manual run) gates on this.
49
51
  */
50
52
  export function jobRunsOnThisDevice(config) {
51
- if (!config.device)
53
+ if (!config.devices || config.devices.length === 0)
52
54
  return true;
53
- return normalizeHost(config.device) === machineId();
55
+ const self = machineId();
56
+ return config.devices.some((d) => normalizeHost(d) === self);
57
+ }
58
+ /**
59
+ * Return null when the job may run here; otherwise return a structured,
60
+ * human-friendly eligibility failure. Centralizes the message/suggestion
61
+ * construction so manual run, executeJob, and executeJobDetached stay in
62
+ * sync. Scheduler/webhook/overdue paths continue to use jobRunsOnThisDevice.
63
+ */
64
+ export function checkJobDeviceEligibility(config) {
65
+ if (jobRunsOnThisDevice(config))
66
+ return null;
67
+ const allowed = (config.devices ?? []).map((d) => normalizeHost(d));
68
+ const allowedLabel = allowed.join(', ');
69
+ const firstHost = allowed[0] ?? 'HOST';
70
+ const message = `Job '${config.name}' can only run on: ${allowedLabel}`;
71
+ const suggestion = `agents routines run ${config.name} --host ${firstHost}`;
72
+ return { message, suggestion, allowedLabel, firstHost };
54
73
  }
55
74
  /** Default values applied to every job config when fields are omitted. */
56
75
  const JOB_DEFAULTS = {
@@ -122,6 +141,11 @@ function readJobFile(filePath) {
122
141
  const parsed = yaml.parse(content);
123
142
  if (!parsed || typeof parsed !== 'object')
124
143
  return null;
144
+ // Fail closed on the legacy singular `device` key. A routine that still
145
+ // carries it after v12 startup migration is unmigrated state and must be
146
+ // treated as unavailable/inert rather than unrestricted.
147
+ if (Object.prototype.hasOwnProperty.call(parsed, 'device'))
148
+ return null;
125
149
  return {
126
150
  ...JOB_DEFAULTS,
127
151
  ...parsed,
@@ -132,11 +156,23 @@ function readJobFile(filePath) {
132
156
  return null;
133
157
  }
134
158
  }
135
- /** Write a job config to disk, omitting fields that match defaults. */
159
+ /** Write a job config to disk, omitting fields that match defaults.
160
+ *
161
+ * Updates the one existing supported extension (.yml or .yaml) atomically.
162
+ * New routines are written as .yml. If both extensions exist for the same
163
+ * name, the write fails explicitly so we never choose or drop a sibling.
164
+ */
136
165
  export function writeJob(config) {
137
166
  ensureAgentsDir();
138
167
  const jobsDir = getRoutinesDir();
139
- const filePath = safeJoin(jobsDir, config.name + '.yml');
168
+ const ymlPath = safeJoin(jobsDir, config.name + '.yml');
169
+ const yamlPath = safeJoin(jobsDir, config.name + '.yaml');
170
+ const ymlExists = fs.existsSync(ymlPath);
171
+ const yamlExists = fs.existsSync(yamlPath);
172
+ if (ymlExists && yamlExists) {
173
+ throw new Error(`Routine '${config.name}' has both .yml and .yaml files; resolve the ambiguity before editing.`);
174
+ }
175
+ const filePath = ymlExists ? ymlPath : yamlExists ? yamlPath : ymlPath;
140
176
  const output = { ...config };
141
177
  if (output.mode === 'auto')
142
178
  delete output.mode;
@@ -148,7 +184,10 @@ export function writeJob(config) {
148
184
  delete output.enabled;
149
185
  if (output.runOnce === false || output.runOnce === undefined)
150
186
  delete output.runOnce;
151
- fs.writeFileSync(filePath, yaml.stringify(output), 'utf-8');
187
+ const devArr = output.devices;
188
+ if (!devArr || devArr.length === 0)
189
+ delete output.devices;
190
+ atomicWriteFileSync(filePath, yaml.stringify(output));
152
191
  }
153
192
  /** Delete a job config file by name. Returns true if the file existed. */
154
193
  export function deleteJob(name) {
@@ -232,8 +271,19 @@ export function validateJob(config) {
232
271
  }
233
272
  }
234
273
  if (config.device !== undefined) {
235
- if (typeof config.device !== 'string' || config.device.trim() === '') {
236
- errors.push('device must be a non-empty device name (as shown by `agents devices`, e.g. yosemite-s0)');
274
+ errors.push('singular "device" key is no longer supported — replace with devices: [<name>] (an array)');
275
+ }
276
+ if (config.devices !== undefined) {
277
+ if (!Array.isArray(config.devices)) {
278
+ errors.push('devices must be an array of device names (as shown by `agents devices`)');
279
+ }
280
+ else {
281
+ for (const d of config.devices) {
282
+ if (typeof d !== 'string' || d.trim() === '') {
283
+ errors.push('each entry in devices must be a non-empty device name');
284
+ break;
285
+ }
286
+ }
237
287
  }
238
288
  }
239
289
  return errors;
@@ -17,7 +17,7 @@ import { spawn, execFileSync } from 'child_process';
17
17
  import * as fs from 'fs';
18
18
  import * as path from 'path';
19
19
  import * as os from 'os';
20
- import { resolveJobPrompt, parseTimeout, writeRunMeta, getRunDir, } from './routines.js';
20
+ import { resolveJobPrompt, parseTimeout, writeRunMeta, getRunDir, checkJobDeviceEligibility, } from './routines.js';
21
21
  import { getRunsDir } from './state.js';
22
22
  import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
23
23
  import { resolveModel, buildReasoningFlags } from './models.js';
@@ -378,6 +378,10 @@ function spawnJobAttempt(cmd, env, attemptLogPath, timeoutMs, combinedLogPath) {
378
378
  * failover across healthy same-agent accounts (RUSH-1016).
379
379
  */
380
380
  export async function executeJob(config, deps) {
381
+ const eligibility = checkJobDeviceEligibility(config);
382
+ if (eligibility) {
383
+ throw new Error(eligibility.message);
384
+ }
381
385
  maybeRotate();
382
386
  const launch = await resolveRoutineLaunch(config);
383
387
  const primaryVersion = launch.chain[0]?.version ?? config.version;
@@ -537,6 +541,11 @@ export async function executeJob(config, deps) {
537
541
  }
538
542
  /** Spawn a job as a detached process and return immediately with run metadata. */
539
543
  export async function executeJobDetached(config) {
544
+ const eligibility = checkJobDeviceEligibility(config);
545
+ if (eligibility) {
546
+ process.stderr.write(`[agents] daemon: skipping '${config.name}' — ${eligibility.message}\n`);
547
+ throw new Error(eligibility.message);
548
+ }
540
549
  // Pre-flight: pick a healthy version/account so the daemon does not launch
541
550
  // into a credit-exhausted install. Detached cannot mid-run failover (no exit
542
551
  // wait); the next schedule tick re-selects if this attempt still fails.
@@ -172,6 +172,27 @@ export declare function shouldWipeOnWatchEvent(chunk: string): boolean;
172
172
  export declare function runSecretsAgent(opts?: {
173
173
  service?: boolean;
174
174
  }): Promise<void>;
175
+ /**
176
+ * Host the secrets broker inside the always-on daemon (#416).
177
+ *
178
+ * Serves the SAME socket and wire protocol as the standalone `runSecretsAgent`
179
+ * — so every existing client (`agentGetSync`, `agentPing`, `agentAutoLoadSync`)
180
+ * keeps working unchanged, no PROTOCOL_VERSION bump — but it is daemon-safe:
181
+ *
182
+ * - no pid-file single-instance guard (the daemon owns the instance);
183
+ * - no `process.exit`, no SIGTERM/SIGINT handlers, no self-heal/idle-exit
184
+ * (those would kill the daemon — the daemon is the always-on backbone and
185
+ * manages its own version/lifecycle). The sweep only TTL-evicts.
186
+ *
187
+ * The caller (`runDaemon`) must only invoke this when NO broker is already
188
+ * reachable (ping first) — this function clears a stale socket before binding,
189
+ * so calling it while a live standalone broker holds the socket would orphan
190
+ * that broker's clients. Returns a handle the daemon closes on shutdown, or
191
+ * null off-darwin (nothing to broker without biometry).
192
+ */
193
+ export declare function startHostedBroker(): Promise<{
194
+ close(): void;
195
+ } | null>;
175
196
  /** True if a broker socket exists at all. Cheap; gates the sync read so the
176
197
  * never-unlocked path stays a single stat. */
177
198
  export declare function agentSocketExists(): boolean;
@@ -246,6 +267,12 @@ export declare function agentLock(name?: string): Promise<number>;
246
267
  * predates the server-side exclusion, so this keeps the internal entry from
247
268
  * surfacing in `agents secrets status` in that skew window. */
248
269
  export declare function agentStatus(): Promise<AgentStatusEntry[]>;
270
+ /** Ping result: whether a broker is reachable + speaking our protocol, and the
271
+ * version of the code it's running (for staleness detection). */
272
+ export declare function agentPing(): Promise<{
273
+ reachable: boolean;
274
+ cliVersion?: string;
275
+ }>;
249
276
  /**
250
277
  * Ensure a broker is running and reachable. Returns true once the socket answers
251
278
  * a ping. macOS only.
@@ -500,6 +500,132 @@ export async function runSecretsAgent(opts = {}) {
500
500
  watcher = null;
501
501
  }
502
502
  }
503
+ /**
504
+ * Host the secrets broker inside the always-on daemon (#416).
505
+ *
506
+ * Serves the SAME socket and wire protocol as the standalone `runSecretsAgent`
507
+ * — so every existing client (`agentGetSync`, `agentPing`, `agentAutoLoadSync`)
508
+ * keeps working unchanged, no PROTOCOL_VERSION bump — but it is daemon-safe:
509
+ *
510
+ * - no pid-file single-instance guard (the daemon owns the instance);
511
+ * - no `process.exit`, no SIGTERM/SIGINT handlers, no self-heal/idle-exit
512
+ * (those would kill the daemon — the daemon is the always-on backbone and
513
+ * manages its own version/lifecycle). The sweep only TTL-evicts.
514
+ *
515
+ * The caller (`runDaemon`) must only invoke this when NO broker is already
516
+ * reachable (ping first) — this function clears a stale socket before binding,
517
+ * so calling it while a live standalone broker holds the socket would orphan
518
+ * that broker's clients. Returns a handle the daemon closes on shutdown, or
519
+ * null off-darwin (nothing to broker without biometry).
520
+ */
521
+ export async function startHostedBroker() {
522
+ if (!onDarwin())
523
+ return null;
524
+ const store = new Map();
525
+ const sock = socketPath(); // agentDir() creates the 0700 dir as a side effect
526
+ const handle = (req) => handleAgentRequest(store, req);
527
+ const onConn = (conn) => {
528
+ conn.setEncoding('utf-8');
529
+ let buf = '';
530
+ conn.on('data', (chunk) => {
531
+ buf += chunk;
532
+ let nl;
533
+ while ((nl = buf.indexOf('\n')) >= 0) {
534
+ const line = buf.slice(0, nl);
535
+ buf = buf.slice(nl + 1);
536
+ if (!line.trim())
537
+ continue;
538
+ let resp;
539
+ try {
540
+ resp = handle(JSON.parse(line));
541
+ }
542
+ catch (err) {
543
+ resp = { ok: false, error: err.message };
544
+ }
545
+ conn.write(JSON.stringify(resp) + '\n');
546
+ }
547
+ });
548
+ conn.on('error', () => { });
549
+ };
550
+ // Bind race-safely: never clobber a LIVE standalone broker. Try to listen; if
551
+ // the socket is already bound, a broker raced us after runDaemon's ping — if
552
+ // it answers, back off and let it serve (return null); if it's a stale socket
553
+ // file with no live listener, reclaim it and retry once. (Unconditionally
554
+ // unlinking before bind — as an earlier draft did — could remove a live
555
+ // broker's socket in the sub-ms window after runDaemon's reachability check.)
556
+ const listenOnce = () => new Promise((resolve, reject) => {
557
+ const s = net.createServer(onConn);
558
+ s.once('error', (err) => {
559
+ if (err.code === 'EADDRINUSE')
560
+ resolve('inuse');
561
+ else
562
+ reject(err);
563
+ });
564
+ s.listen(sock, () => {
565
+ try {
566
+ fs.chmodSync(sock, 0o600);
567
+ }
568
+ catch { /* dir 0700 already gates it */ }
569
+ resolve(s);
570
+ });
571
+ });
572
+ let bound = await listenOnce();
573
+ if (bound === 'inuse') {
574
+ if ((await agentPing()).reachable)
575
+ return null; // a live broker holds it — don't clobber
576
+ try {
577
+ fs.unlinkSync(sock);
578
+ }
579
+ catch { /* gone */ }
580
+ bound = await listenOnce();
581
+ if (bound === 'inuse')
582
+ return null; // still contended — the standalone fallback covers it
583
+ }
584
+ const server = bound;
585
+ // TTL eviction ONLY. Unlike the standalone broker's sweep, there is no
586
+ // self-heal-exit or idle-exit here — the daemon is always-on and owns the
587
+ // upgrade/lifecycle path; a broker that called process.exit() would take the
588
+ // whole daemon down with it.
589
+ const sweepTimer = setInterval(() => {
590
+ const now = Date.now();
591
+ for (const [name, e] of store)
592
+ if (now >= e.expiresAt)
593
+ store.delete(name);
594
+ }, SWEEP_INTERVAL_MS);
595
+ // Auto-lock on sleep, same as the standalone broker: the signed helper emits
596
+ // LOCK/SLEEP lines; wipe the in-memory store on a wipe-worthy event.
597
+ let watcher = null;
598
+ try {
599
+ watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
600
+ watcher.stdout?.setEncoding('utf-8');
601
+ watcher.stdout?.on('data', (chunk) => {
602
+ if (shouldWipeOnWatchEvent(chunk))
603
+ store.clear();
604
+ });
605
+ watcher.on('error', () => { watcher = null; });
606
+ }
607
+ catch {
608
+ watcher = null;
609
+ }
610
+ return {
611
+ close() {
612
+ store.clear();
613
+ clearInterval(sweepTimer);
614
+ try {
615
+ watcher?.kill();
616
+ }
617
+ catch { /* already gone */ }
618
+ try {
619
+ server.close();
620
+ }
621
+ catch { /* not listening */ }
622
+ try {
623
+ fs.unlinkSync(sock);
624
+ }
625
+ catch { /* gone */ }
626
+ },
627
+ };
628
+ }
503
629
  // ─── Client ──────────────────────────────────────────────────────────────────
504
630
  /** Open the socket, send one request, resolve the one response. Async path —
505
631
  * used by the unlock/lock/status commands, which already run in async actions. */
@@ -766,7 +892,7 @@ export async function agentStatus() {
766
892
  }
767
893
  /** Ping result: whether a broker is reachable + speaking our protocol, and the
768
894
  * version of the code it's running (for staleness detection). */
769
- async function agentPing() {
895
+ export async function agentPing() {
770
896
  if (!agentSocketExists())
771
897
  return { reachable: false };
772
898
  const r = await request({ cmd: 'ping' });
@@ -802,6 +928,23 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
802
928
  return true;
803
929
  await teardownStaleBroker();
804
930
  }
931
+ // Path 0 (#416): prefer the always-on daemon — it hosts the broker socket
932
+ // (one supervised backbone rather than a separate launchd service). If
933
+ // bringing the daemon up makes the broker answer, we're done. Fall through to
934
+ // the standalone-service paths below when the daemon path isn't available
935
+ // (kept as a fallback until the standalone service is retired, #416 step 2).
936
+ try {
937
+ const { ensureDaemonStarted } = await import('../daemon.js');
938
+ if (ensureDaemonStarted()) {
939
+ const d0 = Date.now() + timeoutMs;
940
+ while (Date.now() < d0) {
941
+ if ((await agentPing()).reachable)
942
+ return true;
943
+ await new Promise((r) => setTimeout(r, 120));
944
+ }
945
+ }
946
+ }
947
+ catch { /* daemon path unavailable — fall through to the standalone service */ }
805
948
  // Path 1: the persistent service. installSecretsAgentService is idempotent and
806
949
  // waits for the socket; for an already-installed service we kickstart and wait.
807
950
  try {