@phnx-labs/agents-cli 1.22.8 → 1.22.10

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 (65) hide show
  1. package/CHANGELOG.md +64 -4
  2. package/README.md +5 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +2 -2
  5. package/dist/commands/exec.js +41 -9
  6. package/dist/commands/feed.js +38 -1
  7. package/dist/commands/harness.d.ts +40 -1
  8. package/dist/commands/harness.js +316 -76
  9. package/dist/commands/profiles.d.ts +42 -0
  10. package/dist/commands/profiles.js +91 -3
  11. package/dist/commands/secrets.js +38 -39
  12. package/dist/commands/sessions-picker.js +3 -3
  13. package/dist/commands/sessions-render.d.ts +12 -0
  14. package/dist/commands/sessions-render.js +124 -0
  15. package/dist/commands/sessions.js +28 -1
  16. package/dist/commands/ssh.js +30 -8
  17. package/dist/commands/teams.js +12 -1
  18. package/dist/commands/workflows.js +1 -1
  19. package/dist/index.js +9 -2
  20. package/dist/lib/crabbox/cli.d.ts +35 -0
  21. package/dist/lib/crabbox/cli.js +46 -0
  22. package/dist/lib/crabbox/config.d.ts +21 -0
  23. package/dist/lib/crabbox/config.js +43 -0
  24. package/dist/lib/crabbox/lease.d.ts +15 -5
  25. package/dist/lib/crabbox/lease.js +57 -17
  26. package/dist/lib/daemon.js +12 -11
  27. package/dist/lib/devices/resolve-target.d.ts +4 -3
  28. package/dist/lib/devices/resolve-target.js +4 -3
  29. package/dist/lib/hosts/passthrough.js +18 -1
  30. package/dist/lib/hosts/registry.d.ts +4 -0
  31. package/dist/lib/hosts/registry.js +16 -0
  32. package/dist/lib/hosts/remote-cmd.js +1 -0
  33. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  34. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  35. package/dist/lib/observe-aliases.d.ts +30 -0
  36. package/dist/lib/observe-aliases.js +56 -0
  37. package/dist/lib/plugins.d.ts +14 -5
  38. package/dist/lib/plugins.js +29 -5
  39. package/dist/lib/redact.js +4 -0
  40. package/dist/lib/resources/types.d.ts +2 -1
  41. package/dist/lib/resources/workflows.d.ts +1 -1
  42. package/dist/lib/resources/workflows.js +22 -20
  43. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  44. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  45. package/dist/lib/secrets/bundles.d.ts +9 -0
  46. package/dist/lib/secrets/bundles.js +9 -23
  47. package/dist/lib/secrets/headless.d.ts +3 -15
  48. package/dist/lib/secrets/headless.js +5 -33
  49. package/dist/lib/secrets/index.d.ts +8 -0
  50. package/dist/lib/secrets/index.js +16 -36
  51. package/dist/lib/session/parse.d.ts +5 -1
  52. package/dist/lib/session/parse.js +23 -13
  53. package/dist/lib/session/prompt.js +5 -0
  54. package/dist/lib/session/render.d.ts +3 -0
  55. package/dist/lib/session/render.js +33 -10
  56. package/dist/lib/share/config.js +5 -7
  57. package/dist/lib/startup/command-registry.js +15 -1
  58. package/dist/lib/types.d.ts +6 -0
  59. package/dist/lib/usage-refresh.d.ts +19 -11
  60. package/dist/lib/usage-refresh.js +75 -43
  61. package/dist/lib/usage.d.ts +12 -0
  62. package/dist/lib/usage.js +37 -6
  63. package/dist/lib/workflows.d.ts +19 -4
  64. package/dist/lib/workflows.js +81 -7
  65. package/package.json +1 -1
@@ -9,7 +9,7 @@
9
9
  // `cloudflare` bundle.
10
10
  import { randomBytes } from 'node:crypto';
11
11
  import { readMeta, updateMeta } from '../state.js';
12
- import { bundleExists, bundleItemStore, bundlePolicy, isHeadlessSecretsContext, keychainRef, readAndResolveBundleEnv, readBundle, writeBundle, } from '../secrets/bundles.js';
12
+ import { bundleExists, bundleItemStore, bundlePolicy, keychainRef, readAndResolveBundleEnv, readBundle, writeBundle, } from '../secrets/bundles.js';
13
13
  import { secretsKeychainItem } from '../secrets/index.js';
14
14
  export const SHARE_BUNDLE = 'share';
15
15
  export const SHARE_TOKEN_KEY = 'WRITE_TOKEN';
