@phnx-labs/agents-cli 1.22.29 → 1.22.31

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 (82) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +44 -5
  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/focus.d.ts +4 -1
  10. package/dist/commands/focus.js +19 -4
  11. package/dist/commands/routines.js +29 -11
  12. package/dist/commands/secrets.d.ts +37 -0
  13. package/dist/commands/secrets.js +86 -105
  14. package/dist/commands/sessions-bookmark.d.ts +20 -0
  15. package/dist/commands/{sessions-favorite.js → sessions-bookmark.js} +42 -42
  16. package/dist/commands/sessions-browser.d.ts +10 -8
  17. package/dist/commands/sessions-browser.js +61 -32
  18. package/dist/commands/sessions-picker.d.ts +33 -1
  19. package/dist/commands/sessions-picker.js +102 -27
  20. package/dist/commands/sessions-stats.js +1 -1
  21. package/dist/commands/sessions.d.ts +21 -8
  22. package/dist/commands/sessions.js +328 -74
  23. package/dist/commands/view.d.ts +11 -0
  24. package/dist/commands/view.js +56 -29
  25. package/dist/index.js +37 -2
  26. package/dist/lib/account-labels.d.ts +24 -0
  27. package/dist/lib/account-labels.js +72 -0
  28. package/dist/lib/agents.d.ts +32 -1
  29. package/dist/lib/agents.js +96 -31
  30. package/dist/lib/daemon-health.d.ts +24 -0
  31. package/dist/lib/daemon-health.js +84 -0
  32. package/dist/lib/daemon-ticks.d.ts +81 -0
  33. package/dist/lib/daemon-ticks.js +190 -0
  34. package/dist/lib/daemon.d.ts +68 -18
  35. package/dist/lib/daemon.js +303 -338
  36. package/dist/lib/device-config.d.ts +10 -0
  37. package/dist/lib/device-config.js +27 -0
  38. package/dist/lib/exec.d.ts +27 -0
  39. package/dist/lib/exec.js +49 -2
  40. package/dist/lib/hosts/dispatch.d.ts +4 -0
  41. package/dist/lib/hosts/dispatch.js +4 -0
  42. package/dist/lib/hosts/remote-cmd.js +1 -0
  43. package/dist/lib/hosts/run-target.d.ts +1 -0
  44. package/dist/lib/hosts/run-target.js +1 -0
  45. package/dist/lib/import.js +7 -6
  46. package/dist/lib/memory-cache.d.ts +19 -0
  47. package/dist/lib/memory-cache.js +31 -0
  48. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  49. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  50. package/dist/lib/migrate.d.ts +1 -1
  51. package/dist/lib/migrate.js +13 -2
  52. package/dist/lib/picker.d.ts +6 -3
  53. package/dist/lib/picker.js +7 -2
  54. package/dist/lib/routine-activation.d.ts +2 -0
  55. package/dist/lib/routine-activation.js +16 -0
  56. package/dist/lib/runner.d.ts +18 -0
  57. package/dist/lib/runner.js +52 -0
  58. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  59. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  60. package/dist/lib/secrets/agent.d.ts +19 -1
  61. package/dist/lib/secrets/agent.js +32 -6
  62. package/dist/lib/secrets/scope.d.ts +3 -3
  63. package/dist/lib/secrets/scope.js +3 -3
  64. package/dist/lib/secrets/session-store.d.ts +0 -4
  65. package/dist/lib/secrets/session-store.js +0 -5
  66. package/dist/lib/session/{favorites.d.ts → bookmarks.d.ts} +15 -15
  67. package/dist/lib/session/{favorites.js → bookmarks.js} +23 -23
  68. package/dist/lib/session/db.d.ts +15 -0
  69. package/dist/lib/session/db.js +90 -15
  70. package/dist/lib/session/discover.js +91 -39
  71. package/dist/lib/session/parse.d.ts +63 -0
  72. package/dist/lib/session/parse.js +165 -20
  73. package/dist/lib/session/session-cache.d.ts +9 -6
  74. package/dist/lib/session/session-cache.js +23 -6
  75. package/dist/lib/shims.js +12 -0
  76. package/dist/lib/startup/command-registry.d.ts +15 -1
  77. package/dist/lib/startup/command-registry.js +49 -0
  78. package/dist/lib/usage-refresh.js +3 -2
  79. package/dist/lib/usage.d.ts +12 -10
  80. package/dist/lib/usage.js +63 -144
  81. package/package.json +4 -1
  82. package/dist/commands/sessions-favorite.d.ts +0 -20
@@ -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
@@ -1013,6 +1015,11 @@ function migrateRuntimeToHistory() {
1013
1015
  catch { /* best-effort */ }
1014
1016
  }
1015
1017
  }
