@phnx-labs/agents-cli 1.20.57 → 1.20.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +40 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/defaults.js +24 -0
  5. package/dist/commands/exec.js +29 -5
  6. package/dist/commands/output.d.ts +19 -0
  7. package/dist/commands/output.js +333 -0
  8. package/dist/commands/secrets.js +34 -25
  9. package/dist/commands/versions.js +11 -3
  10. package/dist/commands/view.js +19 -4
  11. package/dist/index.js +2 -1
  12. package/dist/lib/agents.d.ts +21 -0
  13. package/dist/lib/agents.js +42 -14
  14. package/dist/lib/daemon.d.ts +5 -5
  15. package/dist/lib/daemon.js +65 -17
  16. package/dist/lib/git.d.ts +9 -0
  17. package/dist/lib/git.js +12 -0
  18. package/dist/lib/hosts/dispatch.d.ts +21 -0
  19. package/dist/lib/hosts/dispatch.js +88 -5
  20. package/dist/lib/hosts/passthrough.js +1 -0
  21. package/dist/lib/mcp.js +1 -1
  22. package/dist/lib/output/git-output.d.ts +74 -0
  23. package/dist/lib/output/git-output.js +213 -0
  24. package/dist/lib/permissions.d.ts +31 -1
  25. package/dist/lib/permissions.js +210 -9
  26. package/dist/lib/project-root.d.ts +65 -0
  27. package/dist/lib/project-root.js +134 -0
  28. package/dist/lib/resources/mcp.js +1 -1
  29. package/dist/lib/resources/permissions.js +5 -1
  30. package/dist/lib/resources/skills.js +6 -1
  31. package/dist/lib/resources/types.d.ts +1 -1
  32. package/dist/lib/secrets/agent.d.ts +26 -23
  33. package/dist/lib/secrets/agent.js +196 -216
  34. package/dist/lib/secrets/remote.d.ts +7 -2
  35. package/dist/lib/secrets/remote.js +12 -10
  36. package/dist/lib/session/active.d.ts +3 -0
  37. package/dist/lib/session/active.js +1 -0
  38. package/dist/lib/session/db.d.ts +3 -0
  39. package/dist/lib/session/db.js +20 -4
  40. package/dist/lib/session/discover.d.ts +2 -0
  41. package/dist/lib/session/discover.js +40 -4
  42. package/dist/lib/session/parse.js +38 -15
  43. package/dist/lib/session/state.d.ts +4 -1
  44. package/dist/lib/session/state.js +18 -1
  45. package/dist/lib/session/types.d.ts +10 -0
  46. package/dist/lib/staleness/detectors/permissions.js +64 -1
  47. package/dist/lib/staleness/detectors/subagents.js +30 -0
  48. package/dist/lib/staleness/writers/commands.js +3 -3
  49. package/dist/lib/staleness/writers/subagents.js +13 -1
  50. package/dist/lib/startup/command-registry.d.ts +1 -0
  51. package/dist/lib/startup/command-registry.js +2 -0
  52. package/dist/lib/subagents.d.ts +23 -0
  53. package/dist/lib/subagents.js +161 -12
  54. package/dist/lib/teams/agents.d.ts +14 -0
  55. package/dist/lib/teams/agents.js +158 -22
  56. package/dist/lib/types.d.ts +13 -0
  57. package/dist/lib/versions.d.ts +39 -0
  58. package/dist/lib/versions.js +199 -12
  59. package/package.json +1 -1
  60. package/scripts/postinstall.js +26 -11
@@ -440,6 +440,37 @@ export function convertToClaudeFormat(set) {
440
440
  }
441
441
  return { permissions };
442
442
  }
