@phnx-labs/agents-cli 1.20.87 → 1.20.89

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 (94) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +12 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/commands.js +7 -7
  5. package/dist/commands/doctor.d.ts +0 -19
  6. package/dist/commands/doctor.js +219 -305
  7. package/dist/commands/exec.js +7 -19
  8. package/dist/commands/factory.js +26 -2
  9. package/dist/commands/funnel.js +16 -1
  10. package/dist/commands/inspect.js +3 -5
  11. package/dist/commands/menubar.js +117 -34
  12. package/dist/commands/routines.js +25 -3
  13. package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
  14. package/dist/commands/secrets-rotate-passphrase.js +96 -0
  15. package/dist/commands/secrets.js +2 -0
  16. package/dist/commands/sessions.d.ts +7 -1
  17. package/dist/commands/sessions.js +40 -12
  18. package/dist/commands/ssh.js +3 -3
  19. package/dist/commands/usage.d.ts +3 -2
  20. package/dist/commands/usage.js +2 -9
  21. package/dist/commands/webhook.js +7 -2
  22. package/dist/lib/agents.d.ts +31 -1
  23. package/dist/lib/agents.js +55 -0
  24. package/dist/lib/command-skills.d.ts +10 -0
  25. package/dist/lib/command-skills.js +14 -0
  26. package/dist/lib/commands.js +28 -2
  27. package/dist/lib/daemon.d.ts +29 -0
  28. package/dist/lib/daemon.js +75 -6
  29. package/dist/lib/devices/doctor-findings.d.ts +167 -0
  30. package/dist/lib/devices/doctor-findings.js +893 -0
  31. package/dist/lib/devices/fleet-divergence.d.ts +22 -0
  32. package/dist/lib/devices/fleet-divergence.js +34 -10
  33. package/dist/lib/devices/fleet-inventory.d.ts +17 -6
  34. package/dist/lib/devices/fleet-inventory.js +56 -8
  35. package/dist/lib/events.d.ts +1 -1
  36. package/dist/lib/exec.d.ts +14 -3
  37. package/dist/lib/exec.js +41 -8
  38. package/dist/lib/factory/snapshot.d.ts +78 -0
  39. package/dist/lib/factory/snapshot.js +209 -0
  40. package/dist/lib/fs-atomic.d.ts +14 -1
  41. package/dist/lib/fs-atomic.js +35 -3
  42. package/dist/lib/funnel.d.ts +1 -0
  43. package/dist/lib/funnel.js +8 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  47. package/dist/lib/menubar/install-menubar.d.ts +53 -2
  48. package/dist/lib/menubar/install-menubar.js +183 -28
  49. package/dist/lib/platform/process.d.ts +2 -0
  50. package/dist/lib/platform/process.js +5 -3
  51. package/dist/lib/project-resources.js +34 -20
  52. package/dist/lib/resources.d.ts +8 -0
  53. package/dist/lib/resources.js +34 -1
  54. package/dist/lib/routines-placement.d.ts +2 -1
  55. package/dist/lib/routines-placement.js +8 -4
  56. package/dist/lib/routines.d.ts +57 -1
  57. package/dist/lib/routines.js +74 -1
  58. package/dist/lib/runner.d.ts +16 -1
  59. package/dist/lib/runner.js +58 -16
  60. package/dist/lib/sandbox.d.ts +2 -0
  61. package/dist/lib/sandbox.js +38 -0
  62. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  63. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  64. package/dist/lib/secrets/bundles.js +9 -34
  65. package/dist/lib/secrets/filestore.d.ts +152 -34
  66. package/dist/lib/secrets/filestore.js +676 -123
  67. package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
  68. package/dist/lib/secrets/rc-hygiene.js +0 -24
  69. package/dist/lib/session/active.d.ts +6 -6
  70. package/dist/lib/session/active.js +6 -6
  71. package/dist/lib/session/discover.d.ts +5 -0
  72. package/dist/lib/session/discover.js +137 -1
  73. package/dist/lib/session/parse.d.ts +2 -0
  74. package/dist/lib/session/parse.js +76 -37
  75. package/dist/lib/session/remote-active.d.ts +4 -1
  76. package/dist/lib/session/remote-active.js +8 -2
  77. package/dist/lib/session/sync/agents.js +0 -0
  78. package/dist/lib/session/types.d.ts +1 -1
  79. package/dist/lib/session/types.js +1 -1
  80. package/dist/lib/session/viewing-in.d.ts +31 -0
  81. package/dist/lib/session/viewing-in.js +47 -0
  82. package/dist/lib/staleness/detectors/commands.js +14 -5
  83. package/dist/lib/staleness/types.d.ts +2 -0
  84. package/dist/lib/staleness/writers/commands.js +13 -7
  85. package/dist/lib/state.d.ts +17 -0
  86. package/dist/lib/state.js +30 -2
  87. package/dist/lib/triggers/handlers.d.ts +95 -0
  88. package/dist/lib/triggers/handlers.js +384 -0
  89. package/dist/lib/triggers/webhook.d.ts +10 -2
  90. package/dist/lib/triggers/webhook.js +65 -11
  91. package/dist/lib/usage.d.ts +72 -1
  92. package/dist/lib/usage.js +21 -27
  93. package/dist/lib/versions.js +30 -13
  94. package/package.json +1 -1
