@phnx-labs/agents-cli 1.22.21 → 1.22.23

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 (105) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/README.md +8 -2
  3. package/dist/commands/doctor.js +15 -7
  4. package/dist/commands/exec.js +20 -6
  5. package/dist/commands/focus.d.ts +76 -4
  6. package/dist/commands/focus.js +219 -40
  7. package/dist/commands/fork.d.ts +20 -2
  8. package/dist/commands/fork.js +91 -64
  9. package/dist/commands/go.d.ts +25 -0
  10. package/dist/commands/go.js +63 -2
  11. package/dist/commands/harness-wizard.d.ts +206 -0
  12. package/dist/commands/harness-wizard.js +403 -0
  13. package/dist/commands/harness.d.ts +12 -0
  14. package/dist/commands/harness.js +97 -101
  15. package/dist/commands/insights.d.ts +32 -0
  16. package/dist/commands/insights.js +478 -0
  17. package/dist/commands/resume.js +10 -3
  18. package/dist/commands/secrets.js +25 -30
  19. package/dist/commands/sessions-picker.d.ts +2 -0
  20. package/dist/commands/sessions-picker.js +1 -0
  21. package/dist/commands/sessions-resume.d.ts +21 -3
  22. package/dist/commands/sessions-resume.js +50 -11
  23. package/dist/commands/sessions.d.ts +81 -5
  24. package/dist/commands/sessions.js +331 -66
  25. package/dist/commands/watchdog.js +13 -2
  26. package/dist/index.js +3 -1
  27. package/dist/lib/agents.d.ts +1 -1
  28. package/dist/lib/agents.js +132 -0
  29. package/dist/lib/codex-policy.d.ts +17 -0
  30. package/dist/lib/codex-policy.js +48 -0
  31. package/dist/lib/crabbox/lease.d.ts +25 -0
  32. package/dist/lib/crabbox/lease.js +62 -0
  33. package/dist/lib/daemon.js +70 -0
  34. package/dist/lib/exec.d.ts +4 -0
  35. package/dist/lib/exec.js +88 -54
  36. package/dist/lib/feed-broadcast.d.ts +1 -20
  37. package/dist/lib/feed-broadcast.js +31 -1
  38. package/dist/lib/hooks.js +12 -2
  39. package/dist/lib/hosts/passthrough.js +1 -0
  40. package/dist/lib/mcp.js +44 -0
  41. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
  44. package/dist/lib/models.d.ts +0 -5
  45. package/dist/lib/models.js +48 -0
  46. package/dist/lib/picker.d.ts +45 -0
  47. package/dist/lib/picker.js +75 -6
  48. package/dist/lib/plugin-marketplace.js +9 -0
  49. package/dist/lib/pricing/prices.json +119 -92
  50. package/dist/lib/pricing/table.js +13 -0
  51. package/dist/lib/remote-agents-json.d.ts +29 -1
  52. package/dist/lib/remote-agents-json.js +47 -10
  53. package/dist/lib/resources/mcp.js +2 -0
  54. package/dist/lib/resources/permissions.js +3 -0
  55. package/dist/lib/resources/types.d.ts +2 -1
  56. package/dist/lib/runner.js +28 -19
  57. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  58. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
  59. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  60. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
  61. package/dist/lib/secrets/index.d.ts +15 -1
  62. package/dist/lib/secrets/index.js +118 -49
  63. package/dist/lib/secrets/reaper.d.ts +87 -0
  64. package/dist/lib/secrets/reaper.js +184 -0
  65. package/dist/lib/secrets/remote.d.ts +29 -0
  66. package/dist/lib/secrets/remote.js +37 -1
  67. package/dist/lib/session/active.d.ts +36 -1
  68. package/dist/lib/session/active.js +60 -19
  69. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  70. package/dist/lib/session/actor-sidecar.js +67 -4
  71. package/dist/lib/session/db.d.ts +35 -1
  72. package/dist/lib/session/db.js +132 -1
  73. package/dist/lib/session/digest.d.ts +3 -0
  74. package/dist/lib/session/digest.js +3 -3
  75. package/dist/lib/session/discover.js +168 -0
  76. package/dist/lib/session/insights.d.ts +126 -0
  77. package/dist/lib/session/insights.js +330 -0
  78. package/dist/lib/session/parse.d.ts +29 -2
  79. package/dist/lib/session/parse.js +111 -5
  80. package/dist/lib/session/remote-list.d.ts +10 -1
  81. package/dist/lib/session/remote-list.js +2 -8
  82. package/dist/lib/session/remote.d.ts +57 -3
  83. package/dist/lib/session/remote.js +90 -26
  84. package/dist/lib/session/resume-command.d.ts +6 -0
  85. package/dist/lib/session/resume-command.js +8 -0
  86. package/dist/lib/session/session-cache.d.ts +173 -0
  87. package/dist/lib/session/session-cache.js +399 -0
  88. package/dist/lib/session/types.d.ts +2 -2
  89. package/dist/lib/session/types.js +1 -1
  90. package/dist/lib/session/width.d.ts +1 -1
  91. package/dist/lib/session/width.js +12 -2
  92. package/dist/lib/shims.d.ts +2 -2
  93. package/dist/lib/shims.js +40 -5
  94. package/dist/lib/staleness/writers/hooks.js +1 -1
  95. package/dist/lib/startup/command-registry.d.ts +1 -0
  96. package/dist/lib/startup/command-registry.js +2 -0
  97. package/dist/lib/types.d.ts +1 -1
  98. package/dist/lib/usage.d.ts +13 -0
  99. package/dist/lib/usage.js +254 -3
  100. package/dist/lib/versions.js +13 -2
  101. package/package.json +1 -1
  102. package/dist/bin/agents +0 -0
  103. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  104. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  105. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
