@phnx-labs/agents-cli 1.22.1 → 1.22.4

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 (38) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.js +1 -1
  5. package/dist/commands/exec.js +1 -1
  6. package/dist/commands/projects.js +25 -1
  7. package/dist/commands/secrets.js +9 -5
  8. package/dist/commands/teams.js +2 -2
  9. package/dist/commands/watchdog.js +64 -5
  10. package/dist/lib/crabbox/cli.d.ts +2 -0
  11. package/dist/lib/crabbox/cli.js +42 -14
  12. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  13. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  14. package/dist/lib/projects.d.ts +15 -0
  15. package/dist/lib/projects.js +12 -0
  16. package/dist/lib/rotate.d.ts +16 -1
  17. package/dist/lib/rotate.js +33 -7
  18. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  19. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  20. package/dist/lib/secrets/bundles.d.ts +25 -30
  21. package/dist/lib/secrets/bundles.js +84 -60
  22. package/dist/lib/secrets/headless.d.ts +39 -0
  23. package/dist/lib/secrets/headless.js +63 -0
  24. package/dist/lib/secrets/index.d.ts +10 -0
  25. package/dist/lib/secrets/index.js +90 -6
  26. package/dist/lib/secrets/read-backoff.d.ts +27 -0
  27. package/dist/lib/secrets/read-backoff.js +64 -0
  28. package/dist/lib/secrets/session-store.js +6 -4
  29. package/dist/lib/secrets/vault.js +3 -1
  30. package/dist/lib/share/config.d.ts +14 -6
  31. package/dist/lib/share/config.js +23 -5
  32. package/dist/lib/types.d.ts +10 -0
  33. package/dist/lib/usage.js +3 -1
  34. package/dist/lib/watchdog/rotate.d.ts +218 -0
  35. package/dist/lib/watchdog/rotate.js +378 -0
  36. package/dist/lib/watchdog/runner.d.ts +33 -1
  37. package/dist/lib/watchdog/runner.js +303 -0
  38. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.4
