@phnx-labs/agents-cli 1.20.15 → 1.20.16

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 (37) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/dist/commands/secrets.js +53 -1
  3. package/dist/commands/sessions-sync.d.ts +13 -0
  4. package/dist/commands/sessions-sync.js +73 -0
  5. package/dist/commands/sessions.js +2 -0
  6. package/dist/commands/view.js +11 -3
  7. package/dist/index.js +1 -1
  8. package/dist/lib/agents.d.ts +11 -0
  9. package/dist/lib/agents.js +11 -9
  10. package/dist/lib/daemon.d.ts +19 -0
  11. package/dist/lib/daemon.js +97 -2
  12. package/dist/lib/migrate.d.ts +22 -0
  13. package/dist/lib/migrate.js +99 -1
  14. package/dist/lib/plugin-marketplace.d.ts +15 -0
  15. package/dist/lib/plugin-marketplace.js +44 -0
  16. package/dist/lib/secrets/index.js +20 -0
  17. package/dist/lib/session/parse.d.ts +2 -0
  18. package/dist/lib/session/parse.js +168 -2
  19. package/dist/lib/session/sync/agents.d.ts +46 -0
  20. package/dist/lib/session/sync/agents.js +94 -0
  21. package/dist/lib/session/sync/config.d.ts +30 -0
  22. package/dist/lib/session/sync/config.js +58 -0
  23. package/dist/lib/session/sync/crdt.d.ts +44 -0
  24. package/dist/lib/session/sync/crdt.js +119 -0
  25. package/dist/lib/session/sync/manifest.d.ts +51 -0
  26. package/dist/lib/session/sync/manifest.js +96 -0
  27. package/dist/lib/session/sync/r2.d.ts +32 -0
  28. package/dist/lib/session/sync/r2.js +121 -0
  29. package/dist/lib/session/sync/sync.d.ts +82 -0
  30. package/dist/lib/session/sync/sync.js +251 -0
  31. package/dist/lib/shims.d.ts +1 -1
  32. package/dist/lib/shims.js +17 -1
  33. package/dist/lib/teams/parsers.js +159 -1
  34. package/dist/lib/usage.d.ts +18 -0
  35. package/dist/lib/usage.js +25 -0
  36. package/dist/lib/versions.js +30 -13
  37. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ **`agents secrets get/set <item>`: raw, cross-platform keychain access for hooks**
6
+
7
+ - New `agents secrets get <item>` / `agents secrets set <item>` read and write a single keychain item **by bare name** (outside the bundle namespace), so shell hooks and automation have one platform-agnostic credential primitive to call instead of hardcoding `/usr/bin/security` (macOS-only) or `secret-tool` (Linux-only). `get` prints the value to stdout (newline-terminated for clean `$(…)` capture), sends diagnostics to stderr, and exits 1 with empty stdout when the item is missing — exactly what a `SessionStart` hook needs to probe-and-fallback quietly. Routing goes through the existing cross-platform keychain layer: macOS via `/usr/bin/security`, Linux via `secret-tool` with the encrypted-file fallback.
8
+ - `setKeychainToken` now writes bare (non-`agents-cli.`) items on macOS **without** the biometry ACL, mirroring the existing no-prompt read path for such items. This is what lets a hook read e.g. `linear-api-key` silently on every launch — routing it through the Touch ID helper would attach an ACL the `/usr/bin/security` read can't satisfy without popping the legacy password sheet. The change is purely additive: every existing caller passes an `agents-cli.`-namespaced item and is unaffected (still biometry-gated via the signed helper).
9
+
5
10
  **`agents inspect` summary: expanded detail for hooks, plugins, and MCP**
6
11
 
7
12
  - The bare `agents inspect <agent>` / `agents inspect <repo>` summary no longer collapses everything to a count table. Simple kinds (commands, skills, rules, subagents, workflows) keep a count line but now preview a few names; the rich kinds get their own expanded sections: **hooks** show their events + `matches:` predicates + cache (`PreToolUse(Bash) · git_dirty · prompt~"deploy" (5m cache)`), **plugins** show version + bundle contents (`v2.1.0 skills:6 commands:5 hooks:2 mcp:1`), and **MCP** show transport + url/command. Drill-down flags (`--hooks`, `--plugins`, `--mcp`) and `--brief` are unchanged; `--json` gains the structured detail additively (existing keys retained).
@@ -8,7 +8,7 @@
8
8
  import chalk from 'chalk';
9
9
  import * as fs from 'fs';
