@phnx-labs/agents-cli 1.20.14 → 1.20.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/dist/commands/repo.js +22 -1
- package/dist/commands/secrets.js +53 -1
- package/dist/commands/sessions-sync.d.ts +13 -0
- package/dist/commands/sessions-sync.js +73 -0
- package/dist/commands/sessions.js +2 -0
- package/dist/commands/view.js +11 -3
- package/dist/index.js +1 -1
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.js +11 -9
- package/dist/lib/browser/service.js +28 -18
- package/dist/lib/daemon.d.ts +19 -0
- package/dist/lib/daemon.js +97 -2
- package/dist/lib/migrate.d.ts +22 -0
- package/dist/lib/migrate.js +99 -1
- package/dist/lib/plugin-marketplace.d.ts +15 -0
- package/dist/lib/plugin-marketplace.js +44 -0
- package/dist/lib/secrets/index.js +20 -0
- package/dist/lib/session/parse.d.ts +2 -0
- package/dist/lib/session/parse.js +168 -2
- package/dist/lib/session/sync/agents.d.ts +46 -0
- package/dist/lib/session/sync/agents.js +94 -0
- package/dist/lib/session/sync/config.d.ts +30 -0
- package/dist/lib/session/sync/config.js +58 -0
- package/dist/lib/session/sync/crdt.d.ts +44 -0
- package/dist/lib/session/sync/crdt.js +119 -0
- package/dist/lib/session/sync/manifest.d.ts +51 -0
- package/dist/lib/session/sync/manifest.js +96 -0
- package/dist/lib/session/sync/r2.d.ts +32 -0
- package/dist/lib/session/sync/r2.js +121 -0
- package/dist/lib/session/sync/sync.d.ts +82 -0
- package/dist/lib/session/sync/sync.js +251 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +29 -3
- package/dist/lib/state.d.ts +18 -0
- package/dist/lib/state.js +73 -0
- package/dist/lib/teams/parsers.js +159 -1
- package/dist/lib/usage.d.ts +18 -0
- package/dist/lib/usage.js +25 -0
- package/dist/lib/versions.js +30 -13
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
**`agents secrets get/set <item>`: raw, cross-platform keychain access for hooks**
|
|
6
|
+
|
|
7
|
+
- New `agents secrets get <item>` / `agents secrets set <item>` read and write a single keychain item **by bare name** (outside the bundle namespace), so shell hooks and automation have one platform-agnostic credential primitive to call instead of hardcoding `/usr/bin/security` (macOS-only) or `secret-tool` (Linux-only). `get` prints the value to stdout (newline-terminated for clean `$(…)` capture), sends diagnostics to stderr, and exits 1 with empty stdout when the item is missing — exactly what a `SessionStart` hook needs to probe-and-fallback quietly. Routing goes through the existing cross-platform keychain layer: macOS via `/usr/bin/security`, Linux via `secret-tool` with the encrypted-file fallback.
|
|
8
|
+
- `setKeychainToken` now writes bare (non-`agents-cli.`) items on macOS **without** the biometry ACL, mirroring the existing no-prompt read path for such items. This is what lets a hook read e.g. `linear-api-key` silently on every launch — routing it through the Touch ID helper would attach an ACL the `/usr/bin/security` read can't satisfy without popping the legacy password sheet. The change is purely additive: every existing caller passes an `agents-cli.`-namespaced item and is unaffected (still biometry-gated via the signed helper).
|
|
9
|
+
|
|
5
10
|
**`agents inspect` summary: expanded detail for hooks, plugins, and MCP**
|
|
6
11
|
|
|
7
12
|
- The bare `agents inspect <agent>` / `agents inspect <repo>` summary no longer collapses everything to a count table. Simple kinds (commands, skills, rules, subagents, workflows) keep a count line but now preview a few names; the rich kinds get their own expanded sections: **hooks** show their events + `matches:` predicates + cache (`PreToolUse(Bash) · git_dirty · prompt~"deploy" (5m cache)`), **plugins** show version + bundle contents (`v2.1.0 skills:6 commands:5 hooks:2 mcp:1`), and **MCP** show transport + url/command. Drill-down flags (`--hooks`, `--plugins`, `--mcp`) and `--brief` are unchanged; `--json` gains the structured detail additively (existing keys retained).
|
package/dist/commands/repo.js
CHANGED
|
@@ -23,7 +23,7 @@ function resolveRepoPath(target) {
|
|
|
23
23
|
}
|
|
24
24
|
return path.join(HOME, `.agents-${trimmed}`);
|
|
25
25
|
}
|
|
26
|
-
import { ensureAgentsDir, getExtraRepoDir, getSystemAgentsDir, getUserAgentsDir, readMeta, resolveExtraRepoDir, updateMeta, } from '../lib/state.js';
|
|
26
|
+
import { applyExtraAliasToVersions, ensureAgentsDir, getExtraRepoDir, getSystemAgentsDir, getUserAgentsDir, readMeta, resolveExtraRepoDir, updateMeta, } from '../lib/state.js';
|
|
27
27
|
import { parseSource, pullRepo, commitAndPush, isGitRepo, isSystemRepoOrigin } from '../lib/git.js';
|
|
28
28
|
import { DEFAULT_SYSTEM_REPO } from '../lib/types.js';
|
|
29
29
|
import { ALL_AGENT_IDS, isAgentName, resolveAgentName } from '../lib/agents.js';
|
|
@@ -259,6 +259,7 @@ export function registerRepoCommands(program) {
|
|
|
259
259
|
const commit = log.latest?.hash.slice(0, 8) || 'unknown';
|
|
260
260
|
extras[alias] = { url: targetDir, path: targetDir, enabled: true };
|
|
261
261
|
updateMeta({ extraRepos: extras });
|
|
262
|
+
syncExtraAliasAcrossVersions(alias, true);
|
|
262
263
|
spinner.succeed(`Created ${targetDir} (${commit})`);
|
|
263
264
|
console.log(chalk.gray(`\nRegistered as "${alias}". Edit files there, then add your own git remote when ready.`));
|
|
264
265
|
}
|
|
@@ -306,6 +307,7 @@ export function registerRepoCommands(program) {
|
|
|
306
307
|
if (parsed.type === 'local') {
|
|
307
308
|
extras[alias] = { url: parsed.url, path: parsed.url, enabled: true };
|
|
308
309
|
updateMeta({ extraRepos: extras });
|
|
310
|
+
syncExtraAliasAcrossVersions(alias, true);
|
|
309
311
|
syncMarketplacesForDefaults();
|
|
310
312
|
console.log(chalk.green(`Registered local repo "${alias}" -> ${parsed.url}`));
|
|
311
313
|
return;
|
|
@@ -342,6 +344,7 @@ export function registerRepoCommands(program) {
|
|
|
342
344
|
}
|
|
343
345
|
extras[alias] = { url: parsed.url, path: targetDir, enabled: true };
|
|
344
346
|
updateMeta({ extraRepos: extras });
|
|
347
|
+
syncExtraAliasAcrossVersions(alias, true);
|
|
345
348
|
syncMarketplacesForDefaults();
|
|
346
349
|
console.log(chalk.gray(`\nRegistered as "${alias}". Skills and commands from this repo will be`));
|
|
347
350
|
console.log(chalk.gray(`picked up automatically the next time you launch any agent.`));
|
|
@@ -379,6 +382,7 @@ export function registerRepoCommands(program) {
|
|
|
379
382
|
}
|
|
380
383
|
delete extras[alias];
|
|
381
384
|
updateMeta({ extraRepos: extras });
|
|
385
|
+
syncExtraAliasAcrossVersions(alias, false);
|
|
382
386
|
syncMarketplacesForDefaults();
|
|
383
387
|
console.log(chalk.green(`Removed "${alias}"`));
|
|
384
388
|
});
|
|
@@ -592,6 +596,19 @@ function collectRepoTargets(alias) {
|
|
|
592
596
|
}
|
|
593
597
|
return [found];
|
|
594
598
|
}
|
|
599
|
+
/**
|
|
600
|
+
* Keep already-installed versions' selectors in sync with an extra-repo change:
|
|
601
|
+
* add `<alias>:*` when the repo is registered/enabled, strip it when removed.
|
|
602
|
+
* Newly-installed versions inherit it from `defaultPatterns()` at scaffold time,
|
|
603
|
+
* so without this a repo added after install is invisible to existing versions.
|
|
604
|
+
*/
|
|
605
|
+
function syncExtraAliasAcrossVersions(alias, add) {
|
|
606
|
+
const n = applyExtraAliasToVersions(alias, add);
|
|
607
|
+
if (n > 0) {
|
|
608
|
+
const verb = add ? 'Added to' : 'Removed from';
|
|
609
|
+
console.log(chalk.gray(`${verb} ${n} existing version selector${n === 1 ? '' : 's'}.`));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
595
612
|
async function toggle(alias, enabled) {
|
|
596
613
|
const meta = readMeta();
|
|
597
614
|
const extras = { ...(meta.extraRepos || {}) };
|
|
@@ -606,6 +623,10 @@ async function toggle(alias, enabled) {
|
|
|
606
623
|
}
|
|
607
624
|
extras[alias] = { ...extras[alias], enabled };
|
|
608
625
|
updateMeta({ extraRepos: extras });
|
|
626
|
+
// Re-enabling backfills the alias into existing versions; disabling leaves the
|
|
627
|
+
// selectors (resolution skips disabled extras) so a later enable is a no-op.
|
|
628
|
+
if (enabled)
|
|
629
|
+
syncExtraAliasAcrossVersions(alias, true);
|
|
609
630
|
syncMarketplacesForDefaults();
|
|
610
631
|
console.log(chalk.green(`${enabled ? 'Enabled' : 'Disabled'} "${alias}"`));
|
|
611
632
|
}
|
package/dist/commands/secrets.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import { bundleExists, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
|
|
11
|
-
import { deleteKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
|
|
11
|
+
import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
|
|
12
12
|
import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
|
|
13
13
|
import { registerCommandGroups, setHelpSections } from '../lib/help.js';
|
|
14
14
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
@@ -365,6 +365,7 @@ export function registerSecretsCommands(program) {
|
|
|
365
365
|
registerCommandGroups(cmd, [
|
|
366
366
|
{ title: 'Bundle commands', names: ['list', 'view', 'create', 'rename', 'describe', 'delete'] },
|
|
367
367
|
{ title: 'Secret commands', names: ['add', 'rotate', 'remove', 'import', 'export'] },
|
|
368
|
+
{ title: 'Raw item commands', names: ['get', 'set'] },
|
|
368
369
|
{ title: 'Sync commands', names: ['push', 'pull', 'remote-list'] },
|
|
369
370
|
{ title: 'Utilities', names: ['exec', 'generate', 'migrate-acl'] },
|
|
370
371
|
]);
|
|
@@ -465,6 +466,57 @@ export function registerSecretsCommands(program) {
|
|
|
465
466
|
process.exit(1);
|
|
466
467
|
}
|
|
467
468
|
});
|
|
469
|
+
cmd
|
|
470
|
+
.command('get <item>')
|
|
471
|
+
.description('Print a raw keychain item by name (for shell hooks/automation). Cross-platform; no bundle required.')
|
|
472
|
+
.action((item) => {
|
|
473
|
+
try {
|
|
474
|
+
// Routes through the platform keychain layer: macOS reads bare items
|
|
475
|
+
// via /usr/bin/security (no Touch ID), Linux via secret-tool with the
|
|
476
|
+
// encrypted-file fallback. The value goes to stdout (newline-terminated
|
|
477
|
+
// so `$(agents secrets get NAME)` captures it cleanly); diagnostics go
|
|
478
|
+
// to stderr so they never pollute the captured value.
|
|
479
|
+
const value = getKeychainToken(item);
|
|
480
|
+
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
// Missing item is a normal, quiet outcome for a hook probe: exit 1,
|
|
484
|
+
// print nothing to stdout. Callers test the exit code / empty capture.
|
|
485
|
+
process.exit(1);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
cmd
|
|
489
|
+
.command('set <item>')
|
|
490
|
+
.description('Store a raw keychain item by name (for shell hooks/automation). Cross-platform; no bundle required.')
|
|
491
|
+
.option('--value <v>', 'Value to store (omit to read from stdin or be prompted)')
|
|
492
|
+
.option('--value-stdin', 'Read the value from stdin')
|
|
493
|
+
.action(async (item, opts) => {
|
|
494
|
+
try {
|
|
495
|
+
let value;
|
|
496
|
+
if (opts.value !== undefined) {
|
|
497
|
+
value = opts.value;
|
|
498
|
+
}
|
|
499
|
+
else if (opts.valueStdin) {
|
|
500
|
+
value = readStdinSync();
|
|
501
|
+
if (!value)
|
|
502
|
+
throw new Error('No value received on stdin.');
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
value = await promptForSecret(`Enter value for ${item}`);
|
|
506
|
+
}
|
|
507
|
+
// setKeychainToken stores bare items WITHOUT the biometry ACL on macOS
|
|
508
|
+
// so `agents secrets get` can read them back without a password sheet;
|
|
509
|
+
// on Linux it goes through secret-tool / encrypted-file fallback.
|
|
510
|
+
setKeychainToken(item, value);
|
|
511
|
+
console.error(chalk.green(`Stored keychain item '${item}'.`));
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
if (isPromptCancelled(err))
|
|
515
|
+
return;
|
|
516
|
+
console.error(chalk.red(err.message));
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
468
520
|
cmd
|
|
469
521
|
.command('create [name]')
|
|
470
522
|
.description('Create an empty bundle')
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents sessions sync` — push this machine's transcripts to R2 and pull every
|
|
3
|
+
* other machine's, merging copies of the same session via CRDT union. The local
|
|
4
|
+
* sessions index is rebuilt from the synced-in mirror by the normal scanner.
|
|
5
|
+
*/
|
|
6
|
+
import type { Command } from 'commander';
|
|
7
|
+
interface SyncCmdOptions {
|
|
8
|
+
verbose?: boolean;
|
|
9
|
+
json?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function runSessionsSync(options: SyncCmdOptions): Promise<void>;
|
|
12
|
+
export declare function registerSessionsSyncCommand(sessionsCmd: Command): void;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents sessions sync` — push this machine's transcripts to R2 and pull every
|
|
3
|
+
* other machine's, merging copies of the same session via CRDT union. The local
|
|
4
|
+
* sessions index is rebuilt from the synced-in mirror by the normal scanner.
|
|
5
|
+
*/
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { setHelpSections } from '../lib/help.js';
|
|
8
|
+
import { isSyncConfigured, SYNC_BUNDLE } from '../lib/session/sync/config.js';
|
|
9
|
+
import { syncSessions } from '../lib/session/sync/sync.js';
|
|
10
|
+
export async function runSessionsSync(options) {
|
|
11
|
+
if (!isSyncConfigured()) {
|
|
12
|
+
console.error(chalk.red(`Sessions sync is not configured.`) +
|
|
13
|
+
`\nAdd R2 credentials to the '${SYNC_BUNDLE}' bundle:\n` +
|
|
14
|
+
` agents secrets add ${SYNC_BUNDLE} R2_ACCOUNT_ID\n` +
|
|
15
|
+
` agents secrets add ${SYNC_BUNDLE} R2_BUCKET_NAME\n` +
|
|
16
|
+
` agents secrets add ${SYNC_BUNDLE} R2_ACCESS_KEY_ID\n` +
|
|
17
|
+
` agents secrets add ${SYNC_BUNDLE} R2_SECRET_ACCESS_KEY`);
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const result = await syncSessions({
|
|
23
|
+
verbose: options.verbose,
|
|
24
|
+
log: msg => console.error(chalk.dim(msg)),
|
|
25
|
+
});
|
|
26
|
+
if (options.json) {
|
|
27
|
+
console.log(JSON.stringify(result, null, 2));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const parts = [
|
|
31
|
+
`pushed ${result.pushed}`,
|
|
32
|
+
`pulled ${result.pulled}`,
|
|
33
|
+
result.merged > 0 ? `merged ${result.merged}` : null,
|
|
34
|
+
].filter(Boolean);
|
|
35
|
+
console.log(chalk.green('synced') + ` ${result.machine}: ` + parts.join(', ') +
|
|
36
|
+
chalk.dim(` (${result.pushSkipped + result.pullSkipped} unchanged)`));
|
|
37
|
+
}
|
|
38
|
+
if (result.errors.length > 0) {
|
|
39
|
+
for (const e of result.errors)
|
|
40
|
+
console.error(chalk.yellow(` ! ${e}`));
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
console.error(chalk.red(`sync failed: ${err.message}`));
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function registerSessionsSyncCommand(sessionsCmd) {
|
|
50
|
+
const syncCmd = sessionsCmd
|
|
51
|
+
.command('sync')
|
|
52
|
+
.description('Sync session transcripts across machines via R2 (CRDT merge). Claude and Codex.')
|
|
53
|
+
.option('-v, --verbose', 'Log each pushed and pulled session')
|
|
54
|
+
.option('--json', 'Output the sync result as JSON');
|
|
55
|
+
setHelpSections(syncCmd, {
|
|
56
|
+
examples: `
|
|
57
|
+
# One sync cycle (push local changes, pull + merge from other machines)
|
|
58
|
+
agents sessions sync
|
|
59
|
+
|
|
60
|
+
# See exactly what moved
|
|
61
|
+
agents sessions sync --verbose
|
|
62
|
+
`,
|
|
63
|
+
notes: `
|
|
64
|
+
- Credentials come from the '${SYNC_BUNDLE}' secrets bundle (R2 S3 API, read+write).
|
|
65
|
+
- Each machine writes only its own prefix; conflicts are impossible by construction.
|
|
66
|
+
- The daemon runs this automatically (~90s); this command forces an immediate cycle.
|
|
67
|
+
- Sessions present locally always win; synced-in copies fill in other machines' sessions.
|
|
68
|
+
`,
|
|
69
|
+
});
|
|
70
|
+
syncCmd.action(async (options) => {
|
|
71
|
+
await runSessionsSync(options);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -29,6 +29,7 @@ import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
|
29
29
|
import { sessionPicker } from './sessions-picker.js';
|
|
30
30
|
import { setHelpSections } from '../lib/help.js';
|
|
31
31
|
import { registerSessionsTailCommand } from './sessions-tail.js';
|
|
32
|
+
import { registerSessionsSyncCommand } from './sessions-sync.js';
|
|
32
33
|
const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
|
|
33
34
|
const CLAUDE_RESUME_MATCH_WINDOW_MS = 10 * 60_000;
|
|
34
35
|
const LOAD_VERBS = ['Loading', 'Scanning', 'Gathering', 'Indexing', 'Reading'];
|
|
@@ -1144,6 +1145,7 @@ export function registerSessionsCommands(program) {
|
|
|
1144
1145
|
await sessionsAction(query, options);
|
|
1145
1146
|
});
|
|
1146
1147
|
registerSessionsTailCommand(sessionsCmd);
|
|
1148
|
+
registerSessionsSyncCommand(sessionsCmd);
|
|
1147
1149
|
}
|
|
1148
1150
|
function formatNoSessionsMessage(showAll, project) {
|
|
1149
1151
|
const projectQuery = project?.trim();
|
package/dist/commands/view.js
CHANGED
|
@@ -3,7 +3,7 @@ import ora from 'ora';
|
|
|
3
3
|
import * as fs from 'fs';
|
|
4
4
|
import * as path from 'path';
|
|
5
5
|
import { AGENTS, ALL_AGENT_IDS, getAllCliStates, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
|
|
6
|
-
import { formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
|
|
6
|
+
import { deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
|
|
7
7
|
import { readManifest } from '../lib/manifest.js';
|
|
8
8
|
import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, resolveVersionAlias, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, } from '../lib/versions.js';
|
|
9
9
|
import { ensureVersionedAliasCurrent, removeShim, } from '../lib/shims.js';
|
|
@@ -306,7 +306,11 @@ async function showInstalledVersions(filterAgentId) {
|
|
|
306
306
|
return {
|
|
307
307
|
...info,
|
|
308
308
|
plan: canon.plan,
|
|
309
|
-
|
|
309
|
+
// Throttle state comes from the live usage windows, not the pay-as-you-go
|
|
310
|
+
// overage flag that AccountInfo.usageStatus used to carry. A maxed window
|
|
311
|
+
// means rate-limited; no snapshot means no badge. See
|
|
312
|
+
// deriveUsageStatusFromSnapshot.
|
|
313
|
+
usageStatus: deriveUsageStatusFromSnapshot(usageByKey.get(key)?.snapshot),
|
|
310
314
|
overageCredits: canon.overageCredits,
|
|
311
315
|
};
|
|
312
316
|
};
|
|
@@ -984,7 +988,11 @@ async function collectAgentsJson(filterAgentId) {
|
|
|
984
988
|
return {
|
|
985
989
|
...info,
|
|
986
990
|
plan: canon.plan,
|
|
987
|
-
|
|
991
|
+
// Throttle state comes from the live usage windows, not the pay-as-you-go
|
|
992
|
+
// overage flag that AccountInfo.usageStatus used to carry. A maxed window
|
|
993
|
+
// means rate-limited; no snapshot means no badge. See
|
|
994
|
+
// deriveUsageStatusFromSnapshot.
|
|
995
|
+
usageStatus: deriveUsageStatusFromSnapshot(usageByKey.get(key)?.snapshot),
|
|
988
996
|
overageCredits: canon.overageCredits,
|
|
989
997
|
};
|
|
990
998
|
};
|
package/dist/index.js
CHANGED
|
@@ -893,7 +893,7 @@ if (process.env.AGENTS_SKIP_MIGRATION !== '1') {
|
|
|
893
893
|
// Bumping the suffix re-runs migrations for every user; binary releases that
|
|
894
894
|
// don't change the schema must NOT re-run (they would destroy user content
|
|
895
895
|
// when migration steps overlap with user-authored paths). See issue #20.
|
|
896
|
-
const sentinelValue = '
|
|
896
|
+
const sentinelValue = 'v10';
|
|
897
897
|
let needRun = true;
|
|
898
898
|
try {
|
|
899
899
|
if (fs.existsSync(sentinel) && fs.readFileSync(sentinel, 'utf-8').trim() === sentinelValue) {
|
package/dist/lib/agents.d.ts
CHANGED
|
@@ -13,6 +13,17 @@ export interface CliState {
|
|
|
13
13
|
export declare const CODEX_HOOKS_MIN_VERSION = "0.116.0";
|
|
14
14
|
/** Minimum Gemini CLI version that supports the hooks system (v0.26.0, Jan 2026). */
|
|
15
15
|
export declare const GEMINI_HOOKS_MIN_VERSION = "0.26.0";
|
|
16
|
+
/**
|
|
17
|
+
* Synchronous PATH search -- no subprocess. Returns first matching binary path.
|
|
18
|
+
*
|
|
19
|
+
* Skips our own shims dir (`~/.agents/.cache/shims/`) — those shims are
|
|
20
|
+
* dispatch helpers, not real installs. Counting them as installed produced a
|
|
21
|
+
* false positive where agents with NO real binary on the host (e.g. a
|
|
22
|
+
* never-installed Cursor whose only PATH entry was our `cursor-agent` shim
|
|
23
|
+
* dispatcher) showed up under `agents view`'s "Not Managed by Agents CLI"
|
|
24
|
+
* section, even though the user had nothing to import.
|
|
25
|
+
*/
|
|
26
|
+
export declare function findInPath(command: string): string | null;
|
|
16
27
|
/**
|
|
17
28
|
* Master registry of all supported agents keyed by AgentId.
|
|
18
29
|
*
|
package/dist/lib/agents.js
CHANGED
|
@@ -66,7 +66,7 @@ function saveCliVersionCache() {
|
|
|
66
66
|
* dispatcher) showed up under `agents view`'s "Not Managed by Agents CLI"
|
|
67
67
|
* section, even though the user had nothing to import.
|
|
68
68
|
*/
|
|
69
|
-
function findInPath(command) {
|
|
69
|
+
export function findInPath(command) {
|
|
70
70
|
const pathEnv = process.env.PATH || '';
|
|
71
71
|
const pathExt = process.platform === 'win32' ? (process.env.PATHEXT || '').split(';') : [''];
|
|
72
72
|
const shimsDir = getShimsDir();
|
|
@@ -814,14 +814,16 @@ export async function getAccountInfo(agentId, home) {
|
|
|
814
814
|
else if (oa?.billingType) {
|
|
815
815
|
plan = oa.billingType;
|
|
816
816
|
}
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
817
|
+
// usageStatus is NOT derived from cachedExtraUsageDisabledReason. That
|
|
818
|
+
// field reports why pay-as-you-go overage is off (out_of_credits = no
|
|
819
|
+
// overage credits purchased; org_level_disabled = admin turned overage
|
|
820
|
+
// off), which says nothing about whether the account is throttled — a
|
|
821
|
+
// Pro account at 5% weekly usage with overage disabled is fully usable.
|
|
822
|
+
// Real throttle state comes from the live usage windows; callers derive
|
|
823
|
+
// it via deriveUsageStatusFromSnapshot(). Here we only report whether
|
|
824
|
+
// the account is signed in at all. Overage state stays visible through
|
|
825
|
+
// overageCredits below.
|
|
826
|
+
const usageStatus = email ? 'available' : null;
|
|
825
827
|
let overageCredits = null;
|
|
826
828
|
const orgId = oa?.organizationUuid;
|
|
827
829
|
const creditCache = orgId && data.overageCreditGrantCache?.[orgId];
|
|
@@ -1701,24 +1701,34 @@ export class BrowserService {
|
|
|
1701
1701
|
targetId: tabId,
|
|
1702
1702
|
flatten: true,
|
|
1703
1703
|
}));
|
|
1704
|
-
// Inject a
|
|
1705
|
-
//
|
|
1706
|
-
//
|
|
1707
|
-
//
|
|
1708
|
-
//
|
|
1709
|
-
//
|
|
1710
|
-
//
|
|
1711
|
-
//
|
|
1712
|
-
//
|
|
1713
|
-
//
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1704
|
+
// Inject a stealth shim before any page script runs. Chromium exposes
|
|
1705
|
+
// navigator.webdriver = true whenever a remote-debug transport is attached;
|
|
1706
|
+
// Cloudflare Turnstile, hCaptcha, and similar bot checks read it first.
|
|
1707
|
+
//
|
|
1708
|
+
// Only attach-to-running profiles (conn.pid === 0 — Comet / Arc / Brave the
|
|
1709
|
+
// user launched themselves) need this. Browsers agents-cli spawns already
|
|
1710
|
+
// carry the --disable-blink-features=AutomationControlled launch flag, which
|
|
1711
|
+
// makes navigator.webdriver a native Navigator.prototype getter returning
|
|
1712
|
+
// false — indistinguishable from an untouched browser. Injecting on top of
|
|
1713
|
+
// that is actively harmful: it defines an OWN getter on the instance, and an
|
|
1714
|
+
// own `webdriver` descriptor (native lives on the prototype) returning
|
|
1715
|
+
// `undefined` (native returns `false`) is itself a tampering signal that
|
|
1716
|
+
// bot.sannysoft.com and similar tests flag as "WebDriver present".
|
|
1717
|
+
//
|
|
1718
|
+
// When we do inject (attach mode), mirror native semantics exactly: define
|
|
1719
|
+
// on Navigator.prototype and return false, so no own descriptor leaks and
|
|
1720
|
+
// the value matches a real browser. Non-page targets (workers, service
|
|
1721
|
+
// workers) reject these calls; swallow the error and keep going.
|
|
1722
|
+
if (conn.pid === 0) {
|
|
1723
|
+
try {
|
|
1724
|
+
await conn.cdp.send('Page.enable', {}, sessionId);
|
|
1725
|
+
await conn.cdp.send('Page.addScriptToEvaluateOnNewDocument', {
|
|
1726
|
+
source: "Object.defineProperty(Navigator.prototype,'webdriver',{get:()=>false,configurable:true});",
|
|
1727
|
+
}, sessionId);
|
|
1728
|
+
}
|
|
1729
|
+
catch {
|
|
1730
|
+
// Target doesn't support Page domain — nothing to inject.
|
|
1731
|
+
}
|
|
1722
1732
|
}
|
|
1723
1733
|
conn.sessionCache.set(tabId, sessionId);
|
|
1724
1734
|
return sessionId;
|
package/dist/lib/daemon.d.ts
CHANGED
|
@@ -18,6 +18,16 @@ export declare function isDaemonRunning(): boolean;
|
|
|
18
18
|
export declare function log(level: string, message: string): void;
|
|
19
19
|
/** Main daemon loop: load jobs, schedule crons, monitor runs, and handle signals. */
|
|
20
20
|
export declare function runDaemon(): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Read the long-lived Claude OAuth token (from `claude setup-token`) that the
|
|
23
|
+
* user stored under the `claude` secrets bundle. Resolves the bundle the same
|
|
24
|
+
* way `agents run --secrets` does, so the token is found whether it was stored
|
|
25
|
+
* keychain-backed or as a literal. Returns null when the bundle/key isn't
|
|
26
|
+
* configured, the Keychain read is cancelled, or the platform has no keychain —
|
|
27
|
+
* the daemon then behaves exactly as before (relying on the interactive OAuth
|
|
28
|
+
* session). Never throws: a misconfigured token must not block daemon startup.
|
|
29
|
+
*/
|
|
30
|
+
export declare function readDaemonClaudeOAuthToken(): string | null;
|
|
21
31
|
/** Generate a macOS launchd plist for auto-starting the daemon. */
|
|
22
32
|
export declare function generateLaunchdPlist(): string;
|
|
23
33
|
/** Generate a Linux systemd user unit for auto-starting the daemon. */
|
|
@@ -27,6 +37,15 @@ export declare function startDaemon(): {
|
|
|
27
37
|
pid: number | null;
|
|
28
38
|
method: string;
|
|
29
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* Environment for the detached daemon fallback. The launchd/systemd paths
|
|
42
|
+
* deliver the long-lived OAuth token via the service manifest's environment;
|
|
43
|
+
* the detached path has no manifest, so inject it here. Read happens during an
|
|
44
|
+
* interactive `routines start`, so a Keychain Touch ID prompt can be satisfied;
|
|
45
|
+
* the daemon then passes it to every routine run it spawns. An already-set
|
|
46
|
+
* value (e.g. inherited from launchd) is left untouched.
|
|
47
|
+
*/
|
|
48
|
+
export declare function buildDetachedDaemonEnv(baseEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
30
49
|
/** Stop the daemon, unloading it from launchd/systemd if applicable. */
|
|
31
50
|
export declare function stopDaemon(): boolean;
|
|
32
51
|
/** Get current daemon status including running state, PID, and enabled job count. */
|
package/dist/lib/daemon.js
CHANGED
|
@@ -18,6 +18,7 @@ import { executeJobDetached, monitorRunningJobs } from './runner.js';
|
|
|
18
18
|
import { detectOverdueJobs, notifyOverdue } from './overdue.js';
|
|
19
19
|
import { BrowserService } from './browser/service.js';
|
|
20
20
|
import { BrowserIPCServer } from './browser/ipc.js';
|
|
21
|
+
import { readAndResolveBundleEnv } from './secrets/bundles.js';
|
|
21
22
|
const PID_FILE = 'daemon.pid';
|
|
22
23
|
const LOCK_FILE = 'daemon.lock';
|
|
23
24
|
const LOG_FILE = 'logs.jsonl';
|
|
@@ -25,6 +26,12 @@ const LOG_MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
|
25
26
|
const LOG_ROTATE_COUNT = 3;
|
|
26
27
|
const PLIST_NAME = 'com.phnx-labs.agents-daemon';
|
|
27
28
|
const SYSTEMD_UNIT = 'agents-daemon.service';
|
|
29
|
+
// A long-lived `claude setup-token` value stored in this secrets bundle/key is
|
|
30
|
+
// baked into the daemon's service-manager environment so headless routine runs
|
|
31
|
+
// authenticate without depending on the short-lived interactive Keychain OAuth
|
|
32
|
+
// session (which expires between runs and produces intermittent 401s).
|
|
33
|
+
const DAEMON_OAUTH_BUNDLE = 'claude';
|
|
34
|
+
const DAEMON_OAUTH_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
|
|
28
35
|
function getDaemonDir() {
|
|
29
36
|
const dir = getDaemonDirRoot();
|
|
30
37
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -225,6 +232,34 @@ export async function runDaemon() {
|
|
|
225
232
|
const monitorInterval = setInterval(() => {
|
|
226
233
|
monitorRunningJobs();
|
|
227
234
|
}, 60_000);
|
|
235
|
+
// Cross-machine session sync: push this machine's transcripts to R2 and pull
|
|
236
|
+
// every other machine's, ~every 90s. Skipped silently when the r2.backups
|
|
237
|
+
// bundle is absent. An overlap guard prevents a slow cycle from stacking.
|
|
238
|
+
let syncing = false;
|
|
239
|
+
const runSessionSync = async () => {
|
|
240
|
+
if (syncing)
|
|
241
|
+
return;
|
|
242
|
+
syncing = true;
|
|
243
|
+
try {
|
|
244
|
+
const { isSyncConfigured } = await import('./session/sync/config.js');
|
|
245
|
+
if (!isSyncConfigured())
|
|
246
|
+
return;
|
|
247
|
+
const { syncSessions } = await import('./session/sync/sync.js');
|
|
248
|
+
const r = await syncSessions();
|
|
249
|
+
if (r.pushed || r.pulled || r.errors.length) {
|
|
250
|
+
log('INFO', `sessions sync: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}` +
|
|
251
|
+
(r.errors.length ? `, ${r.errors.length} error(s): ${r.errors[0]}` : ''));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
log('ERROR', `sessions sync failed: ${err.message}`);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
syncing = false;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const syncInterval = setInterval(() => { void runSessionSync(); }, 90_000);
|
|
262
|
+
void runSessionSync(); // kick once at startup
|
|
228
263
|
const handleReload = () => {
|
|
229
264
|
log('INFO', 'Reloading jobs (SIGHUP)');
|
|
230
265
|
scheduler.reloadAll();
|
|
@@ -236,6 +271,7 @@ export async function runDaemon() {
|
|
|
236
271
|
scheduler.stopAll();
|
|
237
272
|
await browserIPC.stop();
|
|
238
273
|
clearInterval(monitorInterval);
|
|
274
|
+
clearInterval(syncInterval);
|
|
239
275
|
removeDaemonPid();
|
|
240
276
|
process.exit(0);
|
|
241
277
|
};
|
|
@@ -244,10 +280,42 @@ export async function runDaemon() {
|
|
|
244
280
|
process.on('SIGINT', () => handleShutdown());
|
|
245
281
|
await new Promise(() => { });
|
|
246
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Read the long-lived Claude OAuth token (from `claude setup-token`) that the
|
|
285
|
+
* user stored under the `claude` secrets bundle. Resolves the bundle the same
|
|
286
|
+
* way `agents run --secrets` does, so the token is found whether it was stored
|
|
287
|
+
* keychain-backed or as a literal. Returns null when the bundle/key isn't
|
|
288
|
+
* configured, the Keychain read is cancelled, or the platform has no keychain —
|
|
289
|
+
* the daemon then behaves exactly as before (relying on the interactive OAuth
|
|
290
|
+
* session). Never throws: a misconfigured token must not block daemon startup.
|
|
291
|
+
*/
|
|
292
|
+
export function readDaemonClaudeOAuthToken() {
|
|
293
|
+
try {
|
|
294
|
+
const { env } = readAndResolveBundleEnv(DAEMON_OAUTH_BUNDLE, { caller: 'daemon' });
|
|
295
|
+
const token = (env[DAEMON_OAUTH_KEY] ?? '').trim();
|
|
296
|
+
return token.length > 0 ? token : null;
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/** Escape a string for safe inclusion in an XML <string> node. */
|
|
303
|
+
function xmlEscape(s) {
|
|
304
|
+
return s
|
|
305
|
+
.replace(/&/g, '&')
|
|
306
|
+
.replace(/</g, '<')
|
|
307
|
+
.replace(/>/g, '>');
|
|
308
|
+
}
|
|
247
309
|
/** Generate a macOS launchd plist for auto-starting the daemon. */
|
|
248
310
|
export function generateLaunchdPlist() {
|
|
249
311
|
const agentsBin = getAgentsBinPath();
|
|
250
312
|
const logPath = getLogPath();
|
|
313
|
+
const oauthToken = readDaemonClaudeOAuthToken();
|
|
314
|
+
const oauthEntry = oauthToken
|
|
315
|
+
? `
|
|
316
|
+
<key>${DAEMON_OAUTH_KEY}</key>
|
|
317
|
+
<string>${xmlEscape(oauthToken)}</string>`
|
|
318
|
+
: '';
|
|
251
319
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
252
320
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
253
321
|
<plist version="1.0">
|
|
@@ -271,7 +339,7 @@ export function generateLaunchdPlist() {
|
|
|
271
339
|
<key>EnvironmentVariables</key>
|
|
272
340
|
<dict>
|
|
273
341
|
<key>PATH</key>
|
|
274
|
-
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin</string
|
|
342
|
+
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin</string>${oauthEntry}
|
|
275
343
|
</dict>
|
|
276
344
|
</dict>
|
|
277
345
|
</plist>`;
|
|
@@ -279,6 +347,10 @@ export function generateLaunchdPlist() {
|
|
|
279
347
|
/** Generate a Linux systemd user unit for auto-starting the daemon. */
|
|
280
348
|
export function generateSystemdUnit() {
|
|
281
349
|
const agentsBin = getAgentsBinPath();
|
|
350
|
+
const oauthToken = readDaemonClaudeOAuthToken();
|
|
351
|
+
const oauthLine = oauthToken
|
|
352
|
+
? `\nEnvironment=${DAEMON_OAUTH_KEY}=${oauthToken}`
|
|
353
|
+
: '';
|
|
282
354
|
return `[Unit]
|
|
283
355
|
Description=Agents Daemon - Scheduled Job Runner
|
|
284
356
|
After=network.target
|
|
@@ -288,7 +360,7 @@ Type=simple
|
|
|
288
360
|
ExecStart=${agentsBin} daemon _run
|
|
289
361
|
Restart=always
|
|
290
362
|
RestartSec=10
|
|
291
|
-
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin
|
|
363
|
+
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin${oauthLine}
|
|
292
364
|
|
|
293
365
|
[Install]
|
|
294
366
|
WantedBy=default.target`;
|
|
@@ -338,6 +410,9 @@ function startDaemonLocked() {
|
|
|
338
410
|
fs.mkdirSync(plistDir, { recursive: true });
|
|
339
411
|
}
|
|
340
412
|
fs.writeFileSync(plistPath, generateLaunchdPlist(), 'utf-8');
|
|
413
|
+
// The plist may embed a long-lived OAuth token in EnvironmentVariables;
|
|
414
|
+
// keep it owner-only so it isn't world/group readable on disk.
|
|
415
|
+
fs.chmodSync(plistPath, 0o600);
|
|
341
416
|
try {
|
|
342
417
|
execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
343
418
|
}
|
|
@@ -365,6 +440,8 @@ function startDaemonLocked() {
|
|
|
365
440
|
fs.mkdirSync(unitDir, { recursive: true });
|
|
366
441
|
}
|
|
367
442
|
fs.writeFileSync(unitPath, generateSystemdUnit(), 'utf-8');
|
|
443
|
+
// May embed a long-lived OAuth token in an Environment= line; owner-only.
|
|
444
|
+
fs.chmodSync(unitPath, 0o600);
|
|
368
445
|
execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8' });
|
|
369
446
|
execFileSync('systemctl', ['--user', 'enable', SYSTEMD_UNIT], { encoding: 'utf-8' });
|
|
370
447
|
execFileSync('systemctl', ['--user', 'start', SYSTEMD_UNIT], { encoding: 'utf-8' });
|
|
@@ -377,6 +454,23 @@ function startDaemonLocked() {
|
|
|
377
454
|
}
|
|
378
455
|
return startDetached();
|
|
379
456
|
}
|
|
457
|
+
/**
|
|
458
|
+
* Environment for the detached daemon fallback. The launchd/systemd paths
|
|
459
|
+
* deliver the long-lived OAuth token via the service manifest's environment;
|
|
460
|
+
* the detached path has no manifest, so inject it here. Read happens during an
|
|
461
|
+
* interactive `routines start`, so a Keychain Touch ID prompt can be satisfied;
|
|
462
|
+
* the daemon then passes it to every routine run it spawns. An already-set
|
|
463
|
+
* value (e.g. inherited from launchd) is left untouched.
|
|
464
|
+
*/
|
|
465
|
+
export function buildDetachedDaemonEnv(baseEnv = process.env) {
|
|
466
|
+
const env = { ...baseEnv };
|
|
467
|
+
if (!env.CLAUDE_CODE_OAUTH_TOKEN) {
|
|
468
|
+
const token = readDaemonClaudeOAuthToken();
|
|
469
|
+
if (token)
|
|
470
|
+
env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
471
|
+
}
|
|
472
|
+
return env;
|
|
473
|
+
}
|
|
380
474
|
function startDetached() {
|
|
381
475
|
const agentsBin = getAgentsBinPath();
|
|
382
476
|
const logPath = getLogPath();
|
|
@@ -384,6 +478,7 @@ function startDetached() {
|
|
|
384
478
|
const child = spawn(agentsBin, ['daemon', '_run'], {
|
|
385
479
|
stdio: ['ignore', logFd, logFd],
|
|
386
480
|
detached: true,
|
|
481
|
+
env: buildDetachedDaemonEnv(),
|
|
387
482
|
});
|
|
388
483
|
child.unref();
|
|
389
484
|
fs.closeSync(logFd);
|