@phnx-labs/agents-cli 1.22.0 → 1.22.3

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 (42) hide show
  1. package/CHANGELOG.md +115 -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/doctor.js +34 -2
  6. package/dist/commands/exec.js +1 -1
  7. package/dist/commands/projects.js +128 -66
  8. package/dist/commands/secrets.js +9 -5
  9. package/dist/commands/teams.js +2 -2
  10. package/dist/commands/watchdog.js +64 -5
  11. package/dist/lib/app-bundle-install.d.ts +17 -0
  12. package/dist/lib/app-bundle-install.js +94 -0
  13. package/dist/lib/devices/doctor-findings.js +12 -4
  14. package/dist/lib/devices/doctor-overview-cache.d.ts +45 -0
  15. package/dist/lib/devices/doctor-overview-cache.js +168 -0
  16. package/dist/lib/devices/fleet.js +7 -2
  17. package/dist/lib/devices/self-host.d.ts +9 -0
  18. package/dist/lib/devices/self-host.js +61 -0
  19. package/dist/lib/hosts/passthrough.js +8 -6
  20. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  21. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  22. package/dist/lib/menubar/install-menubar.js +27 -23
  23. package/dist/lib/project-status.d.ts +7 -0
  24. package/dist/lib/project-status.js +9 -0
  25. package/dist/lib/projects.d.ts +26 -2
  26. package/dist/lib/projects.js +69 -9
  27. package/dist/lib/rotate.d.ts +16 -1
  28. package/dist/lib/rotate.js +33 -7
  29. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  30. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  31. package/dist/lib/secrets/bundles.d.ts +20 -0
  32. package/dist/lib/secrets/bundles.js +50 -0
  33. package/dist/lib/secrets/install-helper.js +28 -31
  34. package/dist/lib/share/config.d.ts +14 -6
  35. package/dist/lib/share/config.js +23 -5
  36. package/dist/lib/types.d.ts +10 -0
  37. package/dist/lib/versions.js +69 -22
  38. package/dist/lib/watchdog/rotate.d.ts +218 -0
  39. package/dist/lib/watchdog/rotate.js +378 -0
  40. package/dist/lib/watchdog/runner.d.ts +33 -1
  41. package/dist/lib/watchdog/runner.js +303 -0
  42. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,120 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.3
