@phnx-labs/agents-cli 1.22.29 → 1.22.30

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +39 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/accounts.d.ts +13 -0
  5. package/dist/commands/accounts.js +32 -0
  6. package/dist/commands/daemon.d.ts +18 -0
  7. package/dist/commands/daemon.js +581 -0
  8. package/dist/commands/exec.js +66 -20
  9. package/dist/commands/routines.js +29 -11
  10. package/dist/commands/secrets.d.ts +17 -0
  11. package/dist/commands/secrets.js +30 -15
  12. package/dist/commands/sessions-browser.js +6 -6
  13. package/dist/commands/sessions-favorite.d.ts +7 -7
  14. package/dist/commands/sessions-favorite.js +30 -30
  15. package/dist/commands/sessions-picker.d.ts +33 -1
  16. package/dist/commands/sessions-picker.js +102 -27
  17. package/dist/commands/sessions.d.ts +12 -1
  18. package/dist/commands/sessions.js +259 -20
  19. package/dist/commands/view.d.ts +11 -0
  20. package/dist/commands/view.js +56 -29
  21. package/dist/index.js +37 -2
  22. package/dist/lib/account-labels.d.ts +24 -0
  23. package/dist/lib/account-labels.js +72 -0
  24. package/dist/lib/agents.d.ts +32 -1
  25. package/dist/lib/agents.js +96 -31
  26. package/dist/lib/daemon-health.d.ts +24 -0
  27. package/dist/lib/daemon-health.js +84 -0
  28. package/dist/lib/daemon-ticks.d.ts +81 -0
  29. package/dist/lib/daemon-ticks.js +190 -0
  30. package/dist/lib/daemon.d.ts +68 -18
  31. package/dist/lib/daemon.js +303 -338
  32. package/dist/lib/device-config.d.ts +10 -0
  33. package/dist/lib/device-config.js +27 -0
  34. package/dist/lib/exec.d.ts +27 -0
  35. package/dist/lib/exec.js +49 -2
  36. package/dist/lib/hosts/dispatch.d.ts +4 -0
  37. package/dist/lib/hosts/dispatch.js +4 -0
  38. package/dist/lib/hosts/remote-cmd.js +1 -0
  39. package/dist/lib/hosts/run-target.d.ts +1 -0
  40. package/dist/lib/hosts/run-target.js +1 -0
  41. package/dist/lib/import.js +7 -6
  42. package/dist/lib/memory-cache.d.ts +19 -0
  43. package/dist/lib/memory-cache.js +31 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  46. package/dist/lib/migrate.d.ts +1 -1
  47. package/dist/lib/migrate.js +7 -2
  48. package/dist/lib/routine-activation.d.ts +2 -0
  49. package/dist/lib/routine-activation.js +16 -0
  50. package/dist/lib/runner.d.ts +18 -0
  51. package/dist/lib/runner.js +52 -0
  52. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  53. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  54. package/dist/lib/secrets/agent.d.ts +19 -0
  55. package/dist/lib/secrets/agent.js +32 -2
  56. package/dist/lib/secrets/scope.d.ts +3 -3
  57. package/dist/lib/secrets/scope.js +3 -3
  58. package/dist/lib/session/db.d.ts +15 -0
  59. package/dist/lib/session/db.js +90 -15
  60. package/dist/lib/session/discover.js +91 -39
  61. package/dist/lib/session/favorites.d.ts +2 -2
  62. package/dist/lib/session/favorites.js +2 -2
  63. package/dist/lib/session/parse.d.ts +63 -0
  64. package/dist/lib/session/parse.js +165 -20
  65. package/dist/lib/session/session-cache.d.ts +9 -6
  66. package/dist/lib/session/session-cache.js +23 -6
  67. package/dist/lib/shims.js +12 -0
  68. package/dist/lib/startup/command-registry.d.ts +15 -1
  69. package/dist/lib/startup/command-registry.js +49 -0
  70. package/dist/lib/usage-refresh.js +3 -2
  71. package/dist/lib/usage.d.ts +12 -10
  72. package/dist/lib/usage.js +63 -144
  73. package/package.json +4 -1
@@ -73,6 +73,16 @@ export declare function isSchedulerEnabled(): boolean;
73
73
  * scheduler init) refuses with.
74
74
  */
75
75
  export declare function assertSchedulerEnabled(): void;