@@ -76,10 +76,8 @@ export function storeWriteToken(token) {
76
76
  export function readWriteTokenFromBundle() {
77
77
  const { env } = readAndResolveBundleEnv(SHARE_BUNDLE, {
78
78
  caller: 'share',
79
- // Explicit `agents share` command (a human published a file): a headless agent
80
- // subprocess resolves broker-only, an interactive human may unlock. This is NOT
81
- // an agent LAUNCH read (that is exec.ts's --secrets injection, always agentOnly).
82
- agentOnly: isHeadlessSecretsContext(),
79
+ // Explicit share commands are reads, not authorization to authenticate.
80
+ agentOnly: true,
83
81
  });
84
82
  const token = env[SHARE_TOKEN_KEY];
85
83
  if (!token) {
@@ -136,8 +134,8 @@ export function readCloudflareCreds(bundle = DEFAULT_CF_BUNDLE, override) {
136
134
  }
137
135
  const { env } = readAndResolveBundleEnv(bundle, {
138
136
  caller: 'share',
139
- // Explicit `agents share setup` provisioning read — not an agent launch.
140
- agentOnly: isHeadlessSecretsContext(),
137
+ // Setup is still a read; only `agents secrets unlock` may authenticate.
138
+ agentOnly: true,
141
139
  });
142
140
  const find = (re) => {
143
141
  for (const [k, v] of Object.entries(env))
@@ -89,7 +89,16 @@ export const loadFunnel = async () => (await import('../../commands/funnel.js'))
89
89
  * inherit the root's custom help formatter rather than getting the per-command
90
90
  * recursive pass. Keeping that ordering preserves their `--help` output exactly.
91
91
  */
92
- export const LAZY_COMMAND_NAMES = new Set(['sessions', 'teams', 'cloud', 'message', 'serve']);
92
+ // `roster` is an observe-umbrella alias of `sessions --active` — same module,
93
+ // same SQLite stack, same post-help registration order as sessions.
94
+ export const LAZY_COMMAND_NAMES = new Set([
95
+ 'sessions',
96
+ 'roster',
97
+ 'teams',
98
+ 'cloud',
99
+ 'message',
100
+ 'serve',
101
+ ]);
93
102
  /**
94
103
  * User-typed top-level command name -> ordered list of module loaders to run.
95
104
  *
@@ -194,12 +203,17 @@ export const COMMAND_LOADERS = {
194
203
  setup: [loadSetup],
195
204
  uninstall: [loadUninstall],
196
205
  sessions: [loadSessions],
206
+ // Observe-umbrella alias of sessions --active (same lazy module).
207
+ roster: [loadSessions],
197
208
  teams: [loadTeams],
198
209
  cloud: [loadCloud],
199
210
  message: [loadMessage],
200
211
  send: [loadSend],
201
212
  notify: [loadSend],
202
213
  feed: [loadFeed],
214
+ // Observe-umbrella aliases of feed / feed --filter updates.
215
+ inbox: [loadFeed],
216
+ timeline: [loadFeed],
203
217
  mailboxes: [loadMailboxes],
204
218
  mailbox: [loadMailboxes],
205
219
  serve: [loadServe],
@@ -610,6 +610,12 @@ export interface DiscoveredPlugin {
610
610
  commands: string[];
611
611
  /** Subagent .md files in the plugin's agents/ directory (names without extension). */
612
612
  agentDefs: string[];
613
+ /**
614
+ * Workflow directory names under the plugin's `workflows/` (each must contain
615
+ * WORKFLOW.md). Phase 5 packaging: plugins may package workflows as entrypoints;
616
+ * `agents run <name>` resolves them via project > user > plugin > extra > system.
617
+ */
618
+ workflows: string[];
613
619
  /** Memory fact basenames from the plugin's memory/ directory (without .md). */
614
620
  memory: string[];
615
621
  /** Executable files in the plugin's bin/ directory. */
@@ -1,16 +1,23 @@
1
1
  import { type UsageHeadroom, type UsageSnapshot, type UsageInfo } from './usage.js';
2
2
  import type { AgentId } from './types.js';
3
- /** Floor on the adaptive interval: an account seconds from its cap still isn't
4
- * polled faster than this. */
3
+ /**
4
+ * Default schedule between successful (or attempted) live usage fetches for one
5
+ * account. Floor and ceiling of the delay helper are pinned to this so the
6
+ * daemon does not poll faster than 5 minutes even under high burn, and does not
7
+ * let an idle account rot longer than 5 minutes between attempts.
8
+ */
9
+ export declare const REFRESH_INTERVAL_MS: number;
10
+ /** @deprecated alias — use {@link REFRESH_INTERVAL_MS}. Kept for test imports. */
5
11
  export declare const REFRESH_MIN_MS: number;
6
- /** Ceiling on the adaptive interval: an idle account is still re-checked at
7
- * least this often so a cache never silently rots. */
12
+ /** @deprecated alias — use {@link REFRESH_INTERVAL_MS}. Kept for test imports. */
8
13
  export declare const REFRESH_MAX_MS: number;
9
- /** Schedule the next refresh at `minutesToLimit / K` — poll well before the cap,
10
- * not exactly at it. */
14
+ /** Burn-rate divisor retained for the pure delay helper / tests; with min=max
15
+ * the divisor does not change the scheduled interval. */
11
16
  export declare const REFRESH_BURN_DIVISOR = 4;
12
- /** At most this many live fetches per account per rolling hour. */
13
- export declare const HOURLY_CALL_CAP = 6;
17
+ /** At most this many live fetches per account per rolling hour (5m cadence ⇒ 12). */
18
+ export declare const HOURLY_CALL_CAP = 12;
19
+ /** How often the daemon wakes to *consider* a refresh pass (due accounts only). */
20
+ export declare const USAGE_REFRESH_TICK_MS: number;
14
21
  /**
15
22
  * One account's refresh state + published headroom. `sessionUsedPercent` /
16
23
  * `capturedAt` are the prior sample the NEXT tick projects the burn rate from;
@@ -38,9 +45,10 @@ export declare function readHeadroomEntry(usageKey: string): HeadroomEntry | nul
38
45
  /** Merge entries into the cache (best-effort; preserves other accounts' rows). */
39
46
  export declare function writeHeadroomEntries(entries: Record<string, HeadroomEntry>): void;
40
47
  /**
41
- * The adaptive interval until the next refresh, clamped to [MIN, MAX]. A
42
- * shorter `minutesToLimit` (closer to the cap) polls sooner; `null` (unknown /
43
- * idle / not burning) waits the full ceiling.
48
+ * Interval until the next refresh attempt, clamped to [minMs, maxMs].
49
+ * Defaults pin both ends to {@link REFRESH_INTERVAL_MS} (5 minutes) so the
50
+ * live daemon path is a fixed schedule. Tests may pass a wider range to
51
+ * exercise burn-aware scheduling without changing production cadence.
44
52
  */
45
53
  export declare function computeNextRefreshDelayMs(minutesToLimit: number | null, opts?: {
46
54
  minMs?: number;
@@ -1,49 +1,74 @@
1
1
  /**
2
- * Daemon-owned adaptive usage refresher.
2
+ * Daemon-owned usage refresher (per host).
3
3
  *
4
4
  * The routing hot path (`agents run` → collectRunCandidates) reads usage
5
5
  * CACHE-ONLY (`getUsageInfoForIdentity` `readOnly`, RUSH-2061) and never blocks
6
6
  * on a provider fetch. Something still has to keep that cache fresh — this
7
- * module is that something, running inside the daemon.
7
+ * module is that something, running inside the daemon on **each machine**.
8
8
  *
9
- * Design (per account, this host is the SOLE writer — the refresher only ever
10
- * touches accounts whose credentials live locally, so no cross-host
11
- * coordination and no shared-token contention):
9
+ * Design:
12
10
  *
13
- * - **Adaptive cadence from burn rate.** Each refresh stores the session
14
- * window's `usedPercent` + `capturedAt`. The next refresh is scheduled from
15
- * `deriveUsageHeadroom`'s projected `minutesToLimit`: an account racing
16
- * toward its cap is polled more often (down to 90s), an idle one rarely (up
17
- * to 15min). `computeNextRefreshDelayMs` is the pure clamp.
18
- * - **Hard hourly cap.** Regardless of cadence, at most `HOURLY_CALL_CAP` live
19
- * fetches per account per rolling hour, so a fast burn can't turn the 90s
20
- * floor into a hammering loop.
21
- * - **Respects the existing 429 backoff.** A provider under
22
- * `usageRateLimitedUntil` is skipped entirely — the whole point of the
23
- * backoff is to stop poking an endpoint that just said no.
11
+ * - **Per-host sole writer.** Only accounts whose credentials live on THIS
12
+ * box are listed; no fleet-wide usage sync.
13
+ * - **Fixed 5-minute cadence** (`REFRESH_INTERVAL_MS`). Enough to keep
14
+ * balanced/`agents view` off multi-hour stale data without thrashing
15
+ * provider APIs when the user runs agents frequently. The delay helpers
16
+ * still accept a burn projection for tests/future tuning, but the default
17
+ * floor and ceiling are both 5 minutes.
18
+ * - **Hard hourly cap** (`HOURLY_CALL_CAP`) so a stuck "due" loop cannot
19
+ * hammer an endpoint past ~12 calls/account/hour.
20
+ * - **429 backoff.** A provider under `usageRateLimitedUntil` is skipped
21
+ * entirely — no live fetch, no Touch ID, no re-armed penalty.
22
+ * - **File-only credentials on the daemon path.** Refresh never opens the
23
+ * ACL-bound macOS keychain item (Touch ID storm). It uses the no-ACL
24
+ * access-token cache / setup-token / `.credentials.json` only
25
+ * (`fileOnly: true` on `getUsageInfo`).
26
+ * - **Concurrency-safe cache writes.** Usage + headroom files are updated
27
+ * under `withFileLock` + atomic rename so a concurrent `agents view`
28
+ * background refresh cannot tear or drop another account's row.
24
29
  *
25
- * It also publishes the projected headroom (`minutesToLimit` + status) to a
26
- * small cache keyed by usage key, so the routing hot path can deprioritize an
27
- * account projected to cap without recomputing a burn rate it has no prior
28
- * sample for. `fleet-cache.ts` is the sync reader.
30
+ * Scenarios (what this path must survive):
31
+ *
32
+ * 1. **Daemon tick overlaps a slow tick** — overlap guard in daemon.ts;
33
+ * second tick is a no-op.
34
+ * 2. **`agents view` writes cache while daemon refreshes** — file lock
35
+ * serializes read-modify-write; no lost updates.
36
+ * 3. **macOS keychain ACL / Touch ID** — fileOnly refresh never calls
37
+ * `security find-generic-password` on Claude's ACL item.
38
+ * 4. **Provider 429** — whole provider skipped until Retry-After; cache
39
+ * freezes (visible as aged `capturedAt`) rather than hammering.
40
+ * 5. **Expired access token (no refresh)** — usage path never rotates
41
+ * single-use refresh tokens; counts as `failed`, reschedules 5m later.
42
+ * 6. **No file credential on this host** — account skipped / failed; no
43
+ * keychain fallback from the daemon refresher.
44
+ * 7. **Grok/Codex (network:false)** — not listed by `buildLocalUsageAccounts`;
45
+ * their "cache" is local logs, not this HTTP refresher.
29
46
  */
30
47
  import * as fs from 'fs';
31
48
  import * as path from 'path';
32
49
  import { getCacheDir } from './state.js';
50
+ import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
33
51
  import { deriveUsageHeadroom, getUsageInfo, buildCanonicalUsageContext, agentUsesNetworkUsage, USAGE_SOURCE_AGENT_IDS, } from './usage.js';
34
52
  import { getAccountInfo } from './agents.js';
35
53
  import { listInstalledVersions, getVersionHomePath } from './versions.js';
36
- /** Floor on the adaptive interval: an account seconds from its cap still isn't
37
- * polled faster than this. */
38
- export const REFRESH_MIN_MS = 90 * 1000;
39
- /** Ceiling on the adaptive interval: an idle account is still re-checked at
40
- * least this often so a cache never silently rots. */
41
- export const REFRESH_MAX_MS = 15 * 60 * 1000;
42
- /** Schedule the next refresh at `minutesToLimit / K` — poll well before the cap,
43
- * not exactly at it. */
54
+ /**
55
+ * Default schedule between successful (or attempted) live usage fetches for one
56
+ * account. Floor and ceiling of the delay helper are pinned to this so the
57
+ * daemon does not poll faster than 5 minutes even under high burn, and does not
58
+ * let an idle account rot longer than 5 minutes between attempts.
59
+ */
60
+ export const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
61
+ /** @deprecated alias — use {@link REFRESH_INTERVAL_MS}. Kept for test imports. */
62
+ export const REFRESH_MIN_MS = REFRESH_INTERVAL_MS;
63
+ /** @deprecated alias — use {@link REFRESH_INTERVAL_MS}. Kept for test imports. */
64
+ export const REFRESH_MAX_MS = REFRESH_INTERVAL_MS;
65
+ /** Burn-rate divisor retained for the pure delay helper / tests; with min=max
66
+ * the divisor does not change the scheduled interval. */
44
67
  export const REFRESH_BURN_DIVISOR = 4;
45
- /** At most this many live fetches per account per rolling hour. */
46
- export const HOURLY_CALL_CAP = 6;
68
+ /** At most this many live fetches per account per rolling hour (5m cadence ⇒ 12). */
69
+ export const HOURLY_CALL_CAP = 12;
70
+ /** How often the daemon wakes to *consider* a refresh pass (due accounts only). */
71
+ export const USAGE_REFRESH_TICK_MS = 60 * 1000;
47
72
  const HOUR_MS = 60 * 60 * 1000;
48
73
  /** Test seam for the headroom cache path (see usage.ts `setClaudeUsageCachePathForTest`). */
49
74
  let headroomCachePathOverride = null;
@@ -74,27 +99,30 @@ export function readHeadroomEntry(usageKey) {
74
99
  /** Merge entries into the cache (best-effort; preserves other accounts' rows). */
75
100
  export function writeHeadroomEntries(entries) {
76
101
  try {
77
- const dir = getCacheDir();
78
- if (!fs.existsSync(dir))
79
- fs.mkdirSync(dir, { recursive: true });
80
- const merged = {
81
- version: 1,
82
- entries: { ...readHeadroomCache(), ...entries },
83
- };
84
- fs.writeFileSync(headroomCachePath(), JSON.stringify(merged, null, 2));
102
+ const cachePath = headroomCachePath();
103
+ ensureLockTarget(cachePath, JSON.stringify({ version: 1, entries: {} }, null, 2));
104
+ withFileLock(cachePath, () => {
105
+ // Re-read under the lock so a concurrent tick/view cannot drop rows.
106
+ const merged = {
107
+ version: 1,
108
+ entries: { ...readHeadroomCache(), ...entries },
109
+ };
110
+ atomicWriteFileSync(cachePath, JSON.stringify(merged, null, 2));
111
+ });
85
112
  }
86
113
  catch {
87
114
  // best-effort; a failed write just means the router sees no projection
88
115
  }
89
116
  }
90
117
  /**
91
- * The adaptive interval until the next refresh, clamped to [MIN, MAX]. A
92
- * shorter `minutesToLimit` (closer to the cap) polls sooner; `null` (unknown /
93
- * idle / not burning) waits the full ceiling.
118
+ * Interval until the next refresh attempt, clamped to [minMs, maxMs].
119
+ * Defaults pin both ends to {@link REFRESH_INTERVAL_MS} (5 minutes) so the
120
+ * live daemon path is a fixed schedule. Tests may pass a wider range to
121
+ * exercise burn-aware scheduling without changing production cadence.
94
122
  */
95
123
  export function computeNextRefreshDelayMs(minutesToLimit, opts = {}) {
96
- const minMs = opts.minMs ?? REFRESH_MIN_MS;
97
- const maxMs = opts.maxMs ?? REFRESH_MAX_MS;
124
+ const minMs = opts.minMs ?? REFRESH_INTERVAL_MS;
125
+ const maxMs = opts.maxMs ?? REFRESH_INTERVAL_MS;
98
126
  const divisor = opts.divisor ?? REFRESH_BURN_DIVISOR;
99
127
  if (minutesToLimit === null || !Number.isFinite(minutesToLimit))
100
128
  return maxMs;
@@ -169,10 +197,14 @@ export async function buildLocalUsageAccounts() {
169
197
  accounts.push({
170
198
  usageKey,
171
199
  agentId,
200
+ // fileOnly: never open the ACL-bound keychain item from the daemon —
201
+ // that path is the Touch ID storm. Usage reads setup-token /
202
+ // no-ACL cache / .credentials.json only (see loadClaudeOauth).
172
203
  fetch: () => getUsageInfo(agentId, {
173
204
  home: fetchInput.home,
174
205
  cliVersion: fetchInput.cliVersion,
175
206
  organizationId: fetchInput.organizationId,
207
+ fileOnly: true,
176
208
  }),
177
209
  });
178
210
  }
@@ -86,6 +86,13 @@ interface UsageOptions {
86
86
  home?: string;
87
87
  cliVersion?: string | null;
88
88
  organizationId?: string | null;
89
+ /**
90
+ * When true, never open the ACL-bound OS keychain item (macOS Touch ID).
91
+ * Daemon usage refresh sets this so a background tick cannot pop biometrics.
92
+ * Credentials come from the no-ACL access-token cache, a file-based
93
+ * setup-token, or `<home>/.claude/.credentials.json` only.
94
+ */
95
+ fileOnly?: boolean;
89
96
  }
90
97
  /** Canonical input for a single usage fetch operation. */
91
98
  export interface UsageFetchInput {
@@ -462,9 +469,14 @@ export declare function normalizeDroidWindows(data: DroidBillingLimitsResponse):
462
469
  * `getClaudeAccessToken`) or export the full blob (`readClaudeCredentialsBlob`
463
470
  * for Rush Cloud dispatch) must NOT pass it. Only the read-only, high-frequency
464
471
  * access-token-only consumers (the usage fetch and the auth-health probe) opt in.
472
+ *
473
+ * `opts.fileOnly` (implies access-token-only consumers) skips the ACL keychain
474
+ * read entirely — setup-token, no-ACL cache, and `.credentials.json` only. Used
475
+ * by the daemon usage refresher so a background tick can never pop Touch ID.
465
476
  */
466
477
  export declare function loadClaudeOauth(home?: string, opts?: {
467
478
  accessTokenCache?: boolean;
479
+ fileOnly?: boolean;
468
480
  }): Promise<ClaudeOauthCredentials | null>;
469
481
  /**
470
482
  * Save Claude OAuth credentials to the system keychain/keyring.
package/dist/lib/usage.js CHANGED
@@ -24,6 +24,7 @@ import { resolveClaudeSetupToken } from './claude-account-token.js';
24
24
  import { formatBackoffRemaining, noteUsageRateLimited, usageRateLimitedUntil, } from './usage-backoff.js';
25
25
  import { getCacheDir } from './state.js';
26
26
  import { mapBounded } from './concurrency.js';
27
+ import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
27
28
  const execFileAsync = promisify(execFile);
28
29
  const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
29
30
  const CLAUDE_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
@@ -654,7 +655,12 @@ async function getClaudeUsageInfo(options) {
654
655
  try {
655
656
  // Opt into the no-ACL access-token cache: this is the every-60s watchdog hot
656
657
  // path and usage needs only the access token, so it kills the Touch ID storm.
657
- const oauth = await loadClaudeOauth(options?.home, { accessTokenCache: true });
658
+ // Daemon refresh also sets fileOnly so we never fall through to the ACL
659
+ // keychain item (that path is the Touch ID prompt).
660
+ const oauth = await loadClaudeOauth(options?.home, {
661
+ accessTokenCache: true,
662
+ fileOnly: options?.fileOnly === true,
663
+ });
658
664
  if (!oauth?.accessToken) {
659
665
  return { snapshot: null, error: usageNoCredentialError('Claude') };
660
666
  }
@@ -1333,6 +1339,10 @@ function deleteCachedClaudeOauth(service) {
1333
1339
  * `getClaudeAccessToken`) or export the full blob (`readClaudeCredentialsBlob`
1334
1340
  * for Rush Cloud dispatch) must NOT pass it. Only the read-only, high-frequency
1335
1341
  * access-token-only consumers (the usage fetch and the auth-health probe) opt in.
1342
+ *
1343
+ * `opts.fileOnly` (implies access-token-only consumers) skips the ACL keychain
1344
+ * read entirely — setup-token, no-ACL cache, and `.credentials.json` only. Used
1345
+ * by the daemon usage refresher so a background tick can never pop Touch ID.
1336
1346
  */
1337
1347
  export async function loadClaudeOauth(home, opts) {
1338
1348
  // Read-only usage/probe callers (accessTokenCache) authenticate with a
@@ -1359,7 +1369,11 @@ export async function loadClaudeOauth(home, opts) {
1359
1369
  // anywhere, so the platform check must yield to it exactly as the inner
1360
1370
  // claudeOauthCacheActive() gate does — otherwise this whole block is dead on
1361
1371
  // a Windows runner and the tests below it read an empty home.
1362
- if (process.platform === 'darwin' || process.platform === 'linux' || isKeychainBackendOverridden()) {
1372
+ //
1373
+ // fileOnly: daemon refresh must never open the ACL-bound item (Touch ID).
1374
+ // It may still read the no-ACL cache item (set-no-acl — prompt-free).
1375
+ if (!opts?.fileOnly
1376
+ && (process.platform === 'darwin' || process.platform === 'linux' || isKeychainBackendOverridden())) {
1363
1377
  const service = getClaudeKeychainService(home);
1364
1378
  // Serve the no-ACL cache first (opt-in, macOS/test only) so the ACL-gated read
1365
1379
  // below — the one that pops Touch ID — happens at most once per token lifetime
@@ -1386,6 +1400,13 @@ export async function loadClaudeOauth(home, opts) {
1386
1400
  deleteCachedClaudeOauth(service);
1387
1401
  }
1388
1402
  }
1403
+ else if (opts?.fileOnly === true && opts?.accessTokenCache === true && claudeOauthCacheActive()) {
1404
+ // fileOnly + accessTokenCache: still allow the no-ACL cache (never Touch ID).
1405
+ const service = getClaudeKeychainService(home);
1406
+ const cached = readCachedClaudeOauth(service);
1407
+ if (cached)
1408
+ return cached;
1409
+ }
1389
1410
  const credsPath = path.join(home ?? os.homedir(), '.claude', '.credentials.json');
1390
1411
  try {
1391
1412
  if (fs.existsSync(credsPath)) {
@@ -1487,9 +1508,19 @@ export function readClaudeUsageCache(usageKey, cachePath = getClaudeUsageCachePa
1487
1508
  }
1488
1509
  /** Write a usage snapshot to the on-disk cache. */
1489
1510
  export function writeClaudeUsageCache(usageKey, snapshot, cachePath = getClaudeUsageCachePath()) {
1490
- const cache = readClaudeUsageCacheFile(cachePath);
1491
- cache[usageKey] = serializeClaudeUsageSnapshot(snapshot);
1492
- writeClaudeUsageCacheFile(cache, cachePath);
1511
+ try {
1512
+ ensureLockTarget(cachePath, '{}');
1513
+ withFileLock(cachePath, () => {
1514
+ // Re-read under the lock so a concurrent daemon tick / agents view
1515
+ // refresh cannot drop another account's row (lost update).
1516
+ const cache = readClaudeUsageCacheFile(cachePath);
1517
+ cache[usageKey] = serializeClaudeUsageSnapshot(snapshot);
1518
+ atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
1519
+ });
1520
+ }
1521
+ catch {
1522
+ /* best-effort cache write — lock busy or disk full */
1523
+ }
1493
1524
  }
1494
1525
  /** Read the entire usage cache file from disk. */
1495
1526
  function readClaudeUsageCacheFile(cachePath) {
@@ -1508,7 +1539,7 @@ function readClaudeUsageCacheFile(cachePath) {
1508
1539
  function writeClaudeUsageCacheFile(cache, cachePath) {
1509
1540
  try {
1510
1541
  fs.mkdirSync(path.dirname(cachePath), { recursive: true });
1511
- fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
1542
+ atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
1512
1543
  }
1513
1544
  catch {
1514
1545
  /* best-effort cache write */
@@ -312,11 +312,25 @@ export declare const GROK_WORKFLOW_MARKER = "agents_workflow";
312
312
  export declare function transformWorkflowForGrok(workflowPath: string, name: string): string;
313
313
  /** Read the agents_workflow marker from a Grok `.rhai` file, if present. */
314
314
  export declare function grokWorkflowMarker(filePath: string): string | null;
315
+ /**
316
+ * Plugin `workflows/` directories in discovery order (project → user → system →
317
+ * extra). Used by name resolution and listing so a plugin-packaged workflow is
318
+ * runnable via `agents run <name>` without a separate install into
319
+ * ~/.agents/workflows/ (Phase 5 packaging). Within the plugin band, project
320
+ * plugins beat user/system plugins (same first-hit-wins as other layers).
321
+ */
322
+ export declare function listPluginWorkflowDirs(cwd?: string): string[];
323
+ /**
324
+ * True when `ref` is a single bare workflow name (no path separators, no `..`).
325
+ * Name lookup must not path-join multi-segment or traversal refs into search roots.
326
+ */
327
+ export declare function isBareWorkflowName(ref: string): boolean;
315
328
  /**
316
329
  * Resolve an `agents run <workflow>` reference.
317
330
  *
318
331
  * Directories are accepted anywhere on disk when they contain WORKFLOW.md.
319
- * Name lookup keeps the normal resource precedence: project > user > system > extras.
332
+ * Name lookup precedence (docs/07-entrypoints): project > user > plugin > extra > system.
333
+ * Bare name only; `name@plugin` disambiguation is a follow-up.
320
334
  */
321
335
  export declare function resolveWorkflowRef(ref: string, cwd?: string): string | null;
322
336
  /**
@@ -326,10 +340,11 @@ export declare function resolveWorkflowRef(ref: string, cwd?: string): string |
326
340
  */
327
341
  export declare function discoverWorkflowsFromRepo(repoPath: string): DiscoveredWorkflow[];
328
342
  /**
329
- * List all workflows in central storage.
330
- * User layer (~/.agents/workflows/) wins over system (~/.agents/.system/workflows/).
343
+ * List all workflows in central storage + plugin packages.
344
+ * Precedence: user > plugin > extra > system (first writer wins; project is
345
+ * cwd-scoped and handled by resolveWorkflowRef / the resource handler).
331
346
  */
332
- export declare function listInstalledWorkflows(): Map<string, InstalledWorkflow>;
347
+ export declare function listInstalledWorkflows(cwd?: string): Map<string, InstalledWorkflow>;
333
348
  /** Copy a workflow directory into user central storage (~/.agents/workflows/<name>/). */
334
349
  export declare function installWorkflowCentrally(sourcePath: string, name: string): {
335
350
  success: boolean;
@@ -10,7 +10,7 @@ import * as os from 'os';
10
10
  import * as path from 'path';
11
11
  import * as yaml from 'yaml';
12
12
  import { capableAgents, supports } from './capabilities.js';
13
- import { getProjectAgentsDir, getSystemWorkflowsDir, getUserWorkflowsDir, getTrashWorkflowsDir, getEnabledExtraRepos, } from './state.js';
13
+ import { getProjectAgentsDir, getSystemWorkflowsDir, getUserWorkflowsDir, getTrashWorkflowsDir, getEnabledExtraRepos, getPluginsDir, getSystemPluginsDir, getProjectPluginsDir, } from './state.js';
14
14
  import { listInstalledVersions, getVersionHomePath } from './versions.js';
15
15
  /**
16
16
  * Hard upper bound on items a single `for_each` expands, absent an explicit
@@ -522,22 +522,94 @@ function resolveWorkflowPath(ref, cwd) {
522
522
  const candidate = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
523
523
  return isWorkflowDir(candidate) ? candidate : null;
524
524
  }
525
+ /**
526
+ * Plugin `workflows/` directories in discovery order (project → user → system →
527
+ * extra). Used by name resolution and listing so a plugin-packaged workflow is
528
+ * runnable via `agents run <name>` without a separate install into
529
+ * ~/.agents/workflows/ (Phase 5 packaging). Within the plugin band, project
530
+ * plugins beat user/system plugins (same first-hit-wins as other layers).
531
+ */
532
+ export function listPluginWorkflowDirs(cwd = process.cwd()) {
533
+ const pluginRoots = [];
534
+ const pluginsDirs = [];
535
+ const projectPlugins = getProjectPluginsDir(cwd);
536
+ if (projectPlugins)
537
+ pluginsDirs.push(projectPlugins);
538
+ pluginsDirs.push(getPluginsDir(), getSystemPluginsDir());
539
+ for (const extra of getEnabledExtraRepos()) {
540
+ pluginsDirs.push(path.join(extra.dir, 'plugins'));
541
+ }
542
+ for (const pluginsDir of pluginsDirs) {
543
+ if (!fs.existsSync(pluginsDir))
544
+ continue;
545
+ let entries;
546
+ try {
547
+ entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
548
+ }
549
+ catch {
550
+ continue;
551
+ }
552
+ for (const entry of entries) {
553
+ if (entry.name.startsWith('.'))
554
+ continue;
555
+ // Directories and symlinks-to-directories (plugin marketplaces often symlink).
556
+ const pluginRoot = path.join(pluginsDir, entry.name);
557
+ let isDir = entry.isDirectory();
558
+ if (!isDir && entry.isSymbolicLink()) {
559
+ try {
560
+ isDir = fs.statSync(pluginRoot).isDirectory();
561
+ }
562
+ catch {
563
+ isDir = false;
564
+ }
565
+ }
566
+ if (!isDir)
567
+ continue;
568
+ const workflowsDir = path.join(pluginRoot, 'workflows');
569
+ if (fs.existsSync(workflowsDir))
570
+ pluginRoots.push(workflowsDir);
571
+ }
572
+ }
573
+ return pluginRoots;
574
+ }
575
+ /**
576
+ * True when `ref` is a single bare workflow name (no path separators, no `..`).
577
+ * Name lookup must not path-join multi-segment or traversal refs into search roots.
578
+ */
579
+ export function isBareWorkflowName(ref) {
580
+ if (!ref || ref === '.' || ref === '..')
581
+ return false;
582
+ if (ref.includes('/') || ref.includes('\\'))
583
+ return false;
584
+ if (ref.includes('..'))
585
+ return false;
586
+ // Reject absolute paths (posix or Windows).
587
+ if (path.isAbsolute(ref))
588
+ return false;
589
+ return path.basename(ref) === ref;
590
+ }
525
591
  /**
526
592
  * Resolve an `agents run <workflow>` reference.
527
593
  *
528
594
  * Directories are accepted anywhere on disk when they contain WORKFLOW.md.
529
- * Name lookup keeps the normal resource precedence: project > user > system > extras.
595
+ * Name lookup precedence (docs/07-entrypoints): project > user > plugin > extra > system.
596
+ * Bare name only; `name@plugin` disambiguation is a follow-up.
530
597
  */
531
598
  export function resolveWorkflowRef(ref, cwd = process.cwd()) {
532
599
  const direct = resolveWorkflowPath(ref, cwd);
533
600
  if (direct)
534
601
  return direct;
602
+ // Name lookup only — reject traversal / multi-segment so path.join(dir, ref)
603
+ // cannot escape a workflows root (absolute paths already handled above).
604
+ if (!isBareWorkflowName(ref))
605
+ return null;
535
606
  const projectAgentsDir = getProjectAgentsDir(cwd);
536
607
  const searchDirs = [
537
608
  ...(projectAgentsDir ? [path.join(projectAgentsDir, 'workflows')] : []),
538
609
  getUserWorkflowsDir(),
539
- getSystemWorkflowsDir(),
610
+ ...listPluginWorkflowDirs(cwd),
540
611
  ...getEnabledExtraRepos().map(r => path.join(r.dir, 'workflows')),
612
+ getSystemWorkflowsDir(),
541
613
  ];
542
614
  for (const dir of searchDirs) {
543
615
  const workflowPath = path.join(dir, ref);
@@ -592,16 +664,18 @@ export function discoverWorkflowsFromRepo(repoPath) {
592
664
  return results;
593
665
  }
594
666
  /**
595
- * List all workflows in central storage.
596
- * User layer (~/.agents/workflows/) wins over system (~/.agents/.system/workflows/).
667
+ * List all workflows in central storage + plugin packages.
668
+ * Precedence: user > plugin > extra > system (first writer wins; project is
669
+ * cwd-scoped and handled by resolveWorkflowRef / the resource handler).
597
670
  */
598
- export function listInstalledWorkflows() {
671
+ export function listInstalledWorkflows(cwd = process.cwd()) {
599
672
  const result = new Map();
600
673
  const extraRepos = getEnabledExtraRepos();
601
674
  const searchDirs = [
602
675
  getUserWorkflowsDir(),
603
- getSystemWorkflowsDir(),
676
+ ...listPluginWorkflowDirs(cwd),
604
677
  ...extraRepos.map(r => path.join(r.dir, 'workflows')),
678
+ getSystemWorkflowsDir(),
605
679
  ];
606
680
  for (const dir of searchDirs) {
607
681
  if (!fs.existsSync(dir))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.8",
3
+ "version": "1.22.10",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",