@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.
- package/CHANGELOG.md +64 -4
- package/README.md +5 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +2 -2
- package/dist/commands/exec.js +41 -9
- package/dist/commands/feed.js +38 -1
- package/dist/commands/harness.d.ts +40 -1
- package/dist/commands/harness.js +316 -76
- package/dist/commands/profiles.d.ts +42 -0
- package/dist/commands/profiles.js +91 -3
- package/dist/commands/secrets.js +38 -39
- package/dist/commands/sessions-picker.js +3 -3
- package/dist/commands/sessions-render.d.ts +12 -0
- package/dist/commands/sessions-render.js +124 -0
- package/dist/commands/sessions.js +28 -1
- package/dist/commands/ssh.js +30 -8
- package/dist/commands/teams.js +12 -1
- package/dist/commands/workflows.js +1 -1
- package/dist/index.js +9 -2
- package/dist/lib/crabbox/cli.d.ts +35 -0
- package/dist/lib/crabbox/cli.js +46 -0
- package/dist/lib/crabbox/config.d.ts +21 -0
- package/dist/lib/crabbox/config.js +43 -0
- package/dist/lib/crabbox/lease.d.ts +15 -5
- package/dist/lib/crabbox/lease.js +57 -17
- package/dist/lib/daemon.js +12 -11
- package/dist/lib/devices/resolve-target.d.ts +4 -3
- package/dist/lib/devices/resolve-target.js +4 -3
- package/dist/lib/hosts/passthrough.js +18 -1
- package/dist/lib/hosts/registry.d.ts +4 -0
- package/dist/lib/hosts/registry.js +16 -0
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/observe-aliases.d.ts +30 -0
- package/dist/lib/observe-aliases.js +56 -0
- package/dist/lib/plugins.d.ts +14 -5
- package/dist/lib/plugins.js +29 -5
- package/dist/lib/redact.js +4 -0
- package/dist/lib/resources/types.d.ts +2 -1
- package/dist/lib/resources/workflows.d.ts +1 -1
- package/dist/lib/resources/workflows.js +22 -20
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.d.ts +9 -0
- package/dist/lib/secrets/bundles.js +9 -23
- package/dist/lib/secrets/headless.d.ts +3 -15
- package/dist/lib/secrets/headless.js +5 -33
- package/dist/lib/secrets/index.d.ts +8 -0
- package/dist/lib/secrets/index.js +16 -36
- package/dist/lib/session/parse.d.ts +5 -1
- package/dist/lib/session/parse.js +23 -13
- package/dist/lib/session/prompt.js +5 -0
- package/dist/lib/session/render.d.ts +3 -0
- package/dist/lib/session/render.js +33 -10
- package/dist/lib/share/config.js +5 -7
- package/dist/lib/startup/command-registry.js +15 -1
- package/dist/lib/types.d.ts +6 -0
- package/dist/lib/usage-refresh.d.ts +19 -11
- package/dist/lib/usage-refresh.js +75 -43
- package/dist/lib/usage.d.ts +12 -0
- package/dist/lib/usage.js +37 -6
- package/dist/lib/workflows.d.ts +19 -4
- package/dist/lib/workflows.js +81 -7
- package/package.json +1 -1
package/dist/lib/share/config.js
CHANGED
|
@@ -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,
|
|
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
|
|
80
|
-
|
|
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
|
-
//
|
|
140
|
-
agentOnly:
|
|
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
|
-
|
|
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],
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
4
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
10
|
-
* not
|
|
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 =
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
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
|
|
10
|
-
* touches accounts whose credentials live locally, so no cross-host
|
|
11
|
-
* coordination and no shared-token contention):
|
|
9
|
+
* Design:
|
|
12
10
|
*
|
|
13
|
-
* - **
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 =
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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 ??
|
|
97
|
-
const maxMs = opts.maxMs ??
|
|
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
|
}
|
package/dist/lib/usage.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
-
|
|
1542
|
+
atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
|
|
1512
1543
|
}
|
|
1513
1544
|
catch {
|
|
1514
1545
|
/* best-effort cache write */
|
package/dist/lib/workflows.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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;
|
package/dist/lib/workflows.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
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",
|