76
+ /** True unless this machine's device doc disables the daemon outright (top-level kill switch). */
77
+ export declare function isDaemonEnabled(): boolean;
78
+ /**
79
+ * Throw when the daemon is disabled on this machine, naming the setting and
80
+ * the fix. Every AUTO-start surface (routines add/start/catchup/webhook,
81
+ * `ensureDaemonStarted`) refuses with this before calling `startDaemon()`.
82
+ * `agents daemon start` is the deliberate override and does NOT call this —
83
+ * disable only blocks auto-start, mirroring `systemctl disable`.
84
+ */
85
+ export declare function assertDaemonEnabled(): void;
76
86
  /**
77
87
  * Read the `agents.max-concurrent` cap for each named device from its synced
78
88
  * device doc (no SSH). Devices without a cap are omitted — uncapped is the
@@ -67,6 +67,16 @@ export const CONFIG_KEYS = [
67
67
  type: 'bool',
68
68
  description: 'Whether the routines scheduler (daemon) may fire on this device.',
69
69
  },
70
+ {
71
+ name: 'daemon.enabled',
72
+ yamlKey: 'daemonEnabled',
73
+ scope: 'device',
74
+ type: 'bool',
75
+ description: 'Whether the daemon may run on this device at all (secrets broker, browser IPC, watchdog, and the ' +
76
+ 'routines scheduler). Disabling is the top-level kill switch: nothing auto-starts the daemon while it ' +
77
+ 'is set, including `routines add`/`routines start`/`routines catchup`/webhook triggers. ' +
78
+ '`agents daemon start` still starts it explicitly.',
79
+ },
70
80
  {
71
81
  name: 'watchdog.enabled',
72
82
  yamlKey: 'watchdogEnabled',
@@ -294,6 +304,23 @@ export function assertSchedulerEnabled() {
294
304
  throw new Error(`The routines scheduler is disabled on this device (scheduler.enabled=false in ~/.agents/devices/${machineId()}/agents.yaml). ` +
295
305
  `Re-enable with: agents devices configure ${machineId()} --scheduler on`);
296
306
  }
307
+ /** True unless this machine's device doc disables the daemon outright (top-level kill switch). */
308
+ export function isDaemonEnabled() {
309
+ return getConfigValue('daemon.enabled').value !== false;
310
+ }
311
+ /**
312
+ * Throw when the daemon is disabled on this machine, naming the setting and
313
+ * the fix. Every AUTO-start surface (routines add/start/catchup/webhook,
314
+ * `ensureDaemonStarted`) refuses with this before calling `startDaemon()`.
315
+ * `agents daemon start` is the deliberate override and does NOT call this —
316
+ * disable only blocks auto-start, mirroring `systemctl disable`.
317
+ */
318
+ export function assertDaemonEnabled() {
319
+ if (isDaemonEnabled())
320
+ return;
321
+ throw new Error(`The daemon is disabled on this device (daemon.enabled=false in ~/.agents/devices/${machineId()}/agents.yaml). ` +
322
+ `Re-enable with: agents daemon enable`);
323
+ }
297
324
  /**
298
325
  * Read the `agents.max-concurrent` cap for each named device from its synced
299
326
  * device doc (no SSH). Devices without a cap are omitted — uncapped is the
@@ -288,6 +288,33 @@ export declare function nativeResume(agent: AgentId, version?: string): boolean;
288
288
  * only accept `-c` config overrides.
289
289
  */
290
290
  export declare function codexWritableRootsConfig(dir: string): string;
291
+ /**
292
+ * Resolve the executable `buildExecCommand` will put in `cmd[0]`, or null when
293
+ * that resolution finds nothing on disk.
294
+ *
295
+ * This is an EXISTENCE probe, not an "is it managed by us" check. A harness the
296
+ * user installed themselves (Homebrew, a vendor `curl | sh`, a distro package)
297
+ * has no version home at all, and running it is a supported state — so with no
298
+ * version pinned we answer with a PATH lookup of the bare launch command, the
299
+ * same thing `spawnAgent` would resolve.
300
+ *
301
+ * `findInPath` deliberately excludes our own shims dir, because a shim is a
302
+ * dispatcher rather than an install. That exclusion alone is too strong here:
303
+ * the shim DOES launch whenever agents-cli owns at least one version of the
304
+ * agent — it resolves the version itself, and when no default is pinned it
305
+ * prints its own accurate `no default set … agents use <agent> <version>`
306
+ * guidance. Pre-empting that with "not installed" would name the wrong fix
307
+ * (`agents add`) for a machine that already has the harness. So the shim counts
308
+ * only when a managed version exists; with zero managed versions it is the dead
309
+ * end this probe was written to catch (RUSH-2339).
310
+ *
311
+ * The version-pinned branch mirrors buildExecCommand exactly: versioned shim
312
+ * first, then the version home's real binary. It deliberately does NOT fall back
313
+ * to PATH — with a version pinned, buildExecCommand spawns the literal
314
+ * `<cli>@<version>`, which is not on PATH, so a PATH hit here would be a lie
315
+ * that still exits 127.
316
+ */
317
+ export declare function resolveLaunchBinary(agent: AgentId, version?: string): string | null;
291
318
  /** Assemble the full CLI argument array for an agent invocation. */
292
319
  export declare function buildExecCommand(options: ExecOptions): string[];
293
320
  /** Spawn an agent and return its exit code. Convenience wrapper over spawnAgent. */
package/dist/lib/exec.js CHANGED
@@ -9,9 +9,9 @@ import { randomUUID } from 'crypto';
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import { ALL_MODES } from './types.js';
12
- import { AGENTS } from './agents.js';
12
+ import { AGENTS, findInPath } from './agents.js';
13
13
  import { parseTimeout } from './routines.js';
14
- import { compareVersions, getBinaryPath, getVersionHomePath, isVersionInstalled, resolveVersion } from './versions.js';
14
+ import { compareVersions, getBinaryPath, getVersionHomePath, isVersionInstalled, listInstalledVersions, resolveVersion } from './versions.js';
15
15
  import { resolveModel, buildReasoningFlags } from './models.js';