4
+
5
+ - **Menubar home-base self-test accepts `MenubarHelper-universal`.** The
6
+ signed-helper gate required `executablePath` to end in exactly `MenubarHelper`,
7
+ but lipo production builds name the binary `MenubarHelper-universal`, so every
8
+ 1.22.2 publish on mac-mini failed after the release PR had already merged and
9
+ tagged. Source: `menubar/Sources/MenubarHelper/ChildProcessSelfTest.swift`.
10
+
11
+ ## 1.22.2
12
+
13
+ - **Menu bar recovers from a stale single-instance lock instead of staying dead.**
14
+ The helper's lock fd is now opened `O_CLOEXEC`, so a spawned `doctor` child can
15
+ never inherit it and hold the `menubar.lock` flock after the helper crashes; and
16
+ `SingleInstance.acquire` now self-heals — when the flock is held but no live
17
+ `MenubarHelper` owns it (a leaked orphan / dead pid), it reaps the orphan and
18
+ retries rather than exiting as "already running". Previously a leaked orphan
19
+ bricked the menu bar until reboot. The headless Swift self-tests (single-instance
20
+ + child-process) now run as a build gate (`menubar/scripts/test-menubar.sh`),
21
+ which nothing invoked before. Source: `apps/cli/menubar/Sources/MenubarHelper/SingleInstance.swift`.
22
+
23
+ - **`agents projects` definitions can now carry goals — the OKR-shaped "why".**
24
+ A project serves one or more `goals[]`, each an `objective` (the outcome) plus an
25
+ optional `measure` (the key result). Set them at scaffold time with
26
+ `agents projects add <name> --goal "objective:measure"` (repeatable), replace them
27
+ later with `agents projects set <name> --goal …`, or hand-edit the YAML. Goals show
28
+ on the `status` card (compact) and in `projects view` (in full), and survive a
29
+ `--from-linear` re-import like every other hand-set field. Milestones (pulled from
30
+ Linear) remain the dated checkpoints toward these goals. Source:
31
+ `apps/cli/src/lib/projects.ts`, `apps/cli/src/commands/projects.ts`.
32
+
33
+ - **Balanced rotation no longer picks version homes that only inherit the active login.**
34
+ `getAccountInfo` falls back to the active/global HOME credential so `agents view`
35
+ still shows who is signed in when a version home has no auth file of its own.
36
+ Launch paths isolate config (`GROK_HOME`, `CODEX_HOME`, …) to the per-version home,
37
+ so those empty homes died at spawn with "Not signed in" after balanced picked them
38
+ (observed: `grok@0.2.118` with no `auth.json` looking signed-in via `~/.grok` →
39
+ `0.2.32`). Rotation now requires a real per-version credential when we know where
40
+ it lives (`credentialPresence.perVersion`). Source: `src/lib/rotate.ts`
41
+ (`isLaunchableSignedIn`, `collectRunCandidates`).
42
+
43
+ - **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`.
44
+
45
+ - **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`.
46
+
47
+ - **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`.
48
+
49
+ ## 1.22.1
50
+
51
+ - **`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`.
52
+
53
+ - **The "… is damaged and can't be opened" dialog stops — both helper `.app` bundles now install atomically and serialized.** The secrets keychain helper (`Agents CLI.app`) and the menu-bar helper (`MenubarHelper.app`) are each (re)installed on the hot path of ordinary `agents` invocations, and both did a non-atomic `rm -rf dest` + `cp -R src dest` straight onto the live bundle. On a busy box dozens of concurrent invocations raced that path, so a reader (Gatekeeper, or an exec of the bundle) could see a half-written `.app` — a truncated Mach-O / mismatched code signature — which macOS reports as damaged. A new shared installer (`lib/app-bundle-install.ts`, replacing the two duplicated copy functions) stages the copy in a sibling dir and swaps it in with renames (the live bundle is only ever a complete, signed `.app`, and a failed copy never touches it), and serializes concurrent installers behind the shared `withFileLock` with a double-checked skip so a burst copies once instead of stampeding. Source: `apps/cli/src/lib/app-bundle-install.ts`, `apps/cli/src/lib/secrets/install-helper.ts`, `apps/cli/src/lib/menubar/install-menubar.ts`.
54
+
55
+ - **`agents doctor --json` no longer stampedes into dozens of concurrent runs — the overview is singleflighted and cached, with a new `--refresh` to force a live recompute.** The bare `doctor --json` overview probes every host CLI, every agent's sign-in, and every agent×version diff — seconds on an idle box, minutes on a loaded one. The menu-bar helper polls it on a timer with only a per-*process* in-flight guard, so a helper relaunch (or any second poller) each launched its own live compute, and a helper killed mid-run orphaned a `doctor --json` that kept spinning — stacking to dozens of concurrent runs pinning the CPU. Now a fresh snapshot (< 90s) serves instantly from a disk cache, and when a live compute IS needed exactly one runs while every other caller serves its result (a lock-directory singleflight that self-heals if the computer dies). `agents doctor --json --refresh` bypasses the cache. Source: `apps/cli/src/lib/devices/doctor-overview-cache.ts`, `apps/cli/src/commands/doctor.ts`.
56
+
57
+ - **`doctor --json` releases its singleflight lock before it returns.** The overview gate
58
+ fired the lock release without awaiting it on the path where a waiter serves the winner's
59
+ fresh snapshot, so the call returned with the lockfile still on disk. The next caller then
60
+ retried against a lock that was already logically free — the pile-up the gate exists to
61
+ prevent, narrowed to the window between return and unlink. The release is now awaited.
62
+ The existing coalescing test failed 5 times in 15 runs before this and 0 in 15 after.
63
+ Source: `apps/cli/src/lib/devices/doctor-overview-cache.ts`.
64
+
65
+ - **`agents add grok@latest` no longer strands the freshly-downloaded binary in the old version's home.** When the post-install version probe (`<cli> --version`) transiently failed right after grok's self-updating installer exited, `installVersion` silently fell back to the literal string `'latest'` as the resolved version — creating a bogus `versions/grok/latest/` directory and defeating `relocateGrokBinaryToVersionHome`'s exact-filename match (its regex could never match `grok-latest-...`, since the real file is named `grok-<semver>-<platform>`). The real multi-hundred-MB binary was left behind in the PREVIOUS default's downloads dir, and `agents view grok` never listed the new version as installed even though `agents add` reported success. The probe now retries briefly instead of silently falling back, and fails loudly if it still can't resolve a version rather than corrupting the version bookkeeping. Relocation also now self-heals: if the current `~/.grok` symlink target has nothing matching, it sweeps every other installed grok version home for a binary stranded by a past occurrence of this bug. Source: `apps/cli/src/lib/versions.ts`.
66
+
67
+ - **A blocked menu-bar row now takes you to the session (RUSH-2110).** A NEEDS-YOU row
68
+ exists because an agent is waiting on you, but its only action was "Reveal working
69
+ dir", which unblocks nothing — you still had to go find the session by hand. Blocked
70
+ rows now lead with **Focus session**, which runs `agents focus <id>`: attach the live
71
+ terminal, or open a new tab and resume, cross-host. Reveal stays underneath. Both
72
+ render paths are covered — the single inline row and each entry inside a collapsed
73
+ multi-waiter group. A row the engine could not identify (a cloud task, a stale
74
+ sentinel) simply omits the item rather than offering an action that would do nothing.
75
+ Source: `apps/cli/menubar/Sources/MenubarHelper/StatusItemController.swift`,
76
+ `AgentsCLI.swift`.
77
+
78
+ - **Two projects sharing one monorepo checkout are no longer indistinguishable.** Session,
79
+ activity, and feed attribution anchored a project on `root ?? defaultPath`, so a subproject
80
+ whose `root` is the monorepo and whose `defaultPath` is a subdir collapsed onto the same
81
+ path as its umbrella — the longest-match tiebreak had nothing to separate them, and work in
82
+ `rush/apps/cli` counted toward whichever definition happened to be listed first. A
83
+ `defaultPath` nested under `root` now takes precedence over that `root` (the root says where
84
+ the checkout is; `defaultPath` says which work is this project's), and each bound repo's
85
+ checkout and subpath anchor too. A narrowed `root` still covers the rest of its checkout as
86
+ a fallback, so a lone project defined with `--path` keeps attributing work across its own
87
+ repo instead of only inside the subdir. Source: `apps/cli/src/lib/projects.ts`.
88
+
89
+ - **`agents projects view <name>` now shows more than `status`, not less.** The command you
90
+ open to learn everything about one project built its own short list — root, repos, a raw
91
+ Linear project id, an issue count, milestones — and never called the card renderer, so it
92
+ omitted the agents roster, merged PRs and release, focus areas, the schedule verdict,
93
+ tickets, and artifacts that `status` had shown all along. `view` and `status` now gather
94
+ through one function and render through one card; `view` adds every milestone (instead of
95
+ just the next) and the stored definition in full underneath — each repo with its subpath and
96
+ checkout, each context with its purpose, each integration with its URL. It also takes
97
+ `--window <days>` to match `status`. Source: `apps/cli/src/commands/projects.ts`.
98
+ - **The `agents` roster on the card lists live sessions only.** It included every matched
99
+ session, so a card headed `23 live` went on to print `claude · crashed ×25` — the corpses the
100
+ `dead` row already reports, counted twice and contradicting the headline. Both now derive
101
+ from one `isDeadStatus` predicate, pinned by a test across every `ActiveStatus`. Source:
102
+ `apps/cli/src/lib/project-status.ts`.
103
+
104
+ - **`--host <self>` and the fleet-health fan-out now short-circuit ALL of the
105
+ local machine's names, not just its short hostname (RUSH-2114).** A `--host`
106
+ target or fleet probe that referenced this box by its **tailscale dnsName**
107
+ (`zion.tail1a85a1.ts.net`) slipped past a `=== machineId()` check and SSH'd to
108
+ the local box over its own name; on a loaded machine that self-SSH'd `doctor
109
+ --json` orphaned on timeout and piled up until the host was crushed. A new
110
+ `isSelfHost()` matches every identity the box answers to (short id, loopback,
111
+ tailscale dnsName + its short form) and gates all four self-checks — the
112
+ generic `--host` passthrough (`maybeRunOnHost`), the `--devices`-all fan-out
113
+ (`runFleetPassthrough`), `remoteFleetTargets`, and `runFleet` — so a
114
+ self-reference runs locally instead of self-SSHing. Source:
115
+ `apps/cli/src/lib/devices/self-host.ts`, `apps/cli/src/lib/hosts/passthrough.ts`,
116
+ `apps/cli/src/lib/devices/fleet.ts`.
117
+
3
118
  ## 1.22.0
4
119
 
5
120
  - **`agents run auto` — full-auto dispatch (RUSH-2132).** `run auto` composes all three routing layers: host (14d launch affinity, unless `--host` is given), harness (installed CLIs weighted by best-account headroom), and account (the configured strategy). `balanced`/`available` now exit nonzero when every installed account is unhealthy — naming each excluded account, the earliest window reset, and the `--strategy pinned` escape hatch — instead of warning "falling back to defaults" and launching the exhausted pinned default. The error text is a machine-readable contract (`no healthy` + `resets <iso-time>`) the Factory watchdog tail-detects for rotate cooldowns. Source: `apps/cli/src/lib/rotate.ts`, `apps/cli/src/commands/exec.ts`, `apps/cli/src/lib/runner.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)