1018
+ /** Rename the session marker store after the product vocabulary changed from
1019
+ * favorite to bookmark. The destination wins if a newer CLI already wrote it. */
1020
+ function migrateLegacySessionMarkersToBookmarks() {
1021
+ moveFileOnce(path.join(HISTORY_DIR, 'favorites.json'), path.join(HISTORY_DIR, 'bookmarks.json'));
1022
+ }
1016
1023
  /**
1017
1024
  * Restore plugins from the cache bucket back to the user-root.
1018
1025
  *
@@ -2176,6 +2183,7 @@ export async function runMigration() {
2176
2183
  migrateSplitDeviceLocalMeta();
2177
2184
  // Bucket moves: collapse runtime state into ~/.agents/.history and ~/.agents/.cache.
2178
2185
  migrateRuntimeToHistory();
2186
+ migrateLegacySessionMarkersToBookmarks();
2179
2187
  migrateRuntimeToCache();
2180
2188
  // Restore plugins (user-authored) from cache back to user-root. Runs AFTER
2181
2189
  // migrateRuntimeToCache so any legacy plugins/ still at the user-root from
@@ -2205,6 +2213,9 @@ export async function runMigration() {
2205
2213
  // Rewrite routine YAML files: singular `device:` -> plural `devices: []`.
2206
2214
  migrateRoutineDeviceToDevices();
2207
2215
  migrateLegacyRoutineActivation();
2216
+ // These routines replace daemon timers that were always active. Devices with
2217
+ // an existing activation manifest must retain that behavior after upgrade.
2218
+ addEnabledRoutinesOnUpgrade(DAEMON_TICK_ROUTINE_NAMES);
2208
2219
  // Fold the legacy watchdog enable sentinel into the watchdog routine so a user
2209
2220
  // who opted in under the old build stays opted in after upgrading. After the
2210
2221
  // routine rewrites above so the routines dir is in its canonical shape.
@@ -96,7 +96,7 @@ export declare function itemPicker<T>(config: PickerConfig<T>): Promise<PickedIt
96
96
  */
97
97
  export declare function multiItemPicker<T>(config: MultiPickerConfig<T>): Promise<T[] | null>;
98
98
  /** Configuration for the dynamic (async-refetch) picker prompt. */
99
- export interface DynamicPickerConfig<T, F> {
99
+ export interface DynamicPickerConfig<T, F, A = never> {
100
100
  message: string;
101
101
  /** The initial filter state. Changing it (via a keybinding) re-runs {@link load}. */
102
102
  initialFilter: F;
@@ -117,6 +117,8 @@ export interface DynamicPickerConfig<T, F> {
117
117
  * SAME reference is a no-op; a new object triggers a reload.
118
118
  */
119
119
  keyBindings?: Record<string, (filter: F) => F>;
120
+ /** Keys that submit the highlighted row with an alternate typed action. */
121
+ submitKeys?: Record<string, A>;
120
122
  /**
121
123
  * Side-effecting keys that don't change the filter (e.g. `y` copies a command).
122
124
  * Receives the live search `query` so the effect can be search-aware. Return a
@@ -160,9 +162,10 @@ export declare function hotkeyToken(key: {
160
162
  meta?: boolean;
161
163
  }): string;
162
164
  /** The result returned when the user selects a row: the item plus the live filter. */
163
- export interface DynamicPicked<T, F> {
165
+ export interface DynamicPicked<T, F, A = never> {
164
166
  item: T;
165
167
  filter: F;
168
+ action?: A;
166
169
  }
167
170
  /**
168
171
  * Async-refetch variant of {@link itemPicker}. Holds a `filter` object in state and
@@ -174,4 +177,4 @@ export interface DynamicPicked<T, F> {
174
177
  * Same render/pagination/preview machinery as the static pickers — only the data
175
178
  * source and keymap are dynamic.
176
179
  */
177
- export declare function dynamicPicker<T, F>(config: DynamicPickerConfig<T, F>): Promise<DynamicPicked<T, F> | null>;
180
+ export declare function dynamicPicker<T, F, A = never>(config: DynamicPickerConfig<T, F, A>): Promise<DynamicPicked<T, F, A> | null>;
@@ -490,11 +490,11 @@ export function dynamicPicker(config) {
490
490
  setActive(0);
491
491
  }, [results]);
492
492
  const selected = results[active];
493
- const finish = () => {
493
+ const finish = (action) => {
494
494
  if (!selected)
495
495
  return;
496
496
  setStatus('done');
497
- done({ item: selected.value, filter });
497
+ done({ item: selected.value, filter, ...(action === undefined ? {} : { action }) });
498
498
  };
499
499
  useKeypress((key, rl) => {
500
500
  if (isEnterKey(key)) {
@@ -575,6 +575,11 @@ export function dynamicPicker(config) {
575
575
  // existing single-letter hotkey: `R`/`C`/`A` used to reach their bindings
576
576
  // via `key.name`, and keying on the character alone would silently retire
577
577
  // them for anyone with caps lock on.
578
+ const submitAction = cfg.submitKeys?.[token] ?? cfg.submitKeys?.[key.name ?? ''];
579
+ if (submitAction !== undefined) {
580
+ finish(submitAction);
581
+ return;
582
+ }
578
583
  const binding = cfg.keyBindings?.[token] ?? cfg.keyBindings?.[key.name ?? ''];
579
584
  if (binding) {
580
585
  const next = binding(filter);
@@ -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
@@ -444,7 +463,6 @@ export declare function agentLoad(name: string, bundle: SecretsBundle, env: Reco
444
463
  /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
445
464
  * wiped, or 0 when no broker is running. */
446
465
  export declare function agentLock(name?: string): Promise<number>;
447
- export declare function agentRevoke(leaseId: string): Promise<number>;
448
466
  /** List currently-unlocked bundles, or [] when no broker is running. The
449
467
  * internal `secrets list` metadata-cache entry is filtered out here as well as
450
468
  * server-side: during a rollout a NEW client can talk to an OLD broker that