16
16
  import { isTierToken, resolveTier } from './model-tiers.js';
17
17
  import { createTimer, redactPrompt, redactArgs } from './events.js';
@@ -725,6 +725,53 @@ export function nativeResume(agent, version) {
725
725
  export function codexWritableRootsConfig(dir) {
726
726
  return `sandbox_workspace_write.writable_roots=[${JSON.stringify(dir)}]`;
727
727
  }
728
+ /**
729
+ * Resolve the executable `buildExecCommand` will put in `cmd[0]`, or null when
730
+ * that resolution finds nothing on disk.
731
+ *
732
+ * This is an EXISTENCE probe, not an "is it managed by us" check. A harness the
733
+ * user installed themselves (Homebrew, a vendor `curl | sh`, a distro package)
734
+ * has no version home at all, and running it is a supported state — so with no
735
+ * version pinned we answer with a PATH lookup of the bare launch command, the
736
+ * same thing `spawnAgent` would resolve.
737
+ *
738
+ * `findInPath` deliberately excludes our own shims dir, because a shim is a
739
+ * dispatcher rather than an install. That exclusion alone is too strong here:
740
+ * the shim DOES launch whenever agents-cli owns at least one version of the
741
+ * agent — it resolves the version itself, and when no default is pinned it
742
+ * prints its own accurate `no default set … agents use <agent> <version>`
743
+ * guidance. Pre-empting that with "not installed" would name the wrong fix
744
+ * (`agents add`) for a machine that already has the harness. So the shim counts
745
+ * only when a managed version exists; with zero managed versions it is the dead
746
+ * end this probe was written to catch (RUSH-2339).
747
+ *
748
+ * The version-pinned branch mirrors buildExecCommand exactly: versioned shim
749
+ * first, then the version home's real binary. It deliberately does NOT fall back
750
+ * to PATH — with a version pinned, buildExecCommand spawns the literal
751
+ * `<cli>@<version>`, which is not on PATH, so a PATH hit here would be a lie
752
+ * that still exits 127.
753
+ */
754
+ export function resolveLaunchBinary(agent, version) {
755
+ const command = AGENT_COMMANDS[agent].base[0];
756
+ if (version) {
757
+ const versionedShim = path.join(getShimsDir(), `${command}@${version}`);
758
+ if (process.platform === 'win32' && fs.existsSync(versionedShim + '.cmd')) {
759
+ return versionedShim + '.cmd';
760
+ }
761
+ if (fs.existsSync(versionedShim))
762
+ return versionedShim;
763
+ const binary = getBinaryPath(agent, version);
764
+ return binary && fs.existsSync(binary) ? binary : null;
765
+ }
766
+ const native = findInPath(command);
767
+ if (native)
768
+ return native;
769
+ if (listInstalledVersions(agent).length === 0)
770
+ return null;
771
+ // Re-scan PATH accepting the shim: point findInPath's exclusion at a path that
772
+ // matches nothing, so the real shims dir participates like any other PATH entry.
773
+ return findInPath(command, { shimsDir: path.join(getShimsDir(), '.no-such-dir') });
774
+ }
728
775
  /** Assemble the full CLI argument array for an agent invocation. */
729
776
  export function buildExecCommand(options) {
730
777
  const template = AGENT_COMMANDS[options.agent];
@@ -118,6 +118,8 @@ export interface DispatchOptions {
118
118
  version?: string;
119
119
  /** Run strategy (e.g. "balanced") — the remote picks among ITS signed-in accounts. */
120
120
  strategy?: string;
121
+ /** Named provider account — the remote resolves it against ITS signed-in versions. */
122
+ account?: string;
121
123
  balanced?: boolean;
122
124
  fallback?: string;
123
125
  mode?: string;
@@ -193,6 +195,8 @@ export interface InteractiveDispatchOptions {
193
195
  version?: string;
194
196
  /** Explicit run strategy (e.g. "balanced") to forward as `--strategy <strategy>`. */
195
197
  strategy?: string;
198
+ /** Named provider account to resolve on the remote host. */
199
+ account?: string;
196
200
  /** Optional prompt — forwarded only when the caller explicitly forced interactive mode. */
197
201
  prompt?: string;
198
202
  mode?: string;
@@ -401,6 +401,8 @@ export function buildRunForwardedArgs(opts) {
401
401
  args.push('--timeout', opts.timeout);
402
402
  if (opts.strategy)
403
403
  args.push('--strategy', opts.strategy);
404
+ if (opts.account)
405
+ args.push('--account', opts.account);
404
406
  if (opts.balanced)
405
407
  args.push('--balanced');
406
408
  if (opts.fallback)
@@ -466,6 +468,8 @@ export function buildInteractiveRunForwardedArgs(opts) {
466
468
  args.push('--timeout', opts.timeout);
467
469
  if (opts.strategy)
468
470
  args.push('--strategy', opts.strategy);
471
+ if (opts.account)
472
+ args.push('--account', opts.account);
469
473
  if (opts.balanced)
470
474
  args.push('--balanced');
471
475
  if (opts.fallback)
@@ -76,6 +76,7 @@ export const RUN_OPTION_FORWARDING = {
76
76
  fallback: 'forward',
77
77
  balanced: 'forward',
78
78
  strategy: 'forward',
79
+ account: 'forward',
79
80
  loop: 'forward',
80
81
  maxIterations: 'forward',
81
82
  budget: 'forward',
@@ -62,6 +62,7 @@ export interface HostPromptRun {
62
62
  addDir?: string[];
63
63
  timeout?: string;
64
64
  strategy?: string;
65
+ account?: string;
65
66
  balanced?: boolean;
66
67
  fallback?: string;
67
68
  loop?: boolean;
@@ -109,6 +109,7 @@ export async function dispatchPromptToHost(host, opts) {
109
109
  addDir: opts.addDir,
110
110
  timeout: opts.timeout,
111
111
  strategy: opts.strategy,
112
+ account: opts.account,
112
113
  balanced: opts.balanced,
113
114
  fallback: opts.fallback,
114
115
  loop: opts.loop,
@@ -19,7 +19,7 @@
19
19
  import * as fs from 'fs';
20
20
  import * as os from 'os';
21
21
  import * as path from 'path';
22
- import { AGENTS } from './agents.js';
22
+ import { AGENTS, resolveNativeBinaryPath } from './agents.js';
23
23
  import { getUserAgentsDir, getVersionsDir } from './state.js';
24
24
  import { setGlobalDefault } from './versions.js';
25
25
  import { createShim, createVersionedAlias, ensureShimCurrent, switchHomeFileSymlinks, assertIsolationBoundary } from './shims.js';
@@ -200,15 +200,16 @@ export function importInstallScriptBinary(spec, version, binaryPath, versionDir)
200
200
  if (alreadyExists) {
201
201
  return { success: false, skipped: true, error: `${version} already installed`, resolvedFromPath: binaryPath };
202
202
  }
203
- if (!fs.existsSync(binaryPath)) {
204
- return { success: false, error: `Binary does not exist: ${binaryPath}` };
203
+ const nativeBinary = resolveNativeBinaryPath(spec.cliCommand, binaryPath);
204
+ if (!nativeBinary) {
205
+ return { success: false, error: `Binary does not resolve to a native executable: ${binaryPath}` };
205
206
  }
206
207
  try {
207
208
  fs.mkdirSync(path.join(versionDir, 'home'), { recursive: true });
208
209
  fs.mkdirSync(path.join(versionDir, 'node_modules', '.bin'), { recursive: true });
209
- fs.writeFileSync(path.join(versionDir, 'package.json'), JSON.stringify({ name: `agents-${spec.agentId}-${version}`, version: '1.0.0', private: true, imported: true, from: binaryPath, installScriptBased: true }, null, 2));
210
- fs.symlinkSync(binaryPath, binaryLink);
211
- return { success: true, resolvedFromPath: binaryPath };
210
+ fs.writeFileSync(path.join(versionDir, 'package.json'), JSON.stringify({ name: `agents-${spec.agentId}-${version}`, version: '1.0.0', private: true, imported: true, from: nativeBinary, installScriptBased: true }, null, 2));
211
+ fs.symlinkSync(nativeBinary, binaryLink);
212
+ return { success: true, resolvedFromPath: nativeBinary };
212
213
  }
213
214
  catch (err) {
214
215
  return { success: false, error: err.message };
@@ -0,0 +1,19 @@
1
+ import { LRUCache } from 'lru-cache';
2
+ export interface MemoryCacheOptions<K extends {} = string, V extends {} = {}> {
3
+ /** Hard entry bound. A cache without a bound is not allowed. */
4
+ max: number;
5
+ /** Milliseconds before an entry is treated as absent. */
6
+ ttlMs: number;
7
+ /** Optional async loader. Concurrent fetches for one key are coalesced. */
8
+ fetchMethod?: (key: K, staleValue: V | undefined) => Promise<V>;
9
+ /** Injectable monotonic clock for deterministic TTL tests. */
10
+ now?: () => number;
11
+ }
12
+ /**
13
+ * Correctness-first process-local cache defaults.
14
+ *
15
+ * The cache is always bounded, never returns stale values, and does not extend
16
+ * an entry's life merely because it was read. Durable/cross-process state must
17
+ * continue to live in SQLite or an atomic on-disk snapshot.
18
+ */
19
+ export declare function createMemoryCache<K extends {}, V extends {}>(options: MemoryCacheOptions<K, V>): LRUCache<K, V>;
@@ -0,0 +1,31 @@
1
+ import { LRUCache } from 'lru-cache';
2
+ /**
3
+ * Correctness-first process-local cache defaults.
4
+ *
5
+ * The cache is always bounded, never returns stale values, and does not extend
6
+ * an entry's life merely because it was read. Durable/cross-process state must
7
+ * continue to live in SQLite or an atomic on-disk snapshot.
8
+ */
9
+ export function createMemoryCache(options) {
10
+ if (!Number.isSafeInteger(options.max) || options.max < 1) {
11
+ throw new Error('memory cache max must be a positive integer');
12
+ }
13
+ if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) {
14
+ throw new Error('memory cache ttlMs must be positive');
15
+ }
16
+ return new LRUCache({
17
+ max: options.max,
18
+ ttl: options.ttlMs,
19
+ allowStale: false,
20
+ updateAgeOnGet: false,
21
+ updateAgeOnHas: false,
22
+ ttlAutopurge: false,
23
+ // A supplied deterministic clock must be read on every operation; the
24
+ // library's 1ms cached-now optimization follows wall time, not that clock.
25
+ ttlResolution: options.now ? 0 : 1,
26
+ fetchMethod: options.fetchMethod
27
+ ? async (key, staleValue) => options.fetchMethod(key, staleValue)
28
+ : undefined,
29
+ perf: options.now ? { now: options.now } : undefined,
30
+ });
31
+ }
@@ -77,7 +77,7 @@ export declare function foldBrowserSessionsIntoProfiles(browserDir?: string): vo
77
77
  * Params default to the real on-disk locations; they are injectable so tests
78
78
  * can drive a fixture tree without touching the user's ~/.agents.
79
79
  */
80
- export declare function repairSelfReferentialBinShims(versionsRoot?: string, shimsDir?: string): void;
80
+ export declare function repairSelfReferentialBinShims(versionsRoot?: string, shimsDir?: string, historyDir?: string): void;
81
81
  /**
82
82
  * Rename the legacy `extras-extras/` plugin-marketplace dir to `agents-extras/`
83
83
  * inside every installed agent version-home, and rewrite cross-references in
@@ -14,6 +14,8 @@ import { machineId } from './machine-id.js';
14
14
  import { AGENTS, agentConfigDirName, findInPath } from './agents.js';
15
15
  import { createLink } from './platform/index.js';
16
16
  import { migrateLegacyRoutineActivation, setJobEnabled } from './routines.js';
17
+ import { addEnabledRoutinesOnUpgrade } from './routine-activation.js';
18
+ import { DAEMON_TICK_ROUTINE_NAMES } from './daemon-ticks.js';
17
19
  const HOME = process.env.HOME ?? os.homedir();
18
20
  const USER_DIR = path.join(HOME, '.agents');
19
21
  /** Canonical system-repo location (post-fold). */
@@ -825,7 +827,7 @@ function repairAgentConfigSymlinks() {
825
827
  * Params default to the real on-disk locations; they are injectable so tests
826
828
  * can drive a fixture tree without touching the user's ~/.agents.
827
829
  */
828
- export function repairSelfReferentialBinShims(versionsRoot = path.join(HISTORY_DIR, 'versions'), shimsDir = path.resolve(CACHE_DIR, 'shims')) {
830
+ export function repairSelfReferentialBinShims(versionsRoot = path.join(HISTORY_DIR, 'versions'), shimsDir = path.resolve(CACHE_DIR, 'shims'), historyDir = path.dirname(versionsRoot)) {
829
831
  // Normalize the shims dir through realpath so the prefix check below survives
830
832
  // a symlinked ~/.agents (or macOS's /tmp -> /private/tmp): fs.realpathSync on
831
833
  // the link target resolves those symlinks, so the dir we compare against must
@@ -884,7 +886,7 @@ export function repairSelfReferentialBinShims(versionsRoot = path.join(HISTORY_D
884
886
  // Self-referential: the link resolves back into our own shims dir.
885
887
  // findInPath does a pure-Node PATH scan (no subprocess) and already
886
888
  // skips our shims dir, so it returns the genuine install if one exists.
887
- const realBinary = findInPath(cli);
889
+ const realBinary = findInPath(cli, { shimsDir, historyDir });
888
890
  try {
889
891
  fs.unlinkSync(binLink);
890
892
  // createLink: a real symlink where the OS allows it (POSIX, and Windows
@@ -2205,6 +2207,9 @@ export async function runMigration() {
2205
2207
  // Rewrite routine YAML files: singular `device:` -> plural `devices: []`.
2206
2208
  migrateRoutineDeviceToDevices();
2207
2209
  migrateLegacyRoutineActivation();
2210
+ // These routines replace daemon timers that were always active. Devices with
2211
+ // an existing activation manifest must retain that behavior after upgrade.
2212
+ addEnabledRoutinesOnUpgrade(DAEMON_TICK_ROUTINE_NAMES);
2208
2213
  // Fold the legacy watchdog enable sentinel into the watchdog routine so a user
2209
2214
  // who opted in under the old build stays opted in after upgrading. After the
2210
2215
  // routine rewrites above so the routines dir is in its canonical shape.
@@ -3,6 +3,8 @@ export declare function normalizeRoutineNames(names: Iterable<string>): string[]
3
3
  export declare function enabledRoutineNames(): string[] | null;
4
4
  export declare function routineEnabledOnThisDevice(name: string): boolean | null;
5
5
  export declare function replaceEnabledRoutines(names: Iterable<string>): string[];
6
+ /** Add newly introduced replacements to an already-materialized device manifest. */
7
+ export declare function addEnabledRoutinesOnUpgrade(names: Iterable<string>): boolean;
6
8
  /**
7
9
  * Add or remove one routine on this machine. `legacyEnabledNames` seeds the
8
10
  * manifest the first time an upgraded host changes activation, preserving every
@@ -26,6 +26,22 @@ export function replaceEnabledRoutines(names) {
26
26
  updateMeta((meta) => ({ ...meta, deviceRoutines: normalized }));
27
27
  return normalized;
28
28
  }
29
+ /** Add newly introduced replacements to an already-materialized device manifest. */
30
+ export function addEnabledRoutinesOnUpgrade(names) {
31
+ const additions = normalizeRoutineNames(names);
32
+ let changed = false;
33
+ updateMeta((meta) => {
34
+ if (!Array.isArray(meta.deviceRoutines))
35
+ return meta;
36
+ const current = normalizeRoutineNames(meta.deviceRoutines);
37
+ const next = normalizeRoutineNames([...current, ...additions]);
38
+ if (next.length === current.length && next.every((name, index) => name === current[index]))
39
+ return meta;
40
+ changed = true;
41
+ return { ...meta, deviceRoutines: next };
42
+ });
43
+ return changed;
44
+ }
29
45
  /**
30
46
  * Add or remove one routine on this machine. `legacyEnabledNames` seeds the
31
47
  * manifest the first time an upgraded host changes activation, preserving every
@@ -59,6 +59,13 @@ export interface RoutineLaunchPlan {
59
59
  */
60
60
  export declare function resolveRoutineLaunch(config: JobConfig, cwd?: string, deps?: {
61
61
  resolveRunVersion?: typeof resolveRunVersion;
62
+ readAccountLabels?: () => {
63
+ labels: Record<string, {
64
+ agent: AgentId;
65
+ fingerprint: string;
66
+ }>;
67
+ };
68
+ resolveAccountLabel?: (agent: AgentId, label: string) => Promise<string>;
62
69
  }): Promise<RoutineLaunchPlan>;
63
70
  /**
64
71
  * Rewrite `cmd[0]` to the absolute binary for `agent@version` when installed.
@@ -105,5 +112,16 @@ export declare function inferFinalStatusFromLog(stdoutPath: string, agent: Agent
105
112
  status: 'completed' | 'failed';
106
113
  exitCode: number;
107
114
  } | null;
115
+ /**
116
+ * PIDs of the in-flight detached routine children on THIS device — every local
117
+ * run record still marked `running` whose spawned process is genuinely alive
118
+ * (`isPidOurs`, so a dead-and-reused pid does not count). These are the `unref`'d
119
+ * spawns that survive a daemon exit in their own process group (SING-11a): a
120
+ * takeover must not kill them and `stopDaemon` reports them rather than pretending
121
+ * the process tree is clean (SING-12). Scoped to `getRunsDir()`, which is under
122
+ * this state dir's HOME, so a different state dir's children are invisible here.
123
+ * `host:`-placed runs have no local pid and are excluded.
124
+ */
125
+ export declare function listLiveRoutineChildren(): number[];
108
126
  /** Scan all runs marked "running" and finalize any whose process has exited. */
109
127
  export declare function monitorRunningJobs(): void;
@@ -513,6 +513,11 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd(), deps = {
513
513
  // credential (RUSH-1957). Falls through to the strategy only when the account
514
514
  // is not signed in on this box, with a loud warning rather than a silent stall.
515
515
  if (config.account) {
516
+ const { readAccountLabels, resolveAccountLabel } = await import('./account-labels.js');
517
+ if ((deps.readAccountLabels ?? readAccountLabels)().labels[config.account]) {
518
+ const version = await (deps.resolveAccountLabel ?? resolveAccountLabel)(agent, config.account);
519
+ return { chain: [{ agent, version }], rotation: null, pinned: true };
520
+ }
516
521
  const version = await resolveAccountVersion(agent, config.account);
517
522
  if (version) {
518
523
  return { chain: [{ agent, version }], rotation: null, pinned: true };
@@ -1720,6 +1725,53 @@ function finalizeHostRun(meta) {
1720
1725
  }
1721
1726
  catch { /* unreachable host or unreadable sidecar — retry next sweep */ }
1722
1727
  }
1728
+ /**
1729
+ * PIDs of the in-flight detached routine children on THIS device — every local
1730
+ * run record still marked `running` whose spawned process is genuinely alive
1731
+ * (`isPidOurs`, so a dead-and-reused pid does not count). These are the `unref`'d
1732
+ * spawns that survive a daemon exit in their own process group (SING-11a): a
1733
+ * takeover must not kill them and `stopDaemon` reports them rather than pretending
1734
+ * the process tree is clean (SING-12). Scoped to `getRunsDir()`, which is under
1735
+ * this state dir's HOME, so a different state dir's children are invisible here.
1736
+ * `host:`-placed runs have no local pid and are excluded.
1737
+ */
1738
+ export function listLiveRoutineChildren() {
1739
+ const runsDir = getRunsDir();
1740
+ if (!fs.existsSync(runsDir))
1741
+ return [];
1742
+ const pids = [];
1743
+ let jobDirs;
1744
+ try {
1745
+ jobDirs = fs.readdirSync(runsDir, { withFileTypes: true }).filter((e) => e.isDirectory());
1746
+ }
1747
+ catch {
1748
+ return pids;
1749
+ }
1750
+ for (const jobDir of jobDirs) {
1751
+ const jobRunsPath = path.join(runsDir, jobDir.name);
1752
+ let runDirs;
1753
+ try {
1754
+ runDirs = fs.readdirSync(jobRunsPath, { withFileTypes: true }).filter((e) => e.isDirectory());
1755
+ }
1756
+ catch {
1757
+ continue;
1758
+ }
1759
+ for (const runDirEntry of runDirs) {
1760
+ const metaPath = path.join(jobRunsPath, runDirEntry.name, 'meta.json');
1761
+ if (!fs.existsSync(metaPath))
1762
+ continue;
1763
+ try {
1764
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
1765
+ if (meta.status !== 'running' || meta.hostTaskId || !meta.pid)
1766
+ continue;
1767
+ if (isPidOurs(meta.pid, meta.spawnedAt))
1768
+ pids.push(meta.pid);
1769
+ }
1770
+ catch { /* unreadable/partial record — skip */ }
1771
+ }
1772
+ }
1773
+ return pids;
1774
+ }
1723
1775
  /** Scan all runs marked "running" and finalize any whose process has exited. */
1724
1776
  export function monitorRunningJobs() {
1725
1777
  const runsDir = getRunsDir();
@@ -67,6 +67,23 @@ export declare function shouldSelfHealForUpgrade(persistent: boolean, storeSize:
67
67
  * keeps serving; its own sweep adopts the new code at the next quiet moment.
68
68
  */
69
69
  export declare function shouldTeardownVersionSkewedBroker(realHeldBundles: number): boolean;
70
+ /**
71
+ * Whether a version-skewed client may evict the reachable broker at all. The
72
+ * held-bundle gate above is necessary but not sufficient: a broker the always-on
73
+ * daemon is hosting must NEVER be client-evicted, even when it holds zero
74
+ * unlocks. teardownStaleBroker() recognizes only the standalone broker's
75
+ * pidPath() O_EXCL claim (the daemon writes ownerPath(), never pidPath()), so
76
+ * evicting a daemon-hosted broker unlinks its socket WITHOUT stopping the daemon;
77
+ * the daemon then keeps hostedBroker != null and shouldTakeOverBroker() refuses
78
+ * to re-host, orphaning its broker until the daemon restarts while every reader
79
+ * falls onto cold one-off brokers that re-prompt Touch ID — the storm. Deferring
80
+ * is safe: daemon code-version upgrades are handled by postinstall.js restarting
81
+ * it, and agentPing() already gated on PROTOCOL_VERSION, so a code-skewed daemon
82
+ * broker is still wire-compatible. Only when NO daemon owns the broker (churning
83
+ * dev installs with a dead/absent daemon — the case #435's client twin was built
84
+ * for) does the zero-held-bundles teardown apply, exactly as before.
85
+ */
86
+ export declare function shouldClientEvictSkewedBroker(daemonRunning: boolean, realHeldBundles: number): boolean;
70
87
  export interface StoredBundle {
71
88
  bundle: SecretsBundle;
72
89
  env: Record<string, string>;
@@ -84,6 +101,8 @@ export interface AgentStatusEntry {
84
101
  leaseId?: string;
85
102
  keys?: string[];
86
103
  }
104
+ /** Public accessor for the broker's socket path — `agents daemon status`/`services` reads it for display. */
105
+ export declare function secretsBrokerSocketPath(): string;
87
106
  /**
88
107
  * Read the current broker capability token, or null if none is present. Clients
89
108
  * read it fresh per request and attach it to every non-ping command; a broker
@@ -142,6 +142,27 @@ export function shouldSelfHealForUpgrade(persistent, storeSize, runningVersion,
142
142
  export function shouldTeardownVersionSkewedBroker(realHeldBundles) {
143
143
  return realHeldBundles === 0;
144
144
  }
145
+ /**
146
+ * Whether a version-skewed client may evict the reachable broker at all. The
147
+ * held-bundle gate above is necessary but not sufficient: a broker the always-on
148
+ * daemon is hosting must NEVER be client-evicted, even when it holds zero
149
+ * unlocks. teardownStaleBroker() recognizes only the standalone broker's
150
+ * pidPath() O_EXCL claim (the daemon writes ownerPath(), never pidPath()), so
151
+ * evicting a daemon-hosted broker unlinks its socket WITHOUT stopping the daemon;
152
+ * the daemon then keeps hostedBroker != null and shouldTakeOverBroker() refuses
153
+ * to re-host, orphaning its broker until the daemon restarts while every reader
154
+ * falls onto cold one-off brokers that re-prompt Touch ID — the storm. Deferring
155
+ * is safe: daemon code-version upgrades are handled by postinstall.js restarting
156
+ * it, and agentPing() already gated on PROTOCOL_VERSION, so a code-skewed daemon
157
+ * broker is still wire-compatible. Only when NO daemon owns the broker (churning
158
+ * dev installs with a dead/absent daemon — the case #435's client twin was built
159
+ * for) does the zero-held-bundles teardown apply, exactly as before.
160
+ */
161
+ export function shouldClientEvictSkewedBroker(daemonRunning, realHeldBundles) {
162
+ if (daemonRunning)
163
+ return false;
164
+ return shouldTeardownVersionSkewedBroker(realHeldBundles);
165
+ }
145
166
  function onDarwin() {
146
167
  return process.platform === 'darwin';
147
168
  }
@@ -175,6 +196,10 @@ function agentDir() {
175
196
  function socketPath() {
176
197
  return path.join(agentDir(), 'agent.sock');
177
198
  }
199
+ /** Public accessor for the broker's socket path — `agents daemon status`/`services` reads it for display. */
200
+ export function secretsBrokerSocketPath() {
201
+ return socketPath();
202
+ }
178
203
  function pidPath() {
179
204
  return path.join(agentDir(), 'agent.pid');
180
205
  }
@@ -345,7 +370,7 @@ export function handleAgentRequest(store, req, now = Date.now()) {
345
370
  // the broker is running pre-upgrade code and should be restarted.
346
371
  return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION, cliVersion: getCliVersion() };
347
372
  case 'get': {
348
- // Walk own-harness → global so a `--for` grant wins over a global one and
373
+ // Walk own-harness → global so an `--agent` grant wins over a global one and
349
374
  // an unscoped unlock serves every harness (bundleScopeChain).
350
375
  for (const scope of bundleScopeChain(req.harness)) {
351
376
  const key = scopedBundleKey(req.name, scope);
@@ -1334,7 +1359,12 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
1334
1359
  if (ping.reachable) {
1335
1360
  if (ping.cliVersion === undefined || ping.cliVersion === getCliVersionFresh())
1336
1361
  return true;
1337
- if (!shouldTeardownVersionSkewedBroker((await agentStatus()).length))
1362
+ // A reachable but version-skewed broker: tear it down ONLY when no daemon
1363
+ // hosts it and it holds no unlocks. Evicting a daemon-hosted broker orphans
1364
+ // the daemon's socket and starts the Touch ID storm — see
1365
+ // shouldClientEvictSkewedBroker.
1366
+ const { isDaemonRunning } = await import('../daemon.js');
1367
+ if (!shouldClientEvictSkewedBroker(isDaemonRunning(), (await agentStatus()).length))
1338
1368
  return true;
1339
1369
  await teardownStaleBroker();
1340
1370
  }
@@ -4,7 +4,7 @@
4
4
  * (bundles.ts).
5
5
  *
6
6
  * A grant is stored under a scope and read under a scope; the two must agree or
7
- * the bundle is invisible. `agents secrets unlock --for <agent>` exists to NARROW
7
+ * the bundle is invisible. `agents secrets unlock --agent <agent>` exists to NARROW
8
8
  * a grant to one harness, so an unlock without it is global by definition.
9
9
  *
10
10
  * This module deliberately has NO imports: agent.ts and session-store.ts already
@@ -13,14 +13,14 @@
13
13
  * and throw at runtime even though tsc is happy.
14
14
  */
15
15
  /**
16
- * Scope of an unlock that was not narrowed with `--for`: readable by every
16
+ * Scope of an unlock that was not narrowed with `--agent`: readable by every
17
17
  * harness. Not a valid harness name, so it can never collide with one.
18
18
  */
19
19
  export declare const GLOBAL_HARNESS = "*";
20
20
  /**
21
21
  * Scopes a reader consults, most specific first: its own harness, then the global
22
22
  * grant. This is the resolution order of the scoped-grant model — a narrow
23
- * `--for claude` unlock stays claude-only while an unscoped unlock serves
23
+ * `--agent claude` unlock stays claude-only while an unscoped unlock serves
24
24
  * everyone — not a fallback papering over a miss.
25
25
  */
26
26
  export declare function bundleScopeChain(harness: string | undefined): string[];
@@ -4,7 +4,7 @@
4
4
  * (bundles.ts).
5
5
  *
6
6
  * A grant is stored under a scope and read under a scope; the two must agree or
7
- * the bundle is invisible. `agents secrets unlock --for <agent>` exists to NARROW
7
+ * the bundle is invisible. `agents secrets unlock --agent <agent>` exists to NARROW
8
8
  * a grant to one harness, so an unlock without it is global by definition.
9
9
  *
10
10
  * This module deliberately has NO imports: agent.ts and session-store.ts already
@@ -13,14 +13,14 @@
13
13
  * and throw at runtime even though tsc is happy.
14
14
  */
15
15
  /**
16
- * Scope of an unlock that was not narrowed with `--for`: readable by every
16
+ * Scope of an unlock that was not narrowed with `--agent`: readable by every
17
17
  * harness. Not a valid harness name, so it can never collide with one.
18
18
  */
19
19
  export const GLOBAL_HARNESS = '*';
20
20
  /**
21
21
  * Scopes a reader consults, most specific first: its own harness, then the global
22
22
  * grant. This is the resolution order of the scoped-grant model — a narrow
23
- * `--for claude` unlock stays claude-only while an unscoped unlock serves
23
+ * `--agent claude` unlock stays claude-only while an unscoped unlock serves
24
24
  * everyone — not a fallback papering over a miss.
25
25
  */
26
26
  export function bundleScopeChain(harness) {