4
+
5
+ - **Background processes no longer storm macOS Touch ID sheets (secrets-touchid-storm).**
6
+ Raw keychain item reads — a profile's provider token on `agents run <profile>`,
7
+ the Claude OAuth read behind `agents view`, any `getKeychainToken` caller — now
8
+ fail fast with an actionable error naming the item when the process is
9
+ non-interactive (an agent runtime, or a TTY-less background spawn like the
10
+ Factory extension host's `agents view` poll), instead of raising a sheet nobody
11
+ is watching. A cancelled or failed interactive read opens a 5-minute back-off
12
+ memo (`~/.agents/.cache/keychain-read-backoff/`) so a polling caller can't
13
+ re-prompt every few seconds; any successful read or write clears it. Reads that
14
+ are prompt-free by construction (bundle metadata, `never`-policy bundles, the
15
+ unlock session store, the OAuth token cache) attest their no-ACL write and are
16
+ unaffected. crabbox's tailscale key is now read at most once per process
17
+ instead of on every `crabboxEnv` call (list/wait/spawn/stop). Source:
18
+ `apps/cli/src/lib/secrets/index.ts`, `apps/cli/src/lib/secrets/headless.ts`,
19
+ `apps/cli/src/lib/secrets/read-backoff.ts`,
20
+ `apps/cli/src/lib/secrets/bundles.ts`, `apps/cli/src/lib/crabbox/cli.ts`,
21
+ `apps/cli/docs/specifications.md` (SEC-13, SEC-27).
22
+
23
+ ## 1.22.3
24
+
25
+ - **Menubar home-base self-test accepts `MenubarHelper-universal`.** The
26
+ signed-helper gate required `executablePath` to end in exactly `MenubarHelper`,
27
+ but lipo production builds name the binary `MenubarHelper-universal`, so every
28
+ 1.22.2 publish on mac-mini failed after the release PR had already merged and
29
+ tagged. Source: `menubar/Sources/MenubarHelper/ChildProcessSelfTest.swift`.
30
+
31
+ ## 1.22.2
32
+
33
+ - **Menu bar recovers from a stale single-instance lock instead of staying dead.**
34
+ The helper's lock fd is now opened `O_CLOEXEC`, so a spawned `doctor` child can
35
+ never inherit it and hold the `menubar.lock` flock after the helper crashes; and
36
+ `SingleInstance.acquire` now self-heals — when the flock is held but no live
37
+ `MenubarHelper` owns it (a leaked orphan / dead pid), it reaps the orphan and
38
+ retries rather than exiting as "already running". Previously a leaked orphan
39
+ bricked the menu bar until reboot. The headless Swift self-tests (single-instance
40
+ + child-process) now run as a build gate (`menubar/scripts/test-menubar.sh`),
41
+ which nothing invoked before. Source: `apps/cli/menubar/Sources/MenubarHelper/SingleInstance.swift`.
42
+
43
+ - **`agents projects` definitions can now carry goals — the OKR-shaped "why".**
44
+ A project serves one or more `goals[]`, each an `objective` (the outcome) plus an
45
+ optional `measure` (the key result). Set them at scaffold time with
46
+ `agents projects add <name> --goal "objective:measure"` (repeatable), replace them
47
+ later with `agents projects set <name> --goal …`, or hand-edit the YAML. Goals show
48
+ on the `status` card (compact) and in `projects view` (in full), and survive a
49
+ `--from-linear` re-import like every other hand-set field. Milestones (pulled from
50
+ Linear) remain the dated checkpoints toward these goals. Source:
51
+ `apps/cli/src/lib/projects.ts`, `apps/cli/src/commands/projects.ts`.
52
+
53
+ - **Balanced rotation no longer picks version homes that only inherit the active login.**
54
+ `getAccountInfo` falls back to the active/global HOME credential so `agents view`
55
+ still shows who is signed in when a version home has no auth file of its own.
56
+ Launch paths isolate config (`GROK_HOME`, `CODEX_HOME`, …) to the per-version home,
57
+ so those empty homes died at spawn with "Not signed in" after balanced picked them
58
+ (observed: `grok@0.2.118` with no `auth.json` looking signed-in via `~/.grok` →
59
+ `0.2.32`). Rotation now requires a real per-version credential when we know where
60
+ it lives (`credentialPresence.perVersion`). Source: `src/lib/rotate.ts`
61
+ (`isLaunchableSignedIn`, `collectRunCandidates`).
62
+
63
+ - **A `never`-policy secrets bundle now actually stays silent — no more Touch ID for a bundle you set to silent, and no more double prompt.** Two bugs made `agents secrets policy <b> never` a lie. (1) The command rewrote only the bundle *metadata*, never the value items — but macOS gates each read on the item's own ACL, not the tier label, so a bundle created under `hold`/`always` kept its biometry ACL and kept popping Touch ID forever after the switch. `policy` now reconciles the value items to the new tier (`reAclBundleItems`): tightening to `never` re-stores them no-ACL (a single last prompt to read them once), and loosening back re-attaches the gate. (2) The signed keychain helper's just-in-time migration (`migrateInline`/`rehomeOrphan`) re-stamped a biometry ACL onto *every* `agents-cli.*` item it touched on read — including bundle metadata and the HMAC key, which are supposed to be silent — so a metadata/hmackey read in its own helper process raised a *second* Touch ID sheet on top of the value read. The migration now re-adds silent items (metadata, hmackey) without an ACL, so metadata enumeration and the pre-value hmackey read never prompt. Net: unlock/read a `never` bundle once and it stays silent through sleep, reboot, 30+ days, an agents-cli upgrade, and a macOS upgrade — with no Touch ID and no passphrase. The `never` tier's durability and attribution guarantees are now written into the `§Secrets` spec (SEC-19, SEC-27, SEC-28). Source: `apps/cli/src/commands/secrets.ts`, `apps/cli/src/lib/secrets/bundles.ts`, `apps/cli/src/lib/secrets/keychain-helper.swift`, `apps/cli/docs/specifications.md`.
64
+
65
+ - **The per-run Touch ID storm is fixed: `agents run` no longer pops a sheet to auto-inject the share token.** On every `agents run`, `shareRuntimeEnv` auto-reads the `share` bundle's R2 write token to hand it to the spawned agent — and because `share` is a keychain bundle that is rarely broker-held, an interactive read spawned the helper and raised Touch ID on EVERY launch. Auto-injecting a token on an agent launch is a background convenience, not a user-initiated secret access, so it must never raise a sheet (SEC-13): the read is now always `agentOnly` — it resolves the token from the injected env or an already-held / no-ACL bundle and silently returns nothing otherwise (the agent can still publish via its own explicit `agents share`). And a NEW `share` bundle now defaults to the `never` tier (no biometry ACL) — the write token is low-sensitivity automation infra — so auto-share is silent with no unlock at all; an existing bundle keeps its tier (change it with `agents secrets policy share never`, which now actually strips the ACL). Source: `apps/cli/src/lib/share/config.ts`, `apps/cli/src/commands/exec.ts`.
66
+
67
+ - **The daemon watchdog now rotates rate-limited sessions in place.** A stalled session whose transcript tail shows a hard account limit ("You've hit your weekly limit · resets …", "usage limit reached", "out of credits") is rotated instead of nudged: the tick gates on the same first-party healthy-account selection `agents run auto` makes (`collectHarnessCandidates` + `pickHarnessWeighted` — zero healthy logs one `rotate` skip event per cooldown window to `watchdog.log` and leaves the terminal untouched), injects the harness's exit sequence (claude: Esc, Ctrl+C, Ctrl+C; codex/gemini/cursor/opencode: Ctrl+C twice), relaunches `agents run auto --interactive --session-id <uuid>` in the SAME tab via the inject rail, then — once the new session's TUI is live — injects the resume replay for the old session. Readiness is the new session's transcript (primary) or a fresh active session correlated by cwd + machine, never an unrelated one; the wait is bounded (60s), and on timeout the session is flagged with a bare-shell message pointing at a manual `agents run auto` and suppressed for 15m before retry — never blind-typed into. The state machine (`exiting → launching → awaiting-tui → replaying → done | failed`) persists at `~/.agents/.cache/state/watchdog/rotate/<sessionId>.json` and spans ticks via a post-loop sweep. Config: **`agents watchdog rotate on|off`** writes `watchdog.rotate` in `~/.agents/agents.yaml` (default on; rotate-only, nudging is unaffected), honored per tick; `agents watchdog status` / `--json` report the rotate config and every persisted rotate state. This replaces the Factory extension's own watchdog rotate loop, which is being deleted in the companion change. Source: `apps/cli/src/lib/watchdog/rotate.ts`, `apps/cli/src/lib/watchdog/runner.ts`, `apps/cli/src/commands/watchdog.ts`.
68
+
3
69
  ## 1.22.1
4
70
 
5
71
  - **`agents doctor` de-noise: never-synced and cross-version hook drift are warnings, not criticals (RUSH-2162).** The CRITICAL section now holds only "needs you now" problems — a logged-out account, or a hook/plugin missing from a version you keep synced. A version that was never synced (an old/unused install with nothing installed) and a hook that merely *differs* across versions (installed but stale) are surfaced as WARNINGs instead, cutting the critical count on a busy machine from ~11 to the handful that actually need action. Source: `apps/cli/src/lib/devices/doctor-findings.ts`.
package/README.md CHANGED
@@ -382,6 +382,8 @@ agents watchdog --watch # daemon loop: a tick every --interval
382
382
 
383
383
  `agents watchdog` detects a stalled session, resolves the *exact* terminal split it lives in (tmux, iTerm, VSCodium, or a raw pty), and injects a nudge -- `Continue.` by default, or set `--text`. It's dry by default; `--nudge` acts on a single tick, and `agents watchdog enable` flips global auto-nudge on so `--watch` injects on its own. Steer a single run with `agents watchdog policy <id> off | keep | handsoff`.
384
384
 
385
+ A stalled session whose tail shows a hard account limit ("You've hit your weekly limit · resets …") is **rotated in place** instead of nudged: the watchdog gates on the same healthy-account selection `agents run auto` makes (zero healthy → one skip event per cooldown window, terminal untouched), injects the harness's exit sequence, relaunches `agents run auto --interactive --session-id <uuid>` in the *same* tab, then replays the old session's resume once the new TUI is live. Default on; `agents watchdog rotate off` disables it (nudging stays on).
386
+
385
387
  ---
386
388
 
387
389
  ## Sync the fleet
package/dist/bin/agents CHANGED
Binary file
@@ -248,7 +248,7 @@ Examples:
248
248
  model: options.model,
249
249
  providerOptions: {},
250
250
  };