10
10
  import { bundleExists, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
11
- import { deleteKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
11
+ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
12
12
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
13
13
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
14
14
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
@@ -365,6 +365,7 @@ export function registerSecretsCommands(program) {
365
365
  registerCommandGroups(cmd, [
366
366
  { title: 'Bundle commands', names: ['list', 'view', 'create', 'rename', 'describe', 'delete'] },
367
367
  { title: 'Secret commands', names: ['add', 'rotate', 'remove', 'import', 'export'] },
368
+ { title: 'Raw item commands', names: ['get', 'set'] },
368
369
  { title: 'Sync commands', names: ['push', 'pull', 'remote-list'] },
369
370
  { title: 'Utilities', names: ['exec', 'generate', 'migrate-acl'] },
370
371
  ]);
@@ -465,6 +466,57 @@ export function registerSecretsCommands(program) {
465
466
  process.exit(1);
466
467
  }
467
468
  });
469
+ cmd
470
+ .command('get <item>')
471
+ .description('Print a raw keychain item by name (for shell hooks/automation). Cross-platform; no bundle required.')
472
+ .action((item) => {
473
+ try {
474
+ // Routes through the platform keychain layer: macOS reads bare items
475
+ // via /usr/bin/security (no Touch ID), Linux via secret-tool with the
476
+ // encrypted-file fallback. The value goes to stdout (newline-terminated
477
+ // so `$(agents secrets get NAME)` captures it cleanly); diagnostics go
478
+ // to stderr so they never pollute the captured value.
479
+ const value = getKeychainToken(item);
480
+ process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
481
+ }
482
+ catch {
483
+ // Missing item is a normal, quiet outcome for a hook probe: exit 1,
484
+ // print nothing to stdout. Callers test the exit code / empty capture.
485
+ process.exit(1);
486
+ }
487
+ });
488
+ cmd
489
+ .command('set <item>')
490
+ .description('Store a raw keychain item by name (for shell hooks/automation). Cross-platform; no bundle required.')
491
+ .option('--value <v>', 'Value to store (omit to read from stdin or be prompted)')
492
+ .option('--value-stdin', 'Read the value from stdin')
493
+ .action(async (item, opts) => {
494
+ try {
495
+ let value;
496
+ if (opts.value !== undefined) {
497
+ value = opts.value;
498
+ }
499
+ else if (opts.valueStdin) {
500
+ value = readStdinSync();
501
+ if (!value)
502
+ throw new Error('No value received on stdin.');
503
+ }
504
+ else {
505
+ value = await promptForSecret(`Enter value for ${item}`);
506
+ }
507
+ // setKeychainToken stores bare items WITHOUT the biometry ACL on macOS
508
+ // so `agents secrets get` can read them back without a password sheet;
509
+ // on Linux it goes through secret-tool / encrypted-file fallback.
510
+ setKeychainToken(item, value);
511
+ console.error(chalk.green(`Stored keychain item '${item}'.`));
512
+ }
513
+ catch (err) {
514
+ if (isPromptCancelled(err))
515
+ return;
516
+ console.error(chalk.red(err.message));
517
+ process.exit(1);
518
+ }
519
+ });
468
520
  cmd
469
521
  .command('create [name]')
470
522
  .description('Create an empty bundle')
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `agents sessions sync` — push this machine's transcripts to R2 and pull every
3
+ * other machine's, merging copies of the same session via CRDT union. The local
4
+ * sessions index is rebuilt from the synced-in mirror by the normal scanner.
5
+ */
6
+ import type { Command } from 'commander';
7
+ interface SyncCmdOptions {
8
+ verbose?: boolean;
9
+ json?: boolean;
10
+ }
11
+ export declare function runSessionsSync(options: SyncCmdOptions): Promise<void>;
12
+ export declare function registerSessionsSyncCommand(sessionsCmd: Command): void;
13
+ export {};
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `agents sessions sync` — push this machine's transcripts to R2 and pull every
3
+ * other machine's, merging copies of the same session via CRDT union. The local
4
+ * sessions index is rebuilt from the synced-in mirror by the normal scanner.
5
+ */
6
+ import chalk from 'chalk';
7
+ import { setHelpSections } from '../lib/help.js';
8
+ import { isSyncConfigured, SYNC_BUNDLE } from '../lib/session/sync/config.js';
9
+ import { syncSessions } from '../lib/session/sync/sync.js';
10
+ export async function runSessionsSync(options) {
11
+ if (!isSyncConfigured()) {
12
+ console.error(chalk.red(`Sessions sync is not configured.`) +
13
+ `\nAdd R2 credentials to the '${SYNC_BUNDLE}' bundle:\n` +
14
+ ` agents secrets add ${SYNC_BUNDLE} R2_ACCOUNT_ID\n` +
15
+ ` agents secrets add ${SYNC_BUNDLE} R2_BUCKET_NAME\n` +
16
+ ` agents secrets add ${SYNC_BUNDLE} R2_ACCESS_KEY_ID\n` +
17
+ ` agents secrets add ${SYNC_BUNDLE} R2_SECRET_ACCESS_KEY`);
18
+ process.exitCode = 1;
19
+ return;
20
+ }
21
+ try {
22
+ const result = await syncSessions({
23
+ verbose: options.verbose,
24
+ log: msg => console.error(chalk.dim(msg)),
25
+ });
26
+ if (options.json) {
27
+ console.log(JSON.stringify(result, null, 2));
28
+ }
29
+ else {
30
+ const parts = [
31
+ `pushed ${result.pushed}`,
32
+ `pulled ${result.pulled}`,
33
+ result.merged > 0 ? `merged ${result.merged}` : null,
34
+ ].filter(Boolean);
35
+ console.log(chalk.green('synced') + ` ${result.machine}: ` + parts.join(', ') +
36
+ chalk.dim(` (${result.pushSkipped + result.pullSkipped} unchanged)`));
37
+ }
38
+ if (result.errors.length > 0) {
39
+ for (const e of result.errors)
40
+ console.error(chalk.yellow(` ! ${e}`));
41
+ process.exitCode = 1;
42
+ }
43
+ }
44
+ catch (err) {
45
+ console.error(chalk.red(`sync failed: ${err.message}`));
46
+ process.exitCode = 1;
47
+ }
48
+ }
49
+ export function registerSessionsSyncCommand(sessionsCmd) {
50
+ const syncCmd = sessionsCmd
51
+ .command('sync')
52
+ .description('Sync session transcripts across machines via R2 (CRDT merge). Claude and Codex.')
53
+ .option('-v, --verbose', 'Log each pushed and pulled session')
54
+ .option('--json', 'Output the sync result as JSON');
55
+ setHelpSections(syncCmd, {
56
+ examples: `
57
+ # One sync cycle (push local changes, pull + merge from other machines)
58
+ agents sessions sync
59
+
60
+ # See exactly what moved
61
+ agents sessions sync --verbose
62
+ `,
63
+ notes: `
64
+ - Credentials come from the '${SYNC_BUNDLE}' secrets bundle (R2 S3 API, read+write).
65
+ - Each machine writes only its own prefix; conflicts are impossible by construction.
66
+ - The daemon runs this automatically (~90s); this command forces an immediate cycle.
67
+ - Sessions present locally always win; synced-in copies fill in other machines' sessions.
68
+ `,
69
+ });
70
+ syncCmd.action(async (options) => {
71
+ await runSessionsSync(options);
72
+ });
73
+ }
@@ -29,6 +29,7 @@ import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
29
29
  import { sessionPicker } from './sessions-picker.js';
30
30
  import { setHelpSections } from '../lib/help.js';
31
31
  import { registerSessionsTailCommand } from './sessions-tail.js';
32
+ import { registerSessionsSyncCommand } from './sessions-sync.js';
32
33
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
33
34
  const CLAUDE_RESUME_MATCH_WINDOW_MS = 10 * 60_000;
34
35
  const LOAD_VERBS = ['Loading', 'Scanning', 'Gathering', 'Indexing', 'Reading'];
@@ -1144,6 +1145,7 @@ export function registerSessionsCommands(program) {
1144
1145
  await sessionsAction(query, options);
1145
1146
  });