@@ -4,6 +4,7 @@ import { addHostOption } from '../lib/hosts/option.js';
4
4
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
5
5
  import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
6
6
  import { fanOutDevices, planFleetTargets, remoteFleetTargets } from '../lib/devices/fleet.js';
7
+ import { enterDoctorOverviewGate, writeDoctorOverviewCache } from '../lib/devices/doctor-overview-cache.js';
7
8
  import { fleetDialTarget } from '../lib/devices/connect.js';
8
9
  import { compareFleetInventories } from '../lib/devices/fleet-divergence.js';
9
10
  import { collectLocalFleetInventory } from '../lib/devices/fleet-inventory.js';
@@ -1155,12 +1156,18 @@ export function registerDoctorCommand(program) {
1155
1156
  .option('--devices', 'Check agent readiness AND cross-device harness divergence (missing resources/versions, repo drift) on every registered device (alias --hosts)')
1156
1157
  .option('--hosts', 'Alias of --devices')
1157
1158
  .option('--check', 'CI drift gate: exit non-zero when any installed version is out of sync (stale or never-synced), zero when clean. Combine with --devices to gate the whole fleet.')
1159
+ .option('--refresh', 'Bypass the cached overview snapshot: recompute the bare `doctor --json` overview live and refresh the shared cache that the menu-bar and other pollers read')
1158
1160
  .option('-q, --quiet', 'With --check, suppress per-version lines; print only the one-line verdict');
1159
1161
  setHelpSections(doctorCmd, {
1160
1162
  examples: `
1161
1163
  # Overview: CLI availability + sync status + orphans across all defaults
1162
1164
  agents doctor
1163
1165
 
1166
+ # Machine-readable overview (served from a ~90s cache for pollers like the
1167
+ # menu-bar helper); --refresh recomputes live and refreshes that cache
1168
+ agents doctor --json
1169
+ agents doctor --json --refresh
1170
+
1164
1171
  # Full per-resource report for the active default
1165
1172
  agents doctor claude@default
1166
1173
 
@@ -1288,6 +1295,26 @@ export function registerDoctorCommand(program) {
1288
1295
  return;
1289
1296
  }
1290
1297
  if (!target) {
1298
+ // Singleflight + short-TTL cache for the bare `doctor --json` overview.
1299
+ // This overview probes every host CLI, every agent's sign-in, and every
1300
+ // agent×version diff — seconds on an idle box, minutes on a loaded one.
1301
+ // The menu-bar helper polls it with only a per-*process* in-flight guard,
1302
+ // so a helper relaunch (or any second poller) each launched its own live
1303
+ // compute, and a helper killed mid-run orphaned a `doctor --json` that
1304
+ // kept spinning — stacking to dozens of concurrent runs pinning the CPU
1305
+ // (RUSH-2153). Now: a fresh snapshot serves instantly, and when a compute
1306
+ // IS needed exactly one runs while every other caller serves its result.
1307
+ // A crashed computer never wedges the gate — the lock is stolen once its
1308
+ // directory mtime goes stale (see enterDoctorOverviewGate).
1309
+ let releaseOverviewGate;
1310
+ if (opts.json) {
1311
+ const gate = await enterDoctorOverviewGate({ forceRefresh: !!opts.refresh });
1312
+ if (gate.cached !== null) {
1313
+ console.log(gate.cached);
1314
+ return;
1315
+ }
1316
+ releaseOverviewGate = gate.release;
1317
+ }
1291
1318
  const clis = checkAllClis();
1292
1319
  const syncRows = checkSyncStatus(cwd);
1293
1320
  const orphanRows = countOrphans();
@@ -1355,7 +1382,7 @@ export function registerDoctorCommand(program) {
1355
1382
  isolatedVersions,
1356
1383
  });
1357
1384
  if (opts.json) {
1358
- console.log(JSON.stringify({
1385
+ const overviewPayload = {
1359
1386
  clis,
1360
1387
  signIn,
1361
1388
  // Cached auth-health rollup for THIS host — lets `agents fleet status`
@@ -1398,7 +1425,12 @@ export function registerDoctorCommand(program) {
1398
1425
  branch: m.branch,
1399
1426
  fetchedAt: m.fetchedAt,
1400
1427
  })),
1401
- }, null, 2));
1428
+ };
1429
+ // Persist for the next poller and release the singleflight lock BEFORE
1430
+ // printing, so a concurrent caller picks up the fresh snapshot at once.
1431
+ writeDoctorOverviewCache(overviewPayload);
1432
+ releaseOverviewGate?.();
1433
+ console.log(JSON.stringify(overviewPayload, null, 2));
1402
1434
  return;
1403
1435
  }
1404
1436
  // Single-machine hybrid: CRITICAL section + one `▸ <machine>` block.
@@ -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
@@ -25,7 +25,7 @@ import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
25
25
  import { factoryProjectsPath } from '../lib/auto-dispatch.js';
26
26
  import { formatFleetWorkspaces, parseRemoteProbe, probeProjectWorkspaces, workspaceTargetsForDef, } from '../lib/project-probe.js';
27
27
  import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, } from '../lib/projects.js';
28
- import { rollupSessionsByProject, liveDeadSplit, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
28
+ import { rollupSessionsByProject, isDeadStatus, liveDeadSplit, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
29
29
  import { fetchLinearProjectCounts } from '../lib/linear-project-counts.js';
30
30
  import { listLinearProjects, pickLinearProject } from '../lib/linear-projects.js';
31
31
  import { checkRepoSlug } from '../lib/project-doctor.js';
@@ -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
  *
@@ -268,11 +279,45 @@ function statusBar(r) {
268
279
  parts.push(chalk.gray(`+${live - shown} other`));
269
280
  return parts.join(' · ') || chalk.gray('no live agents');
270
281
  }
282
+ /**
283
+ * Gather the live/remote/Linear/focus signals for the projects about to be
284
+ * rendered. `status` and `view` share this deliberately: `view` used to build
285
+ * its own thinner picture, so the command you open to learn everything about
286
+ * ONE project showed strictly less than the roll-up across all of them — no
287
+ * agents, no ships, no focus, no schedule verdict. One gatherer means a signal
288
+ * added for either surface appears on both.
289
+ *
290
+ * Only the shown projects are enriched. `skipRemote` still reads the local
291
+ * artifact log and git focus, and skips just the network calls (`gh`, Linear).
292
+ */
293
+ async function enrichProjectsForRender(defs, all, opts) {
294
+ const roll = rollupSessionsByProject(all, [...(await getActiveSessions()), ...(opts.extraSessions ?? [])]);
295
+ const remote = new Map();
296
+ const linear = new Map();
297
+ // Local git, no API, no rate limit — measured 0.23s over a 897-commit week.
298
+ const focus = new Map();
299
+ await Promise.all(defs.map(async (d) => {
300
+ const [sig, counts] = await Promise.all([
301
+ enrichProjectSignals(d, opts.windowDays, opts.nowMs, { skipRemote: opts.skipRemote }),
302
+ !opts.skipRemote && d.linear?.projectId
303
+ ? fetchLinearProjectCounts(d.linear.projectId)
304
+ : Promise.resolve(undefined),
305
+ ]);
306
+ remote.set(d.name, sig);
307
+ if (counts)
308
+ linear.set(d.name, counts);
309
+ if (d.root)
310
+ focus.set(d.name, await readFocusAreas(expandLocalHome(d.root), opts.windowDays));
311
+ }));
312
+ return { roll, remote, linear, focus };
313
+ }
271
314
  function renderCard(def, r, remote, fleet, linear, nowMs = Date.now(),
272
315
  /** How many milestones to print. `status` shows the next one; `view` shows all. */
273
316
  milestoneLimit = 1,
274
317
  /** Directories the window's work landed in, from local git. */
275
- focus = []) {
318
+ focus = [],
319
+ /** `view` mode: the caller prints the stored definition in full afterwards. */
320
+ detail = false) {
276
321
  // The headline counts LIVE agents. It used to be every matched session, which
277
322
  // read `39 agents` on a project where 19 had crashed. `planPct` used to sit
278
323
  // here too and is gone: it summed each session's latest checklist snapshot,
@@ -288,8 +333,12 @@ focus = []) {
288
333
  const detail = split.deadByStatus.map((d) => `${d.n} ${d.status}`).join(', ');
289
334
  console.log(` ${chalk.dim('dead')} ${chalk.yellow(`${split.dead} finished or lost`)} ${chalk.dim(`(${detail})`)}`);
290
335
  }
291
- if (r && r.members.length)
292
- console.log(` ${chalk.dim('agents')} ${formatProjectMembers(r.members)}`);
336
+ // Live only. The roster used to list every matched session, so a card headed
337
+ // `23 live` went on to show `crashed ×25` — the corpses the `dead` row above
338
+ // already accounts for, counted twice and contradicting the headline.
339
+ const liveMembers = r?.members.filter((m) => !isDeadStatus(m.status)) ?? [];
340
+ if (liveMembers.length)
341
+ console.log(` ${chalk.dim('agents')} ${formatProjectMembers(liveMembers)}`);
293
342
  const ships = [];
294
343
  if (remote?.mergedPrs) {
295
344
  ships.push(chalk.green(`${remote.mergedPrs}${remote.mergedPrsTruncated ? '+' : ''} merged (${remote.windowDays}d)`));
@@ -343,13 +392,19 @@ focus = []) {
343
392
  console.log(` ${chalk.yellow('!')} ${chalk.yellow(mismatch.message)}`);
344
393
  console.log(` ${' '.repeat(8)}${chalk.gray(mismatch.remediation)}`);
345
394
  }
346
- if (def.contexts?.length) {
395
+ // `view` prints these in full (path + purpose, label + URL) right below, so
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
+ }
400
+ if (!detail && def.contexts?.length) {
347
401
  console.log(` ${chalk.dim('context')} ${def.contexts.map((c) => c.path).join(' · ')}`);
348
402
  }
349
- if (def.integrations?.length) {
403
+ if (!detail && def.integrations?.length) {
350
404
  console.log(` ${chalk.dim('links')} ${def.integrations.map((i) => i.label ?? i.kind).join(' · ')}`);
351
405
  }
352
- console.log('');
406
+ if (!detail)
407
+ console.log('');
353
408
  }
354
409
  export function registerProjectsCommands(program) {
355
410
  const enabled = isBetaEnabled('projects');
@@ -426,6 +481,7 @@ export function registerProjectsCommands(program) {
426
481
  .option('--path <subdir>', 'Default cwd for agents (a monorepo subdir)')
427
482
  .option('--repo <owner/repo>', 'Primary GitHub slug (defaults to the origin remote)')
428
483
  .option('--context <path:purpose...>', 'A described starting point; repeatable')
484
+ .option('--goal <objective:measure...>', 'An outcome the project serves; repeatable')
429
485
  .option('--linear <url-or-id>', 'Linear project URL or id')
430
486
  .option('--force', 'Overwrite an existing definition')
431
487
  .action(async (name, opts) => {
@@ -456,6 +512,8 @@ export function registerProjectsCommands(program) {
456
512
  def.repo = repo;
457
513
  if (opts.context?.length)
458
514
  def.contexts = opts.context.map(parseContextFlag);
515
+ if (opts.goal?.length)
516
+ def.goals = opts.goal.map(parseGoalFlag);
459
517
  if (opts.linear) {
460
518
  def.linear = /^https?:/.test(opts.linear) ? { url: opts.linear } : { projectId: opts.linear };
461
519
  }
@@ -468,58 +526,72 @@ export function registerProjectsCommands(program) {
468
526
  projects
469
527
  .command('view <name>')
470
528
  .alias('show')
471
- .description('Show a project definition, resolved paths, repos, contexts, links, and milestones.')
529
+ .description('Everything about one project: the full status card, every milestone, and the stored definition.')
472
530
  .option('--json', 'Machine-readable output')
473
- .option('--no-remote', 'Skip the Linear lookup; definition only')
531
+ .option('--window <days>', 'Window for merged PRs, artifacts, and focus areas', '7')
532
+ .option('--no-remote', 'Skip the GitHub and Linear lookups; definition only')
474
533
  .action(async (name, opts) => {
475
534
  const def = loadProjectDef(name);
476
535
  if (!def) {
477
536
  console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
478
537
  process.exit(1);
479
538
  }
480
- // Every milestone the project declares, not just the next one — the whole
481
- // point of opening ONE project is to see the shape of its plan.
482
- const counts = opts.remote !== false && def.linear?.projectId
483
- ? await fetchLinearProjectCounts(def.linear.projectId)
484
- : undefined;
539
+ // `view` is `status` for one project PLUS the stored definition and every
540
+ // milestone. It used to render its own short list — root, repos, a raw
541
+ // Linear id, issues — so opening a single project told you LESS than the
542
+ // roll-up did: no agents, no ships, no focus, no schedule verdict.
543
+ const windowDays = Math.max(1, Number.parseInt(opts.window ?? '7', 10) || 7);
544
+ const nowMs = Date.now();
545
+ const all = listProjectDefs();
546
+ const { roll, remote, linear, focus } = await enrichProjectsForRender([def], all, {
547
+ windowDays,
548
+ nowMs,
549
+ skipRemote: opts.remote === false,
550
+ });
551
+ const r = roll.get(def.name);
552
+ const sig = remote.get(def.name);
553
+ const counts = linear.get(def.name);
485
554
  if (opts.json) {
486
- console.log(JSON.stringify({ ...def, linear: { ...def.linear, ...(counts ?? {}) } }, null, 2));
555
+ console.log(JSON.stringify({
556
+ ...def,
557
+ linear: { ...def.linear, ...(counts ?? {}) },
558
+ schedule: counts?.milestones?.length ? scheduleVerdict(counts.milestones, nowMs) : null,
559
+ live: r ? liveDeadSplit(r.byStatus).live : 0,
560
+ dead: r ? liveDeadSplit(r.byStatus).dead : 0,
561
+ byStatus: r?.byStatus ?? {},
562
+ members: r?.members ?? [],
563
+ openPrs: r?.openPrs ?? [],
564
+ tickets: r?.tickets ?? [],
565
+ mergedPrs: sig?.mergedPrs ?? 0,
566
+ latestRelease: sig?.latestRelease ?? null,
567
+ artifacts: sig?.artifacts ?? 0,
568
+ focus: focus.get(def.name) ?? [],
569
+ }, null, 2));
487
570
  return;
488
571
  }
489
- console.log(chalk.bold(def.name) + (def.description ? chalk.dim(` — ${def.description}`) : ''));
572
+ // The same card `status` prints, with every milestone instead of the next.
573
+ renderCard(def, r, sig, undefined, counts, nowMs, Number.POSITIVE_INFINITY, focus.get(def.name) ?? [], true);
574
+ // Then what only `view` shows: the stored definition, in full.
575
+ console.log();
490
576
  if (def.root)
491
- console.log(` root ${def.root}`);
577
+ console.log(` ${chalk.dim('root')} ${def.root}`);
492
578
  if (def.defaultPath)
493
- console.log(` defaultPath ${def.defaultPath}`);
494
- const repos = [def.repo, ...(def.repos ?? []).map((r) => (r.subpath ? `${r.slug} (${r.subpath})` : r.slug))].filter(Boolean);
495
- if (repos.length)
496
- console.log(` repos ${repos.join(', ')}`);
579
+ console.log(` ${chalk.dim('path')} ${def.defaultPath}`);
580
+ for (const rp of def.repos ?? []) {
581
+ const where = [rp.subpath ? `subpath ${rp.subpath}` : undefined, rp.path].filter(Boolean).join(' · ');
582
+ console.log(` ${chalk.dim('repo')} ${rp.slug}${where ? chalk.dim(` (${where})`) : ''}`);
583
+ }
584
+ for (const g of def.goals ?? [])
585
+ console.log(` ${chalk.dim('goal')} ${g.objective}${g.measure ? chalk.dim(` · ${g.measure}`) : ''}`);
497
586
  for (const c of def.contexts ?? [])
498
- console.log(` context ${chalk.cyan(c.path)} — ${c.purpose}`);
499
- for (const i of def.integrations ?? [])
500
- console.log(` ${i.kind.padEnd(12)} ${i.url}${i.label ? chalk.dim(` (${i.label})`) : ''}`);
587
+ console.log(` ${chalk.dim('context')} ${chalk.cyan(c.path)} ${chalk.dim('—')} ${c.purpose}`);
588
+ for (const ig of def.integrations ?? []) {
589
+ console.log(` ${chalk.dim(ig.kind.padEnd(8))} ${ig.url}${ig.label ? chalk.dim(` (${ig.label})`) : ''}`);
590
+ }
501
591
  if (def.linear?.url || def.linear?.projectId)
502
- console.log(` linear ${def.linear.url ?? def.linear.projectId}`);
592
+ console.log(` ${chalk.dim('linear')} ${def.linear.url ?? def.linear.projectId}`);
503
593
  for (const d of def.docs ?? [])
504
- console.log(` doc ${d}`);
505
- if (counts) {
506
- const staleNote = counts.stale ? chalk.dim(' (cached)') : '';
507
- console.log(` issues ${counts.done}/${counts.total}${counts.truncated ? '+' : ''} done · ${counts.inProgress} in progress${staleNote}`);
508
- }
509
- const ms = counts?.milestones ?? [];
510
- if (ms.length) {
511
- console.log(` ${chalk.bold('milestones')}`);
512
- for (const m of ms) {
513
- const mark = m.name === counts?.nextMilestone?.name ? chalk.cyan('next') : '';
514
- console.log(` ${mark.padEnd(6)} ${formatNextMilestone(m, Date.now())}`);
515
- }
516
- // A milestone nothing is filed against cannot report progress. Say that
517
- // once, plainly, rather than printing a row of silent 0%s.
518
- const unfiled = ms.filter((m) => m.total === 0).length;
519
- if (unfiled === ms.length) {
520
- console.log(` ${chalk.yellow('!')} ${chalk.yellow(`no issues are assigned to any milestone — progress against them cannot be measured`)}`);
521
- }
522
- }
594
+ console.log(` ${chalk.dim('doc')} ${d}`);
523
595
  console.log(chalk.gray(` ${projectDefPath(name)}`));
524
596
  });
525
597
  // ---- edit ----
@@ -590,27 +662,12 @@ export function registerProjectsCommands(program) {
590
662
  fleetSkipped = probeRes.skipped;
591
663
  fleetSessions = activeRes.sessions;
592
664
  }
593
- const roll = rollupSessionsByProject(all, [...await getActiveSessions(), ...fleetSessions]);
594
- // Enrich only the shown projects. --no-remote still reads the local artifact
595
- // log but skips the gh calls + Linear counts (both are network).
596
- const remote = new Map();
597
- const linear = new Map();
598
- // Local git, no API, no rate limit — measured 0.23s over a 897-commit week.
599
- const focus = new Map();
600
- await Promise.all(defs.map(async (d) => {
601
- const skipRemote = opts.remote === false;
602
- const [sig, counts] = await Promise.all([
603
- enrichProjectSignals(d, windowDays, nowMs, { skipRemote }),
604
- !skipRemote && d.linear?.projectId
605
- ? fetchLinearProjectCounts(d.linear.projectId)
606
- : Promise.resolve(undefined),
607
- ]);
608
- remote.set(d.name, sig);
609
- if (counts)
610
- linear.set(d.name, counts);
611
- if (d.root)
612
- focus.set(d.name, await readFocusAreas(expandLocalHome(d.root), windowDays));
613
- }));
665
+ const { roll, remote, linear, focus } = await enrichProjectsForRender(defs, all, {
666
+ windowDays,
667
+ nowMs,
668
+ skipRemote: opts.remote === false,
669
+ extraSessions: fleetSessions,
670
+ });
614
671
  /** This def's slice of the fleet probe, in its own target order. */
615
672
  const fleetFor = (d) => {
616
673
  const targets = new Set(workspaceTargetsForDef(d));
@@ -704,6 +761,7 @@ export function registerProjectsCommands(program) {
704
761
  .option('--root <path>', 'Repo / monorepo root')
705
762
  .option('--path <subdir>', 'Default cwd for agents (a monorepo subdir)')
706
763
  .option('--description <text>', 'One-line description shown on the card')
764
+ .option('--goal <objective:measure...>', 'Replace the goals this project serves; repeatable')
707
765
  .action((name, opts) => {
708
766
  const def = loadProjectDef(name);
709
767
  if (!def) {
@@ -711,7 +769,7 @@ export function registerProjectsCommands(program) {
711
769
  process.exit(1);
712
770
  }
713
771
  const fields = ['repo', 'root', 'path', 'description'].filter((k) => opts[k] !== undefined);
714
- if (fields.length === 0) {
772
+ if (fields.length === 0 && !opts.goal?.length) {
715
773
  console.error(chalk.red('Nothing to set. Pass a field, e.g. --repo <owner/repo>.'));
716
774
  process.exit(1);
717
775
  }
@@ -724,6 +782,8 @@ export function registerProjectsCommands(program) {
724
782
  def.root = opts.root;
725
783
  if (opts.description !== undefined)
726
784
  def.description = opts.description;
785
+ if (opts.goal?.length)
786
+ def.goals = opts.goal.map(parseGoalFlag);
727
787
  if (opts.path !== undefined) {
728
788
  // `--path` is a subdir OF the root, so without one there is nothing to
729
789
  // hang it off. A def imported with `--from-linear` that found no local
@@ -742,6 +802,8 @@ export function registerProjectsCommands(program) {
742
802
  console.log(chalk.green(`Updated ${def.name}`));
743
803
  for (const f of fields)
744
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(' · ')}`));
745
807
  });
746
808
  // ---- link ----
747
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);