@phnx-labs/agents-cli 1.20.0 → 1.20.4

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 (111) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +4 -4
  3. package/dist/commands/cli.js +3 -3
  4. package/dist/commands/cloud.js +1 -1
  5. package/dist/commands/commands.js +24 -7
  6. package/dist/commands/exec.js +36 -16
  7. package/dist/commands/feedback.d.ts +7 -0
  8. package/dist/commands/feedback.js +89 -0
  9. package/dist/commands/helper.d.ts +12 -0
  10. package/dist/commands/helper.js +87 -0
  11. package/dist/commands/hooks.js +86 -7
  12. package/dist/commands/import.js +90 -37
  13. package/dist/commands/mcp.js +166 -10
  14. package/dist/commands/packages.js +196 -27
  15. package/dist/commands/permissions.js +21 -6
  16. package/dist/commands/profiles.d.ts +8 -0
  17. package/dist/commands/profiles.js +117 -4
  18. package/dist/commands/pull.js +4 -4
  19. package/dist/commands/routines.js +6 -6
  20. package/dist/commands/rules.js +8 -4
  21. package/dist/commands/secrets-migrate.d.ts +24 -0
  22. package/dist/commands/secrets-migrate.js +198 -0
  23. package/dist/commands/secrets-sync.d.ts +11 -0
  24. package/dist/commands/secrets-sync.js +155 -0
  25. package/dist/commands/secrets.js +74 -39
  26. package/dist/commands/skills.js +22 -5
  27. package/dist/commands/subagents.js +69 -49
  28. package/dist/commands/teams.js +48 -10
  29. package/dist/commands/utils.d.ts +33 -0
  30. package/dist/commands/utils.js +139 -0
  31. package/dist/commands/versions.js +4 -4
  32. package/dist/commands/view.d.ts +6 -0
  33. package/dist/commands/view.js +169 -8
  34. package/dist/commands/workflows.js +29 -6
  35. package/dist/index.js +4 -0
  36. package/dist/lib/acp/client.js +6 -1
  37. package/dist/lib/agents.d.ts +4 -0
  38. package/dist/lib/agents.js +41 -17
  39. package/dist/lib/auto-pull-worker.js +18 -1
  40. package/dist/lib/browser/chrome.js +4 -0
  41. package/dist/lib/browser/drivers/ssh.js +1 -1
  42. package/dist/lib/browser/profiles.d.ts +3 -3
  43. package/dist/lib/browser/profiles.js +3 -3
  44. package/dist/lib/browser/service.js +19 -0
  45. package/dist/lib/browser/types.d.ts +4 -4
  46. package/dist/lib/cli-resources.d.ts +36 -8
  47. package/dist/lib/cli-resources.js +268 -46
  48. package/dist/lib/cloud/factory.d.ts +1 -1
  49. package/dist/lib/cloud/factory.js +1 -1
  50. package/dist/lib/events.d.ts +16 -2
  51. package/dist/lib/events.js +33 -2
  52. package/dist/lib/exec.d.ts +39 -11
  53. package/dist/lib/exec.js +90 -31
  54. package/dist/lib/help.js +11 -5
  55. package/dist/lib/hooks/cache.d.ts +38 -0
  56. package/dist/lib/hooks/cache.js +242 -0
  57. package/dist/lib/hooks/profile.d.ts +33 -0
  58. package/dist/lib/hooks/profile.js +129 -0
  59. package/dist/lib/hooks.d.ts +0 -10
  60. package/dist/lib/hooks.js +68 -15
  61. package/dist/lib/import.d.ts +21 -0
  62. package/dist/lib/import.js +55 -2
  63. package/dist/lib/mcp.d.ts +15 -0
  64. package/dist/lib/mcp.js +40 -0
  65. package/dist/lib/permissions.d.ts +13 -0
  66. package/dist/lib/permissions.js +51 -1
  67. package/dist/lib/plugin-marketplace.d.ts +10 -0
  68. package/dist/lib/plugin-marketplace.js +47 -1
  69. package/dist/lib/plugins.js +15 -1
  70. package/dist/lib/profiles-presets.d.ts +26 -0
  71. package/dist/lib/profiles-presets.js +187 -8
  72. package/dist/lib/profiles.d.ts +34 -0
  73. package/dist/lib/profiles.js +112 -1
  74. package/dist/lib/pty-server.js +27 -3
  75. package/dist/lib/routines-format.d.ts +17 -5
  76. package/dist/lib/routines-format.js +37 -16
  77. package/dist/lib/routines.d.ts +1 -1
  78. package/dist/lib/routines.js +2 -2
  79. package/dist/lib/runner.js +64 -10
  80. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -9
  83. package/dist/lib/secrets/bundles.d.ts +18 -22
  84. package/dist/lib/secrets/bundles.js +75 -99
  85. package/dist/lib/secrets/index.d.ts +51 -27
  86. package/dist/lib/secrets/index.js +147 -156
  87. package/dist/lib/secrets/install-helper.d.ts +45 -0
  88. package/dist/lib/secrets/install-helper.js +165 -0
  89. package/dist/lib/secrets/linux.js +4 -4
  90. package/dist/lib/secrets/sync.d.ts +56 -0
  91. package/dist/lib/secrets/sync.js +180 -0
  92. package/dist/lib/session/render.js +4 -4
  93. package/dist/lib/session/types.d.ts +1 -1
  94. package/dist/lib/shims.d.ts +4 -1
  95. package/dist/lib/shims.js +5 -35
  96. package/dist/lib/state.d.ts +14 -1
  97. package/dist/lib/state.js +49 -5
  98. package/dist/lib/teams/agents.d.ts +5 -4
  99. package/dist/lib/teams/agents.js +47 -21
  100. package/dist/lib/teams/api.d.ts +2 -1
  101. package/dist/lib/teams/api.js +4 -3
  102. package/dist/lib/types.d.ts +57 -1
  103. package/dist/lib/types.js +2 -0
  104. package/dist/lib/usage.d.ts +27 -2
  105. package/dist/lib/usage.js +100 -17
  106. package/dist/lib/versions.d.ts +35 -1
  107. package/dist/lib/versions.js +288 -64
  108. package/package.json +13 -12
  109. package/scripts/install-helper.js +97 -0
  110. package/scripts/postinstall.js +16 -0
  111. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