251
- const shareEnv = shareRuntimeEnv({ agentOnly: true });
251
+ const shareEnv = shareRuntimeEnv();
252
252
  if (shareEnv)
253
253
  dispatchOptions.env = shareEnv;
254
254
  if (options.env)
@@ -2261,7 +2261,7 @@ export function registerRunCommand(program) {
2261
2261
  }
2262
2262
  }
2263
2263
  const autoShareEnv = options.autoSecrets !== false
2264
- ? shareRuntimeEnv({ agentOnly: isHeadlessSecretsContext() })
2264
+ ? shareRuntimeEnv()
2265
2265
  : undefined;
2266
2266
  // Merge order (later wins): profile env < auto share token < secrets bundles < --env K=V.
2267
2267
  // Profile carries provider auth; secrets bundles carry user-defined
@@ -59,6 +59,17 @@ function parseContextFlag(raw) {
59
59
  return { path: raw.trim(), purpose: '' };
60
60
  return { path: raw.slice(0, i).trim(), purpose: raw.slice(i + 1).trim() };
61
61
  }
62
+ /** `objective:measure` → a goal. The measure is optional; the objective may contain colons only after the first is claimed. */
63
+ function parseGoalFlag(raw) {
64
+ const i = raw.indexOf(':');
65
+ if (i === -1)
66
+ return { objective: raw.trim() };
67
+ const measure = raw.slice(i + 1).trim();
68
+ const goal = { objective: raw.slice(0, i).trim() };
69
+ if (measure)
70
+ goal.measure = measure;
71
+ return goal;
72
+ }
62
73
  /**
63
74
  * Best-effort `owner/repo` from a repo's origin remote.
64
75
  *
@@ -383,6 +394,9 @@ detail = false) {
383
394
  }
384
395
  // `view` prints these in full (path + purpose, label + URL) right below, so
385
396
  // the compact one-line summaries would just say the same thing twice.
397
+ if (!detail && def.goals?.length) {
398
+ console.log(` ${chalk.dim('goal')} ${def.goals.map((g) => g.objective).join(' · ')}`);
399
+ }
386
400
  if (!detail && def.contexts?.length) {
387
401
  console.log(` ${chalk.dim('context')} ${def.contexts.map((c) => c.path).join(' · ')}`);
388
402
  }
@@ -467,6 +481,7 @@ export function registerProjectsCommands(program) {
467
481
  .option('--path <subdir>', 'Default cwd for agents (a monorepo subdir)')
468
482
  .option('--repo <owner/repo>', 'Primary GitHub slug (defaults to the origin remote)')
469
483
  .option('--context <path:purpose...>', 'A described starting point; repeatable')
484
+ .option('--goal <objective:measure...>', 'An outcome the project serves; repeatable')
470
485
  .option('--linear <url-or-id>', 'Linear project URL or id')
471
486
  .option('--force', 'Overwrite an existing definition')
472
487
  .action(async (name, opts) => {
@@ -497,6 +512,8 @@ export function registerProjectsCommands(program) {
497
512
  def.repo = repo;
498
513
  if (opts.context?.length)
499
514
  def.contexts = opts.context.map(parseContextFlag);
515
+ if (opts.goal?.length)
516
+ def.goals = opts.goal.map(parseGoalFlag);
500
517
  if (opts.linear) {
501
518
  def.linear = /^https?:/.test(opts.linear) ? { url: opts.linear } : { projectId: opts.linear };
502
519
  }
@@ -564,6 +581,8 @@ export function registerProjectsCommands(program) {
564
581
  const where = [rp.subpath ? `subpath ${rp.subpath}` : undefined, rp.path].filter(Boolean).join(' · ');
565
582
  console.log(` ${chalk.dim('repo')} ${rp.slug}${where ? chalk.dim(` (${where})`) : ''}`);
566
583
  }
584
+ for (const g of def.goals ?? [])
585
+ console.log(` ${chalk.dim('goal')} ${g.objective}${g.measure ? chalk.dim(` · ${g.measure}`) : ''}`);
567
586
  for (const c of def.contexts ?? [])
568
587
  console.log(` ${chalk.dim('context')} ${chalk.cyan(c.path)} ${chalk.dim('—')} ${c.purpose}`);
569
588
  for (const ig of def.integrations ?? []) {
@@ -742,6 +761,7 @@ export function registerProjectsCommands(program) {
742
761
  .option('--root <path>', 'Repo / monorepo root')
743
762
  .option('--path <subdir>', 'Default cwd for agents (a monorepo subdir)')
744
763
  .option('--description <text>', 'One-line description shown on the card')
764
+ .option('--goal <objective:measure...>', 'Replace the goals this project serves; repeatable')
745
765
  .action((name, opts) => {
746
766
  const def = loadProjectDef(name);
747
767
  if (!def) {
@@ -749,7 +769,7 @@ export function registerProjectsCommands(program) {
749
769
  process.exit(1);
750
770
  }
751
771
  const fields = ['repo', 'root', 'path', 'description'].filter((k) => opts[k] !== undefined);
752
- if (fields.length === 0) {
772
+ if (fields.length === 0 && !opts.goal?.length) {
753
773
  console.error(chalk.red('Nothing to set. Pass a field, e.g. --repo <owner/repo>.'));
754
774
  process.exit(1);
755
775
  }
@@ -762,6 +782,8 @@ export function registerProjectsCommands(program) {
762
782
  def.root = opts.root;
763
783
  if (opts.description !== undefined)
764
784
  def.description = opts.description;
785
+ if (opts.goal?.length)
786
+ def.goals = opts.goal.map(parseGoalFlag);
765
787
  if (opts.path !== undefined) {
766
788
  // `--path` is a subdir OF the root, so without one there is nothing to
767
789
  // hang it off. A def imported with `--from-linear` that found no local
@@ -780,6 +802,8 @@ export function registerProjectsCommands(program) {
780
802
  console.log(chalk.green(`Updated ${def.name}`));
781
803
  for (const f of fields)
782
804
  console.log(chalk.gray(` ${f} ${f === 'path' ? def.defaultPath : def[f]}`));
805
+ if (opts.goal?.length)
806
+ console.log(chalk.gray(` goals ${def.goals?.map((g) => g.objective).join(' · ')}`));
783
807
  });
784
808
  // ---- link ----
785
809
  projects
@@ -18,7 +18,7 @@ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
18
18
  import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, verifyRemoteKeychainPush, keychainWriteFailureMessage, } from '../lib/secrets/remote.js';
19
19
  import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
20
20
  import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
21
- import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, readBundleIfDecryptable, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
21
+ import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
22
22
  import { parseListFilters, bundleMatchesFilter, bundleExpiry, filterIsActive, describeFilter, parseSortField, sortBundles, SORT_FIELDS, REF_KINDS, DEFAULT_EXPIRING_DAYS, } from '../lib/secrets/list-filter.js';
23
23
  import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
24
24
  import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
@@ -2738,10 +2738,14 @@ Examples:
2738
2738
  return;
2739
2739
  }
2740
2740
  bundle.policy = next;
2741
- // writeBundle evicts any broker-held copy, so tightening hold ->
2742
- // always/never takes effect NOW: the next read re-prompts (`always`)
2743
- // or reads its no-ACL item directly (`never`).
2744
- writeBundle(bundle);
2741
+ // Re-store the VALUE items so their keychain ACL matches the new tier —
2742
+ // NOT just the metadata. macOS gates each read on the item's own ACL
2743
+ // (SEC-19); a metadata-only write would leave a hold/always bundle's
2744
+ // biometry ACL in place and keep popping Touch ID forever after a switch
2745
+ // to `never`. reAclBundleItems reads once (the last prompt this bundle
2746
+ // raises when tightening to `never`), rewrites no-ACL, and evicts any
2747
+ // broker-held copy so the change takes effect on the very next read.
2748
+ reAclBundleItems(bundle);
2745
2749
  console.log(chalk.green(`${bundle.name} policy set to ${next}.`));
2746
2750
  if (next === 'hold') {
2747
2751
  console.log(chalk.gray('Held by the secrets-agent for ~7 days after one unlock (auto-cache is on by default; disable with `secrets.agent.auto: false` in agents.yaml).'));
@@ -1539,7 +1539,7 @@ export function registerTeamsCommands(program) {
1539
1539
  repo: opts.repo,
1540
1540
  branch: opts.branch,
1541
1541
  model: a.model ?? undefined,
1542
- env: shareRuntimeEnv({ agentOnly: true }),
1542
+ env: shareRuntimeEnv(),
1543
1543
  };
1544
1544
  const cloudTask = await prov.dispatch(dispatchOpts);
1545
1545
  return { cloudSessionId: cloudTask.id };
@@ -1557,7 +1557,7 @@ export function registerTeamsCommands(program) {
1557
1557
  repo: opts.repo,
1558
1558
  branch: opts.branch,
1559
1559
  model: opts.model,
1560
- env: shareRuntimeEnv({ agentOnly: true }),
1560
+ env: shareRuntimeEnv(),
1561
1561
  };
1562
1562
  try {
1563
1563
  const cloudTask = await prov.dispatch(dispatchOpts);
@@ -29,6 +29,7 @@ import { mailboxIdForActiveSession } from '../lib/mailbox-target.js';
29
29
  import { gcMailbox } from '../lib/mailbox-gc.js';
30
30
  import { ensureWatchdogRoutine, isWatchdogRoutineEnabled, watchdogRoutineExists, WATCHDOG_ROUTINE_NAME, WATCHDOG_ROUTINE_SCHEDULE, } from '../lib/watchdog/routine.js';
31
31
  import { runWatchdogTick, writePolicySentinel, DEFAULT_THRESHOLDS, } from '../lib/watchdog/runner.js';
32
+ import { isWatchdogRotateEnabled, listRotateStates, setWatchdogRotateEnabled } from '../lib/watchdog/rotate.js';
32
33
  /** Default state dir the runner and these subcommands share. */