443
+ /**
444
+ * Map a canonical rule to Cursor CLI syntax.
445
+ * Cursor uses Shell(...) instead of Bash(...); other tools keep TitleCase names.
446
+ * https://cursor.com/docs/cli/reference/permissions
447
+ */
448
+ function canonicalToCursorRule(perm) {
449
+ if (perm === 'Bash' || perm.startsWith('Bash(')) {
450
+ return perm.replace(/^Bash/, 'Shell');
451
+ }
452
+ // Canonical WebSearch maps to WebFetch family for network allow.
453
+ if (perm.startsWith('WebSearch(')) {
454
+ return perm.replace(/^WebSearch/, 'WebFetch');
455
+ }
456
+ // Cursor has no Edit prefix — file writes use Write(...).
457
+ if (perm === 'Edit' || perm.startsWith('Edit(')) {
458
+ return perm.replace(/^Edit/, 'Write');
459
+ }
460
+ return perm;
461
+ }
462
+ /**
463
+ * Convert canonical permission set to Cursor CLI format
464
+ * (`~/.cursor/cli-config.json` permissions.allow/deny).
465
+ */
466
+ export function convertToCursorFormat(set) {
467
+ return {
468
+ permissions: {
469
+ allow: set.allow.map(canonicalToCursorRule),
470
+ deny: (set.deny ?? []).map(canonicalToCursorRule),
471
+ },
472
+ };
473
+ }
443
474
  /**
444
475
  * Parse canonical permission pattern to extract tool and pattern.
445
476
  * "Bash(git *)" -> { tool: "bash", pattern: "git *" }
@@ -494,6 +525,26 @@ function normalizeBashPattern(pattern) {
494
525
  return pattern.slice(0, -2) + ' *';
495
526
  return pattern;
496
527
  }
528
+ /** Convert canonical Bash rules into Droid's command arrays. */
529
+ export function convertToDroidFormat(set) {
530
+ const commands = (permissions) => {
531
+ const result = new Set();
532
+ for (const permission of permissions) {
533
+ if (BLANKET_BASH_FORMS.has(permission)) {
534
+ result.add('*');
535
+ continue;
536
+ }
537
+ const parsed = parseCanonicalPattern(permission);
538
+ if (parsed?.tool === 'bash')
539
+ result.add(normalizeBashPattern(parsed.pattern));
540
+ }
541
+ return Array.from(result);
542
+ };
543
+ return {
544
+ commandAllowlist: commands(set.allow),
545
+ commandDenylist: commands(set.deny ?? []),
546
+ };
547
+ }
497
548
  /**
498
549
  * Convert canonical permission set to Antigravity format.
499
550
  * Antigravity reads ~/.gemini/antigravity-cli/settings.json with
@@ -592,6 +643,57 @@ function canonicalToGrokRule(perm, action) {
592
643
  }
593
644
  return { action, tool, pattern };
594
645
  }
646
+ /**
647
+ * Convert canonical permissions to Kiro CLI v3 capability rules.
648
+ * Kiro stores these rules in ~/.kiro/settings/permissions.yaml.
649
+ */
650
+ export function convertToKiroFormat(set) {
651
+ const rules = [];
652
+ for (const perm of set.allow) {
653
+ const rule = canonicalToKiroRule(perm, 'allow');
654
+ if (rule)
655
+ rules.push(rule);
656
+ }
657
+ for (const perm of set.deny ?? []) {
658
+ const rule = canonicalToKiroRule(perm, 'deny');
659
+ if (rule)
660
+ rules.push(rule);
661
+ }
662
+ return { rules };
663
+ }
664
+ function canonicalToKiroRule(perm, effect) {
665
+ if (BLANKET_BASH_FORMS.has(perm)) {
666
+ return { capability: 'shell', effect };
667
+ }
668
+ const parsed = parseCanonicalPreserveCase(perm);
669
+ const capability = KIRO_CAPABILITY_BY_TOOL[parsed.tool.toLowerCase()];
670
+ if (!capability)
671
+ return null;
672
+ if (parsed.pattern === null || parsed.pattern === '*' || parsed.pattern === '**') {
673
+ return { capability, effect };
674
+ }
675
+ const lowerTool = parsed.tool.toLowerCase();
676
+ const pattern = lowerTool === 'bash'
677
+ ? normalizeBashPattern(parsed.pattern)
678
+ : (lowerTool === 'webfetch' || lowerTool === 'websearch') && parsed.pattern.startsWith('domain:')
679
+ ? parsed.pattern.slice('domain:'.length)
680
+ : parsed.pattern;
681
+ return { capability, effect, match: [pattern] };
682
+ }
683
+ const KIRO_CAPABILITY_BY_TOOL = {
684
+ bash: 'shell',
685
+ read: 'fs_read',
686
+ grep: 'fs_read',
687
+ glob: 'fs_read',
688
+ write: 'fs_write',
689
+ edit: 'fs_write',
690
+ notebookedit: 'fs_write',
691
+ webfetch: 'web_fetch',
692
+ websearch: 'web_search',
693
+ mcp: 'mcp',
694
+ subagent: 'subagent',
695
+ skill: 'skill',
696
+ };
595
697
  /**
596
698
  * Parse a canonical permission string preserving the tool's original casing.
597
699
  * `parseCanonicalPattern` lowercases the tool name, which is fine for Grok
@@ -837,9 +939,7 @@ function readClaudePermissions(scope = 'user', cwd, options) {
837
939
  */