@@ -19,6 +19,7 @@ import { debug } from './debug.js';
19
19
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from '../gemini-settings.js';
20
20
  import { getAgentsDir as getSystemAgentsDir } from '../state.js';
21
21
  import { AGENTS } from '../agents.js';
22
+ import { sanitizeProcessEnv } from '../secrets/bundles.js';
22
23
  let lastMemoryWarnAt = 0;
23
24
  // On macOS, os.freemem() returns only the truly-free pool and ignores the
24
25
  // large inactive+purgeable cache the kernel will reclaim under pressure, so
@@ -183,12 +184,17 @@ When you're done, provide a brief summary of:
183
184
  const CLAUDE_PLAN_MODE_PREFIX = `You are running in HEADLESS PLAN MODE. This mode works like normal plan mode with one exception: you cannot write to ~/.claude/plans/ directory. Instead of writing a plan file, output your complete plan/response as your final message.
184
185
 
185
186
  `;
186
- const VALID_MODES = ['plan', 'edit', 'full', 'auto'];
187
+ // Canonical modes plus the historical `full` alias (rewritten to `skip` by
188
+ // normalizeModeValue). Keep `full` listed so user-typed CLI flags and stored
189
+ // metadata that pre-date the rename continue to parse.
190
+ export const VALID_MODES = ['plan', 'edit', 'auto', 'skip', 'full'];
187
191
  function normalizeModeValue(modeValue) {
188
192
  if (!modeValue)
189
193
  return null;
190
194
  const normalized = modeValue.trim().toLowerCase();
191
- if (VALID_MODES.includes(normalized)) {
195
+ if (normalized === 'full')
196
+ return 'skip';
197
+ if (['plan', 'edit', 'auto', 'skip'].includes(normalized)) {
192
198
  return normalized;
193
199
  }
194
200
  return null;
@@ -201,7 +207,7 @@ function defaultModeFromEnv() {
201
207
  return parsed;
202
208
  }
203
209
  if (rawValue) {
204
- console.warn(`Invalid ${envVar}='${rawValue}'. Use 'plan' or 'edit'. Falling back to plan mode.`);
210
+ console.warn(`Invalid ${envVar}='${rawValue}'. Use plan, edit, auto, or skip. Falling back to plan mode.`);
205
211
  }
206
212
  }
207
213
  return 'plan';
@@ -253,12 +259,12 @@ function extractTimestamp(raw) {
253
259
  export function resolveMode(requestedMode, defaultMode = 'plan') {
254
260
  const normalizedDefault = normalizeModeValue(defaultMode);
255
261
  if (!normalizedDefault) {
256
- throw new Error(`Invalid default mode '${defaultMode}'. Use 'plan' or 'edit'.`);
262
+ throw new Error(`Invalid default mode '${defaultMode}'. Use plan, edit, auto, or skip.`);
257
263
  }
258
264
  if (requestedMode !== null && requestedMode !== undefined) {
259
265
  const normalizedMode = normalizeModeValue(requestedMode);
260
266
  if (!normalizedMode) {
261
- throw new Error(`Invalid mode '${requestedMode}'. Valid modes: 'plan' (read-only) or 'edit' (can write).`);
267
+ throw new Error(`Invalid mode '${requestedMode}'. Valid modes: plan (read-only), edit (can write), auto (smart classifier), skip (bypass all permissions). 'full' is accepted as alias for skip.`);
262
268
  }
263
269
  return normalizedMode;
264
270
  }
@@ -363,6 +369,11 @@ export class AgentProcess {
363
369
  // Pinned model for this teammate. When null, the agent's CLI picks its
364
370
  // own default (no --model forwarded).
365
371
  model = null;
372
+ // Profile target name when the teammate was added via `agents teams add
373
+ // <team> <profile>`. The launcher targets the profile name so env/keychain
374
+ // injection happens; agentType stays the underlying harness so event
375
+ // parsers and CLI availability checks keep working.
376
+ profileName = null;
366
377
  // Extra env vars passed through to the child process (from --env KEY=VALUE).
367
378
  envOverrides = null;
368
379
  // Factory task-type label. Drives planner fan-out. Null for plain teammates — no behavioral change.
@@ -378,13 +389,14 @@ export class AgentProcess {
378
389
  eventsCache = [];
379
390
  lastReadPos = 0;
380
391
  baseDir = null;
381
- constructor(agentId, taskName, agentType, prompt, cwd = null, mode = 'plan', pid = null, status = AgentStatus.RUNNING, startedAt = new Date(), completedAt = null, baseDir = null, parentSessionId = null, workspaceDir = null, cloudSessionId = null, cloudProvider = null, prUrl = null, version = null, remoteSessionId = null, name = null, after = [], effort = null, model = null, envOverrides = null, taskType = null, cloudRepo = null, cloudBranch = null, worktreeName = null, worktreePath = null) {
392
+ constructor(agentId, taskName, agentType, prompt, cwd = null, mode = 'plan', pid = null, status = AgentStatus.RUNNING, startedAt = new Date(), completedAt = null, baseDir = null, parentSessionId = null, workspaceDir = null, cloudSessionId = null, cloudProvider = null, prUrl = null, version = null, remoteSessionId = null, name = null, after = [], effort = null, model = null, envOverrides = null, taskType = null, cloudRepo = null, cloudBranch = null, worktreeName = null, worktreePath = null, profileName = null) {
382
393
  this.agentId = agentId;
383
394
  this.remoteSessionId = remoteSessionId;
384
395
  this.name = name;
385
396
  this.after = after;
386
397
  this.effort = effort;
387
398
  this.model = model;
399
+ this.profileName = profileName;
388
400
  this.envOverrides = envOverrides;
389
401
  this.taskType = taskType;
390
402
  this.cloudRepo = cloudRepo;
@@ -409,7 +421,9 @@ export class AgentProcess {
409
421
  this.version = version;
410
422
  }
411
423
  get isEditMode() {
412
- return this.mode === 'edit' || this.mode === 'full';
424
+ // Any mode that can mutate the workspace counts as "edit mode" for the
425
+ // purposes of guarding read-only flows (plan-mode teammates).
426
+ return this.mode === 'edit' || this.mode === 'auto' || this.mode === 'skip';
413
427
  }
414
428
  async getAgentDir() {
415
429
  const base = this.baseDir || await getAgentsDir();
@@ -466,6 +480,7 @@ export class AgentProcess {
466
480
  after: this.after,
467
481
  effort: this.effort,
468
482
  model: this.model,
483
+ profile_name: this.profileName,
469
484
  env_overrides: this.envOverrides,
470
485
  task_type: this.taskType,
471
486
  cloud_repo: this.cloudRepo,
@@ -596,6 +611,7 @@ export class AgentProcess {
596
611
  after: this.after,
597
612
  effort: this.effort,
598
613
  model: this.model,
614
+ profile_name: this.profileName,
599
615
  env_overrides: this.envOverrides,
600
616
  task_type: this.taskType,
601
617
  cloud_repo: this.cloudRepo,
@@ -619,12 +635,16 @@ export class AgentProcess {
619
635
  try {
620
636
  const metaContent = await fs.readFile(metaPath, 'utf-8');
621
637
  const meta = JSON.parse(metaContent);
622
- // Legacy teammates may have mode='ralph' or 'cloud' from before modes
623
- // were narrowed. Coerce to the closest current mode so they still load.
638
+ // Legacy teammates may have mode='ralph', 'cloud', or 'full' from before
639
+ // modes were narrowed/renamed. Coerce to the closest current mode so they
640
+ // still load.
624
641
  const modeMap = {
642
+ plan: 'plan',
625
643
  edit: 'edit',
626
- full: 'full',
627
- ralph: 'full', // ralph used the same "no-permission" flags as full
644
+ auto: 'auto',
645
+ skip: 'skip',
646
+ full: 'skip', // historical alias — `full` is the old name for `skip`
647
+ ralph: 'skip', // ralph used the same "no-permission" flags as full
628
648
  cloud: 'edit', // cloud teammates had edit-level write access
629
649
  };
630
650
  const resolvedMode = modeMap[meta.mode] || 'plan';
@@ -637,7 +657,7 @@ export class AgentProcess {
637
657
  : AgentStatus.RUNNING;
638
658
  const agent = new AgentProcess(meta.agent_id, meta.task_name || 'default', meta.agent_type, meta.prompt, meta.cwd || null, resolvedMode, meta.pid || null, resolvedStatus, new Date(meta.started_at), meta.completed_at ? new Date(meta.completed_at) : null, baseDir, meta.parent_session_id || null, meta.workspace_dir || null, meta.cloud_session_id || null, meta.cloud_provider || null, meta.pr_url || null, meta.version || null, meta.remote_session_id || null, meta.name || null, Array.isArray(meta.after) ? meta.after : [], meta.effort || null, meta.model || null, meta.env_overrides || null, meta.task_type && VALID_TASK_TYPES.includes(meta.task_type)
639
659
  ? meta.task_type
640
- : null, meta.cloud_repo || null, meta.cloud_branch || null, meta.worktree_name || null, meta.worktree_path || null);
660
+ : null, meta.cloud_repo || null, meta.cloud_branch || null, meta.worktree_name || null, meta.worktree_path || null, meta.profile_name || null);
641
661
  agent.startTime = typeof meta.start_time === 'string' ? meta.start_time : null;
642
662
  return agent;
643
663
  }
@@ -759,7 +779,7 @@ export class AgentManager {
759
779
  this.cleanupAgeDays = cleanupAgeDays;
760
780
  const resolvedDefaultMode = defaultMode ? normalizeModeValue(defaultMode) : defaultModeFromEnv();
761
781
  if (!resolvedDefaultMode) {
762
- throw new Error(`Invalid default_mode '${defaultMode}'. Use 'plan' or 'edit'.`);
782
+ throw new Error(`Invalid default_mode '${defaultMode}'. Use plan, edit, auto, or skip.`);
763
783
  }
764
784
  this.defaultMode = resolvedDefaultMode;
765
785
  this.initPromise = this.doInitialize();
@@ -875,7 +895,7 @@ export class AgentManager {
875
895
  }
876
896
  debug(`Loaded ${loadedCount} agents from disk`);
877
897
  }
878
- async spawn(taskName, agentType, prompt, cwd = null, mode = null, effort = 'medium', parentSessionId = null, workspaceDir = null, version = null, name = null, after = [], model = null, envOverrides = null, taskType = null, cloudProvider = null, cloudSessionId = null, cloudRepo = null, cloudBranch = null, worktreeName = null, worktreePath = null) {
898
+ async spawn(taskName, agentType, prompt, cwd = null, mode = null, effort = 'medium', parentSessionId = null, workspaceDir = null, version = null, name = null, after = [], model = null, envOverrides = null, taskType = null, cloudProvider = null, cloudSessionId = null, cloudRepo = null, cloudBranch = null, worktreeName = null, worktreePath = null, profileName = null) {
879
899
  await this.initialize();
880
900
  const resolvedMode = resolveMode(mode, this.defaultMode);
881
901
  // Enforce: teammate names are unique within a team.
@@ -922,6 +942,9 @@ export class AgentManager {
922
942
  // dispatched via the cloud provider and passed us the provider + session.
923
943
  const isCloudBacked = Boolean(cloudProvider);
924
944
  if (!isCloudBacked) {
945
+ // Profile-backed teammates still spawn through `agents run`, which
946
+ // resolves the profile to its host harness — so the CLI we need to be
947
+ // present is the underlying agentType, not the profile name.
925
948
  const [available, pathOrError] = checkCliAvailable(agentType);
926
949
  if (!available) {
927
950
  throw new Error(pathOrError || 'CLI tool not available');
@@ -934,7 +957,7 @@ export class AgentManager {
934
957
  const initialStatus = isStaged || !isCloudBacked
935
958
  ? AgentStatus.PENDING
936
959
  : AgentStatus.RUNNING;
937
- const agent = new AgentProcess(agentId, taskName, agentType, prompt, resolvedCwd, resolvedMode, null, initialStatus, new Date(), null, this.agentsDir, parentSessionId, workspaceDir, cloudSessionId, cloudProvider, null, version, null, name, cleanAfter, effort, model, envOverrides && Object.keys(envOverrides).length > 0 ? envOverrides : null, taskType, cloudRepo, cloudBranch, worktreeName, worktreePath);
960
+ const agent = new AgentProcess(agentId, taskName, agentType, prompt, resolvedCwd, resolvedMode, null, initialStatus, new Date(), null, this.agentsDir, parentSessionId, workspaceDir, cloudSessionId, cloudProvider, null, version, null, name, cleanAfter, effort, model, envOverrides && Object.keys(envOverrides).length > 0 ? envOverrides : null, taskType, cloudRepo, cloudBranch, worktreeName, worktreePath, profileName);
938
961
  const agentDir = await agent.getAgentDir();
939
962
  try {
940
963
  await fs.mkdir(agentDir, { recursive: true });
@@ -971,7 +994,7 @@ export class AgentManager {
971
994
  // forwarded). Effort is a separate knob wired into buildReasoningFlags
972
995
  // inside buildCommand.
973
996
  const resolvedModel = agent.model ?? null;
974
- const cmd = this.buildCommand(agent.agentType, agent.prompt, agent.mode, resolvedModel, agent.cwd, agent.agentId, effort, agent.version);
997
+ const cmd = this.buildCommand(agent.agentType, agent.prompt, agent.mode, resolvedModel, agent.cwd, agent.agentId, effort, agent.version, agent.profileName);
975
998
  debug(`Launching ${agent.agentType} agent ${agent.agentId} [${agent.mode}]: ${cmd.slice(0, 3).join(' ')}...`);
976
999
  try {
977
1000
  const stdoutPath = await agent.getStdoutPath();
@@ -982,8 +1005,8 @@ export class AgentManager {
982
1005
  cwd: agent.cwd || undefined,
983
1006
  detached: true,
984
1007
  env: agent.envOverrides
985
- ? { ...process.env, ...agent.envOverrides }
986
- : process.env,
1008
+ ? { ...sanitizeProcessEnv(process.env), ...agent.envOverrides }
1009
+ : sanitizeProcessEnv(process.env),
987
1010
  });
988
1011
  childProcess.unref();
989
1012
  stdoutFile.close().catch(() => { });
@@ -1055,15 +1078,18 @@ export class AgentManager {
1055
1078
  * exec path (src/lib/exec.ts). The team runner just supplies prompt + mode
1056
1079
  * and reads stream-json events off stdout.
1057
1080
  */
1058
- buildCommand(agentType, prompt, mode, model, cwd = null, sessionId = null, effort = 'medium', version = null) {
1081
+ buildCommand(agentType, prompt, mode, model, cwd = null, sessionId = null, effort = 'medium', version = null, profileName = null) {
1059
1082
  // Compose the prompt: a plan-mode prefix for Claude (clarifying headless
1060
1083
  // plan-mode restrictions) and a universal summary suffix. These are
1061
1084
  // team-specific prompt scaffolding — `agents run` does not apply them.
1062
1085
  let fullPrompt = prompt + PROMPT_SUFFIX;
1063
- if (agentType === 'claude' && mode !== 'edit') {
1086
+ if (agentType === 'claude' && mode === 'plan') {
1064
1087
  fullPrompt = CLAUDE_PLAN_MODE_PREFIX + fullPrompt;
1065
1088
  }
1066
- const target = version ? `${agentType}@${version}` : agentType;
1089
+ // Profile target takes precedence `agents run <profile>` resolves the
1090
+ // host harness, version pin, and env injection in one place. Plain
1091
+ // version pins only apply when no profile is selected.
1092
+ const target = profileName ?? (version ? `${agentType}@${version}` : agentType);
1067
1093
  const agentsCli = process.argv[1];
1068
1094
  const cmd = [
1069
1095
  process.execPath,
@@ -13,6 +13,7 @@ export interface SpawnResult {
13
13
  status: string;
14
14
  started_at: string;
15
15
  version?: string | null;
16
+ profile_name?: string | null;
16
17
  remote_session_id?: string | null;
17
18
  name?: string | null;
18
19
  after?: string[];
@@ -88,7 +89,7 @@ export interface TasksResult {
88
89
  tasks: TaskInfo[];
89
90
  }
90
91
  /** Spawn a new teammate in a task and return its initial metadata. */
91
- export declare function handleSpawn(manager: AgentManager, taskName: string, agentType: AgentType, prompt: string, cwd: string | null, mode: string | null, effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto' | null, parentSessionId?: string | null, workspaceDir?: string | null, version?: string | null, name?: string | null, after?: string[], model?: string | null, envOverrides?: Record<string, string> | null, taskType?: TaskType | null, cloudProvider?: string | null, cloudSessionId?: string | null, cloudRepo?: string | null, cloudBranch?: string | null, worktreeName?: string | null, worktreePath?: string | null): Promise<SpawnResult>;
92
+ export declare function handleSpawn(manager: AgentManager, taskName: string, agentType: AgentType, prompt: string, cwd: string | null, mode: string | null, effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto' | null, parentSessionId?: string | null, workspaceDir?: string | null, version?: string | null, name?: string | null, after?: string[], model?: string | null, envOverrides?: Record<string, string> | null, taskType?: TaskType | null, cloudProvider?: string | null, cloudSessionId?: string | null, cloudRepo?: string | null, cloudBranch?: string | null, worktreeName?: string | null, worktreePath?: string | null, profileName?: string | null): Promise<SpawnResult>;
92
93
  /** Retrieve the current status of all teammates in a task, with optional timestamp-based delta filtering. */
93
94
  export declare function handleStatus(manager: AgentManager, taskName: string | null | undefined, filter?: string, since?: string, // Optional ISO timestamp - return only events after this time
94
95
  parentSessionId?: string | null): Promise<TaskStatusResult>;
@@ -56,12 +56,12 @@ function recentToolCalls(events, max = 10) {
56
56
  }));
57
57
  }
58
58
  /** Spawn a new teammate in a task and return its initial metadata. */
59
- export async function handleSpawn(manager, taskName, agentType, prompt, cwd, mode, effort = 'medium', parentSessionId = null, workspaceDir = null, version = null, name = null, after = [], model = null, envOverrides = null, taskType = null, cloudProvider = null, cloudSessionId = null, cloudRepo = null, cloudBranch = null, worktreeName = null, worktreePath = null) {
59
+ export async function handleSpawn(manager, taskName, agentType, prompt, cwd, mode, effort = 'medium', parentSessionId = null, workspaceDir = null, version = null, name = null, after = [], model = null, envOverrides = null, taskType = null, cloudProvider = null, cloudSessionId = null, cloudRepo = null, cloudBranch = null, worktreeName = null, worktreePath = null, profileName = null) {
60
60
  const defaultMode = manager.getDefaultMode();
61
61
  const resolvedMode = resolveMode(mode, defaultMode);
62
62
  const resolvedEffort = effort ?? 'medium';
63
- debug(`[spawn] Spawning ${agentType} agent for task "${taskName}" [${resolvedMode}] effort=${resolvedEffort}...`);
64
- const agent = await manager.spawn(taskName, agentType, prompt, cwd, resolvedMode, resolvedEffort, parentSessionId, workspaceDir, version, name, after, model, envOverrides, taskType, cloudProvider, cloudSessionId, cloudRepo, cloudBranch, worktreeName, worktreePath);
63
+ debug(`[spawn] Spawning ${agentType} agent for task "${taskName}" [${resolvedMode}] effort=${resolvedEffort}${profileName ? ` profile=${profileName}` : ''}...`);
64
+ const agent = await manager.spawn(taskName, agentType, prompt, cwd, resolvedMode, resolvedEffort, parentSessionId, workspaceDir, version, name, after, model, envOverrides, taskType, cloudProvider, cloudSessionId, cloudRepo, cloudBranch, worktreeName, worktreePath, profileName);
65
65
  debug(`[spawn] Spawned ${agentType} agent ${agent.agentId} for task "${taskName}"`);
66
66
  return {
67
67
  task_name: taskName,
@@ -70,6 +70,7 @@ export async function handleSpawn(manager, taskName, agentType, prompt, cwd, mod
70
70
  status: agent.status,
71
71
  started_at: agent.startedAt.toISOString(),
72
72
  version: agent.version,
73
+ profile_name: agent.profileName,
73
74
  remote_session_id: agent.remoteSessionId,
74
75
  name: agent.name,
75
76
  after: agent.after,
@@ -40,6 +40,12 @@ export interface AgentConfig {
40
40
  skills: Capability;
41
41
  commands: Capability;
42
42
  plugins: Capability;
43
+ /**
44
+ * Permission modes this agent natively supports. Modes outside this set
45
+ * are gated by buildExecCommand: `auto` silently degrades to `edit`,
46
+ * `skip` errors with a clear message naming the supported modes.
47
+ */
48
+ modes: Mode[];
43
49
  /**
44
50
  * Whether the agent natively resolves `@path/to/file` imports inside its
45
51
  * rules file at session start. If false, agents-cli must pre-compile the
@@ -60,6 +66,19 @@ export type Capability = boolean | {
60
66
  };
61
67
  /** Names of every gateable capability on AgentConfig. */
62
68
  export type CapabilityName = 'hooks' | 'mcp' | 'allowlist' | 'skills' | 'commands' | 'plugins';
69
+ /**
70
+ * Permission modes controlling agent autonomy.
71
+ * plan read-only investigation; no writes, no shell side-effects
72
+ * edit may edit files; prompts for shell/risky operations
73
+ * auto smart classifier auto-approves safe operations, prompts for risky ones
74
+ * skip bypasses every permission prompt (dangerously-skip-permissions)
75
+ *
76
+ * `full` is accepted as a permanent silent alias for `skip` via normalizeMode().
77
+ * Per-agent support is declared on AgentConfig.capabilities.modes.
78
+ */
79
+ export type Mode = 'plan' | 'edit' | 'auto' | 'skip';
80
+ /** Every canonical mode in declaration order. Useful for iteration / validation. */
81
+ export declare const ALL_MODES: readonly Mode[];
63
82
  /** Reason a capability check failed. */
64
83
  export type CapabilityFailReason = 'unsupported' | 'too_old' | 'too_new';
65
84
  /** Result of `supports(agent, cap, version?)`. */
@@ -100,6 +119,36 @@ export interface HookMatches {
100
119
  cwd_includes?: string | string[];
101
120
  project_has?: string;
102
121
  }
122
+ /**
123
+ * Cache scoping. Determines which cache file a hook invocation reads/writes:
124
+ * - `global` one file per hook, shared across cwds/sessions. Right for
125
+ * SessionStart hooks pulling org-wide context (Linear sprint).
126
+ * - `per-cwd` keyed on the working directory the hook fires from.
127
+ * - `per-session` keyed on the agent's session_id (read from stdin JSON).
128
+ * - `per-project` keyed on the nearest git repo root above cwd.
129
+ */
130
+ export type HookCacheKey = 'global' | 'per-cwd' | 'per-session' | 'per-project';
131
+ /** Prefetch strategy when the cache is stale. */
132
+ export type HookCachePrefetch = 'none' | 'background';
133
+ /**
134
+ * Full hook cache config. Authors usually use the shorthand string form
135
+ * (`HookCache`) below. Shorthand examples in hooks.yaml:
136
+ *
137
+ * cache: 5m # → { ttl: 300, key: 'global', prefetch: 'none' }
138
+ * cache: 5m-bg # → { ttl: 300, key: 'global', prefetch: 'background' }
139
+ * cache: # full form
140
+ * ttl: 1h
141
+ * key: per-cwd
142
+ * prefetch: background
143
+ */
144
+ export interface HookCacheConfig {
145
+ /** TTL in seconds or duration string ("30s", "5m", "1h"). */
146
+ ttl: number | string;
147
+ key?: HookCacheKey;
148
+ prefetch?: HookCachePrefetch;
149
+ }
150
+ /** Cache shorthand: duration string, optionally suffixed `-bg` for background prefetch. */
151
+ export type HookCache = string | HookCacheConfig;
103
152
  /** Hook entry as declared in a package manifest (agents.yaml). */
104
153
  export interface ManifestHook {
105
154
  script: string;
@@ -114,6 +163,13 @@ export interface ManifestHook {
114
163
  override?: boolean;
115
164
  /** Optional pre-filter predicates evaluated before invoking the script. */
116
165
  matches?: HookMatches;
166
+ /**
167
+ * Opt-in caching. When set, the registrar generates a per-hook shim
168
+ * under the hook shims dir that handles cache lookup, stale-while-revalidate,
169
+ * and per-invocation timing/logging, then registers that shim with the agent
170
+ * instead of the raw script. The underlying script is unchanged.
171
+ */
172
+ cache?: HookCache;
117
173
  }
118
174
  /** Lightweight hook descriptor used in resource listings. */
119
175
  export interface HookResourceEntry {
@@ -469,7 +525,7 @@ export interface BrowserProfileConfig {
469
525
  };
470
526
  /** Directory holding source-side JSONL logs (e.g. ~/.rush/logs). */
471
527
  logDir?: string;
472
- /** Optional SSH host where logDir lives, e.g. "user@mac-mini". */
528
+ /** Optional SSH host where logDir lives, e.g. "user@remote-host". */
473
529
  logHost?: string;
474
530
  }
475
531
  /** Options controlling which agents and resources are synced during `agents pull` / `agents use`. */
package/dist/lib/types.js CHANGED
@@ -5,6 +5,8 @@
5
5
  * configuration schemas, resource tracking, registry types, and permission
6
6
  * formats for each supported agent.
7
7
  */
8
+ /** Every canonical mode in declaration order. Useful for iteration / validation. */
9
+ export const ALL_MODES = ['plan', 'edit', 'auto', 'skip'];
8
10
  /** Canonical system repo cloned into ~/.agents-system/. */
9
11
  export const DEFAULT_SYSTEM_REPO = 'gh:phnx-labs/.agents-system';
10
12
  /** Strip the `gh:` prefix and `.git` suffix to get a GitHub `owner/repo` slug. */
@@ -71,12 +71,37 @@ export declare function getUsageInfoByIdentity(inputs: UsageIdentityInput[]): Pr
71
71
  usageByKey: Map<string, UsageInfo>;
72
72
  }>;
73
73
  /**
74
- * Fetch usage for a single identity, with a 2-minute cache fast path.
75
- * Falls back to cached data when the live fetch fails.
74
+ * Fetch usage for a single identity using stale-while-revalidate.
75
+ *
76
+ * - Cache fresh (< 2 min): return cached snapshot, NO network.
77
+ * - Cache stale but < 24h: return cached snapshot instantly, fire background refresh.
78
+ * - Cache too stale or absent: block on live fetch, fall back to cache on error.
79
+ *
80
+ * This keeps `agents run` startup off the network on the hot path. The first
81
+ * invocation after a cold install or 24h gap still blocks once to seed the
82
+ * cache; every run after that returns instantly while the cache silently
83
+ * refreshes in the background.
76
84
  */
77
85
  export declare function getUsageInfoForIdentity(input: UsageIdentityInput): Promise<UsageInfo>;
78
86
  /** Format a one-line usage summary with compact bars for inline display. */
79
87
  export declare function formatUsageSummary(plan: string | null, snapshot: UsageSnapshot | null, planWidth?: number): string;
88
+ /**
89
+ * Compact colored badge for the account's overall usage status. Renders only
90
+ * when the account is throttled — `available` and `null` return ''.
91
+ *
92
+ * - `out_of_credits` → red "out of credits" (terminal account, all buckets dry)
93
+ * - `rate_limited` → yellow "rate-limited" (transient throttling)
94
+ *
95
+ * The badge sits between the usage bars and `lastActive` in `agents view`, so
96
+ * a glance at the row tells the user whether the version can do useful work.
97
+ * The same signal is exposed as `usageStatus` in `agents view --json` for
98
+ * programmatic consumers (e.g. the swarmify panel's "resume in healthy agent").
99
+ *
100
+ * The switch is exhaustive on purpose — adding a new `AccountInfo.usageStatus`
101
+ * value without updating the cases here is a build error at `_exhaustive`,
102
+ * which is exactly the bug class this PR is fixing.
103
+ */
104
+ export declare function formatUsageStatusBadge(usageStatus: 'available' | 'rate_limited' | 'out_of_credits' | null | undefined): string;
80
105
  /** Format a multi-line usage section for detailed agent views. */
81
106
  export declare function formatUsageSection(usage: UsageInfo): string[];
82
107
  /** Load Claude OAuth credentials from the system keychain/keyring. */
package/dist/lib/usage.js CHANGED
@@ -96,42 +96,95 @@ export async function getUsageInfoByIdentity(inputs) {
96
96
  usageByKey: new Map(usageResults.map(({ key, usage }) => [key, usage])),
97
97
  };
98
98
  }
99
- const USAGE_CACHE_FRESH_MS = 2 * 60 * 1000; // 2 minutes
99
+ const USAGE_CACHE_FRESH_MS = 2 * 60 * 1000; // 2 minutes — fresh window: don't refresh.
100
+ const USAGE_CACHE_SWR_MS = 24 * 60 * 60 * 1000; // 24 hours — beyond this, block on live fetch.
101
+ /** In-process dedup: don't fire concurrent background refreshes for the same identity. */
102
+ const inFlightRefreshes = new Map();
100
103
  /**
101
- * Fetch usage for a single identity, with a 2-minute cache fast path.
102
- * Falls back to cached data when the live fetch fails.
104
+ * Fetch usage for a single identity using stale-while-revalidate.
105
+ *
106
+ * - Cache fresh (< 2 min): return cached snapshot, NO network.
107
+ * - Cache stale but < 24h: return cached snapshot instantly, fire background refresh.
108
+ * - Cache too stale or absent: block on live fetch, fall back to cache on error.
109
+ *
110
+ * This keeps `agents run` startup off the network on the hot path. The first
111
+ * invocation after a cold install or 24h gap still blocks once to seed the
112
+ * cache; every run after that returns instantly while the cache silently
113
+ * refreshes in the background.
103
114
  */
104
115
  export async function getUsageInfoForIdentity(input) {
105
116
  const usageKey = getUsageLookupKey(input.info);
106
- // Fast path: serve from cache if fresh. Skips the network call entirely.
107
- if (input.agentId === 'claude' && usageKey) {
108
- const cached = readClaudeUsageCache(usageKey);
109
- if (cached?.capturedAt) {
110
- const ageMs = Date.now() - cached.capturedAt.getTime();
111
- if (ageMs < USAGE_CACHE_FRESH_MS) {
112
- return { snapshot: cached, error: null };
113
- }
114
- }
117
+ // Non-Claude or no identity: legacy path, blocking fetch.
118
+ if (input.agentId !== 'claude' || !usageKey) {
119
+ return getUsageInfo(input.agentId, {
120
+ home: input.home,
121
+ cliVersion: input.cliVersion,
122
+ organizationId: input.info.organizationId,
123
+ });
115
124
  }
116
- // Cache miss or stale -- make the network call.
125
+ const cached = readClaudeUsageCache(usageKey);
126
+ const ageMs = cached?.capturedAt ? Date.now() - cached.capturedAt.getTime() : Infinity;
127
+ // Fresh: cache is recent enough, skip network entirely.
128
+ if (cached && ageMs < USAGE_CACHE_FRESH_MS) {
129
+ return { snapshot: cached, error: null };
130
+ }
131
+ // Stale-while-revalidate: cache exists and isn't ancient, return it now and
132
+ // refresh in the background so the next invocation has fresh data.
133
+ if (cached && ageMs < USAGE_CACHE_SWR_MS) {
134
+ triggerBackgroundUsageRefresh(input, usageKey);
135
+ return { snapshot: cached, error: null };
136
+ }
137
+ // Cold cache or > 24h old: block on live fetch.
117
138
  const usage = await getUsageInfo(input.agentId, {
118
139
  home: input.home,
119
140
  cliVersion: input.cliVersion,
120
141
  organizationId: input.info.organizationId,
121
142
  });
122
- if (input.agentId !== 'claude' || !usageKey) {
123
- return usage;
124
- }
125
143
  if (usage.snapshot?.source === 'live') {
126
144
  writeClaudeUsageCache(usageKey, usage.snapshot);
127
145
  return usage;
128
146
  }
129
- const cached = readClaudeUsageCache(usageKey);
147
+ // Live fetch failed — last-resort fallback to whatever cache we had.
130
148
  if (cached) {
131
149
  return { snapshot: cached, error: usage.error };
132
150
  }
133
151
  return usage;
134
152
  }
153
+ /**
154
+ * Kick off a background refresh of the usage cache. Errors are swallowed —
155
+ * a failed background refresh leaves the existing cache in place for the
156
+ * next invocation. The work is deferred to a future event-loop tick via
157
+ * `setImmediate` because some of the call chain (loadClaudeOauth →
158
+ * getKeychainToken → execFileSync) does synchronous I/O even though the
159
+ * functions are declared `async`. Without the defer, that sync I/O blocks
160
+ * the SWR caller and defeats the whole point of returning the cache instantly.
161
+ */
162
+ function triggerBackgroundUsageRefresh(input, usageKey) {
163
+ if (inFlightRefreshes.has(usageKey))
164
+ return;
165
+ const promise = new Promise((resolve) => {
166
+ setImmediate(async () => {
167
+ try {
168
+ const usage = await getUsageInfo(input.agentId, {
169
+ home: input.home,
170
+ cliVersion: input.cliVersion,
171
+ organizationId: input.info.organizationId,
172
+ });
173
+ if (usage.snapshot?.source === 'live') {
174
+ writeClaudeUsageCache(usageKey, usage.snapshot);
175
+ }
176
+ }
177
+ catch {
178
+ /* background refresh failed — leave existing cache in place */
179
+ }
180
+ finally {
181
+ inFlightRefreshes.delete(usageKey);
182
+ resolve();
183
+ }
184
+ });
185
+ });
186
+ inFlightRefreshes.set(usageKey, promise);
187
+ }
135
188
  /** Format a one-line usage summary with compact bars for inline display. */
136
189
  export function formatUsageSummary(plan, snapshot, planWidth = 3) {
137
190
  const parts = [];
@@ -148,6 +201,36 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3) {
148
201
  }
149
202
  return parts.join(' ');
150
203
  }
204
+ /**
205
+ * Compact colored badge for the account's overall usage status. Renders only
206
+ * when the account is throttled — `available` and `null` return ''.
207
+ *
208
+ * - `out_of_credits` → red "out of credits" (terminal account, all buckets dry)
209
+ * - `rate_limited` → yellow "rate-limited" (transient throttling)
210
+ *
211
+ * The badge sits between the usage bars and `lastActive` in `agents view`, so
212
+ * a glance at the row tells the user whether the version can do useful work.
213
+ * The same signal is exposed as `usageStatus` in `agents view --json` for
214
+ * programmatic consumers (e.g. the swarmify panel's "resume in healthy agent").
215
+ *
216
+ * The switch is exhaustive on purpose — adding a new `AccountInfo.usageStatus`
217
+ * value without updating the cases here is a build error at `_exhaustive`,
218
+ * which is exactly the bug class this PR is fixing.
219
+ */
220
+ export function formatUsageStatusBadge(usageStatus) {
221
+ if (usageStatus === null || usageStatus === undefined)
222
+ return '';
223
+ switch (usageStatus) {
224
+ case 'available': return '';
225
+ case 'out_of_credits': return chalk.red('out of credits');
226
+ case 'rate_limited': return chalk.yellow('rate-limited');
227
+ default: {
228
+ const _exhaustive = usageStatus;
229
+ void _exhaustive;
230
+ return '';
231
+ }
232
+ }
233
+ }
151
234
  /** Format a multi-line usage section for detailed agent views. */
152
235
  export function formatUsageSection(usage) {
153
236
  if (!usage.snapshot && !usage.error) {
@@ -48,12 +48,35 @@ export declare function getAvailableResources(cwd?: string): AvailableResources;
48
48
  export declare function getActuallySyncedResources(agent: AgentId, version: string, options?: {
49
49
  cwd?: string;
50
50
  }): AvailableResources;
51
+ /** Resource names that only exist in the project's `.agents/` layer, grouped by kind. */
52
+ export interface ProjectOnlyResources {
53
+ commands: Set<string>;
54
+ skills: Set<string>;
55
+ hooks: Set<string>;
56
+ subagents: Set<string>;
57
+ plugins: Set<string>;
58
+ workflows: Set<string>;
59
+ }
60
+ /**
61
+ * Names that exist ONLY in the project's `.agents/` layer (no matching entry in
62
+ * user/system/extra layers). Sync intentionally skips project-layer commands,
63
+ * skills, hooks, subagents, plugins, and workflows for security — see the
64
+ * defense comments above each sync branch in syncResourcesToVersion. Without
65
+ * this filter, those names would forever appear in the "New resources" diff
66
+ * because they live in `available` but never reach `actuallySynced`.
67
+ */
68
+ export declare function getProjectOnlyResources(cwd?: string): ProjectOnlyResources;
51
69
  /**
52
70
  * Compare available resources with what's ACTUALLY synced to version home.
53
71
  * Returns only NEW resources that haven't been synced yet.
54
72
  * Source of truth: the actual files/config, NOT agents.yaml tracking.
73
+ *
74
+ * `projectOnly` (recommended): the result of `getProjectOnlyResources(cwd)`.
75
+ * Names listed there are filtered out for kinds that sync intentionally
76
+ * excludes the project layer — otherwise they would re-appear as "new"
77
+ * on every run and "Yes, sync all new" would silently do nothing for them.
55
78
  */
56
- export declare function getNewResources(available: AvailableResources, actuallySynced: AvailableResources): AvailableResources;
79
+ export declare function getNewResources(available: AvailableResources, actuallySynced: AvailableResources, projectOnly?: ProjectOnlyResources): AvailableResources;
57
80
  /**
58
81
  * Check if there are any new resources to sync.
59
82
  * When version is provided, uses version-specific capability checks.
@@ -285,6 +308,17 @@ export interface InstalledAgentTargetResult {
285
308
  directAgents: AgentId[];
286
309
  versionSelections: Map<AgentId, string[]>;
287
310
  }
311
+ /**
312
+ * Thrown when the user references an agent@version that is not installed.
313
+ * Carries the parsed (agentId, version) so callers can react — e.g. prompt
314
+ * to install it on demand — without having to parse the error message.
315
+ */
316
+ export declare class VersionNotInstalledError extends Error {
317
+ readonly agentId: AgentId;
318
+ readonly version: string;
319
+ readonly installedVersions: readonly string[];
320
+ constructor(agentId: AgentId, version: string, installedVersions: readonly string[]);
321
+ }
288
322
  /**
289
323
  * Resolve a comma-separated --agents list into concrete version selections.
290
324
  * Bare agents target the default version, or the newest installed version when no default exists.