33
34
  function stateDir() {
34
35
  return path.join(getRuntimeStateDir(), 'watchdog');
@@ -79,6 +80,8 @@ async function runMailboxGc(sessions) {
79
80
  function colorForOutcome(o) {
80
81
  if (o.injected)
81
82
  return chalk.green;
83
+ if (o.decision === 'rotate')
84
+ return chalk.magenta;
82
85
  if (o.addressable === false)
83
86
  return chalk.yellow;
84
87
  if (o.stall === 'stalled' && o.decision === 'nudge')
@@ -92,12 +95,14 @@ function printTick(result, willInject) {
92
95
  console.log(`${chalk.bold('watchdog')} ${mode} ` +
93
96
  `${counts.total} live · ${counts.stalled} stalled · ` +
94
97
  `${chalk.green(String(counts.nudged))} nudged · ` +
95
- `${chalk.yellow(String(counts.unaddressable))} un-addressable`);
98
+ `${chalk.yellow(String(counts.unaddressable))} un-addressable` +
99
+ (counts.rotating > 0 ? ` · ${chalk.magenta(String(counts.rotating))} rotating` : ''));
96
100
  for (const o of result.outcomes) {
97
101
  const tag = o.injected ? 'NUDGED'
98
- : o.addressable === false ? 'FLAGGED'
99
- : o.decision === 'nudge' ? 'WOULD-NUDGE'
100
- : 'skip';
102
+ : o.decision === 'rotate' ? (o.rotatePhase === 'failed' ? 'ROTATE-FAIL' : 'ROTATE')
103
+ : o.addressable === false ? 'FLAGGED'
104
+ : o.decision === 'nudge' ? 'WOULD-NUDGE'
105
+ : 'skip';
101
106
  const c = colorForOutcome(o);
102
107
  const who = o.label || o.sessionId?.slice(0, 8) || o.kind;
103
108
  const where = o.host ? chalk.dim(`[${o.host}]`) : '';
@@ -194,8 +199,14 @@ export function registerWatchdogCommand(program) {
194
199
  # Turn on the ALWAYS-ON watchdog (a daemon-fired routine)
195
200
  agents watchdog enable
196
201
 
202
+ # Show the routine, rotate config, and any in-flight in-place rotates
203
+ agents watchdog status
204
+
197
205
  # Leave one session detected-but-untouched
198
206
  agents watchdog policy <sessionId> handsoff
207
+
208
+ # Opt out of in-place rotate only (nudging stays on)
209
+ agents watchdog rotate off
199
210
  `,
200
211
  notes: `
201
212
  Decision path: a cheap deterministic pre-filter resolves the obvious cases
@@ -217,6 +228,20 @@ export function registerWatchdogCommand(program) {
217
228
  Ghostty with no tmux) is flagged for the menu-bar and SKIPPED -- never a
218
229
  guessed or frontmost target.
219
230
 
231
+ Rotate: a stalled session whose tail shows a HARD account limit ("You've
232
+ hit your weekly limit - resets ...") is ROTATED IN PLACE instead of nudged:
233
+ the tick gates on the same healthy-account selection 'agents run auto'
234
+ makes (zero healthy -> one skip event per cooldown window, terminal
235
+ untouched), injects the harness's exit sequence, relaunches
236
+ 'agents run auto --interactive --session-id <uuid>' in the SAME tab, waits
237
+ (bounded, 60s) for the new TUI, then injects the resume replay for the old
238
+ session. On timeout the session is flagged and never blind-typed into; the
239
+ flag says the terminal may sit at a bare shell and needs a manual
240
+ 'agents run auto'. A failed rotate is suppressed for 15m before retry.
241
+ Default ON; disable with 'agents watchdog rotate off' (writes
242
+ 'watchdog.rotate: off' to ~/.agents/agents.yaml; nudging stays on).
243
+ State machine: ~/.agents/.cache/state/watchdog/rotate/<sessionId>.json.
244
+
220
245
  Always-on: 'agents watchdog enable' creates + enables a 'watchdog' command
221
246
  routine ('${WATCHDOG_ROUTINE_SCHEDULE}' -> agents watchdog --nudge) and reloads the
222
247
  daemon; 'disable' pauses it. Inspect it with 'agents routines list'. Defaults
@@ -244,6 +269,20 @@ export function registerWatchdogCommand(program) {
244
269
  await reloadDaemonForRoutine(false);
245
270
  console.log(chalk.yellow('watchdog: DISABLED (routine paused)'));
246
271
  });
272
+ cmd.command('rotate <state>')
273
+ .description('Turn in-place rotate of rate-limited sessions on|off (watchdog.rotate in agents.yaml). ' +
274
+ 'Rotate-only: nudging stays on — unlike `watchdog disable`, which pauses the whole watchdog.')
275
+ .action((state) => {
276
+ const s = state.toLowerCase();
277
+ if (s !== 'on' && s !== 'off') {
278
+ console.error(chalk.red(`invalid state '${state}'. Use: on | off`));
279
+ process.exitCode = 1;
280
+ return;
281
+ }
282
+ setWatchdogRotateEnabled(s === 'on');
283
+ console.log(`watchdog: rotate ${s === 'on' ? chalk.green('ON') : chalk.yellow('OFF')} ` +
284
+ chalk.dim(`(watchdog.rotate: ${s} in agents.yaml)`));
285
+ });
247
286
  cmd.command('status')
248
287
  .description('Show whether the always-on watchdog routine is enabled and where state is written.')
249
288
  .option('--json', 'Emit status as JSON (for the menu-bar / scripts)')
@@ -254,11 +293,31 @@ export function registerWatchdogCommand(program) {
254
293
  // read it correctly regardless of which command commander bound it to.
255
294
  const json = command.optsWithGlobals().json === true;
256
295
  const on = isWatchdogRoutineEnabled();
296
+ const rotate = isWatchdogRotateEnabled() ? 'on' : 'off';
297
+ const rotates = listRotateStates(stateDir());
298
+ const inflight = rotates.filter((r) => r.phase !== 'done' && r.phase !== 'failed');
257
299
  if (json) {
258
- console.log(JSON.stringify({ enabled: on, routine: WATCHDOG_ROUTINE_NAME, stateDir: stateDir() }));
300
+ console.log(JSON.stringify({
301
+ enabled: on,
302
+ routine: WATCHDOG_ROUTINE_NAME,
303
+ stateDir: stateDir(),
304
+ rotate,
305
+ rotates: rotates.map((r) => ({
306
+ sessionId: r.sessionId,
307
+ newSessionId: r.newSessionId,
308
+ agent: r.agent,
309
+ phase: r.phase,
310
+ updatedAtMs: r.updatedAtMs,
311
+ error: r.error,
312
+ })),
313
+ }));
259
314
  return;
260
315
  }
261
316
  console.log(`always-on watchdog: ${on ? chalk.green('ON') : chalk.dim('off')} (routine '${WATCHDOG_ROUTINE_NAME}')`);
317
+ console.log(`rotate: ${rotate === 'on' ? chalk.green('on') : chalk.yellow('off')} (watchdog.rotate in agents.yaml) · ${inflight.length} in-flight`);
318
+ for (const r of inflight) {
319
+ console.log(` ${chalk.magenta(r.phase.padEnd(12))} ${chalk.bold(r.sessionId.slice(0, 8))} → ${r.newSessionId.slice(0, 8)}${r.error ? chalk.red(` ${r.error}`) : ''}`);
320
+ }
262
321
  console.log(`state dir: ${chalk.dim(stateDir())}`);
263
322
  });
264
323
  // --- per-session policy ----------------------------------------------------
@@ -83,6 +83,8 @@ export declare function pickTailscaleBundleFromList(bundles: SecretsBundle[]): {
83
83
  name: string;
84
84
  key: string;
85
85
  } | undefined;
86
+ /** Test seam: drop every crabboxEnv secrets memo so each test resolves fresh. */
87
+ export declare function resetCrabboxSecretsMemosForTest(): void;
86
88
  /** A resolved lease bundle: its name, plus (auto-detect only) the exact keys to inject. */
87
89
  export interface ResolvedLeaseBundle {
88
90
  name: string;
@@ -67,6 +67,42 @@ function resolveTailscaleBundleMemo() {
67
67
  }
68
68
  return tailscaleBundleMemo.value;
69
69
  }
70
+ /**
71
+ * Process-lifetime memo for the RESOLVED tailscale key value, next to the pick
72
+ * memo above. `crabboxEnv` runs several times per lease (list/wait/spawn/stop),
73
+ * and the single-key subset read (`keys: [ts.key]`) is rejected by
74
+ * `canCacheResolvedEnv` for broker auto-cache — so without this memo a
75
+ * non-broker-held tailscale bundle re-read the keychain on EVERY call (and,
76
+ * pre-guard, could pop a Touch ID sheet each time). One read per process,
77
+ * success or failure (a failed read memoizes as undefined: the catch below
78
+ * already degrades to a public-network lease, retrying mid-process only
79
+ * repeats the same failure).
80
+ */
81
+ let tailscaleValueMemo;
82
+ function resolveTailscaleKeyValueMemo(ts) {
83
+ if (!tailscaleValueMemo) {
84
+ let value;
85
+ try {
86
+ const { env } = readAndResolveBundleEnv(ts.name, {
87
+ caller: 'agents run --lease (crabbox tailscale)',
88
+ keys: [ts.key],
89
+ agentOnly: isHeadlessSecretsContext(),
90
+ });
91
+ value = env[ts.key];
92
+ }
93
+ catch {
94
+ /* best-effort — tailscale is opt-in plumbing, never blocks a public lease */
95
+ }
96
+ tailscaleValueMemo = { value };
97
+ }
98
+ return tailscaleValueMemo.value;
99
+ }
100
+ /** Test seam: drop every crabboxEnv secrets memo so each test resolves fresh. */
101
+ export function resetCrabboxSecretsMemosForTest() {
102
+ tailscaleBundleMemo = undefined;
103
+ tailscaleValueMemo = undefined;
104
+ leaseBundleMemo = undefined;
105
+ }
70
106
  /**
71
107
  * The secrets bundle to feed crabbox, resolved in priority order:
72
108
  * 1. `AGENTS_LEASE_SECRETS_BUNDLE` env var — explicit, no keychain
@@ -151,23 +187,15 @@ export function crabboxEnv(opts) {
151
187
  // declares a tailscale auth key, when the ambient env doesn't already set one.
152
188
  // Best-effort and opt-in — a missing/unreadable tailscale bundle never fails a
153
189
  // public-network lease; `crabboxWarmup({ netMode: 'tailscale' })` is what decides
154
- // whether the key is actually used.
190
+ // whether the key is actually used. The resolved value is memoized per process
191
+ // (see resolveTailscaleKeyValueMemo) so repeated crabboxEnv calls read the
192
+ // keychain at most once.
155
193
  if (!out.CRABBOX_TAILSCALE_AUTH_KEY) {
156
194
  const ts = resolveTailscaleBundleMemo();
157
195
  if (ts) {
158
- try {
159
- const { env } = readAndResolveBundleEnv(ts.name, {
160
- caller: 'agents run --lease (crabbox tailscale)',
161
- keys: [ts.key],
162
- agentOnly: isHeadlessSecretsContext(),
163
- });
164
- const value = env[ts.key];
165
- if (value)
166
- out.CRABBOX_TAILSCALE_AUTH_KEY = value;
167
- }
168
- catch {
169
- /* best-effort — tailscale is opt-in plumbing, never blocks a public lease */
170
- }
196
+ const value = resolveTailscaleKeyValueMemo(ts);
197
+ if (value)
198
+ out.CRABBOX_TAILSCALE_AUTH_KEY = value;
171
199
  }
172
200
  }
173
201
  return out;
@@ -51,6 +51,19 @@ export interface ProjectContext {
51
51
  /** One line on how this subtree relates to the project. */
52
52
  purpose: string;
53
53
  }
54
+ /**
55
+ * A project goal — the OKR-shaped "why". A project serves one or more goals: a
56
+ * qualitative `objective` ("Ship agents-cli 2.0") and an optional `measure`, the
57
+ * key result that says whether it's landing ("fleet on 2.x", "p95 < 200ms"). The
58
+ * goal is the outcome the work is chasing; milestones (dated checkpoints, pulled
59
+ * from Linear) and live work (agents / PRs / artifacts) are how far along it is.
60
+ */
61
+ export interface ProjectGoal {
62
+ /** The outcome, in a line. */
63
+ objective: string;
64
+ /** Optional key result — how success is measured. */
65
+ measure?: string;
66
+ }
54
67
  /** An external context source hung off the project (surfaced in `projects show`). */
55
68
  export interface ProjectIntegration {
56
69
  /** e.g. `gdrive`, `notion`, `figma`, `url`. */
@@ -73,6 +86,8 @@ export interface ProjectDef {
73
86
  repos?: ProjectRepo[];
74
87
  /** Described starting points inside the project. */
75
88
  contexts?: ProjectContext[];
89
+ /** The outcomes this project serves (OKR-shaped); a project may have several. */
90
+ goals?: ProjectGoal[];
76
91
  /** External context sources (Drive, docs, …). */
77
92
  integrations?: ProjectIntegration[];
78
93
  /** Linear project link — reuses the existing GraphQL path. */
@@ -121,6 +121,18 @@ export function validateProjectDef(raw, sourceName) {
121
121
  return [];
122
122
  });
123
123
  }
124
+ if (Array.isArray(o.goals)) {
125
+ def.goals = o.goals.flatMap((g) => {
126
+ if (g && typeof g === 'object' && typeof g.objective === 'string') {
127
+ const gg = g;
128
+ const goal = { objective: gg.objective };
129
+ if (typeof gg.measure === 'string')
130
+ goal.measure = gg.measure;
131
+ return [goal];
132
+ }
133
+ return [];
134
+ });
135
+ }
124
136
  if (Array.isArray(o.integrations)) {
125
137
  def.integrations = o.integrations.flatMap((i) => {
126
138
  if (i &&
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import type { AgentId, RunStrategy } from './types.js';
8
8
  import type { FallbackEntry } from './exec.js';
9
- import { type AccountInfo } from './agents.js';
9
+ import { type AccountInfo, type CredentialPresence } from './agents.js';
10
10
  import { type UsageSnapshot } from './usage.js';
11
11
  export interface RotateCandidate {
12
12
  agent: AgentId;
@@ -75,6 +75,21 @@ export declare function getProjectRunStrategy(agent: AgentId, startPath: string)
75
75
  export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: string): RunStrategy;
76
76
  /** Persist the global run strategy used by bare `agents run <agent>`. */
77
77
  export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrategy): void;
78
+ /**
79
+ * Whether a version home can actually authenticate a launch.
80
+ *
81
+ * `getAccountInfo` falls back to the active/global HOME when a version home has
82
+ * no credential of its own, so `agents view` still shows who is logged in. Launch
83
+ * paths isolate config (GROK_HOME, CODEX_HOME, KIMI_CODE_HOME, CLAUDE_CONFIG_DIR,
84
+ * …) to the per-version home, so a home that only "inherits" the active login
85
+ * cannot spawn a signed-in agent — balanced kept picking those empty homes and
86
+ * the run died on "Not signed in".
87
+ *
88
+ * When we know where the credential lives (`knownLocation`), require it under
89
+ * THIS version home. When we don't (keychain-only / unmapped agents), trust the
90
+ * existing `signedIn` signal.
91
+ */
92
+ export declare function isLaunchableSignedIn(signedIn: boolean, presence: Pick<CredentialPresence, 'knownLocation' | 'perVersion'>): boolean;
78
93
  /**
79
94
  * How old a usage snapshot may be and still settle a routing DECISION.
80
95
  *
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
- import { accountDisplayLabel, getAccountInfo, ALL_AGENT_IDS } from './agents.js';
9
+ import { accountDisplayLabel, getAccountInfo, credentialPresence, ALL_AGENT_IDS, } from './agents.js';
10
10
  import { readMeta, writeMeta, getHelpersDir } from './state.js';
11
11
  import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
12
12
  import { getProjectRunConfigs } from './run-config.js';
@@ -68,6 +68,27 @@ export function setGlobalRunStrategy(agent, strategy) {
68
68
  function isRotationEligible(candidate) {
69
69
  return candidate.signedIn && hasUsageAvailable(candidate);
70
70
  }
71
+ /**
72
+ * Whether a version home can actually authenticate a launch.
73
+ *
74
+ * `getAccountInfo` falls back to the active/global HOME when a version home has
75
+ * no credential of its own, so `agents view` still shows who is logged in. Launch
76
+ * paths isolate config (GROK_HOME, CODEX_HOME, KIMI_CODE_HOME, CLAUDE_CONFIG_DIR,
77
+ * …) to the per-version home, so a home that only "inherits" the active login
78
+ * cannot spawn a signed-in agent — balanced kept picking those empty homes and
79
+ * the run died on "Not signed in".
80
+ *
81
+ * When we know where the credential lives (`knownLocation`), require it under
82
+ * THIS version home. When we don't (keychain-only / unmapped agents), trust the
83
+ * existing `signedIn` signal.
84
+ */
85
+ export function isLaunchableSignedIn(signedIn, presence) {
86
+ if (!signedIn)
87
+ return false;
88
+ if (!presence.knownLocation)
89
+ return true;
90
+ return presence.perVersion;
91
+ }
71
92
  function isAvailableEligible(candidate) {
72
93
  return isRotationEligible(candidate);
73
94
  }
@@ -484,17 +505,22 @@ export async function collectRunCandidates(agent) {
484
505
  // one per installed version, every time `agents run` cold-starts. If
485
506
  // claude's stored token has actually expired, the spawned agent detects
486
507
  // it at its own startup and re-auths; that's the correct UX.
508
+ //
509
+ // Gate signedIn on a real per-version credential when we know where it
510
+ // lives — see isLaunchableSignedIn. Do not reuse the active-home fallback
511
+ // identity for routing, or empty version homes look healthy and die at spawn.
512
+ const launchable = isLaunchableSignedIn(info.signedIn, credentialPresence(agent, home));
487
513
  return {
488
514
  agent,
489
515
  version,
490
516
  home,
491
517
  info,
492
- accountKey: info.accountKey,
493
- accountLabel: accountDisplayLabel(info),
494
- email: info.email,
495
- usageStatus: info.usageStatus,
496
- plan: info.plan,
497
- signedIn: info.signedIn,
518
+ accountKey: launchable ? info.accountKey : null,
519
+ accountLabel: launchable ? accountDisplayLabel(info) : '',
520
+ email: launchable ? info.email : null,
521
+ usageStatus: launchable ? info.usageStatus : null,
522
+ plan: launchable ? info.plan : null,
523
+ signedIn: launchable,
498
524
  lastActive: info.lastActive,
499
525
  };
500
526
  }));