838
940
  function readOpenCodePermissions(scope = 'user', cwd, options) {
839
941
  const home = options?.home || HOME;
840
- const configPath = scope === 'user'
841
- ? path.join(home, '.opencode', 'opencode.jsonc')
842
- : path.join(cwd || process.cwd(), '.opencode', 'opencode.jsonc');
942
+ const configPath = openCodeConfigPath(scope, cwd, home);
843
943
  if (!fs.existsSync(configPath)) {
844
944
  return null;
845
945
  }
@@ -949,14 +1049,36 @@ export function applyClaudePermissions(set, scope = 'user', cwd, merge = true) {
949
1049
  return { success: false, error: err.message };
950
1050
  }
951
1051
  }
1052
+ /**
1053
+ * Path OpenCode actually loads for global config:
1054
+ * ~/.config/opencode/opencode.jsonc (or .json)
1055
+ * Project: <cwd>/opencode.jsonc (or .json) at project root — not .opencode/.
1056
+ * See https://opencode.ai/docs/config/
1057
+ */
1058
+ export function openCodeConfigPath(scope, cwd, home = HOME) {
1059
+ if (scope === 'project') {
1060
+ const root = cwd || process.cwd();
1061
+ for (const name of ['opencode.jsonc', 'opencode.json']) {
1062
+ const candidate = path.join(root, name);
1063
+ if (fs.existsSync(candidate))
1064
+ return candidate;
1065
+ }
1066
+ return path.join(root, 'opencode.jsonc');
1067
+ }
1068
+ const globalDir = path.join(home, '.config', 'opencode');
1069
+ for (const name of ['opencode.jsonc', 'opencode.json']) {
1070
+ const candidate = path.join(globalDir, name);
1071
+ if (fs.existsSync(candidate))
1072
+ return candidate;
1073
+ }
1074
+ return path.join(globalDir, 'opencode.jsonc');
1075
+ }
952
1076
  /**
953
1077
  * Apply a permission set to OpenCode's opencode.jsonc.
954
1078
  */