package/dist/index.js CHANGED
@@ -94,7 +94,7 @@ if (IS_DEV_BUILD) {
94
94
  // module on each invocation (which loaded the whole ~50-module tree before the
95
95
  // first byte of output), the registry maps a command name to a thunk that
96
96
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
97
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
97
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadInsights, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
98
98
  import { applyGlobalHelpConventions } from './lib/help.js';
99
99
  import { renderWhatsNew } from './lib/whats-new.js';
100
100
  import { getCliLaunch } from './lib/cli-entry.js';
@@ -327,6 +327,7 @@ Credentials and profiles:
327
327
  Diagnostics:
328
328
  doctor [agent[@version]] Diagnose CLI availability, sync status, and resource divergence; --check for the CI drift gate
329
329
  usage [agent] Show rate-limit and quota usage per agent
330
+ insights How you work — tools, friction, rhythm, split by Claude account
330
331
  perf Latency rollups (hooks, commands, runs) from the disposable perf warehouse
331
332
 
332
333
  Config sync:
@@ -963,6 +964,7 @@ async function registerAllEagerCommands() {
963
964
  await reg(loadFactory);
964
965
  await reg(loadUsage);
965
966
  await reg(loadCost);
967
+ await reg(loadInsights);
966
968
  await reg(loadPerf);
967
969
  await reg(loadTrends);
968
970
  await reg(loadOutput);
@@ -165,7 +165,7 @@ export declare function formatClaudeOrgLabel(orgType: string | null | undefined)
165
165
  */
166
166
  export declare function accountOrgBadge(info?: Pick<AccountInfo, 'organizationType' | 'organizationName'> | null): string | null;
167
167
  /** Agents whose local credential formats expose enough state for account selection. */
168
- export declare const ACCOUNT_INSPECTION_AGENT_IDS: readonly ["claude", "codex", "gemini", "cursor", "grok", "antigravity", "kimi", "droid", "opencode"];
168
+ export declare const ACCOUNT_INSPECTION_AGENT_IDS: readonly ["claude", "codex", "gemini", "cursor", "grok", "antigravity", "kimi", "droid", "opencode", "muse"];
169
169
  /** Whether agents-cli can determine this agent's per-version sign-in state. */
170
170
  export declare function supportsAccountInspection(agentId: AgentId): boolean;
171
171
  /**
@@ -751,6 +751,66 @@ export const AGENTS = {
751
751
  interactiveRepl: true,
752
752
  },
753
753
  },
754
+ // Meta Muse Code (`muse`) — first-class harness for Meta's coding agent (terminal/CI), built on
755
+ // Muse Spark. Native binary via curl installer (self-updating; no npm package).
756
+ // Config: `~/.config/muse/settings.json` (requires `"schema_version": 1`).
757
+ // Sessions: `~/.local/share/muse/sessions/YYYY/MM/DD/<uuid>/session.jsonl`.
758
+ // Skills: `~/.config/muse/skills` + `~/.agents/skills` + project `.agents/skills`.
759
+ // MCP: `mcp_servers` in settings.json (stdio / streamable_http transports).
760
+ // Headless: `muse exec "<prompt>"` with --model, --reasoning-effort, --json,
761
+ // --yolo / --disable-approval / --disable-sandbox. Auth: META_API_KEY or
762
+ // browser OAuth stored at `~/.config/muse/auth.json`. Default model:
763
+ // muse-spark-1.2. Docs: https://dev.meta.ai/docs/muse-code
764
+ muse: {
765
+ id: 'muse',
766
+ name: 'Muse',
767
+ color: 'blueBright',
768
+ cliCommand: 'muse',
769
+ npmPackage: '',
770
+ installScript: 'curl -fsSL https://dev.meta.ai/install.sh | sh',
771
+ configDir: path.join(HOME, '.config', 'muse'),
772
+ authFiles: ['auth.json'],
773
+ commandsDir: '',
774
+ commandsSubdir: '',
775
+ skillsDir: path.join(HOME, '.config', 'muse', 'skills'),
776
+ hooksDir: 'hooks',
777
+ instructionsFile: 'AGENTS.md',
778
+ format: 'markdown',
779
+ variableSyntax: '$ARGUMENTS',
780
+ // Muse hooks use the Claude-shaped settings.json hooks block (matcher +
781
+ // command groups) under ~/.config/muse/settings.json, plus project
782
+ // `.muse/hooks.json`. registerHooksForClaude is reused with Muse's config
783
+ // dir and schema_version: 1.
784
+ supportsHooks: true,
785
+ // Claude-compatible marketplace layout + native `.muse-plugin/` manifest.
786
+ pluginManifestDir: '.muse-plugin',
787
+ capabilities: {
788
+ // Hooks: Claude-compatible event→matcher→command groups in settings.json.
789
+ hooks: true,
790
+ mcp: true,
791
+ mcpHttp: true,
792
+ mcpHeaders: true,
793
+ // Muse's safety model is approval-mode + OS sandbox (CLI flags), not a
794
+ // Claude-style tool-name allow/deny list. No settings.permissions writer.
795
+ allowlist: false,
796
+ skills: true,
797
+ // No slash-command file dir; skills double as reusable workflows.
798
+ commands: false,
799
+ // Plugins: Claude marketplace + .muse-plugin (syncPluginToVersion).
800
+ plugins: true,
801
+ // Runtime multi-agent / subagents exist, but there is no installable
802
+ // subagent-definition directory for agents-cli to sync into (capability
803
+ // table must stay truthful — see subagents-registry completeness).
804
+ subagents: false,
805
+ rules: { file: 'AGENTS.md' },
806
+ workflows: false,
807
+ // Portable memory at <repo>/.agents/memory/ + personal scopes.
808
+ memory: true,
809
+ modes: ['plan', 'edit', 'auto', 'skip'],
810
+ rulesImports: false,
811
+ interactiveRepl: true,
812
+ },
813
+ },
754
814
  };
755
815
  /** All current and legacy agent IDs derived from the AGENTS registry. */
756
816
  export const ALL_AGENT_IDS = Object.keys(AGENTS);
@@ -965,6 +1025,7 @@ export const UNMANAGED_DETECTION_CANDIDATES = [
965
1025
  'grok',
966
1026
  'copilot',
967
1027
  'droid',
1028
+ 'muse',
968
1029
  ];
969
1030
  /**
970
1031
  * Detect existing agent installations that are NOT yet managed by agents-cli.
@@ -1071,6 +1132,7 @@ export const ACCOUNT_INSPECTION_AGENT_IDS = [
1071
1132
  'kimi',
1072
1133
  'droid',
1073
1134
  'opencode',
1135
+ 'muse',
1074
1136
  ];
1075
1137
  const ACCOUNT_INSPECTION_AGENTS = new Set(ACCOUNT_INSPECTION_AGENT_IDS);
1076
1138
  /** Whether agents-cli can determine this agent's per-version sign-in state. */
@@ -1150,6 +1212,9 @@ const CREDENTIAL_FILE_SEGMENTS = {
1150
1212
  droid: [['.factory', 'auth.v2.file']],
1151
1213
  antigravity: [['.gemini', 'antigravity-cli', 'antigravity-oauth-token']],
1152
1214
  opencode: [['.local', 'share', 'opencode', 'auth.json']],
1215
+ // Muse Code stores OAuth / API credentials at ~/.config/muse/auth.json
1216
+ // (or META_API_KEY in the environment, which is not a file).
1217
+ muse: [['.config', 'muse', 'auth.json']],
1153
1218
  };
1154
1219
  /** Whether an agent's credential file exists under a given home. */
1155
1220
  function credentialFileExistsUnder(agentId, home) {
@@ -1440,6 +1505,31 @@ function isValidOpenCodeCredential(value) {
1440
1505
  default: return false;
1441
1506
  }
1442
1507
  }
1508
+ /**
1509
+ * Whether a Muse Code `~/.config/muse/auth.json` document holds any usable
1510
+ * access token. The launcher stores per-provider slots (e.g. `meta`) each with
1511
+ * an `access_token` string; a top-level `access_token` is also accepted.
1512
+ * Never returns the secret itself — presence only.
1513
+ */
1514
+ function museAuthHasToken(value) {
1515
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1516
+ return false;
1517
+ const root = value;
1518
+ if (typeof root.access_token === 'string' && root.access_token.length > 0)
1519
+ return true;
1520
+ if (typeof root.api_key === 'string' && root.api_key.length > 0)
1521
+ return true;
1522
+ for (const slot of Object.values(root)) {
1523
+ if (!slot || typeof slot !== 'object' || Array.isArray(slot))
1524
+ continue;
1525
+ const entry = slot;
1526
+ if (typeof entry.access_token === 'string' && entry.access_token.length > 0)
1527
+ return true;
1528
+ if (typeof entry.api_key === 'string' && entry.api_key.length > 0)
1529
+ return true;
1530
+ }
1531
+ return false;
1532
+ }
1443
1533
  /**
1444
1534
  * Whether a Claude version home's credential file is present but carries no
1445
1535
  * token — the "must have a real credential" floor (see
@@ -1832,6 +1922,30 @@ export async function getAccountInfo(agentId, home) {
1832
1922
  const accountKey = buildIdentityKey(agentId, [['providers', accountId]]);
1833
1923
  return { ...empty, signedIn: true, accountId, accountKey, lastActive };
1834
1924
  }
1925
+ case 'muse': {
1926
+ // Muse Code authenticates with META_API_KEY (env, highest priority) or
1927
+ // a stored OAuth/API credential at ~/.config/muse/auth.json. The auth
1928
+ // file is launcher-managed JSON with per-provider slots holding
1929
+ // access_token; there is no email claim, so we report signed-in + a
1930
+ // stable identity key (env-key vs provider slots) like kimi/opencode.
1931
+ if (process.env.META_API_KEY?.trim() || process.env.MODEL_API_KEY?.trim()) {
1932
+ const accountKey = buildIdentityKey(agentId, [['auth', 'env']]);
1933
+ return { ...empty, signedIn: true, accountId: 'env', accountKey, lastActive };
1934
+ }
1935
+ const authPath = resolveAccountCredentialPath(base, '.config', 'muse', 'auth.json');
1936
+ if (!authPath)
1937
+ return { ...empty, lastActive };
1938
+ const data = JSON.parse(await fs.promises.readFile(authPath, 'utf-8'));
1939
+ if (!data || typeof data !== 'object')
1940
+ return { ...empty, lastActive };
1941
+ // auth.json shape: { meta?: { access_token }, … } or similar slots.
1942
+ // Any object with a nested access_token (or a top-level one) counts.
1943
+ const hasToken = museAuthHasToken(data);
1944
+ if (!hasToken)
1945
+ return { ...empty, lastActive };
1946
+ const accountKey = buildIdentityKey(agentId, [['auth', 'file']]);
1947
+ return { ...empty, signedIn: true, accountId: 'file', accountKey, lastActive };
1948
+ }
1835
1949
  default:
1836
1950
  return { ...empty, lastActive };
1837
1951
  }
@@ -1935,6 +2049,10 @@ function getSessionDir(agentId, base) {
1935
2049
  return path.join(base, '.copilot', 'session-state');
1936
2050
  case 'droid':
1937
2051
  return path.join(base, '.factory', 'sessions');
2052
+ case 'muse':
2053
+ // Muse sessions live under XDG data, not the config dir:
2054
+ // ~/.local/share/muse/sessions/YYYY/MM/DD/<uuid>/session.jsonl
2055
+ return path.join(base, '.local', 'share', 'muse', 'sessions');
1938
2056
  default:
1939
2057
  return null;
1940
2058
  }
@@ -1946,6 +2064,7 @@ function getSessionExtension(agentId) {
1946
2064
  case 'codex':
1947
2065
  case 'copilot':
1948
2066
  case 'droid':
2067
+ case 'muse':
1949
2068
  return '.jsonl';
1950
2069
  case 'gemini':
1951
2070
  return '.json';
@@ -2441,6 +2560,9 @@ export function getUserMcpConfigPath(agentId) {
2441
2560
  case 'pi':
2442
2561
  // omp reads user-scope MCP from ~/.omp/agent/.mcp.json (Claude schema).
2443
2562
  return path.join(agent.configDir, '.mcp.json');
2563
+ case 'muse':
2564
+ // Muse Code: MCP lives in ~/.config/muse/settings.json under mcp_servers.
2565
+ return path.join(agent.configDir, 'settings.json');
2444
2566
  default:
2445
2567
  // Gemini and others use settings.json
2446
2568
  return path.join(agent.configDir, 'settings.json');
@@ -2479,6 +2601,8 @@ export function getMcpConfigPathForHome(agentId, home) {
2479
2601
  return path.join(home, '.hermes', 'config.yaml');
2480
2602
  case 'pi':
2481
2603
  return path.join(home, '.omp', 'agent', '.mcp.json');
2604
+ case 'muse':
2605
+ return path.join(home, '.config', 'muse', 'settings.json');
2482
2606
  default:
2483
2607
  return path.join(home, agentConfigDirName(agentId), 'settings.json');
2484
2608
  }
@@ -2521,6 +2645,10 @@ export function getProjectMcpConfigPath(agentId, cwd = process.cwd()) {
2521
2645
  case 'pi':
2522
2646
  // omp reads project MCP from <root>/.mcp.json (Claude-compatible).
2523
2647
  return path.join(cwd, '.mcp.json');
2648
+ case 'muse':
2649
+ // Muse project MCP rides the same settings schema under .muse/settings.json
2650
+ // when present; otherwise fall back to the user settings path.
2651
+ return path.join(cwd, '.muse', 'settings.json');
2524
2652
  default:
2525
2653
  return path.join(cwd, `.${agentId}`, 'settings.json');
2526
2654
  }
@@ -2664,6 +2792,10 @@ export const AGENT_NAME_ALIASES = {
2664
2792
  droid: 'droid',
2665
2793
  hermes: 'hermes',
2666
2794
  'hermes-agent': 'hermes',
2795
+ muse: 'muse',
2796
+ 'muse-code': 'muse',
2797
+ 'muse-spark': 'muse',
2798
+ 'meta-muse': 'muse',
2667
2799
  };
2668
2800
  /**
2669
2801
  * Resolve a user-provided agent name (alias, shorthand, or canonical) to its AgentId.
@@ -0,0 +1,17 @@
1
+ export type CodexPolicyMode = 'plan' | 'edit' | 'skip';
2
+ export declare const CODEX_PLAN_PROFILE = "agents-plan";
3
+ export declare const CODEX_EDIT_PROFILE = "agents-edit";
4
+ export declare function codexEditWritableRoots(): string[];
5
+ export declare function codexPermissionProfileConfig(mode: Exclude<CodexPolicyMode, 'skip'>, writableRoots?: string[]): string;
6
+ /**
7
+ * Canonical Codex safety policy used by every native launch path.
8
+ *
9
+ * Config overrides are deliberately used instead of the legacy `--sandbox`
10
+ * flags: named permission profiles are the only Codex surface that can keep a
11
+ * plan run filesystem-read-only while independently enabling network access.
12
+ */
13
+ export declare function codexPolicyArgs(mode: CodexPolicyMode, writableRoots?: string[]): string[];
14
+ /** Preserve whether --mode was omitted when a run is re-dispatched remotely. */
15
+ export declare function modeForRemoteDispatch(mode: string, source: string | undefined): string | undefined;
16
+ /** Only the untouched Commander default selects Codex's writable default. */
17
+ export declare function modeWasImplicit(source: string | undefined, hasConfiguredDefault: boolean): boolean;
@@ -0,0 +1,48 @@
1
+ import { getUserAgentsDir } from './state.js';
2
+ import { codexDefaultWritableRoots } from './permissions.js';
3
+ export const CODEX_PLAN_PROFILE = 'agents-plan';
4
+ export const CODEX_EDIT_PROFILE = 'agents-edit';
5
+ function unique(values) {
6
+ return [...new Set(values)];
7
+ }
8
+ export function codexEditWritableRoots() {
9
+ return unique([getUserAgentsDir(), ...codexDefaultWritableRoots()]);
10
+ }
11
+ function inlineWorkspaceRoots(roots) {
12
+ return roots.map((root) => `${JSON.stringify(root)} = true`).join(', ');
13
+ }
14
+ export function codexPermissionProfileConfig(mode, writableRoots = codexEditWritableRoots()) {
15
+ const parent = mode === 'plan' ? ':read-only' : ':workspace';
16
+ const roots = mode === 'edit'
17
+ ? `, workspace_roots = { ${inlineWorkspaceRoots(unique(writableRoots))} }`
18
+ : '';
19
+ return `{ extends = ${JSON.stringify(parent)}${roots}, network = { enabled = true, allow_local_binding = true } }`;
20
+ }
21
+ /**
22
+ * Canonical Codex safety policy used by every native launch path.
23
+ *
24
+ * Config overrides are deliberately used instead of the legacy `--sandbox`
25
+ * flags: named permission profiles are the only Codex surface that can keep a
26
+ * plan run filesystem-read-only while independently enabling network access.
27
+ */
28
+ export function codexPolicyArgs(mode, writableRoots = codexEditWritableRoots()) {
29
+ if (mode === 'skip')
30
+ return ['--dangerously-bypass-approvals-and-sandbox'];
31
+ const profile = mode === 'plan' ? CODEX_PLAN_PROFILE : CODEX_EDIT_PROFILE;
32
+ return [
33
+ '-c',
34
+ 'approval_policy="on-request"',
35
+ '-c',
36
+ `default_permissions=${JSON.stringify(profile)}`,
37
+ '-c',
38
+ `permissions.${profile}=${codexPermissionProfileConfig(mode, writableRoots)}`,
39
+ ];
40
+ }
41
+ /** Preserve whether --mode was omitted when a run is re-dispatched remotely. */
42
+ export function modeForRemoteDispatch(mode, source) {
43
+ return source === 'default' ? undefined : mode;
44
+ }
45
+ /** Only the untouched Commander default selects Codex's writable default. */
46
+ export function modeWasImplicit(source, hasConfiguredDefault) {
47
+ return source === 'default' && !hasConfiguredDefault;
48
+ }
@@ -118,4 +118,29 @@ export interface LeaseRunResult {
118
118
  * the credential files. Best-effort install steps never abort the run.
119
119
  */
120
120
  export declare function buildBootstrapScript(opts: LeaseRunOptions): string;
121
+ /** Untouched-for-this-long ⇒ no active run holds the box, so an expired one is a stray. */
122
+ export declare const STRAY_GRACE_SECS = 600;
123
+ export interface StrayMatchOptions {
124
+ /** Pool the lease belongs to (defaults to DEFAULT_CRABBOX_PROFILE). */
125
+ profile?: string;
126
+ /** Network mode of the lease (default 'public'); a box is partitioned by it. */
127
+ netMode?: 'public' | 'tailscale';
128
+ /** The box this run is using — never a stray. */
129
+ keepSlug: string;
130
+ /** Injectable clock (unix seconds). */
131
+ nowSecs?: number;
132
+ /** Idle grace window (defaults to {@link STRAY_GRACE_SECS}). */
133
+ graceSecs?: number;
134
+ }
135
+ /**
136
+ * Whether `box` is an EXPIRED, idle stray in this run's pool — the boxes that
137
+ * accumulate cost. An expired box can never be reused (`poolReusableBoxes` gates
138
+ * on an unexpired lease), yet `keep:true` leaves it running, so without a sweep a
139
+ * fresh provision leaves the old one billing until `gc`'s 1h-idle window. Only a
140
+ * box that is running, in the SAME profile+netMode pool, has an EXPIRED lease, and
141
+ * has been untouched for the grace window is a stray — a mid-boot or in-use box
142
+ * (recent `lastTouchedAt`, or an unexpired lease) is never one. Pure — testable
143
+ * without a shell.
144
+ */
145
+ export declare function isExpiredPoolStray(box: CrabboxBox, opts: StrayMatchOptions): boolean;
121
146
  export declare function leaseAndRun(opts: LeaseRunOptions): Promise<LeaseRunResult>;
@@ -33,6 +33,7 @@ import * as yaml from 'yaml';
33
33
  import { buildCredentialScript, buildHomeFileWriteScript, CLAUDE_TOKEN_REMOTE } from './runtimes.js';
34
34
  import { LEASE_AGENT_MARKER, leasePhaseSentinel } from './progress.js';
35
35
  import { copySetupToBox } from './setup-copy.js';
36
+ import { DEFAULT_CRABBOX_PROFILE } from './config.js';
36
37
  /** POSIX single-quote for safe embedding in the generated bootstrap script. */
37
38
  function q(s) {
38
39
  return "'" + s.replace(/'/g, "'\\''") + "'";
@@ -173,6 +174,60 @@ function pickReadyPoolBox(opts) {
173
174
  }
174
175
  return null;
175
176
  }
177
+ /** Untouched-for-this-long ⇒ no active run holds the box, so an expired one is a stray. */
178
+ export const STRAY_GRACE_SECS = 600;
179
+ /**
180
+ * Whether `box` is an EXPIRED, idle stray in this run's pool — the boxes that
181
+ * accumulate cost. An expired box can never be reused (`poolReusableBoxes` gates
182
+ * on an unexpired lease), yet `keep:true` leaves it running, so without a sweep a
183
+ * fresh provision leaves the old one billing until `gc`'s 1h-idle window. Only a
184
+ * box that is running, in the SAME profile+netMode pool, has an EXPIRED lease, and
185
+ * has been untouched for the grace window is a stray — a mid-boot or in-use box
186
+ * (recent `lastTouchedAt`, or an unexpired lease) is never one. Pure — testable
187
+ * without a shell.
188
+ */
189
+ export function isExpiredPoolStray(box, opts) {
190
+ const profile = opts.profile ?? DEFAULT_CRABBOX_PROFILE;
191
+ const netMode = opts.netMode ?? 'public';
192
+ const nowSecs = opts.nowSecs ?? Math.floor(Date.now() / 1000);
193
+ const graceSecs = opts.graceSecs ?? STRAY_GRACE_SECS;
194
+ if (box.slug === opts.keepSlug)
195
+ return false;
196
+ if (box.status !== 'running')
197
+ return false;
198
+ if ((box.profile ?? DEFAULT_CRABBOX_PROFILE) !== profile)
199
+ return false;
200
+ const boxNet = box.tailscaleIPv4 || box.tailscaleFQDN ? 'tailscale' : 'public';
201
+ if (boxNet !== netMode)
202
+ return false;
203
+ if (box.expiresAt === null || box.expiresAt > nowSecs)
204
+ return false; // unexpired ⇒ reusable
205
+ if (box.lastTouchedAt !== null && nowSecs - box.lastTouchedAt < graceSecs)
206
+ return false; // maybe active
207
+ return true;
208
+ }
209
+ /**
210
+ * Opportunistically stop expired, idle strays in this run's pool — rides the
211
+ * lease (no scheduler), best-effort (never throws, never blocks the run). Returns
212
+ * how many were stopped.
213
+ */
214
+ function reapExpiredPoolStrays(opts, keepSlug) {
215
+ let boxes;
216
+ try {
217
+ boxes = crabboxList({ secretsBundle: opts.secretsBundle });
218
+ }
219
+ catch {
220
+ return 0;
221
+ }
222
+ let reaped = 0;
223
+ for (const b of boxes) {
224
+ if (!isExpiredPoolStray(b, { profile: opts.profile, netMode: opts.netMode, keepSlug }))
225
+ continue;
226
+ if (crabboxStop(b.slug, { secretsBundle: opts.secretsBundle }))
227
+ reaped++;
228
+ }
229
+ return reaped;
230
+ }
176
231
  export async function leaseAndRun(opts) {
177
232
  const startedAt = Date.now();
178
233
  let box;
@@ -212,6 +267,13 @@ export async function leaseAndRun(opts) {
212
267
  }
213
268
  }
214
269
  opts.onPhase?.({ kind: 'ready', box, elapsedMs: Date.now() - startedAt });
270
+ // Reap expired, idle strays in this pool now that we hold our box. An expired
271
+ // box can never be reused, so these are pure cost the 1h-idle `gc` window
272
+ // leaves running. Skip when the caller named an explicit `--box` (they're
273
+ // driving box lifecycle by hand). Best-effort — never blocks or aborts the run.
274
+ if (!opts.reuseBox) {
275
+ reapExpiredPoolStrays(opts, box.slug);
276
+ }
215
277
  // Setup-copy (F1, RUSH-1920): push the git-tracked ~/.agents config onto the
216
278
  // box from the host, over crabbox's own per-lease ssh (a raw ssh fails
217
279
  // publickey). rsync only here — the box has no agents-cli yet, so the matching
@@ -888,6 +888,45 @@ export async function runDaemon() {
888
888
  };
889
889
  const fleetCacheInterval = setInterval(() => { void runFleetCacheWarm(); }, 3 * 60_000);
890
890
  const fleetCacheKickoff = setTimeout(() => { void runFleetCacheWarm(); }, 60_000);
891
+ // Session-status cache warm (RUSH-2062): publish THIS host's local active
892
+ // sessions so menubar / Factory / watchdog / CLI share one warm snapshot
893
+ // instead of each re-running a full ~9s / ~170MB gather. Publish-own only
894
+ // (no cross-host SSH — same N² lesson as RUSH-2061). Short interval keeps
895
+ // live status fresh; forceRefresh still re-gathers. Additive sibling of
896
+ // fleetCacheInterval — does not touch the reaper tick.
897
+ let warmingSessionCache = false;
898
+ // Import once so the interval/kickoff constants stay the single source of truth
899
+ // (SESSION_CACHE_WARM_* in session-cache.ts). Dynamic import of the warm fn
900
+ // itself stays inside the tick so a cold daemon boot does not pay the load.
901
+ const sessionCacheWarmTiming = import('./session/session-cache.js').then((m) => ({
902
+ intervalMs: m.SESSION_CACHE_WARM_INTERVAL_MS,
903
+ kickoffMs: m.SESSION_CACHE_WARM_KICKOFF_MS,
904
+ publish: m.publishLocalActiveSessions,
905
+ }));
906
+ const runSessionCacheWarm = async () => {
907
+ if (warmingSessionCache)
908
+ return;
909
+ warmingSessionCache = true;
910
+ try {
911
+ const { publish } = await sessionCacheWarmTiming;
912
+ const r = await publish();
913
+ log('INFO', `session cache warm: ${r.sessions.length} local session(s)`);
914
+ }
915
+ catch (err) {
916
+ log('ERROR', `session cache warm failed: ${err.message}`);
917
+ }
918
+ finally {
919
+ warmingSessionCache = false;
920
+ }
921
+ };
922
+ // Intervals resolved from the module constants (fallback matches the defaults
923
+ // if the import is still pending — the first tick uses the live import).
924
+ let sessionCacheInterval;
925
+ let sessionCacheKickoff;
926
+ void sessionCacheWarmTiming.then(({ intervalMs, kickoffMs }) => {
927
+ sessionCacheInterval = setInterval(() => { void runSessionCacheWarm(); }, intervalMs);
928
+ sessionCacheKickoff = setTimeout(() => { void runSessionCacheWarm(); }, kickoffMs);
929
+ });
891
930
  // Usage refresh: keep the usage cache the `agents run` router reads
892
931
  // (RUSH-2061, readOnly hot path) fresh, WITHOUT the hot path ever fetching.
893
932
  // This host is the sole writer for its own local accounts. The tick wakes
@@ -955,6 +994,32 @@ export async function runDaemon() {
955
994
  }
956
995
  };
957
996
  const brokerSelfHealInterval = setInterval(() => { void runBrokerSelfHeal(); }, 60_000);
997
+ // RUSH-2232: reap orphaned keychain helpers and `agents` processes stuck on a
998
+ // keychain call. Runs as a 5-min interval in the daemon (the single executor)
999
+ // so no UI surface can race it. The reaper shells `ps` once, plans kills in a
1000
+ // pure function, and executes via killTree.
1001
+ let reapingKeychain = false;
1002
+ const runKeychainReap = async () => {
1003
+ if (reapingKeychain)
1004
+ return;
1005
+ reapingKeychain = true;
1006
+ try {
1007
+ const { reapOrphanedKeychainProcesses } = await import('./secrets/reaper.js');
1008
+ const result = reapOrphanedKeychainProcesses();
1009
+ if (result.reaped > 0) {
1010
+ log('WARN', `Reaped ${result.reaped} keychain orphan/stuck process(es)`);
1011
+ for (const d of result.details)
1012
+ log('WARN', ` ${d}`);
1013
+ }
1014
+ }
1015
+ catch (err) {
1016
+ log('ERROR', `Keychain reaper failed: ${err.message}`);
1017
+ }
1018
+ finally {
1019
+ reapingKeychain = false;
1020
+ }
1021
+ };
1022
+ const keychainReapInterval = setInterval(() => { void runKeychainReap(); }, 5 * 60_000);
958
1023
  const handleReload = () => {
959
1024
  log('INFO', 'Reloading jobs (SIGHUP)');
960
1025
  // Refresh user-layer copies of opted-in project routines BEFORE the
@@ -1013,9 +1078,14 @@ export async function runDaemon() {
1013
1078
  clearTimeout(launchHealthKickoff);
1014
1079
  clearInterval(fleetCacheInterval);
1015
1080
  clearTimeout(fleetCacheKickoff);
1081
+ if (sessionCacheInterval)
1082
+ clearInterval(sessionCacheInterval);
1083
+ if (sessionCacheKickoff)
1084
+ clearTimeout(sessionCacheKickoff);
1016
1085
  clearInterval(usageRefreshInterval);
1017
1086
  clearTimeout(usageRefreshKickoff);
1018
1087
  clearInterval(brokerSelfHealInterval);
1088
+ clearInterval(keychainReapInterval);
1019
1089
  hostedBroker?.close();
1020
1090
  removeDaemonPid();
1021
1091
  removeHeartbeat();
@@ -86,6 +86,8 @@ export interface ModeWarningState {
86
86
  * also maps an unsupported `'plan'` request onto this same value.
87
87
  */
88
88
  export declare function defaultModeFor(agent: AgentId): Mode;
89
+ /** Safe mode used when the user did not provide --mode or a configured default. */
90
+ export declare function implicitModeFor(agent: AgentId): ExecMode;
89
91
  /** Reasoning effort levels passed to agents that support them. 'auto' defers to the agent's default. */
90
92
  export type ExecEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto';
91
93
  /** Options for spawning an agent process. Omitting `prompt` launches the CLI interactively. */
@@ -97,6 +99,8 @@ export interface ExecOptions {
97
99
  /** Force interactive mode even when a prompt is provided. Wins over `headless`. */
98
100
  interactive?: boolean;
99
101
  mode: ExecMode;
102
+ /** True when the caller omitted --mode; fallback agents resolve their own safe default. */
103
+ modeWasImplicit?: boolean;
100
104
  effort: ExecEffort;
101
105
  cwd?: string;
102
106
  /** Force headless mode even when no prompt is provided (e.g. piping via stdin). */