@@ -20,7 +20,7 @@ import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecut
20
20
  import { getActiveSessions } from '../lib/session/active.js';
21
21
  import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
22
22
  import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
23
- import { resolveViewingIn } from '../lib/session/viewing-in.js';
23
+ import { resolveViewingIn, viewingInLabel } from '../lib/session/viewing-in.js';
24
24
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
25
25
  import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
26
26
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
@@ -442,6 +442,11 @@ function modelLabel(model) {
442
442
  * (null when unknown) alongside the raw fields, so every active row is joinable.
443
443
  * `project` uses the same derivation SessionMeta does — basename(cwd) (see
444
444
  * discover.ts) — so the active view and the history view join identically.
445
+ *
446
+ * `viewingIn` flattens to the same display string the row renderer prints —
447
+ * `'codium tab 3'` / `'detached'` / null — so a consumer can tell a watched
448
+ * session from an orphaned one (its terminal died, the agent is still running)
449
+ * without re-implementing the tmux client lookup.
445
450
  */
446
451
  export function serializeActiveSessionsForJson(sessions) {
447
452
  return sessions.map((s) => ({
@@ -449,6 +454,7 @@ export function serializeActiveSessionsForJson(sessions) {
449
454
  ticketId: s.ticket?.id ?? null,
450
455
  project: s.cwd ? path.basename(s.cwd) : null,
451
456
  prLink: s.pr?.url ?? null,
457
+ viewingIn: viewingInLabel(s) ?? null,
452
458
  }));
453
459
  }
454
460
  /**
@@ -496,13 +502,9 @@ function locatorBadge(s) {
496
502
  // For a tmux-hosted session, say which app+tab is looking at it right now
497
503
  // (or that it's running detached). Only meaningful for tmux (the pane is the
498
504
  // durable handle; the viewer is transient).
499
- if (s.viewingIn) {
500
- const tab = s.viewingIn.tab != null ? ` tab ${s.viewingIn.tab}` : '';
501
- parts.push(chalk.gray(`viewing in ${s.viewingIn.app}${tab}`));
502
- }
503
- else {
504
- parts.push(chalk.gray('detached'));
505
- }
505
+ const label = viewingInLabel(s);
506
+ if (label)
507
+ parts.push(chalk.gray(label === 'detached' ? label : `viewing in ${label}`));
506
508
  }
507
509
  else if (p?.mux?.kind === 'screen') {
508
510
  parts.push(chalk.green('screen'));
@@ -860,13 +862,33 @@ async function enrichLocalLocators(local) {
860
862
  }
861
863
  }
862
864
  catch { /* non-fatal */ }
863
- // tmux attach targets + "viewing in <app> tab N", one batched query per socket.
865
+ // One Ghostty enumeration shared across every socket's viewing-in resolve
866
+ // (a tmux client can be attached from a Ghostty tab).
867
+ await enrichTmuxLocators(local, await enumerateGhosttyTabsQuietly());
868
+ }
869
+ /** {@link enumerateGhosttyTabs}, best-effort — an osascript failure yields no surfaces. */
870
+ async function enumerateGhosttyTabsQuietly() {
871
+ try {
872
+ return await enumerateGhosttyTabs();
873
+ }
874
+ catch {
875
+ return [];
876
+ }
877
+ }
878
+ /**
879
+ * The tmux half of {@link enrichLocalLocators}: the `session:window.pane` attach
880
+ * target and "viewing in <app> tab N" / detached, one batched query per socket.
881
+ *
882
+ * Split out because it is the only locator the `--json` path can afford. It costs
883
+ * tmux queries and a `ps` read — no osascript — so scriptable output stays cheap
884
+ * while still answering the question a consumer actually needs: is anyone looking
885
+ * at this session, or is it running orphaned? Without `surfaces`, a Ghostty-attached
886
+ * client still resolves as attached, just without its tab number.
887
+ */
888
+ async function enrichTmuxLocators(local, surfaces = []) {
864
889
  try {
865
890
  const tmux = local.filter(s => s.provenance?.mux?.kind === 'tmux' && s.provenance.mux.pane);
866
891
  if (tmux.length > 0) {
867
- // One Ghostty enumeration shared across every socket's viewing-in resolve
868
- // (a tmux client can be attached from a Ghostty tab).
869
- const surfaces = await enumerateGhosttyTabs();
870
892
  const sockets = new Set(tmux.map(s => s.provenance.mux.socket));
871
893
  for (const socket of sockets) {
872
894
  const paneMap = await mapPanesToTargets(socket);
@@ -964,6 +986,11 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
964
986
  // present so a supervising agent or hook can poll it as a gate.
965
987
  const sessions = waitingOnly ? merged.filter(s => s.status === 'input_required') : merged;
966
988
  if (asJson) {
989
+ // Resolve who is watching each local tmux pane before serializing: `viewingIn`
990
+ // is how a consumer distinguishes a session someone is looking at from one
991
+ // running orphaned after its terminal died. tmux-only (no osascript) so the
992
+ // scriptable path stays cheap — see enrichTmuxLocators.
993
+ await enrichTmuxLocators(sessions.filter(s => !s.machine || s.machine === self));
967
994
  process.stdout.write(JSON.stringify(serializeActiveSessionsForJson(sessions), null, 2) + '\n');
968
995
  if (waitingOnly && sessions.length > 0)
969
996
  process.exitCode = 1;
@@ -2368,6 +2395,7 @@ export function buildResumeCommand(session) {
2368
2395
  case 'grok':
2369
2396
  case 'kimi':
2370
2397
  case 'droid':
2398
+ case 'cursor':
2371
2399
  // Grok (and some others) sessions are captured artifacts, not resumable the same way.
2372
2400
  return null;
2373
2401
  }
@@ -313,7 +313,7 @@ function printFleetResults(results) {
313
313
  if (failed > 0)
314
314
  process.exitCode = 1;
315
315
  }
316
- function localHealthRow(self, stats) {
316
+ async function localHealthRow(self, stats) {
317
317
  return {
318
318
  name: self,
319
319
  platform: process.platform === 'darwin' ? 'macos' : process.platform,
@@ -324,7 +324,7 @@ function localHealthRow(self, stats) {
324
324
  orphans: countOrphans(),
325
325
  // Local baseline inventory for cross-device divergence (RUSH-2027) — the
326
326
  // yardstick every remote box is compared against.
327
- inventory: collectLocalFleetInventory(process.cwd()),
327
+ inventory: await collectLocalFleetInventory(process.cwd()),
328
328
  };
329
329
  }
330
330
  async function probeRemoteHealth(target) {
@@ -369,7 +369,7 @@ async function runFleetStatus(opts) {
369
369
  // read from a fresh probe, not a stale tailscale snapshot (RUSH-1965).
370
370
  // Best-effort: a registry write must never break the status render.
371
371
  await writeReachability(collectReachabilityWriteBacks(reg, statsMap)).catch(() => { });
372
- const rows = [localHealthRow(self, statsMap.get(self))];
372
+ const rows = [await localHealthRow(self, statsMap.get(self))];
373
373
  const remoteTargets = remoteFleetTargets(planned, self)
374
374
  .map((t) => ({
375
375
  name: t.device.name,
@@ -6,8 +6,9 @@
6
6
  * - codex: parsed from latest session log's rate_limits event
7
7
  * - kimi: live Kimi Code /usages API call (cached for 2 minutes)
8
8
  * - droid: live Factory billing/limits API call (cached for 2 minutes)
9
- * - others: marked as "not exposed by CLI" (Gemini, OpenCode, Cursor, etc.
10
- * don't publish per-account usage today)
9
+ * - grok: parsed from the latest local usage event
10
+ * - cursor: live Cursor usage API call (cached for 2 minutes)
11
+ * - others: marked as "not exposed by CLI"
11
12
  */
12
13
  import type { Command } from 'commander';
13
14
  import type { AgentId } from '../lib/types.js';
@@ -2,14 +2,7 @@ import { addHostOption } from '../lib/hosts/option.js';
2
2
  import chalk from 'chalk';
3
3
  import { ALL_AGENT_IDS, AGENTS, getAccountInfo, agentLabel, resolveAgentName, formatAgentError, } from '../lib/agents.js';
4
4
  import { listInstalledVersions, getGlobalDefault, getVersionHomePath } from '../lib/versions.js';
5
- import { formatUsageSection, getUsageInfoForIdentity } from '../lib/usage.js';
6
- /**
7
- * Agents whose CLI surfaces usage data we can read today. Kept in sync with the
8
- * live/last-seen sources `getUsageInfo` dispatches on in `../lib/usage.js`
9
- * (claude, codex, kimi, droid) — an agent with a usage source but missing here
10
- * would wrongly print "does not publish usage data" for a signed-in account.
11
- */
12
- const USAGE_SUPPORTED = new Set(['claude', 'codex', 'kimi', 'droid']);
5
+ import { agentReportsUsage, formatUsageSection, getUsageInfoForIdentity } from '../lib/usage.js';
13
6
  export function registerUsageCommand(program) {
14
7
  addHostOption(program.command('usage [agent]'))
15
8
  .description('Show rate-limit / quota usage per agent')
@@ -55,7 +48,7 @@ async function collectAgentUsage(agentId) {
55
48
  // Plain name — color is applied only at text-render time (formatAgentUsage), so
56
49
  // `--json` never emits ANSI escapes in `label` (e.g. under FORCE_COLOR=1).
57
50
  const label = AGENTS[agentId].name;
58
- if (!USAGE_SUPPORTED.has(agentId)) {
51
+ if (!agentReportsUsage(agentId)) {
59
52
  return { agent: agentId, label, status: 'unsupported' };
60
53
  }
61
54
  const versions = listInstalledVersions(agentId);
@@ -77,9 +77,14 @@ export function registerWebhookCommand(program) {
77
77
  // Durable delivery dedup: replays survive a receiver restart (an
78
78
  // in-memory store would forget every seen delivery on restart).
79
79
  deliveryStore: createFileDeliveryStore(path.join(getRuntimeStateDir(), 'webhook', 'deliveries.json')),
80
- onDelivery: (webhook, fired) => {
80
+ onDelivery: (webhook, fired, handlers) => {
81
+ const parts = [];
82
+ if (fired.length)
83
+ parts.push(`routines ${fired.map((f) => f.jobName).join(', ')}`);
84
+ if (handlers.length)
85
+ parts.push(`handlers ${handlers.map((h) => h.handlerName).join(', ')}`);
81
86
  console.log(`${new Date().toISOString()} ${webhook.source}:${webhook.event} ` +
82
- `${fired.length ? `fired ${fired.map((f) => f.jobName).join(', ')}` : 'no match'}`);
87
+ (parts.length ? `fired ${parts.join('; ')}` : 'no match'));
83
88
  },
84
89
  });
85
90
  await waitForListening(server);
@@ -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", "grok", "antigravity", "kimi", "droid", "opencode"];
168
+ export declare const ACCOUNT_INSPECTION_AGENT_IDS: readonly ["claude", "codex", "gemini", "cursor", "grok", "antigravity", "kimi", "droid", "opencode"];
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
  /**
@@ -176,6 +176,36 @@ export declare function supportsAccountInspection(agentId: AgentId): boolean;
176
176
  export declare function accountDisplayLabel(info?: Pick<AccountInfo, 'email' | 'accountId' | 'signedIn' | 'organizationType' | 'organizationName'> | null): string;
177
177
  /** Return the email address associated with the agent's auth config, or null. */
178
178
  export declare function getAccountEmail(agentId: AgentId, home?: string): Promise<string | null>;
179
+ /** Where an agent's credential file lives, split into the per-version copy and
180
+ * the active/global copy under the real HOME. */
181
+ export interface CredentialPresence {
182
+ /** The credential file exists inside the passed version home. */
183
+ perVersion: boolean;
184
+ /** The credential file exists under the active/global HOME (the one the login
185
+ * symlink actually targets), independent of the version home. */
186
+ active: boolean;
187
+ /** Whether agents-cli knows WHERE this agent's credential lives at all — i.e.
188
+ * the agent has an entry in {@link CREDENTIAL_FILE_SEGMENTS}. When false both
189
+ * probes are trivially false because there is nothing to look for, so absence
190
+ * is NOT evidence of a logout and no caller may treat it as provable.
191
+ *
192
+ * This is deliberately separate from `supportsAccountInspection`: the two
193
+ * registries move independently, and an agent has already been added to the
194
+ * inspection set without a credential path (cursor), which without this flag
195
+ * produced a false "logged out" critical for every installed version. */
196
+ knownLocation: boolean;
197
+ }
198
+ /**
199
+ * File-presence probe for an agent's credential, split by location: whether it
200
+ * exists in a SPECIFIC version home (`perVersion`) and whether it exists under
201
+ * the active/global HOME (`active`). A logged-out claim is only *provable* when
202
+ * BOTH are absent — a version that merely lacks its own copy but shares the
203
+ * global login is signed in, not logged out. Pure file existence; no decrypt,
204
+ * no network, no keychain prompt. Agents with no inspectable identity return
205
+ * `{ perVersion: false, active: false }` and must NEVER yield a provable-logout
206
+ * claim (the caller gates on {@link supportsAccountInspection}).
207
+ */
208
+ export declare function credentialPresence(agentId: AgentId, versionHome: string): CredentialPresence;
179
209
  /** Decrypted contents of Droid's auth.v2.file (subset we consume). */
180
210
  export interface DroidAuthPayload {
181
211
  access_token?: string;
@@ -1014,6 +1014,7 @@ export const ACCOUNT_INSPECTION_AGENT_IDS = [
1014
1014
  'claude',
1015
1015
  'codex',
1016
1016
  'gemini',
1017
+ 'cursor',
1017
1018
  'grok',
1018
1019
  'antigravity',
1019
1020
  'kimi',
@@ -1077,6 +1078,60 @@ function resolveAccountCredentialPath(base, ...segments) {
1077
1078
  }
1078
1079
  return null;
1079
1080
  }
1081
+ /**
1082
+ * The on-disk credential file(s) each account-inspectable agent authenticates
1083
+ * from, expressed as path segments under a home. Mirrors the exact files
1084
+ * {@link getAccountInfo} reads, so a presence check here matches what a real
1085
+ * launch would find. Each entry is a list of alternatives — the FIRST that
1086
+ * exists counts as present (claude writes either `.claude/.claude.json` under
1087
+ * the shimmed config dir or a home-level `.claude.json`). Agents whose login is
1088
+ * stored only in the OS keychain on some platforms (antigravity, and claude's
1089
+ * token) still expose a credential FILE — the presence of that file is the
1090
+ * signal we key off; its absence on BOTH the per-version home and the active
1091
+ * home is what makes a logged-out claim provable.
1092
+ */
1093
+ const CREDENTIAL_FILE_SEGMENTS = {
1094
+ claude: [['.claude', '.claude.json'], ['.claude.json']],
1095
+ codex: [['.codex', 'auth.json']],
1096
+ gemini: [['.gemini', 'google_accounts.json']],
1097
+ grok: [['.grok', 'auth.json']],
1098
+ kimi: [['.kimi-code', 'credentials', 'kimi-code.json']],
1099
+ droid: [['.factory', 'auth.v2.file']],
1100
+ antigravity: [['.gemini', 'antigravity-cli', 'antigravity-oauth-token']],
1101
+ opencode: [['.local', 'share', 'opencode', 'auth.json']],
1102
+ };
1103
+ /** Whether an agent's credential file exists under a given home. */
1104
+ function credentialFileExistsUnder(agentId, home) {
1105
+ const alternatives = CREDENTIAL_FILE_SEGMENTS[agentId];
1106
+ if (!alternatives)
1107
+ return false;
1108
+ for (const segments of alternatives) {
1109
+ const p = path.join(home, ...segments);
1110
+ try {
1111
+ if (fs.existsSync(p))
1112
+ return true;
1113
+ }
1114
+ catch { /* unreadable */ }
1115
+ }
1116
+ return false;
1117
+ }
1118
+ /**
1119
+ * File-presence probe for an agent's credential, split by location: whether it
1120
+ * exists in a SPECIFIC version home (`perVersion`) and whether it exists under
1121
+ * the active/global HOME (`active`). A logged-out claim is only *provable* when
1122
+ * BOTH are absent — a version that merely lacks its own copy but shares the
1123
+ * global login is signed in, not logged out. Pure file existence; no decrypt,
1124
+ * no network, no keychain prompt. Agents with no inspectable identity return
1125
+ * `{ perVersion: false, active: false }` and must NEVER yield a provable-logout
1126
+ * claim (the caller gates on {@link supportsAccountInspection}).
1127
+ */
1128
+ export function credentialPresence(agentId, versionHome) {
1129
+ const realHome = process.env.AGENTS_REAL_HOME || os.homedir();
1130
+ const perVersion = credentialFileExistsUnder(agentId, versionHome);
1131
+ const active = credentialFileExistsUnder(agentId, realHome);
1132
+ const knownLocation = (CREDENTIAL_FILE_SEGMENTS[agentId]?.length ?? 0) > 0;
1133
+ return { perVersion, active, knownLocation };
1134
+ }
1080
1135
  /**
1081
1136
  * Factory Droid stores its OAuth credential encrypted at ~/.factory/auth.v2.file
1082
1137
  * (AES-256-GCM, format `ivB64:tagB64:ctB64`) with the 32-byte key base64-stored
@@ -4,6 +4,16 @@
4
4
  */
5
5
  import type { AgentId } from './types.js';
6
6
  export declare function shouldInstallCommandAsSkill(agent: AgentId, version: string): boolean;
7
+ /**
8
+ * Agents whose native command files serve a separate surface while their CLI
9
+ * consumes the generated skill form. Keep this registry distinct from
10
+ * `shouldInstallCommandAsSkill`: these targets need both writes, not a format
11
+ * replacement.
12
+ */
13
+ export declare const COMMAND_SKILL_DUAL_WRITE_TARGETS: {
14
+ cursor: true;
15
+ };
16
+ export declare function shouldAlsoInstallCommandAsSkill(agent: AgentId, version: string): boolean;
7
17
  export declare function commandSkillName(commandName: string): string;
8
18
  export declare function buildCommandSkillContent(commandName: string, sourcePath: string): string;
9
19
  export declare function skillSourceExists(skillName: string, skillSourceDirs: Array<string | null | undefined>): boolean;
@@ -52,6 +52,20 @@ function readSkillCommandMarker(skillMdPath) {
52
52
  export function shouldInstallCommandAsSkill(agent, version) {
53
53
  return !supports(agent, 'commands', version).ok && supports(agent, 'skills', version).ok;
54
54
  }
55
+ /**
56
+ * Agents whose native command files serve a separate surface while their CLI
57
+ * consumes the generated skill form. Keep this registry distinct from
58
+ * `shouldInstallCommandAsSkill`: these targets need both writes, not a format
59
+ * replacement.
60
+ */
61
+ export const COMMAND_SKILL_DUAL_WRITE_TARGETS = {
62
+ cursor: true,
63
+ };
64
+ export function shouldAlsoInstallCommandAsSkill(agent, version) {
65
+ return COMMAND_SKILL_DUAL_WRITE_TARGETS[agent] === true
66
+ && supports(agent, 'commands', version).ok
67
+ && supports(agent, 'skills', version).ok;
68
+ }
55
69
  export function commandSkillName(commandName) {
56
70
  return commandName;
57
71
  }
@@ -11,11 +11,12 @@ import * as path from 'path';
11
11
  import * as yaml from 'yaml';
12
12
  import { AGENTS, ensureCommandsDir, agentConfigDirName, resolveAgentName } from './agents.js';
13
13
  import { capableAgents, isCapable, supports } from './capabilities.js';
14
+ import { isDirectoryDoc } from './resources.js';
14
15
  import { markdownToToml } from './convert.js';
15
16
  import { getCommandsDir, getUserCommandsDir, getEnabledExtraRepos, getProjectAgentsDir, getSkillsDir, getTrashCommandsDir } from './state.js';
16
17
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions, resolveVersion } from './versions.js';
17
18
  import { discoverPlugins } from './plugins.js';
18
- import { commandSkillMatches, installCommandSkillToVersion, listCommandSkillsInVersion, removeCommandSkillFromVersion, shouldInstallCommandAsSkill, } from './command-skills.js';
19
+ import { commandSkillMatches, installCommandSkillToVersion, listCommandSkillsInVersion, removeCommandSkillFromVersion, shouldAlsoInstallCommandAsSkill, shouldInstallCommandAsSkill, } from './command-skills.js';
19
20
  import { installGooseCommandToVersion, listGooseCommandsInVersion, gooseCommandMatches, removeGooseCommandFromVersion, } from './goose-commands.js';
20
21
  function compareVersions(a, b) {
21
22
  const aParts = a.split('.').map((n) => parseInt(n, 10) || 0);
@@ -150,6 +151,8 @@ export function discoverCommands(repoPath) {
150
151
  for (const file of fs.readdirSync(commandsDir)) {
151
152
  if (file.endsWith('.md')) {
152
153
  const name = file.replace('.md', '');
154
+ if (isDirectoryDoc('commands', name))
155
+ continue;
153
156
  const sourcePath = path.join(commandsDir, file);
154
157
  const metadata = parseCommandMetadata(sourcePath);
155
158
  const validation = validateCommandMetadata(metadata, name);
@@ -211,6 +214,14 @@ export function installCommand(sourcePath, agentId, commandName, method = 'symli
211
214
  const agent = AGENTS[agentId];
212
215
  ensureCommandsDir(agentId);
213
216
  const home = getEffectiveHome(agentId);
217
+ const installVersion = pinnedVersion ?? listInstalledVersions(agentId)[0] ?? '';
218
+ const alsoInstallAsSkill = shouldAlsoInstallCommandAsSkill(agentId, installVersion);
219
+ if (alsoInstallAsSkill) {
220
+ const installed = installCommandSkillToVersion(path.join(home, agentConfigDirName(agentId)), commandName, sourcePath, [getSkillsDir(), ...getEnabledExtraRepos().map((repo) => path.join(repo.dir, 'skills'))]);
221
+ if (!installed.success) {
222
+ return { path: '', method: 'copy', error: installed.error, warnings: validation.warnings };
223
+ }
224
+ }
214
225
  // Goose: a slash command is a recipe YAML registered in config.yaml, not a
215
226
  // native command file under commandsSubdir.
216
227
  if (agentId === 'goose') {
@@ -401,6 +412,11 @@ export function installCommandToVersion(agent, version, commandName, method = 'c
401
412
  ...getEnabledExtraRepos().map((repo) => path.join(repo.dir, 'skills')),
402
413
  ]);
403
414
  }
415
+ if (shouldAlsoInstallCommandAsSkill(agent, version)) {
416
+ const installed = installCommandSkillToVersion(agentDir, commandName, sourcePath, [getSkillsDir(), ...getEnabledExtraRepos().map((repo) => path.join(repo.dir, 'skills'))]);
417
+ if (!installed.success)
418
+ return installed;
419
+ }
404
420
  // Goose: a slash command is a recipe YAML registered in config.yaml, not a
405
421
  // native command file. Write the recipe + slash_commands entry.
406
422
  if (agent === 'goose') {
@@ -441,6 +457,11 @@ export function removeCommandFromVersion(agent, version, commandName) {
441
457
  if (shouldInstallCommandAsSkill(agent, version)) {
442
458
  return removeCommandSkillFromVersion(agentDir, commandName);
443
459
  }
460
+ if (shouldAlsoInstallCommandAsSkill(agent, version)) {
461
+ const removed = removeCommandSkillFromVersion(agentDir, commandName);
462
+ if (!removed.success)
463
+ return removed;
464
+ }
444
465
  if (agent === 'goose') {
445
466
  const trashDir = path.join(getTrashCommandsDir(), agent, version, commandName);
446
467
  return removeGooseCommandFromVersion(versionHome, commandName, trashDir);
@@ -676,7 +697,12 @@ export function listCentralCommands() {
676
697
  if (!fs.existsSync(dir))
677
698
  continue;
678
699
  for (const f of fs.readdirSync(dir).filter((f) => f.endsWith('.md'))) {
679
- seen.add(f.replace('.md', ''));
700
+ const name = f.replace('.md', '');
701
+ // A directory's README/AGENTS/CLAUDE/GEMINI documents the dir, it is not a
702
+ // command. Without this the picker offers a name resolveResource refuses.
703
+ if (isDirectoryDoc('commands', name))
704
+ continue;
705
+ seen.add(name);
680
706
  }
681
707
  }
682
708
  return Array.from(seen);
@@ -86,6 +86,23 @@ export declare function log(level: string, message: string): void;
86
86
  * anchoring failed (logged, non-fatal).
87
87
  */
88
88
  export declare function anchorDaemonCwd(): string | null;
89
+ /**
90
+ * Surface, at the daemon's OWN startup, that it was launched from an ephemeral
91
+ * root that will wedge it if the directory is removed. This is the runtime
92
+ * companion to the launch-time check in validateDaemonBinary (which only runs
93
+ * when the daemon is *spawned* via getDaemonLaunch): a direct
94
+ * `agents __daemon-run` from a temp or worktree build — e.g. a review/verify
95
+ * checkout under /tmp — never passes through that path, so without this the
96
+ * wedge risk stays invisible until jobs start ENOENT-ing on their dynamic
97
+ * imports. Best-effort and non-fatal; the cwd is already handled by
98
+ * anchorDaemonCwd, but a deleted module root can only be flagged, not repaired.
99
+ *
100
+ * `resolveBin` is injectable (defaults to getAgentsBinPath) so the wiring — the
101
+ * predicate call, the WARN, and the non-fatal guard around a throwing resolver —
102
+ * is testable. Returns the warning message it logged, or null when the launch
103
+ * root is stable (or could not be resolved).
104
+ */
105
+ export declare function warnEphemeralDaemonRoot(resolveBin?: () => string): string | null;
89
106
  export declare function runDaemon(): Promise<void>;
90
107
  /**
91
108
  * Write a launchd plist or systemd unit with owner-only permissions atomically.
@@ -175,6 +192,18 @@ export declare function getAgentsInvocation(subArgs: string[], agentsBin?: strin
175
192
  command: string;
176
193
  args: string[];
177
194
  };
195
+ /**
196
+ * A daemon binary living under an ephemeral path — a git worktree, or a temp
197
+ * directory (`/tmp`, `/var/folders`, `/dev/shm`) — is a latent wedge. The daemon
198
+ * is long-lived but resolves its own job modules by dynamic `import()` rooted at
199
+ * this entry (getAgentsBinPath → process.argv[1]). If that directory is later
200
+ * removed (`git worktree remove`, a `/tmp` cleanup, a review/verify checkout
201
+ * teardown) the running daemon keeps ENOENT-ing on every job it loads —
202
+ * `anchorDaemonCwd` rescues the cwd, but nothing can re-root a deleted module
203
+ * tree. Returns a human phrase naming the ephemeral kind, or null for a stable
204
+ * install path (version home, a global npm prefix, a normal source checkout).
205
+ */
206
+ export declare function describeEphemeralDaemonRoot(binPath: string): string | null;
178
207
  export declare function validateDaemonBinary(binPath: string): {
179
208
  warnings: string[];
180
209
  };
@@ -321,6 +321,39 @@ export function anchorDaemonCwd() {
321
321
  return null;
322
322
  }
323
323
  }
324
+ /**
325
+ * Surface, at the daemon's OWN startup, that it was launched from an ephemeral
326
+ * root that will wedge it if the directory is removed. This is the runtime
327
+ * companion to the launch-time check in validateDaemonBinary (which only runs
328
+ * when the daemon is *spawned* via getDaemonLaunch): a direct
329
+ * `agents __daemon-run` from a temp or worktree build — e.g. a review/verify
330
+ * checkout under /tmp — never passes through that path, so without this the
331
+ * wedge risk stays invisible until jobs start ENOENT-ing on their dynamic
332
+ * imports. Best-effort and non-fatal; the cwd is already handled by
333
+ * anchorDaemonCwd, but a deleted module root can only be flagged, not repaired.
334
+ *
335
+ * `resolveBin` is injectable (defaults to getAgentsBinPath) so the wiring — the
336
+ * predicate call, the WARN, and the non-fatal guard around a throwing resolver —
337
+ * is testable. Returns the warning message it logged, or null when the launch
338
+ * root is stable (or could not be resolved).
339
+ */
340
+ export function warnEphemeralDaemonRoot(resolveBin = getAgentsBinPath) {
341
+ try {
342
+ const bin = resolveBin();
343
+ const ephemeralRoot = describeEphemeralDaemonRoot(bin);
344
+ if (!ephemeralRoot)
345
+ return null;
346
+ const message = `Daemon launched from ${ephemeralRoot} (${bin}); if that directory is removed, ` +
347
+ `every routine will fail with ENOENT on its module imports. Run the daemon from the ` +
348
+ `globally installed binary instead (npm i -g @phnx-labs/agents-cli), then restart it.`;
349
+ log('WARN', message);
350
+ return message;
351
+ }
352
+ catch (err) {
353
+ log('WARN', `Could not check daemon launch root: ${err.message}`);
354
+ return null;
355
+ }
356
+ }
324
357
  export async function runDaemon() {
325
358
  // Single-instance guard: a direct `agents __daemon-run` (manual, or a
326
359
  // service-manager restart racing a live predecessor) must not clobber a
@@ -334,6 +367,7 @@ export async function runDaemon() {
334
367
  }
335
368
  log('INFO', `Daemon started (PID: ${process.pid})`);
336
369
  anchorDaemonCwd();
370
+ warnEphemeralDaemonRoot();
337
371
  // The daemon holds NO Claude credential of its own. Routine runs authenticate
338
372
  // exactly like an interactive `agents run`: through the per-account
339
373
  // CLAUDE_CONFIG_DIR login on this device (its own auto-refreshing
@@ -880,7 +914,7 @@ ${[launch.command, ...launch.args].map((arg) => ` <string>${xmlEscape(arg)}</
880
914
  <key>EnvironmentVariables</key>
881
915
  <dict>
882
916
  <key>PATH</key>
883
- <string>${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin</string>
917
+ <string>${daemonPathValue(agentsBin, ['/usr/local/bin', '/usr/bin', '/bin', '/opt/homebrew/bin', `${os.homedir()}/.bun/bin`])}</string>
884
918
  </dict>
885
919
  </dict>
886
920
  </plist>`;
@@ -909,7 +943,7 @@ Type=simple
909
943
  ExecStart=${execStart}
910
944
  Restart=always
911
945
  RestartSec=10
912
- Environment=PATH=${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin
946
+ Environment=PATH=${daemonPathValue(agentsBin, ['/usr/local/bin', '/usr/bin', '/bin'])}
913
947
 
914
948
  [Install]
915
949
  WantedBy=default.target`;
@@ -1074,6 +1108,21 @@ export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
1074
1108
  function daemonNodeBinDir() {
1075
1109
  return path.dirname(process.execPath);
1076
1110
  }
1111
+ /**
1112
+ * The full PATH value the daemon service manifest pins, in order: the Node runtime
1113
+ * dir (so the shim's shebang and any child resolve the exact installing Node — see
1114
+ * {@link daemonNodeBinDir}), then the directory of the `agents` shim itself, then
1115
+ * the platform's system dirs. The shim's own dir matters because a scheduled
1116
+ * `command` routine shells out to the bare name `agents`
1117
+ * (`/bin/sh -c 'agents watchdog --nudge'`): when the shim lives outside the Node
1118
+ * bin dir — a `~/.local/bin` global install, a separate npm prefix — a PATH
1119
+ * carrying only the Node dir resolves `agents` to nothing and every routine dies
1120
+ * with `exit 127`. Deduped across the whole list, so a Node/shim dir that already
1121
+ * appears among the system dirs (e.g. a `/usr/local/bin` install) never doubles.
1122
+ */
1123
+ function daemonPathValue(agentsBin, systemDirs) {
1124
+ return [...new Set([daemonNodeBinDir(), path.dirname(agentsBin), ...systemDirs])].join(':');
1125
+ }
1077
1126
  /**
1078
1127
  * Build the argv to relaunch the `agents` CLI with the given subcommand args.
1079
1128
  *
@@ -1091,15 +1140,35 @@ function daemonNodeBinDir() {
1091
1140
  export function getAgentsInvocation(subArgs, agentsBin = getAgentsBinPath()) {
1092
1141
  return getCliLaunch(subArgs, agentsBin);
1093
1142
  }
1143
+ /**
1144
+ * A daemon binary living under an ephemeral path — a git worktree, or a temp
1145
+ * directory (`/tmp`, `/var/folders`, `/dev/shm`) — is a latent wedge. The daemon
1146
+ * is long-lived but resolves its own job modules by dynamic `import()` rooted at
1147
+ * this entry (getAgentsBinPath → process.argv[1]). If that directory is later
1148
+ * removed (`git worktree remove`, a `/tmp` cleanup, a review/verify checkout
1149
+ * teardown) the running daemon keeps ENOENT-ing on every job it loads —
1150
+ * `anchorDaemonCwd` rescues the cwd, but nothing can re-root a deleted module
1151
+ * tree. Returns a human phrase naming the ephemeral kind, or null for a stable
1152
+ * install path (version home, a global npm prefix, a normal source checkout).
1153
+ */
1154
+ export function describeEphemeralDaemonRoot(binPath) {
1155
+ if (/[/\\]\.agents[/\\]worktrees[/\\]/.test(binPath))
1156
+ return 'a git worktree';
1157
+ if (/^(?:\/private)?\/tmp[/\\]|^(?:\/private)?\/var\/folders[/\\]|^\/dev\/shm[/\\]/.test(binPath)) {
1158
+ return 'a temporary directory';
1159
+ }
1160
+ return null;
1161
+ }
1094
1162
  export function validateDaemonBinary(binPath) {
1095
1163
  const warnings = [];
1096
1164
  if (BUN_VIRTUAL_ROOT.test(binPath)) {
1097
1165
  throw new Error(`Refusing to supervise daemon: resolved binary is a bun virtual path (${binPath}). ` +
1098
1166
  `Install agents globally (npm i -g @phnx-labs/agents-cli) and restart.`);
1099
1167
  }
1100
- if (/[/\\]\.agents[/\\]worktrees[/\\]/.test(binPath)) {
1101
- warnings.push(`Warning: daemon binary is inside a git worktree (${binPath}). ` +
1102
- `A worktree deletion will wedge the daemon. Use the globally installed binary instead.`);
1168
+ const ephemeralRoot = describeEphemeralDaemonRoot(binPath);
1169
+ if (ephemeralRoot) {
1170
+ warnings.push(`Warning: daemon binary is inside ${ephemeralRoot} (${binPath}). ` +
1171
+ `Deleting it will wedge the daemon. Use the globally installed binary instead.`);
1103
1172
  }
1104
1173
  if (!fs.existsSync(binPath) && !/\.(c|m)?js$/.test(binPath)) {
1105
1174
  warnings.push(`Warning: daemon binary does not exist on disk (${binPath}).`);
@@ -1117,7 +1186,7 @@ export function startDetached(opts = {}) {
1117
1186
  // and a console-close event tears it down when the launcher exits (#556).
1118
1187
  const child = spawn(command, args, {
1119
1188
  stdio: ['ignore', logFd, logFd],
1120
- ...backgroundSpawnOptions({ fdStdio: true }),
1189
+ ...backgroundSpawnOptions({ cwd: os.homedir(), fdStdio: true }),
1121
1190
  env: opts.env ?? process.env,
1122
1191
  });
1123
1192
  // A failed spawn (ENOENT/EACCES) emits 'error' asynchronously; without a