955
1079
  function applyOpenCodePermissions(set, scope = 'user', cwd, merge = true) {
956
- const configDir = scope === 'user'
957
- ? path.join(HOME, '.opencode')
958
- : path.join(cwd || process.cwd(), '.opencode');
959
- const configPath = path.join(configDir, 'opencode.jsonc');
1080
+ const configPath = openCodeConfigPath(scope, cwd);
1081
+ const configDir = path.dirname(configPath);
960
1082
  try {
961
1083
  // Ensure directory exists
962
1084
  if (!fs.existsSync(configDir)) {
@@ -1090,7 +1212,10 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
1090
1212
  return { success: true };
1091
1213
  }
1092
1214
  if (agentId === 'opencode') {
1093
- const configPath = path.join(configDir, 'opencode.jsonc');
1215
+ // OpenCode loads ~/.config/opencode/opencode.jsonc under the version home
1216
+ // (HOME isolation), not ~/.opencode/opencode.jsonc.
1217
+ const configPath = openCodeConfigPath('user', undefined, versionHome);
1218
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
1094
1219
  let config = {};
1095
1220
  if (fs.existsSync(configPath)) {
1096
1221
  const content = stripJsonComments(fs.readFileSync(configPath, 'utf-8'));
@@ -1258,6 +1383,82 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
1258
1383
  fs.writeFileSync(configPath, TOML.stringify(config), 'utf-8');
1259
1384
  return { success: true };
1260
1385
  }
1386
+ if (agentId === 'cursor') {
1387
+ // Cursor CLI permissions live in ~/.cursor/cli-config.json
1388
+ const configPath = path.join(configDir, 'cli-config.json');
1389
+ let config = {};
1390
+ if (fs.existsSync(configPath)) {
1391
+ try {
1392
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1393
+ }
1394
+ catch { /* start fresh */ }
1395
+ }
1396
+ const converted = convertToCursorFormat(set);
1397
+ if (merge && config.permissions && typeof config.permissions === 'object') {
1398
+ const existing = config.permissions;
1399
+ const allow = new Set([...(existing.allow || []), ...converted.permissions.allow]);
1400
+ const deny = new Set([...(existing.deny || []), ...converted.permissions.deny]);
1401
+ config.permissions = { allow: [...allow], deny: [...deny] };
1402
+ }
1403
+ else {
1404
+ config.permissions = converted.permissions;
1405
+ }
1406
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
1407
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1408
+ return { success: true };
1409
+ }
1410
+ if (agentId === 'droid') {
1411
+ const configPath = path.join(versionHome, '.factory', 'settings.json');
1412
+ let config = {};
1413
+ if (fs.existsSync(configPath)) {
1414
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1415
+ }
1416
+ const converted = convertToDroidFormat(set);
1417
+ if (merge) {
1418
+ const existingAllow = Array.isArray(config.commandAllowlist) ? config.commandAllowlist : [];
1419
+ const existingDeny = Array.isArray(config.commandDenylist) ? config.commandDenylist : [];
1420
+ config.commandAllowlist = Array.from(new Set([...existingAllow, ...converted.commandAllowlist]));
1421
+ config.commandDenylist = Array.from(new Set([...existingDeny, ...converted.commandDenylist]));
1422
+ }
1423
+ else {
1424
+ config.commandAllowlist = converted.commandAllowlist;
1425
+ config.commandDenylist = converted.commandDenylist;
1426
+ }
1427
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
1428
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1429
+ return { success: true };
1430
+ }
1431
+ if (agentId === 'kiro') {
1432
+ const permissionsPath = path.join(versionHome, '.kiro', 'settings', 'permissions.yaml');
1433
+ let config = {};
1434
+ if (fs.existsSync(permissionsPath)) {
1435
+ const parsed = yaml.parse(fs.readFileSync(permissionsPath, 'utf-8'));
1436
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1437
+ config = parsed;
1438
+ }
1439
+ }
1440
+ const newRules = convertToKiroFormat(set).rules;
1441
+ if (merge) {
1442
+ const existingRules = Array.isArray(config.rules) ? config.rules : [];
1443
+ const seen = new Set();
1444
+ config.rules = [...existingRules, ...newRules].filter((rule) => {
1445
+ if (!rule || typeof rule !== 'object' || Array.isArray(rule))
1446
+ return false;
1447
+ const record = rule;
1448
+ const key = `${String(record.effect)}|${String(record.capability)}|${JSON.stringify(record.match ?? [])}|${JSON.stringify(record.exclude ?? [])}`;
1449
+ if (seen.has(key))
1450
+ return false;
1451
+ seen.add(key);
1452
+ return true;
1453
+ });
1454
+ }
1455
+ else {
1456
+ config.rules = newRules;
1457
+ }
1458
+ fs.mkdirSync(path.dirname(permissionsPath), { recursive: true });
1459
+ fs.writeFileSync(permissionsPath, yaml.stringify(config), 'utf-8');
1460
+ return { success: true };
1461
+ }
1261
1462
  return { success: false, error: `Agent '${agentId}' does not support permissions` };
1262
1463
  }
1263
1464
  catch (err) {
@@ -1359,7 +1560,7 @@ export function exportPermissionsFromPath(filePath) {
1359
1560
  if (fileName === 'settings.json' && parentDir === '.claude') {
1360
1561
  agentId = 'claude';
1361
1562
  }
1362
- else if (fileName === 'opencode.jsonc' || parentDir === '.opencode') {
1563
+ else if (fileName === 'opencode.jsonc' || fileName === 'opencode.json' || parentDir === 'opencode' || parentDir === '.opencode') {
1363
1564
  agentId = 'opencode';
1364
1565
  }
1365
1566
  else if (fileName === 'config.toml' && parentDir === '.codex') {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Projects-root resolution for the `agents run --project <slug>` shorthand.
3
+ *
4
+ * Projects follow a predictable layout — `<root>/<repo>` (e.g.
5
+ * `~/src/github.com/<user>/<repo>`), with git worktrees under
6
+ * `<repo>/.agents/worktrees/<slug>`. The root is auto-inferred from the repo you
7
+ * launch inside (the directory ABOVE the git root) and cached in `agents.yaml`
8
+ * so later runs resolve a bare slug from anywhere. It is stored home-relative
9
+ * (`~/…`) when it sits under `$HOME`, so the SAME value resolves on a remote
10
+ * host whose home differs (`/home/<user>` vs `/Users/<user>`): a `--host` run
11
+ * keeps the `~` and lets the remote login shell expand it (see `remoteCdPrefix`
12
+ * in `hosts/dispatch.ts`), while a local run expands `~` against the local home.
13
+ */
14
+ /** Rewrite an absolute path under the local home to a `~/`-relative string; pass others through. */
15
+ export declare function toHomeRelative(abs: string): string;
16
+ /** Expand a leading `~`/`$HOME` against the LOCAL home. Other paths pass through unchanged. */
17
+ export declare function expandLocalHome(p: string): string;
18
+ /**
19
+ * Make a `--cwd`/`--project` value portable to a remote host: an absolute path
20
+ * under the LOCAL home (which the local shell already expanded from `~`) becomes
21
+ * `~/…` so the *remote* shell re-roots it at its own home. Paths already anchored
22
+ * at `~`/`$HOME` pass through; other absolute or relative paths are left as-is
23
+ * (used verbatim on the host). Explicit `--remote-cwd` is NOT run through this —
24
+ * it is a literal remote path by contract.
25
+ */
26
+ export declare function toRemotePortable(p: string): string;
27
+ /** The configured projects root (home-relative or absolute), or undefined when unset. */
28
+ export declare function getProjectRoot(): string | undefined;
29
+ /** Set (override) the cached projects root. Stored home-relative when under `$HOME`. */
30
+ export declare function setProjectRoot(rootPath: string): string;
31
+ /**
32
+ * Infer the projects root from `cwd`: the directory ABOVE the git repo root
33
+ * (cwd inside `~/src/github.com/user/repo` → `~/src/github.com/user`). Returns a
34
+ * home-relative string when under `$HOME`; undefined when `cwd` is not in a repo.
35
+ */
36
+ export declare function inferProjectRoot(cwd: string): Promise<string | undefined>;
37
+ /**
38
+ * Resolve the projects root, auto-inferring and caching on first use. Throws an
39
+ * actionable error when it is neither configured nor inferrable from `cwd`.
40
+ */
41
+ export declare function ensureProjectRoot(cwd: string): Promise<string>;
42
+ export interface ProjectRef {
43
+ slug: string;
44
+ worktree?: string;
45
+ }
46
+ /** Parse a `--project` value of the form `<slug>[@<worktree>]`. */
47
+ export declare function parseProjectRef(ref: string): ProjectRef;
48
+ /**
49
+ * Join a root + `--project` ref into a working directory. Pure (no I/O) so the
50
+ * slug/worktree layout is unit-testable. `forRemote` keeps the path
51
+ * home-relative (`~/…`) for the remote shell to expand; otherwise it is expanded
52
+ * against the local home into an absolute path.
53
+ */
54
+ export declare function buildProjectPath(root: string, ref: string, forRemote: boolean): string;
55
+ /**
56
+ * Resolve a `--project` ref to a working directory, inferring/caching the root.
57
+ *
58
+ * `forRemote: true` returns a home-relative path (`~/…`) so the REMOTE login
59
+ * shell expands `~`/`$HOME` to its own home. `forRemote: false` returns an
60
+ * absolute local path and verifies it exists (so a mistyped slug fails loudly).
61
+ */
62
+ export declare function resolveProjectRef(ref: string, opts: {
63
+ forRemote: boolean;
64
+ cwd?: string;
65
+ }): Promise<string>;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Projects-root resolution for the `agents run --project <slug>` shorthand.
3
+ *
4
+ * Projects follow a predictable layout — `<root>/<repo>` (e.g.
5
+ * `~/src/github.com/<user>/<repo>`), with git worktrees under
6
+ * `<repo>/.agents/worktrees/<slug>`. The root is auto-inferred from the repo you
7
+ * launch inside (the directory ABOVE the git root) and cached in `agents.yaml`
8
+ * so later runs resolve a bare slug from anywhere. It is stored home-relative
9
+ * (`~/…`) when it sits under `$HOME`, so the SAME value resolves on a remote
10
+ * host whose home differs (`/home/<user>` vs `/Users/<user>`): a `--host` run
11
+ * keeps the `~` and lets the remote login shell expand it (see `remoteCdPrefix`
12
+ * in `hosts/dispatch.ts`), while a local run expands `~` against the local home.
13
+ */
14
+ import * as os from 'os';
15
+ import * as path from 'path';
16
+ import * as fs from 'fs';
17
+ import { readMeta, updateMeta } from './state.js';
18
+ import { getMainRepoRoot } from './git.js';
19
+ import { toPosix } from './platform/index.js';
20
+ const HOME = process.env.HOME ?? os.homedir();
21
+ /** Rewrite an absolute path under the local home to a `~/`-relative string; pass others through. */
22
+ export function toHomeRelative(abs) {
23
+ const rel = path.relative(HOME, abs);
24
+ if (rel === '')
25
+ return '~';
26
+ if (!rel.startsWith('..') && !path.isAbsolute(rel))
27
+ return `~/${toPosix(rel)}`;
28
+ return abs;
29
+ }
30
+ /** Expand a leading `~`/`$HOME` against the LOCAL home. Other paths pass through unchanged. */
31
+ export function expandLocalHome(p) {
32
+ if (p === '~' || p === '$HOME')
33
+ return HOME;
34
+ if (p.startsWith('~/'))
35
+ return path.join(HOME, p.slice(2));
36
+ if (p.startsWith('$HOME/'))
37
+ return path.join(HOME, p.slice(6));
38
+ return p;
39
+ }
40
+ /**
41
+ * Make a `--cwd`/`--project` value portable to a remote host: an absolute path
42
+ * under the LOCAL home (which the local shell already expanded from `~`) becomes
43
+ * `~/…` so the *remote* shell re-roots it at its own home. Paths already anchored
44
+ * at `~`/`$HOME` pass through; other absolute or relative paths are left as-is
45
+ * (used verbatim on the host). Explicit `--remote-cwd` is NOT run through this —
46
+ * it is a literal remote path by contract.
47
+ */
48
+ export function toRemotePortable(p) {
49
+ if (p.startsWith('~') || p.startsWith('$HOME'))
50
+ return p;
51
+ if (path.isAbsolute(p))
52
+ return toHomeRelative(p);
53
+ return p;
54
+ }
55
+ /** The configured projects root (home-relative or absolute), or undefined when unset. */
56
+ export function getProjectRoot() {
57
+ return readMeta().projectRoot;
58
+ }
59
+ /** Set (override) the cached projects root. Stored home-relative when under `$HOME`. */
60
+ export function setProjectRoot(rootPath) {
61
+ const stored = toHomeRelative(path.resolve(expandLocalHome(rootPath)));
62
+ updateMeta({ projectRoot: stored });
63
+ return stored;
64
+ }
65
+ /**
66
+ * Infer the projects root from `cwd`: the directory ABOVE the git repo root
67
+ * (cwd inside `~/src/github.com/user/repo` → `~/src/github.com/user`). Returns a
68
+ * home-relative string when under `$HOME`; undefined when `cwd` is not in a repo.
69
+ */
70
+ export async function inferProjectRoot(cwd) {
71
+ try {
72
+ const mainRoot = await getMainRepoRoot(cwd);
73
+ return toHomeRelative(path.dirname(mainRoot));
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ }
79
+ /**
80
+ * Resolve the projects root, auto-inferring and caching on first use. Throws an
81
+ * actionable error when it is neither configured nor inferrable from `cwd`.
82
+ */
83
+ export async function ensureProjectRoot(cwd) {
84
+ const existing = getProjectRoot();
85
+ if (existing)
86
+ return existing;
87
+ const inferred = await inferProjectRoot(cwd);
88
+ if (!inferred) {
89
+ throw new Error('Could not determine your projects root. Run once from inside a project ' +
90
+ '(a git repo under your projects dir) so it can be inferred, or set it:\n' +
91
+ ' agents defaults project-root ~/src/github.com/<you>');
92
+ }
93
+ updateMeta({ projectRoot: inferred });
94
+ process.stderr.write(`[project] cached projects root: ${inferred}\n`);
95
+ return inferred;
96
+ }
97
+ /** Parse a `--project` value of the form `<slug>[@<worktree>]`. */
98
+ export function parseProjectRef(ref) {
99
+ const at = ref.indexOf('@');
100
+ if (at === -1)
101
+ return { slug: ref };
102
+ return { slug: ref.slice(0, at), worktree: ref.slice(at + 1) || undefined };
103
+ }
104
+ /**
105
+ * Join a root + `--project` ref into a working directory. Pure (no I/O) so the
106
+ * slug/worktree layout is unit-testable. `forRemote` keeps the path
107
+ * home-relative (`~/…`) for the remote shell to expand; otherwise it is expanded
108
+ * against the local home into an absolute path.
109
+ */
110
+ export function buildProjectPath(root, ref, forRemote) {
111
+ const { slug, worktree } = parseProjectRef(ref);
112
+ if (!slug)
113
+ throw new Error(`Invalid --project value: "${ref}"`);
114
+ let rel = `${root}/${slug}`;
115
+ if (worktree)
116
+ rel += `/.agents/worktrees/${worktree}`;
117
+ return forRemote ? rel : path.resolve(expandLocalHome(rel));
118
+ }
119
+ /**
120
+ * Resolve a `--project` ref to a working directory, inferring/caching the root.
121
+ *
122
+ * `forRemote: true` returns a home-relative path (`~/…`) so the REMOTE login
123
+ * shell expands `~`/`$HOME` to its own home. `forRemote: false` returns an
124
+ * absolute local path and verifies it exists (so a mistyped slug fails loudly).
125
+ */
126
+ export async function resolveProjectRef(ref, opts) {
127
+ const cwd = opts.cwd ?? process.cwd();
128
+ const root = await ensureProjectRoot(cwd);
129
+ const resolved = buildProjectPath(root, ref, opts.forRemote);
130
+ if (!opts.forRemote && !fs.existsSync(resolved)) {
131
+ throw new Error(`Project path not found: ${resolved}`);
132
+ }
133
+ return resolved;
134
+ }
@@ -111,7 +111,7 @@ export function getMcpConfigPath(agent, versionHome) {
111
111
  case 'codex':
112
112
  return path.join(versionHome, '.codex', 'config.toml');
113
113
  case 'opencode':
114
- return path.join(versionHome, '.opencode', 'opencode.jsonc');
114
+ return path.join(versionHome, '.config', 'opencode', 'opencode.jsonc');
115
115
  case 'cursor':
116
116
  return path.join(versionHome, '.cursor', 'mcp.json');
117
117
  case 'gemini':
@@ -66,9 +66,13 @@ function getAgentConfigPath(agent, versionHome) {
66
66
  case 'codex':
67
67
  return path.join(versionHome, '.codex', 'config.toml');
68
68
  case 'opencode':
69
- return path.join(versionHome, '.opencode', 'opencode.jsonc');
69
+ return path.join(versionHome, '.config', 'opencode', 'opencode.jsonc');
70
70
  case 'kimi':
71
71
  return path.join(versionHome, '.kimi-code', 'config.toml');
72
+ case 'droid':
73
+ return path.join(versionHome, '.factory', 'settings.json');
74
+ case 'kiro':
75
+ return path.join(versionHome, '.kiro', 'settings', 'permissions.yaml');
72
76
  default:
73
77
  return null;
74
78
  }
@@ -5,7 +5,7 @@
5
5
  * Format is the same for all agents. Resolution order: project > user > system.
6
6
  */
7
7
  import * as fs from 'fs';
8
- import { agentConfigDirName } from '../agents.js';
8
+ import { AGENTS, agentConfigDirName } from '../agents.js';
9
9
  import * as path from 'path';
10
10
  import * as yaml from 'yaml';
11
11
  import { getSystemSkillsDir, getUserSkillsDir, getProjectAgentsDir, getEnabledExtraRepos, } from '../state.js';
@@ -201,6 +201,11 @@ export function createSkillsHandler(provider = defaultProvider) {
201
201
  return null;
202
202
  },
203
203
  sync(agent, versionHome, cwd) {
204
+ // Agents that read directly from central ~/.agents/skills/ (e.g. Gemini,
205
+ // Goose via the Summon extension) should not get a per-version copy.
206
+ if (AGENTS[agent]?.nativeAgentsSkillsDir) {
207
+ return;
208
+ }
204
209
  const targetDir = path.join(versionHome, agentConfigDirName(agent), 'skills');
205
210
  // Ensure target directory exists
206
211
  if (!fs.existsSync(targetDir)) {
@@ -5,7 +5,7 @@
5
5
  * - Union: All resources from all layers are combined
6
6
  * - Override on name conflict: Higher layer wins (project > user > system)
7
7
  */
8
- export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'antigravity' | 'grok' | 'kimi' | 'hermes' | 'forge';
8
+ export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'kiro' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes' | 'forge';
9
9
  export type Layer = 'system' | 'user' | 'project';
10
10
  export type ResourceKind = 'command' | 'hook' | 'skill' | 'rule' | 'mcp' | 'permission' | 'subagent' | 'workflow' | 'memory';
11
11
  /** A resolved resource with its origin layer. */
@@ -74,23 +74,23 @@ export interface AgentStatusEntry {
74
74
  expiresAt: number;
75
75
  keyCount: number;
76
76
  }
77
- /** True if the launchd plist for the persistent broker is installed. */
77
+ /** True if a legacy standalone-broker launchd plist is still installed. */
78
78
  export declare function secretsAgentServiceInstalled(): boolean;
79
79
  /**
80
- * Install + start the persistent broker as a launchd user service (idempotent).
81
- * Writes the plist, bootstraps it into the GUI domain, and waits for the socket.
82
- * `ProcessType: Interactive` asks launchd to schedule it at foreground priority
83
- * so it can boot even when the machine is loaded. Returns true once reachable.
80
+ * Retire the legacy standalone secrets-agent launchd service: bootout the job
81
+ * (falling back to the legacy `unload`) and remove its plist so the always-on
82
+ * daemon owns the broker socket. Idempotent and best-effort a no-op when no
83
+ * legacy plist is present. Does NOT wipe held bundles: the booted-out process's
84
+ * memory is gone anyway, and the daemon-hosted broker starts fresh.
84
85
  */
85
- export declare function installSecretsAgentService(timeoutMs?: number): Promise<boolean>;
86
+ export declare function retireLegacySecretsAgentService(): void;
86
87
  /**
87
- * Kickstart the already-installed persistent broker so launchd relaunches it
88
- * onto the current on-disk code. Used by postinstall heal-on-upgrade. No-op if
89
- * the service isn't installed; never rewrites the plist or waits, so it's safe
90
- * and fast to call from an installer.
88
+ * Stop the persistent broker for `agents secrets stop`: wipe whatever the broker
89
+ * holds (forces Touch ID again on the next read), then retire any legacy
90
+ * standalone service. The daemon-hosted broker itself is left running it is
91
+ * the always-on backbone, and stopping it would take down unrelated background
92
+ * work (routines, browser IPC, session-sync).
91
93
  */
92
- export declare function kickstartSecretsAgentService(): void;
93
- /** Stop + remove the persistent broker service, and wipe whatever it held. */
94
94
  export declare function uninstallSecretsAgentService(): Promise<void>;
95
95
  export type Request = {
96
96
  cmd: 'ping';
@@ -171,7 +171,9 @@ export declare function shouldWipeOnWatchEvent(chunk: string): boolean;
171
171
  */
172
172
  export declare function runSecretsAgent(opts?: {
173
173
  service?: boolean;
174
- }): Promise<void>;
174
+ }): Promise<{
175
+ close(): void;
176
+ } | null>;
175
177
  /**
176
178
  * Host the secrets broker inside the always-on daemon (#416).
177
179
  *
@@ -184,11 +186,11 @@ export declare function runSecretsAgent(opts?: {
184
186
  * (those would kill the daemon — the daemon is the always-on backbone and
185
187
  * manages its own version/lifecycle). The sweep only TTL-evicts.
186
188
  *
187
- * The caller (`runDaemon`) must only invoke this when NO broker is already
188
- * reachable (ping first) this function clears a stale socket before binding,
189
- * so calling it while a live standalone broker holds the socket would orphan
190
- * that broker's clients. Returns a handle the daemon closes on shutdown, or
191
- * null off-darwin (nothing to broker without biometry).
189
+ * The caller (`runDaemon`) normally invokes this only when no broker answers
190
+ * its initial ping. Binding still arbitrates ownership through the same shared
191
+ * path as the standalone service: a live owner wins, while only an unreachable
192
+ * stale socket is reclaimed. Returns a handle the daemon closes on shutdown,
193
+ * or null off-darwin (nothing to broker without biometry).
192
194
  */
193
195
  export declare function startHostedBroker(): Promise<{
194
196
  close(): void;
@@ -277,10 +279,11 @@ export declare function agentPing(): Promise<{
277
279
  * Ensure a broker is running and reachable. Returns true once the socket answers
278
280
  * a ping. macOS only.
279
281
  *
280
- * Prefers the persistent launchd service: if it isn't installed we install it
281
- * (which makes the broker survive for the whole login session, so subsequent
282
- * reads never cold-start); if it's installed but unreachable we kickstart it.
283
- * Only when the service path can't be used do we fall back to a one-off detached
284
- * broker that's the model that gets starved under heavy load, so it's last.
282
+ * Prefers the always-on daemon, which hosts the broker socket (#416): retire any
283
+ * legacy standalone launchd service so the daemon owns the socket, then bring the
284
+ * daemon up (Path 0) one supervised backbone that survives the whole login
285
+ * session, so subsequent reads never cold-start. Only when the daemon can't be
286
+ * used do we fall back to a one-off detached broker (Path 1) the model that
287
+ * gets starved under heavy load, so it's last.
285
288
  */
286
289
  export declare function ensureAgentRunning(timeoutMs?: number): Promise<boolean>;