1146
1147
  registerSessionsTailCommand(sessionsCmd);
1148
+ registerSessionsSyncCommand(sessionsCmd);
1147
1149
  }
1148
1150
  function formatNoSessionsMessage(showAll, project) {
1149
1151
  const projectQuery = project?.trim();
@@ -3,7 +3,7 @@ import ora from 'ora';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
5
5
  import { AGENTS, ALL_AGENT_IDS, getAllCliStates, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
6
- import { formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
6
+ import { deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
7
7
  import { readManifest } from '../lib/manifest.js';
8
8
  import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, resolveVersionAlias, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, } from '../lib/versions.js';
9
9
  import { ensureVersionedAliasCurrent, removeShim, } from '../lib/shims.js';
@@ -306,7 +306,11 @@ async function showInstalledVersions(filterAgentId) {
306
306
  return {
307
307
  ...info,
308
308
  plan: canon.plan,
309
- usageStatus: canon.usageStatus,
309
+ // Throttle state comes from the live usage windows, not the pay-as-you-go
310
+ // overage flag that AccountInfo.usageStatus used to carry. A maxed window
311
+ // means rate-limited; no snapshot means no badge. See
312
+ // deriveUsageStatusFromSnapshot.
313
+ usageStatus: deriveUsageStatusFromSnapshot(usageByKey.get(key)?.snapshot),
310
314
  overageCredits: canon.overageCredits,
311
315
  };
312
316
  };
@@ -984,7 +988,11 @@ async function collectAgentsJson(filterAgentId) {
984
988
  return {
985
989
  ...info,
986
990
  plan: canon.plan,
987
- usageStatus: canon.usageStatus,
991
+ // Throttle state comes from the live usage windows, not the pay-as-you-go
992
+ // overage flag that AccountInfo.usageStatus used to carry. A maxed window
993
+ // means rate-limited; no snapshot means no badge. See
994
+ // deriveUsageStatusFromSnapshot.
995
+ usageStatus: deriveUsageStatusFromSnapshot(usageByKey.get(key)?.snapshot),
988
996
  overageCredits: canon.overageCredits,
989
997
  };
990
998
  };
package/dist/index.js CHANGED
@@ -893,7 +893,7 @@ if (process.env.AGENTS_SKIP_MIGRATION !== '1') {
893
893
  // Bumping the suffix re-runs migrations for every user; binary releases that
894
894
  // don't change the schema must NOT re-run (they would destroy user content
895
895
  // when migration steps overlap with user-authored paths). See issue #20.
896
- const sentinelValue = 'v9';
896
+ const sentinelValue = 'v10';
897
897
  let needRun = true;
898
898
  try {
899
899
  if (fs.existsSync(sentinel) && fs.readFileSync(sentinel, 'utf-8').trim() === sentinelValue) {
@@ -13,6 +13,17 @@ export interface CliState {
13
13
  export declare const CODEX_HOOKS_MIN_VERSION = "0.116.0";
14
14
  /** Minimum Gemini CLI version that supports the hooks system (v0.26.0, Jan 2026). */
15
15
  export declare const GEMINI_HOOKS_MIN_VERSION = "0.26.0";
16
+ /**
17
+ * Synchronous PATH search -- no subprocess. Returns first matching binary path.
18
+ *
19
+ * Skips our own shims dir (`~/.agents/.cache/shims/`) — those shims are
20
+ * dispatch helpers, not real installs. Counting them as installed produced a
21
+ * false positive where agents with NO real binary on the host (e.g. a
22
+ * never-installed Cursor whose only PATH entry was our `cursor-agent` shim
23
+ * dispatcher) showed up under `agents view`'s "Not Managed by Agents CLI"
24
+ * section, even though the user had nothing to import.
25
+ */
26
+ export declare function findInPath(command: string): string | null;
16
27
  /**
17
28
  * Master registry of all supported agents keyed by AgentId.
18
29
  *
@@ -66,7 +66,7 @@ function saveCliVersionCache() {
66
66
  * dispatcher) showed up under `agents view`'s "Not Managed by Agents CLI"
67
67
  * section, even though the user had nothing to import.
68
68
  */
69
- function findInPath(command) {
69
+ export function findInPath(command) {
70
70
  const pathEnv = process.env.PATH || '';
71
71
  const pathExt = process.platform === 'win32' ? (process.env.PATHEXT || '').split(';') : [''];
72
72
  const shimsDir = getShimsDir();
@@ -814,14 +814,16 @@ export async function getAccountInfo(agentId, home) {
814
814
  else if (oa?.billingType) {
815
815
  plan = oa.billingType;
816
816
  }
817
- let usageStatus = null;
818
- const reason = data.cachedExtraUsageDisabledReason;
819
- if (reason === 'out_of_credits')
820
- usageStatus = 'out_of_credits';
821
- else if (reason)
822
- usageStatus = 'rate_limited';
823
- else
824
- usageStatus = 'available';
817
+ // usageStatus is NOT derived from cachedExtraUsageDisabledReason. That
818
+ // field reports why pay-as-you-go overage is off (out_of_credits = no
819
+ // overage credits purchased; org_level_disabled = admin turned overage
820
+ // off), which says nothing about whether the account is throttled — a
821
+ // Pro account at 5% weekly usage with overage disabled is fully usable.
822
+ // Real throttle state comes from the live usage windows; callers derive
823
+ // it via deriveUsageStatusFromSnapshot(). Here we only report whether
824
+ // the account is signed in at all. Overage state stays visible through
825
+ // overageCredits below.
826
+ const usageStatus = email ? 'available' : null;
825
827
  let overageCredits = null;
826
828
  const orgId = oa?.organizationUuid;
827
829
  const creditCache = orgId && data.overageCreditGrantCache?.[orgId];
@@ -18,6 +18,16 @@ export declare function isDaemonRunning(): boolean;
18
18
  export declare function log(level: string, message: string): void;
19
19
  /** Main daemon loop: load jobs, schedule crons, monitor runs, and handle signals. */
20
20
  export declare function runDaemon(): Promise<void>;
21
+ /**
22
+ * Read the long-lived Claude OAuth token (from `claude setup-token`) that the
23
+ * user stored under the `claude` secrets bundle. Resolves the bundle the same
24
+ * way `agents run --secrets` does, so the token is found whether it was stored
25
+ * keychain-backed or as a literal. Returns null when the bundle/key isn't
26
+ * configured, the Keychain read is cancelled, or the platform has no keychain —
27
+ * the daemon then behaves exactly as before (relying on the interactive OAuth
28
+ * session). Never throws: a misconfigured token must not block daemon startup.
29
+ */
30
+ export declare function readDaemonClaudeOAuthToken(): string | null;
21
31
  /** Generate a macOS launchd plist for auto-starting the daemon. */
22
32
  export declare function generateLaunchdPlist(): string;
23
33
  /** Generate a Linux systemd user unit for auto-starting the daemon. */
@@ -27,6 +37,15 @@ export declare function startDaemon(): {
27
37
  pid: number | null;
28
38
  method: string;
29
39
  };
40
+ /**
41
+ * Environment for the detached daemon fallback. The launchd/systemd paths
42
+ * deliver the long-lived OAuth token via the service manifest's environment;
43
+ * the detached path has no manifest, so inject it here. Read happens during an
44
+ * interactive `routines start`, so a Keychain Touch ID prompt can be satisfied;
45
+ * the daemon then passes it to every routine run it spawns. An already-set
46
+ * value (e.g. inherited from launchd) is left untouched.
47
+ */
48
+ export declare function buildDetachedDaemonEnv(baseEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
30
49
  /** Stop the daemon, unloading it from launchd/systemd if applicable. */
31
50
  export declare function stopDaemon(): boolean;
32
51
  /** Get current daemon status including running state, PID, and enabled job count. */
@@ -18,6 +18,7 @@ import { executeJobDetached, monitorRunningJobs } from './runner.js';
18
18
  import { detectOverdueJobs, notifyOverdue } from './overdue.js';
19
19
  import { BrowserService } from './browser/service.js';
20
20
  import { BrowserIPCServer } from './browser/ipc.js';
21
+ import { readAndResolveBundleEnv } from './secrets/bundles.js';
21
22
  const PID_FILE = 'daemon.pid';
22
23
  const LOCK_FILE = 'daemon.lock';
23
24
  const LOG_FILE = 'logs.jsonl';
@@ -25,6 +26,12 @@ const LOG_MAX_SIZE = 5 * 1024 * 1024; // 5 MB
25
26
  const LOG_ROTATE_COUNT = 3;
26
27
  const PLIST_NAME = 'com.phnx-labs.agents-daemon';
27
28
  const SYSTEMD_UNIT = 'agents-daemon.service';
29
+ // A long-lived `claude setup-token` value stored in this secrets bundle/key is
30
+ // baked into the daemon's service-manager environment so headless routine runs
31
+ // authenticate without depending on the short-lived interactive Keychain OAuth
32
+ // session (which expires between runs and produces intermittent 401s).
33
+ const DAEMON_OAUTH_BUNDLE = 'claude';
34
+ const DAEMON_OAUTH_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
28
35
  function getDaemonDir() {
29
36
  const dir = getDaemonDirRoot();
30
37
  fs.mkdirSync(dir, { recursive: true });
@@ -225,6 +232,34 @@ export async function runDaemon() {
225
232
  const monitorInterval = setInterval(() => {
226
233
  monitorRunningJobs();
227
234
  }, 60_000);
235
+ // Cross-machine session sync: push this machine's transcripts to R2 and pull
236
+ // every other machine's, ~every 90s. Skipped silently when the r2.backups
237
+ // bundle is absent. An overlap guard prevents a slow cycle from stacking.
238
+ let syncing = false;
239
+ const runSessionSync = async () => {
240
+ if (syncing)
241
+ return;
242
+ syncing = true;
243
+ try {
244
+ const { isSyncConfigured } = await import('./session/sync/config.js');
245
+ if (!isSyncConfigured())
246
+ return;
247
+ const { syncSessions } = await import('./session/sync/sync.js');
248
+ const r = await syncSessions();
249
+ if (r.pushed || r.pulled || r.errors.length) {
250
+ log('INFO', `sessions sync: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}` +
251
+ (r.errors.length ? `, ${r.errors.length} error(s): ${r.errors[0]}` : ''));
252
+ }
253
+ }
254
+ catch (err) {
255
+ log('ERROR', `sessions sync failed: ${err.message}`);
256
+ }
257
+ finally {
258
+ syncing = false;
259
+ }
260
+ };
261
+ const syncInterval = setInterval(() => { void runSessionSync(); }, 90_000);
262
+ void runSessionSync(); // kick once at startup
228
263
  const handleReload = () => {
229
264
  log('INFO', 'Reloading jobs (SIGHUP)');
230
265
  scheduler.reloadAll();
@@ -236,6 +271,7 @@ export async function runDaemon() {
236
271
  scheduler.stopAll();
237
272
  await browserIPC.stop();
238
273
  clearInterval(monitorInterval);
274
+ clearInterval(syncInterval);
239
275
  removeDaemonPid();
240
276
  process.exit(0);
241
277
  };
@@ -244,10 +280,42 @@ export async function runDaemon() {
244
280
  process.on('SIGINT', () => handleShutdown());
245
281
  await new Promise(() => { });
246
282
  }
283
+ /**
284
+ * Read the long-lived Claude OAuth token (from `claude setup-token`) that the
285
+ * user stored under the `claude` secrets bundle. Resolves the bundle the same
286
+ * way `agents run --secrets` does, so the token is found whether it was stored
287
+ * keychain-backed or as a literal. Returns null when the bundle/key isn't
288
+ * configured, the Keychain read is cancelled, or the platform has no keychain —
289
+ * the daemon then behaves exactly as before (relying on the interactive OAuth
290
+ * session). Never throws: a misconfigured token must not block daemon startup.
291
+ */
292
+ export function readDaemonClaudeOAuthToken() {
293
+ try {
294
+ const { env } = readAndResolveBundleEnv(DAEMON_OAUTH_BUNDLE, { caller: 'daemon' });
295
+ const token = (env[DAEMON_OAUTH_KEY] ?? '').trim();
296
+ return token.length > 0 ? token : null;
297
+ }
298
+ catch {
299
+ return null;
300
+ }
301
+ }
302
+ /** Escape a string for safe inclusion in an XML <string> node. */
303
+ function xmlEscape(s) {
304
+ return s
305
+ .replace(/&/g, '&amp;')
306
+ .replace(/</g, '&lt;')
307
+ .replace(/>/g, '&gt;');
308
+ }
247
309
  /** Generate a macOS launchd plist for auto-starting the daemon. */
248
310
  export function generateLaunchdPlist() {
249
311
  const agentsBin = getAgentsBinPath();
250
312
  const logPath = getLogPath();
313
+ const oauthToken = readDaemonClaudeOAuthToken();
314
+ const oauthEntry = oauthToken
315
+ ? `
316
+ <key>${DAEMON_OAUTH_KEY}</key>
317
+ <string>${xmlEscape(oauthToken)}</string>`
318
+ : '';
251
319
  return `<?xml version="1.0" encoding="UTF-8"?>
252
320
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
253
321
  <plist version="1.0">
@@ -271,7 +339,7 @@ export function generateLaunchdPlist() {
271
339
  <key>EnvironmentVariables</key>
272
340
  <dict>
273
341
  <key>PATH</key>
274
- <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin</string>
342
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin</string>${oauthEntry}
275
343
  </dict>
276
344
  </dict>
277
345
  </plist>`;
@@ -279,6 +347,10 @@ export function generateLaunchdPlist() {
279
347
  /** Generate a Linux systemd user unit for auto-starting the daemon. */
280
348
  export function generateSystemdUnit() {
281
349
  const agentsBin = getAgentsBinPath();
350
+ const oauthToken = readDaemonClaudeOAuthToken();
351
+ const oauthLine = oauthToken
352
+ ? `\nEnvironment=${DAEMON_OAUTH_KEY}=${oauthToken}`
353
+ : '';
282
354
  return `[Unit]
283
355
  Description=Agents Daemon - Scheduled Job Runner
284
356
  After=network.target
@@ -288,7 +360,7 @@ Type=simple
288
360
  ExecStart=${agentsBin} daemon _run
289
361
  Restart=always
290
362
  RestartSec=10
291
- Environment=PATH=/usr/local/bin:/usr/bin:/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin
363
+ Environment=PATH=/usr/local/bin:/usr/bin:/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin${oauthLine}
292
364
 
293
365
  [Install]
294
366
  WantedBy=default.target`;
@@ -338,6 +410,9 @@ function startDaemonLocked() {
338
410
  fs.mkdirSync(plistDir, { recursive: true });
339
411
  }
340
412
  fs.writeFileSync(plistPath, generateLaunchdPlist(), 'utf-8');
413
+ // The plist may embed a long-lived OAuth token in EnvironmentVariables;
414
+ // keep it owner-only so it isn't world/group readable on disk.
415
+ fs.chmodSync(plistPath, 0o600);
341
416
  try {
342
417
  execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
343
418
  }
@@ -365,6 +440,8 @@ function startDaemonLocked() {
365
440
  fs.mkdirSync(unitDir, { recursive: true });
366
441
  }
367
442
  fs.writeFileSync(unitPath, generateSystemdUnit(), 'utf-8');
443
+ // May embed a long-lived OAuth token in an Environment= line; owner-only.
444
+ fs.chmodSync(unitPath, 0o600);
368
445
  execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8' });
369
446
  execFileSync('systemctl', ['--user', 'enable', SYSTEMD_UNIT], { encoding: 'utf-8' });
370
447
  execFileSync('systemctl', ['--user', 'start', SYSTEMD_UNIT], { encoding: 'utf-8' });
@@ -377,6 +454,23 @@ function startDaemonLocked() {
377
454
  }
378
455
  return startDetached();
379
456
  }
457
+ /**
458
+ * Environment for the detached daemon fallback. The launchd/systemd paths
459
+ * deliver the long-lived OAuth token via the service manifest's environment;
460
+ * the detached path has no manifest, so inject it here. Read happens during an
461
+ * interactive `routines start`, so a Keychain Touch ID prompt can be satisfied;
462
+ * the daemon then passes it to every routine run it spawns. An already-set
463
+ * value (e.g. inherited from launchd) is left untouched.
464
+ */
465
+ export function buildDetachedDaemonEnv(baseEnv = process.env) {
466
+ const env = { ...baseEnv };
467
+ if (!env.CLAUDE_CODE_OAUTH_TOKEN) {
468
+ const token = readDaemonClaudeOAuthToken();
469
+ if (token)
470
+ env.CLAUDE_CODE_OAUTH_TOKEN = token;
471
+ }
472
+ return env;
473
+ }
380
474
  function startDetached() {
381
475
  const agentsBin = getAgentsBinPath();
382
476
  const logPath = getLogPath();
@@ -384,6 +478,7 @@ function startDetached() {
384
478
  const child = spawn(agentsBin, ['daemon', '_run'], {
385
479
  stdio: ['ignore', logFd, logFd],
386
480
  detached: true,
481
+ env: buildDetachedDaemonEnv(),
387
482
  });
388
483
  child.unref();
389
484
  fs.closeSync(logFd);
@@ -25,6 +25,28 @@
25
25
  * LEGACY_SYSTEM_DIR" without duplicating data.
26
26
  */
27
27
  export declare function foldLegacySystemRepo(): void;
28
+ /**
29
+ * Repair self-referential agent binary symlinks.
30
+ *
31
+ * Some installScript-based agents — notably Factory's `droid`, whose installer
32
+ * drops a standalone native binary at ~/.local/bin/droid — were registered at
33
+ * install time by resolving the post-install binary with `which <cli>`. Because
34
+ * ~/.agents/.cache/shims sits ahead of ~/.local/bin on PATH, `which` could
35
+ * return OUR OWN dispatcher shim, and the install step symlinked
36
+ * ~/.agents/.history/versions/<agent>/<version>/node_modules/.bin/<cli>
37
+ * back at ~/.agents/.cache/shims/<cli>. Launching that agent then re-execs the
38
+ * dispatcher forever (an infinite exec loop that hangs the terminal).
39
+ *
40
+ * This walks every installed version's node_modules/.bin and, for any entry
41
+ * whose symlink resolves into the shims dir, re-points it at the real binary
42
+ * (found on PATH with the shims dir excluded) — or removes it when no real
43
+ * binary can be found, letting getBinaryPath's per-agent resolver take over.
44
+ * Idempotent: a correctly-pointed link is left untouched on re-run.
45
+ *
46
+ * Params default to the real on-disk locations; they are injectable so tests
47
+ * can drive a fixture tree without touching the user's ~/.agents.
48
+ */
49
+ export declare function repairSelfReferentialBinShims(versionsRoot?: string, shimsDir?: string): void;
28
50
  /**
29
51
  * Rename the legacy `extras-extras/` plugin-marketplace dir to `agents-extras/`
30
52
  * inside every installed agent version-home, and rewrite cross-references in
@@ -8,7 +8,7 @@ import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
  import * as os from 'os';
10
10
  import * as yaml from 'yaml';
11
- import { AGENTS, agentConfigDirName } from './agents.js';
11
+ import { AGENTS, agentConfigDirName, findInPath } from './agents.js';
12
12
  const HOME = process.env.HOME ?? os.homedir();
13
13
  const USER_DIR = path.join(HOME, '.agents');
14
14
  /** Canonical system-repo location (post-fold). */
@@ -715,6 +715,100 @@ function repairAgentConfigSymlinks() {
715
715
  console.error(`Repaired ${repaired} agent config symlink${repaired === 1 ? '' : 's'} to point at ~/.agents/versions/`);
716
716
  }
717
717
  }
718
+ /**
719
+ * Repair self-referential agent binary symlinks.
720
+ *
721
+ * Some installScript-based agents — notably Factory's `droid`, whose installer
722
+ * drops a standalone native binary at ~/.local/bin/droid — were registered at
723
+ * install time by resolving the post-install binary with `which <cli>`. Because
724
+ * ~/.agents/.cache/shims sits ahead of ~/.local/bin on PATH, `which` could
725
+ * return OUR OWN dispatcher shim, and the install step symlinked
726
+ * ~/.agents/.history/versions/<agent>/<version>/node_modules/.bin/<cli>
727
+ * back at ~/.agents/.cache/shims/<cli>. Launching that agent then re-execs the
728
+ * dispatcher forever (an infinite exec loop that hangs the terminal).
729
+ *
730
+ * This walks every installed version's node_modules/.bin and, for any entry
731
+ * whose symlink resolves into the shims dir, re-points it at the real binary
732
+ * (found on PATH with the shims dir excluded) — or removes it when no real
733
+ * binary can be found, letting getBinaryPath's per-agent resolver take over.
734
+ * Idempotent: a correctly-pointed link is left untouched on re-run.
735
+ *
736
+ * Params default to the real on-disk locations; they are injectable so tests
737
+ * can drive a fixture tree without touching the user's ~/.agents.
738
+ */
739
+ export function repairSelfReferentialBinShims(versionsRoot = path.join(HISTORY_DIR, 'versions'), shimsDir = path.resolve(CACHE_DIR, 'shims')) {
740
+ // Normalize the shims dir through realpath so the prefix check below survives
741
+ // a symlinked ~/.agents (or macOS's /tmp -> /private/tmp): fs.realpathSync on
742
+ // the link target resolves those symlinks, so the dir we compare against must
743
+ // too, or every loop would read as "points at a real binary" and be skipped.
744
+ shimsDir = path.resolve(shimsDir);
745
+ try {
746
+ shimsDir = fs.realpathSync(shimsDir);
747
+ }
748
+ catch {
749
+ /* shims dir absent — leave the resolved path; nothing will match it */
750
+ }
751
+ let agents;
752
+ try {
753
+ agents = fs.readdirSync(versionsRoot);
754
+ }
755
+ catch {
756
+ return; // no versions installed yet
757
+ }
758
+ let repaired = 0;
759
+ for (const agent of agents) {
760
+ const cli = agent in AGENTS ? AGENTS[agent].cliCommand : agent;
761
+ let versions;
762
+ try {
763
+ versions = fs.readdirSync(path.join(versionsRoot, agent));
764
+ }
765
+ catch {
766
+ continue;
767
+ }
768
+ for (const version of versions) {
769
+ const binLink = path.join(versionsRoot, agent, version, 'node_modules', '.bin', cli);
770
+ let stat;
771
+ try {
772
+ stat = fs.lstatSync(binLink);
773
+ }
774
+ catch {
775
+ continue; // no .bin entry for this version
776
+ }
777
+ if (!stat.isSymbolicLink())
778
+ continue;
779
+ let real;
780
+ try {
781
+ real = fs.realpathSync(binLink);
782
+ }
783
+ catch {
784
+ // Dangling symlink — if it was aimed at the shims dir it's the loop
785
+ // residue; drop it either way so getBinaryPath reports honestly.
786
+ try {
787
+ fs.unlinkSync(binLink);
788
+ repaired++;
789
+ }
790
+ catch { /* best-effort */ }
791
+ continue;
792
+ }
793
+ if (!path.resolve(real).startsWith(shimsDir + path.sep))
794
+ continue; // points at a real binary — fine
795
+ // Self-referential: the link resolves back into our own shims dir.
796
+ // findInPath does a pure-Node PATH scan (no subprocess) and already
797
+ // skips our shims dir, so it returns the genuine install if one exists.
798
+ const realBinary = findInPath(cli);
799
+ try {
800
+ fs.unlinkSync(binLink);
801
+ if (realBinary)
802
+ fs.symlinkSync(realBinary, binLink);
803
+ repaired++;
804
+ }
805
+ catch { /* best-effort */ }
806
+ }
807
+ }
808
+ if (repaired > 0) {
809
+ console.error(`Repaired ${repaired} self-referential agent binary symlink${repaired === 1 ? '' : 's'} (infinite exec-loop fix).`);
810
+ }
811
+ }
718
812
  /**
719
813
  * Move a directory from `src` to `dest`. No-op when src is absent. When dest
720
814
  * already exists, merge by copying everything that isn't already there, then
@@ -1733,4 +1827,8 @@ export async function runMigration() {
1733
1827
  migrateExtrasExtrasToAgentsExtras();
1734
1828
  // Symlink repair runs LAST so it can find the post-move version homes.
1735
1829
  repairAgentConfigSymlinks();
1830
+ // Repair self-referential node_modules/.bin/<cli> symlinks (the droid
1831
+ // infinite-exec-loop). Also runs after the bucket moves so it scans the
1832
+ // canonical HISTORY_DIR/versions tree.
1833
+ repairSelfReferentialBinShims();
1736
1834
  }