@phnx-labs/agents-cli 1.20.15 → 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/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/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 +17 -1
- 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/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];
|
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);
|
package/dist/lib/migrate.d.ts
CHANGED
|
@@ -25,6 +25,28 @@
|
|
|
25
25
|
* LEGACY_SYSTEM_DIR" without duplicating data.
|
|
26
26
|
*/
|
|
27
27
|
export declare function foldLegacySystemRepo(): void;
|
|
28
|
+
/**
|
|
29
|
+
* Repair self-referential agent binary symlinks.
|
|
30
|
+
*
|
|
31
|
+
* Some installScript-based agents — notably Factory's `droid`, whose installer
|
|
32
|
+
* drops a standalone native binary at ~/.local/bin/droid — were registered at
|
|
33
|
+
* install time by resolving the post-install binary with `which <cli>`. Because
|
|
34
|
+
* ~/.agents/.cache/shims sits ahead of ~/.local/bin on PATH, `which` could
|
|
35
|
+
* return OUR OWN dispatcher shim, and the install step symlinked
|
|
36
|
+
* ~/.agents/.history/versions/<agent>/<version>/node_modules/.bin/<cli>
|
|
37
|
+
* back at ~/.agents/.cache/shims/<cli>. Launching that agent then re-execs the
|
|
38
|
+
* dispatcher forever (an infinite exec loop that hangs the terminal).
|
|
39
|
+
*
|
|
40
|
+
* This walks every installed version's node_modules/.bin and, for any entry
|
|
41
|
+
* whose symlink resolves into the shims dir, re-points it at the real binary
|
|
42
|
+
* (found on PATH with the shims dir excluded) — or removes it when no real
|
|
43
|
+
* binary can be found, letting getBinaryPath's per-agent resolver take over.
|
|
44
|
+
* Idempotent: a correctly-pointed link is left untouched on re-run.
|
|
45
|
+
*
|
|
46
|
+
* Params default to the real on-disk locations; they are injectable so tests
|
|
47
|
+
* can drive a fixture tree without touching the user's ~/.agents.
|
|
48
|
+
*/
|
|
49
|
+
export declare function repairSelfReferentialBinShims(versionsRoot?: string, shimsDir?: string): void;
|
|
28
50
|
/**
|
|
29
51
|
* Rename the legacy `extras-extras/` plugin-marketplace dir to `agents-extras/`
|
|
30
52
|
* inside every installed agent version-home, and rewrite cross-references in
|
package/dist/lib/migrate.js
CHANGED
|
@@ -8,7 +8,7 @@ import * as fs from 'fs';
|
|
|
8
8
|
import * as path from 'path';
|
|
9
9
|
import * as os from 'os';
|
|
10
10
|
import * as yaml from 'yaml';
|
|
11
|
-
import { AGENTS, agentConfigDirName } from './agents.js';
|
|
11
|
+
import { AGENTS, agentConfigDirName, findInPath } from './agents.js';
|
|
12
12
|
const HOME = process.env.HOME ?? os.homedir();
|
|
13
13
|
const USER_DIR = path.join(HOME, '.agents');
|
|
14
14
|
/** Canonical system-repo location (post-fold). */
|
|
@@ -715,6 +715,100 @@ function repairAgentConfigSymlinks() {
|
|
|
715
715
|
console.error(`Repaired ${repaired} agent config symlink${repaired === 1 ? '' : 's'} to point at ~/.agents/versions/`);
|
|
716
716
|
}
|
|
717
717
|
}
|
|
718
|
+
/**
|
|
719
|
+
* Repair self-referential agent binary symlinks.
|
|
720
|
+
*
|
|
721
|
+
* Some installScript-based agents — notably Factory's `droid`, whose installer
|
|
722
|
+
* drops a standalone native binary at ~/.local/bin/droid — were registered at
|
|
723
|
+
* install time by resolving the post-install binary with `which <cli>`. Because
|
|
724
|
+
* ~/.agents/.cache/shims sits ahead of ~/.local/bin on PATH, `which` could
|
|
725
|
+
* return OUR OWN dispatcher shim, and the install step symlinked
|
|
726
|
+
* ~/.agents/.history/versions/<agent>/<version>/node_modules/.bin/<cli>
|
|
727
|
+
* back at ~/.agents/.cache/shims/<cli>. Launching that agent then re-execs the
|
|
728
|
+
* dispatcher forever (an infinite exec loop that hangs the terminal).
|
|
729
|
+
*
|
|
730
|
+
* This walks every installed version's node_modules/.bin and, for any entry
|
|
731
|
+
* whose symlink resolves into the shims dir, re-points it at the real binary
|
|
732
|
+
* (found on PATH with the shims dir excluded) — or removes it when no real
|
|
733
|
+
* binary can be found, letting getBinaryPath's per-agent resolver take over.
|
|
734
|
+
* Idempotent: a correctly-pointed link is left untouched on re-run.
|
|
735
|
+
*
|
|
736
|
+
* Params default to the real on-disk locations; they are injectable so tests
|
|
737
|
+
* can drive a fixture tree without touching the user's ~/.agents.
|
|
738
|
+
*/
|
|
739
|
+
export function repairSelfReferentialBinShims(versionsRoot = path.join(HISTORY_DIR, 'versions'), shimsDir = path.resolve(CACHE_DIR, 'shims')) {
|
|
740
|
+
// Normalize the shims dir through realpath so the prefix check below survives
|
|
741
|
+
// a symlinked ~/.agents (or macOS's /tmp -> /private/tmp): fs.realpathSync on
|
|
742
|
+
// the link target resolves those symlinks, so the dir we compare against must
|
|
743
|
+
// too, or every loop would read as "points at a real binary" and be skipped.
|
|
744
|
+
shimsDir = path.resolve(shimsDir);
|
|
745
|
+
try {
|
|
746
|
+
shimsDir = fs.realpathSync(shimsDir);
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
/* shims dir absent — leave the resolved path; nothing will match it */
|
|
750
|
+
}
|
|
751
|
+
let agents;
|
|
752
|
+
try {
|
|
753
|
+
agents = fs.readdirSync(versionsRoot);
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
return; // no versions installed yet
|
|
757
|
+
}
|
|
758
|
+
let repaired = 0;
|
|
759
|
+
for (const agent of agents) {
|
|
760
|
+
const cli = agent in AGENTS ? AGENTS[agent].cliCommand : agent;
|
|
761
|
+
let versions;
|
|
762
|
+
try {
|
|
763
|
+
versions = fs.readdirSync(path.join(versionsRoot, agent));
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
for (const version of versions) {
|
|
769
|
+
const binLink = path.join(versionsRoot, agent, version, 'node_modules', '.bin', cli);
|
|
770
|
+
let stat;
|
|
771
|
+
try {
|
|
772
|
+
stat = fs.lstatSync(binLink);
|
|
773
|
+
}
|
|
774
|
+
catch {
|
|
775
|
+
continue; // no .bin entry for this version
|
|
776
|
+
}
|
|
777
|
+
if (!stat.isSymbolicLink())
|
|
778
|
+
continue;
|
|
779
|
+
let real;
|
|
780
|
+
try {
|
|
781
|
+
real = fs.realpathSync(binLink);
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
// Dangling symlink — if it was aimed at the shims dir it's the loop
|
|
785
|
+
// residue; drop it either way so getBinaryPath reports honestly.
|
|
786
|
+
try {
|
|
787
|
+
fs.unlinkSync(binLink);
|
|
788
|
+
repaired++;
|
|
789
|
+
}
|
|
790
|
+
catch { /* best-effort */ }
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
if (!path.resolve(real).startsWith(shimsDir + path.sep))
|
|
794
|
+
continue; // points at a real binary — fine
|
|
795
|
+
// Self-referential: the link resolves back into our own shims dir.
|
|
796
|
+
// findInPath does a pure-Node PATH scan (no subprocess) and already
|
|
797
|
+
// skips our shims dir, so it returns the genuine install if one exists.
|
|
798
|
+
const realBinary = findInPath(cli);
|
|
799
|
+
try {
|
|
800
|
+
fs.unlinkSync(binLink);
|
|
801
|
+
if (realBinary)
|
|
802
|
+
fs.symlinkSync(realBinary, binLink);
|
|
803
|
+
repaired++;
|
|
804
|
+
}
|
|
805
|
+
catch { /* best-effort */ }
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (repaired > 0) {
|
|
809
|
+
console.error(`Repaired ${repaired} self-referential agent binary symlink${repaired === 1 ? '' : 's'} (infinite exec-loop fix).`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
718
812
|
/**
|
|
719
813
|
* Move a directory from `src` to `dest`. No-op when src is absent. When dest
|
|
720
814
|
* already exists, merge by copying everything that isn't already there, then
|
|
@@ -1733,4 +1827,8 @@ export async function runMigration() {
|
|
|
1733
1827
|
migrateExtrasExtrasToAgentsExtras();
|
|
1734
1828
|
// Symlink repair runs LAST so it can find the post-move version homes.
|
|
1735
1829
|
repairAgentConfigSymlinks();
|
|
1830
|
+
// Repair self-referential node_modules/.bin/<cli> symlinks (the droid
|
|
1831
|
+
// infinite-exec-loop). Also runs after the bucket moves so it scans the
|
|
1832
|
+
// canonical HISTORY_DIR/versions tree.
|
|
1833
|
+
repairSelfReferentialBinShims();
|
|
1736
1834
|
}
|