@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
@@ -22,6 +22,7 @@ import { getRunsDir } from './state.js';
22
22
  import { prepareJobHome, buildSpawnEnv, getJobHomePath } from './sandbox.js';
23
23
  import { resolveModel, buildReasoningFlags } from './models.js';
24
24
  import { createTimer, redactPrompt } from './events.js';
25
+ import { codexEditWritableRoots, codexPolicyArgs } from './codex-policy.js';
25
26
  import { normalizeMode, resolveHeadlessMode, buildExecEnv, detectRateLimit, detectAuthFailure, isAuthFailureFromLog, authFailureReason, } from './exec.js';
26
27
  import { resolveActor } from './actor.js';
27
28
  import { loadTask as loadHostTask } from './hosts/tasks.js';
@@ -99,11 +100,12 @@ function terminateRoutineTree(pid) {
99
100
  /** CLI command templates per agent, with {prompt} as a placeholder. */
100
101
  const AGENT_COMMANDS = {
101
102
  claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
102
- codex: ['codex', 'exec', '--sandbox', 'workspace-write', '{prompt}', '--json'],
103
+ codex: ['codex', 'exec', '{prompt}', '--json'],
103
104
  gemini: ['gemini', '{prompt}', '--output-format', 'stream-json'],
104
105
  cursor: ['cursor-agent', '-p', '{prompt}', '--output-format', 'stream-json'],
105
106
  kimi: ['kimi', '--prompt', '{prompt}', '--output-format', 'stream-json'],
106
107
  droid: ['droid', 'exec', '{prompt}', '-o', 'stream-json'],
108
+ muse: ['muse', 'exec', '{prompt}', '--json'],
107
109
  };
108
110
  /** Agents the daemon can actually run, derived from the command table above
109
111
  * so the `--agent` help and any validation can never drift from it. */
@@ -137,6 +139,8 @@ const ROUTINE_TRANSCRIPT_SPECS = {
137
139
  { root: ['.kimi-code', 'sessions'], ext: '.jsonl' },
138
140
  ],
139
141
  grok: [{ root: ['.grok', 'sessions'], ext: '.json' }],
142
+ // Muse: ~/.local/share/muse/sessions/YYYY/MM/DD/<uuid>/session.jsonl
143
+ muse: [{ root: ['.local', 'share', 'muse', 'sessions'], ext: '.jsonl' }],
140
144
  };
141
145
  /** Stable working directory for routine children, independent of the daemon's launch cwd. */
142
146
  export function routineSpawnCwd(config, configuredRoot = getProjectRoot()) {
@@ -210,24 +214,14 @@ export function buildJobCommand(config, resolvedPrompt) {
210
214
  appendModelAndReasoning(cmd, config);
211
215
  }
212
216
  if (config.agent === 'codex') {
213
- if (mode === 'plan') {
214
- // The template defaults to workspace-write; plan means read-only.
215
- const sbIndex = cmd.indexOf('--sandbox');
216
- if (sbIndex !== -1)
217
- cmd[sbIndex + 1] = 'read-only';
218
- }
219
- else if (mode === 'edit' || mode === 'auto') {
220
- // Keep the workspace-write sandbox — no approval bypass; only skip drops
221
- // the guardrails. Re-enable network, which workspace-write turns off.
222
- cmd.push('-c', 'sandbox_workspace_write.network_access=true');
223
- }
224
- else if (mode === 'skip') {
225
- // Remove sandbox restriction, just --dangerously-bypass-approvals-and-sandbox
226
- const sbIndex = cmd.indexOf('--sandbox');
227
- if (sbIndex !== -1)
228
- cmd.splice(sbIndex, 2);
229
- cmd.push('--dangerously-bypass-approvals-and-sandbox');
230
- }
217
+ const policyMode = mode === 'plan' || mode === 'skip' ? mode : 'edit';
218
+ const routineRoots = (config.allow?.dirs ?? []).map((dir) => {
219
+ if (dir.startsWith('-')) {
220
+ throw new Error(`allow.dirs entries must not start with '-': ${JSON.stringify(dir)}`);
221
+ }
222
+ return dir.replace(/^~/, os.homedir());
223
+ });
224
+ cmd.push(...codexPolicyArgs(policyMode, [...codexEditWritableRoots(), ...routineRoots]));
231
225
  appendModelAndReasoning(cmd, config);
232
226
  }
233
227
  if (config.agent === 'gemini') {
@@ -277,6 +271,21 @@ export function buildJobCommand(config, resolvedPrompt) {
277
271
  }
278
272
  appendModelAndReasoning(cmd, config);
279
273
  }
274
+ if (config.agent === 'muse') {
275
+ // muse exec: plan ≈ no non-shell writes; auto skips approval prompts but
276
+ // keeps the OS sandbox; skip is --yolo (no approval, no sandbox, trust).
277
+ if (mode === 'plan') {
278
+ cmd.push('--disable-write');
279
+ }
280
+ else if (mode === 'auto') {
281
+ cmd.push('--disable-approval');
282
+ }
283
+ else if (mode === 'skip') {
284
+ cmd.push('--yolo');
285
+ }
286
+ // edit: default on-request approval + sandbox
287
+ appendModelAndReasoning(cmd, config);
288
+ }
280
289
  return cmd;
281
290
  }
282
291
  /**
@@ -8,8 +8,6 @@
8
8
  <string>Agents CLI</string>
9
9
  <key>CFBundleDisplayName</key>
10
10
  <string>Agents CLI</string>
11
- <key>CFBundleIconFile</key>
12
- <string>AppIcon</string>
13
11
  <key>CFBundleExecutable</key>
14
12
  <string>Agents CLI</string>
15
13
  <key>CFBundlePackageType</key>
@@ -3,21 +3,9 @@
3
3
  <plist version="1.0">
4
4
  <dict>
5
5
  <key>files</key>
6
- <dict>
7
- <key>Resources/AppIcon.icns</key>
8
- <data>
9
- DFq5H08EkhgWIC3UvGMR9B58BZw=
10
- </data>
11
- </dict>
6
+ <dict/>
12
7
  <key>files2</key>
13
8
  <dict>
14
- <key>Resources/AppIcon.icns</key>
15
- <dict>
16
- <key>hash2</key>
17
- <data>
18
- mBSjM6jlvN7J1jQowsvqTl7CHM0pur0e6qsHMuzk5k0=
19
- </data>
20
- </dict>
21
9
  <key>embedded.provisionprofile</key>
22
10
  <dict>
23
11
  <key>hash2</key>
@@ -22,10 +22,24 @@
22
22
  * through the explicit export/import flow in src/lib/secrets/sync.ts
23
23
  * rather than the system's cloud-keychain path.
24
24
  */
25
- import { type SpawnSyncOptions } from 'child_process';
25
+ import { type SpawnSyncOptions, type SpawnSyncReturns } from 'child_process';
26
26
  import type { NativeImportReport } from './fallback.js';
27
27
  export type { NativeImportReport, NativeImportResult, NativeImportStatus } from './fallback.js';
28
28
  export declare const SECRETS_ITEM_PREFIX = "agents-cli.secrets.";
29
+ /**
30
+ * Thrown when a keychain helper / security spawnSync is killed because it
31
+ * exceeded its timeout. A wedged coreauthd / LocalAuthentication dialog can hang
32
+ * the parent forever; this makes the failure explicit and arms the read back-off.
33
+ */
34
+ export declare class KeychainHelperTimeoutError extends Error {
35
+ readonly bin: string;
36
+ readonly args: string[];
37
+ constructor(bin: string, args: string[]);
38
+ }
39
+ /** Test seam: suppress the side-effect daemon boot in unit tests. */
40
+ export declare function setKeychainDaemonBootForTest(enabled: boolean): void;
41
+ /** Test seam: exercise the timeout wrapper with an arbitrary binary. */
42
+ export declare function spawnKeychainHelperForTest(bin: string, args: string[], opts: SpawnSyncOptions, timeoutMs: number): SpawnSyncReturns<Buffer>;
29
43
  /** Supported secret resolution backends. */
30
44
  export type SecretProvider = 'keychain' | 'env' | 'file' | 'exec';
31
45
  /** A typed reference to a secret, consisting of a provider and a provider-specific value. */
@@ -36,6 +36,57 @@ import { deriveShortId } from '../session/short-id.js';
36
36
  const SERVICE_PREFIX = 'agents-cli';
37
37
  export const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
38
38
  const BUNDLES_ITEM_PREFIX = `${SERVICE_PREFIX}.bundles.`;
39
+ /** Timeout for keychain-helper verbs that never raise a user prompt. */
40
+ const KEYCHAIN_SILENT_TIMEOUT_MS = 8_000;
41
+ /** Timeout for verbs that may raise Touch ID / password auth UI. */
42
+ const KEYCHAIN_INTERACTIVE_TIMEOUT_MS = 60_000;
43
+ /**
44
+ * Thrown when a keychain helper / security spawnSync is killed because it
45
+ * exceeded its timeout. A wedged coreauthd / LocalAuthentication dialog can hang
46
+ * the parent forever; this makes the failure explicit and arms the read back-off.
47
+ */
48
+ export class KeychainHelperTimeoutError extends Error {
49
+ bin;
50
+ args;
51
+ constructor(bin, args) {
52
+ super(`keychain helper timed out (${bin} ${args.join(' ')}) — keychain locked / ` +
53
+ `LocalAuthentication unresponsive — retry; if it persists, lock/unlock the screen or reboot`);
54
+ this.bin = bin;
55
+ this.args = args;
56
+ this.name = 'KeychainHelperTimeoutError';
57
+ }
58
+ }
59
+ let keychainDaemonBootEnabled = true;
60
+ let keychainDaemonBootAttempted = false;
61
+ /** Test seam: suppress the side-effect daemon boot in unit tests. */
62
+ export function setKeychainDaemonBootForTest(enabled) {
63
+ keychainDaemonBootEnabled = enabled;
64
+ }
65
+ /**
66
+ * Single wrapper for every keychain-helper (and /usr/bin/security) spawnSync.
67
+ * Applies a hard timeout + SIGKILL so a wedged coreauthd can never hang the
68
+ * parent process. Throws {@link KeychainHelperTimeoutError} when the child is
69
+ * killed by the timeout. Also boots the daemon once per process so the reaper
70
+ * can clean up any stuck helpers this or prior invocations left behind.
71
+ */
72
+ function spawnKeychainHelper(bin, args, opts, timeoutMs) {
73
+ if (keychainDaemonBootEnabled && !keychainDaemonBootAttempted) {
74
+ keychainDaemonBootAttempted = true;
75
+ // Fire-and-forget: daemon start must not block the foreground secrets op.
76
+ import('../daemon.js')
77
+ .then(({ ensureDaemonStarted }) => ensureDaemonStarted())
78
+ .catch(() => { });
79
+ }
80
+ const result = spawnSync(bin, args, { ...opts, timeout: timeoutMs, killSignal: 'SIGKILL' });
81
+ if (result.signal) {
82
+ throw new KeychainHelperTimeoutError(bin, args);
83
+ }
84
+ return result;
85
+ }
86
+ /** Test seam: exercise the timeout wrapper with an arbitrary binary. */
87
+ export function spawnKeychainHelperForTest(bin, args, opts, timeoutMs) {
88
+ return spawnKeychainHelper(bin, args, opts, timeoutMs);
89
+ }
39
90
  const REF_PATTERN = /^(keychain|env|file|exec):(.+)$/s;
40
91
  /** Parse a bundle value into either a literal string or a typed secret ref. */
41
92
  export function parseBundleValue(raw) {
@@ -725,14 +776,14 @@ export function hasKeychainToken(item) {
725
776
  if (isWindows())
726
777
  return windowsBackend.has(item);
727
778
  if (!isOurItem(item)) {
728
- return spawnSync('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item], {
779
+ return spawnKeychainHelper('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item], {
729
780
  stdio: ['ignore', 'ignore', 'ignore'],
730
- }).status === 0;
781
+ }, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
731
782
  }
732
783
  const bin = getKeychainHelperPath();
733
- return spawnSync(bin, ['has', item, os.userInfo().username], {
784
+ return spawnKeychainHelper(bin, ['has', item, os.userInfo().username], {
734
785
  stdio: ['ignore', 'pipe', 'pipe'],
735
- }).status === 0;
786
+ }, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
736
787
  }
737
788
  /**
738
789
  * The detector the raw-read storm guard consults, overridable so tests on any
@@ -808,9 +859,9 @@ export function getKeychainToken(item, context = {}) {
808
859
  if (isWindows())
809
860
  return windowsBackend.get(item);
810
861
  if (!isOurItem(item)) {
811
- const sec = spawnSync('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
862
+ const sec = spawnKeychainHelper('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
812
863
  stdio: ['ignore', 'pipe', 'pipe'],
813
- });
864
+ }, KEYCHAIN_SILENT_TIMEOUT_MS);
814
865
  if (sec.status === 0) {
815
866
  const token = sec.stdout?.toString().trim();
816
867
  if (token) {
@@ -821,15 +872,24 @@ export function getKeychainToken(item, context = {}) {
821
872
  throw new Error(`Keychain item '${requested}' not found.`);
822
873
  }
823
874
  const bin = getKeychainHelperPath();
824
- const result = spawnSync(bin, ['get', item, os.userInfo().username], {
825
- env: {
826
- ...process.env,
827
- AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
828
- AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
829
- AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
830
- },
831
- stdio: ['ignore', 'pipe', 'pipe'],
832
- });
875
+ let result;
876
+ try {
877
+ result = spawnKeychainHelper(bin, ['get', item, os.userInfo().username], {
878
+ env: {
879
+ ...process.env,
880
+ AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
881
+ AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
882
+ AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
883
+ },
884
+ stdio: ['ignore', 'pipe', 'pipe'],
885
+ }, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
886
+ }
887
+ catch (err) {
888
+ if (err instanceof KeychainHelperTimeoutError && !context.silentNoAcl) {
889
+ noteKeychainReadFailure(requested);
890
+ }
891
+ throw err;
892
+ }
833
893
  // Exit 1 is a plain miss — no prompt was raised, so it opens no back-off.
834
894
  if (result.status === 1)
835
895
  throw new Error(`Keychain item '${requested}' not found.`);
@@ -913,19 +973,28 @@ export function getKeychainTokens(items, context = {}) {
913
973
  return result;
914
974
  }
915
975
  const bin = getKeychainHelperPath();
916
- const child = spawnSync(bin, ['get-batch', os.userInfo().username, ...storageItems], {
917
- env: {
918
- ...process.env,
919
- AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
920
- AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
921
- AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
922
- // The signed helper's own vocabulary is unchanged (it predates the rename
923
- // and ships as a separately-versioned binary), so map to its legacy token.
924
- AGENTS_KEYCHAIN_DEFAULT_POLICY: (context.defaultPolicy ?? 'hold') === 'hold' ? 'daily' : context.defaultPolicy,
925
- AGENTS_KEYCHAIN_FORCE_DURATION: context.forceDuration ? '1' : '0',
926
- },
927
- stdio: ['ignore', 'pipe', 'pipe'],
928
- });
976
+ let child;
977
+ try {
978
+ child = spawnKeychainHelper(bin, ['get-batch', os.userInfo().username, ...storageItems], {
979
+ env: {
980
+ ...process.env,
981
+ AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
982
+ AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
983
+ AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
984
+ // The signed helper's own vocabulary is unchanged (it predates the rename
985
+ // and ships as a separately-versioned binary), so map to its legacy token.
986
+ AGENTS_KEYCHAIN_DEFAULT_POLICY: (context.defaultPolicy ?? 'hold') === 'hold' ? 'daily' : context.defaultPolicy,
987
+ AGENTS_KEYCHAIN_FORCE_DURATION: context.forceDuration ? '1' : '0',
988
+ },
989
+ stdio: ['ignore', 'pipe', 'pipe'],
990
+ }, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
991
+ }
992
+ catch (err) {
993
+ if (err instanceof KeychainHelperTimeoutError && !context.silentNoAcl) {
994
+ noteKeychainReadFailure(backoffKey);
995
+ }
996
+ throw err;
997
+ }
929
998
  if (child.status !== 0 && !context.silentNoAcl)
930
999
  noteKeychainReadFailure(backoffKey);
931
1000
  if (child.status === 4) {
@@ -1063,7 +1132,7 @@ export function setKeychainToken(item, value, opts) {
1063
1132
  // controlling terminal, so readpassphrase always falls back to our piped
1064
1133
  // stdin. Without it, an interactive `agents view` (refreshing+saving a Claude
1065
1134
  // OAuth token) or `agents secrets add` pops a keychain password sheet.
1066
- const sec = spawnSync('/usr/bin/security', buildAddGenericPasswordArgs(os.userInfo().username, item), buildAddGenericPasswordSpawnOptions(value));
1135
+ const sec = spawnKeychainHelper('/usr/bin/security', buildAddGenericPasswordArgs(os.userInfo().username, item), buildAddGenericPasswordSpawnOptions(value), KEYCHAIN_SILENT_TIMEOUT_MS);
1067
1136
  if (sec.status !== 0) {
1068
1137
  const msg = sec.stderr?.toString().trim();
1069
1138
  throw new Error(msg || `Failed to write keychain item '${item}'.`);
@@ -1076,10 +1145,10 @@ export function setKeychainToken(item, value, opts) {
1076
1145
  // re-notarized helper; an older pinned helper dies with "Unknown command:
1077
1146
  // set-no-acl" (exit 2), surfaced below — never a silent ACL'd downgrade.
1078
1147
  const helperCmd = opts?.noAcl ? 'set-no-acl' : 'set';
1079
- const result = spawnSync(bin, [helperCmd, item, os.userInfo().username], {
1148
+ const result = spawnKeychainHelper(bin, [helperCmd, item, os.userInfo().username], {
1080
1149
  input: value,
1081
1150
  stdio: ['pipe', 'pipe', 'pipe'],
1082
- });
1151
+ }, KEYCHAIN_SILENT_TIMEOUT_MS);
1083
1152
  if (result.status !== 0) {
1084
1153
  const msg = result.stderr?.toString().trim();
1085
1154
  if (opts?.noAcl && /unknown command/i.test(msg ?? '')) {
@@ -1106,9 +1175,9 @@ export function deleteKeychainToken(item) {
1106
1175
  if (isWindows())
1107
1176
  return windowsBackend.delete(item);
1108
1177
  const bin = getKeychainHelperPath();
1109
- const deleted = spawnSync(bin, ['delete', item, os.userInfo().username], {
1178
+ const deleted = spawnKeychainHelper(bin, ['delete', item, os.userInfo().username], {
1110
1179
  stdio: ['ignore', 'pipe', 'pipe'],
1111
- }).status === 0;
1180
+ }, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
1112
1181
  // A deleted item must fail its next read as plain "not found", not with a
1113
1182
  // stale back-off error left over from a pre-delete cancel.
1114
1183
  if (deleted)
@@ -1149,9 +1218,9 @@ export function listKeychainItems(prefix) {
1149
1218
  if (isWindows())
1150
1219
  return apply(windowsBackend.list(mapped.prefix));
1151
1220
  const bin = getKeychainHelperPath();
1152
- const result = spawnSync(bin, ['list', mapped.prefix], {
1221
+ const result = spawnKeychainHelper(bin, ['list', mapped.prefix], {
1153
1222
  stdio: ['ignore', 'pipe', 'pipe'],
1154
- });
1223
+ }, KEYCHAIN_SILENT_TIMEOUT_MS);
1155
1224
  if (result.status !== 0) {
1156
1225
  const msg = result.stderr?.toString().trim();
1157
1226
  throw new Error(msg || `Failed to enumerate keychain items with prefix '${prefix}'.`);
@@ -1176,9 +1245,9 @@ export function listLegacyKeychainItems(prefix) {
1176
1245
  if (isLinux())
1177
1246
  return [];
1178
1247
  const bin = getKeychainHelperPath();
1179
- const result = spawnSync(bin, ['list-legacy', prefix], {
1248
+ const result = spawnKeychainHelper(bin, ['list-legacy', prefix], {
1180
1249
  stdio: ['ignore', 'pipe', 'pipe'],
1181
- });
1250
+ }, KEYCHAIN_SILENT_TIMEOUT_MS);
1182
1251
  if (result.status !== 0) {
1183
1252
  const msg = result.stderr?.toString().trim();
1184
1253
  throw new Error(msg || `Failed to enumerate legacy keychain items with prefix '${prefix}'.`);
@@ -1209,9 +1278,9 @@ export function listSyncedKeychainItems(prefix) {
1209
1278
  if (isLinux() || isWindows())
1210
1279
  return [];
1211
1280
  const bin = getKeychainHelperPath();
1212
- const result = spawnSync(bin, ['list-synced', prefix], {
1281
+ const result = spawnKeychainHelper(bin, ['list-synced', prefix], {
1213
1282
  stdio: ['ignore', 'pipe', 'pipe'],
1214
- });
1283
+ }, KEYCHAIN_SILENT_TIMEOUT_MS);
1215
1284
  if (result.status !== 0) {
1216
1285
  const msg = result.stderr?.toString().trim();
1217
1286
  throw new Error(msg || `Failed to enumerate iCloud keychain items with prefix '${prefix}'.`);
@@ -1237,9 +1306,9 @@ export function getSyncedKeychainTokens(items) {
1237
1306
  if (isLinux() || isWindows())
1238
1307
  return result;
1239
1308
  const bin = getKeychainHelperPath();
1240
- const child = spawnSync(bin, ['get-batch-synced', os.userInfo().username, ...items], {
1309
+ const child = spawnKeychainHelper(bin, ['get-batch-synced', os.userInfo().username, ...items], {
1241
1310
  stdio: ['ignore', 'pipe', 'pipe'],
1242
- });
1311
+ }, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
1243
1312
  if (child.status === 4) {
1244
1313
  throw new Error(`Auth cancelled while reading ${items.length} iCloud keychain item(s).`);
1245
1314
  }
@@ -1265,9 +1334,9 @@ export function deleteSyncedKeychainItem(item) {
1265
1334
  if (isLinux() || isWindows())
1266
1335
  return false;
1267
1336
  const bin = getKeychainHelperPath();
1268
- return spawnSync(bin, ['delete-synced', item, os.userInfo().username], {
1337
+ return spawnKeychainHelper(bin, ['delete-synced', item, os.userInfo().username], {
1269
1338
  stdio: ['ignore', 'pipe', 'pipe'],
1270
- }).status === 0;
1339
+ }, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
1271
1340
  }
1272
1341
  /**
1273
1342
  * One-time upgrade for a keychain item that was written by a previous helper
@@ -1286,9 +1355,9 @@ export function migrateKeychainItem(item) {
1286
1355
  if (isWindows())
1287
1356
  return windowsBackend.has(item);
1288
1357
  const bin = getKeychainHelperPath();
1289
- const result = spawnSync(bin, ['migrate-acl', item, os.userInfo().username], {
1358
+ const result = spawnKeychainHelper(bin, ['migrate-acl', item, os.userInfo().username], {
1290
1359
  stdio: ['ignore', 'pipe', 'pipe'],
1291
- });
1360
+ }, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
1292
1361
  if (result.status === 0)
1293
1362
  return true;
1294
1363
  if (result.status === 1)
@@ -1310,9 +1379,9 @@ export function listOrphanedKeychainItems(prefix) {
1310
1379
  if (isLinux() || isWindows())
1311
1380
  return [];
1312
1381
  const bin = getKeychainHelperPath();
1313
- const result = spawnSync(bin, ['list-orphans', prefix, os.userInfo().username], {
1382
+ const result = spawnKeychainHelper(bin, ['list-orphans', prefix, os.userInfo().username], {
1314
1383
  stdio: ['ignore', 'pipe', 'pipe'],
1315
- });
1384
+ }, KEYCHAIN_SILENT_TIMEOUT_MS);
1316
1385
  if (result.status !== 0) {
1317
1386
  const msg = result.stderr?.toString().trim();
1318
1387
  throw new Error(msg || `Failed to enumerate orphaned keychain items with prefix '${prefix}'.`);
@@ -1368,9 +1437,9 @@ export function migrateOrphanedKeychainItems(prefix) {
1368
1437
  if (isLinux() || isWindows())
1369
1438
  return [];
1370
1439
  const bin = getKeychainHelperPath();
1371
- const result = spawnSync(bin, ['migrate-orphans', prefix, os.userInfo().username], {
1440
+ const result = spawnKeychainHelper(bin, ['migrate-orphans', prefix, os.userInfo().username], {
1372
1441
  stdio: ['ignore', 'pipe', 'pipe'],
1373
- });
1442
+ }, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
1374
1443
  if (result.status === 4)
1375
1444
  throw new Error('Touch ID cancelled during orphan migration.');
1376
1445
  if (result.status !== 0) {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Reaper for orphaned / wedged keychain-helper processes.
3
+ *
4
+ * macOS keychain-helper calls are synchronous spawnSync invocations. When
5
+ * coreauthd / LocalAuthentication hangs, the helper process (and sometimes its
6
+ * parent `agents` process) can pile up forever. This module detects two classes
7
+ * of stale process and kills them.
8
+ *
9
+ * The design splits cleanly into:
10
+ * - a pure planner ({@link planKeychainReap}) that is unit-testable without a
11
+ * real `ps` shell, and
12
+ * - an impure driver ({@link reapOrphanedKeychainProcesses}) that shells `ps`
13
+ * once per tick and executes kills through {@link killTree}.
14
+ */
15
+ /** Elapsed grace before a helper whose parent exited is considered an orphan. */
16
+ export declare const ORPHAN_GRACE_SEC = 30;
17
+ /** Elapsed grace before a helper with a live parent is considered stuck. */
18
+ export declare const STUCK_GRACE_SEC = 90;
19
+ /** Snapshot of one process row from `ps`, fed into the pure planner. */
20
+ export interface KeychainProcessSnapshot {
21
+ pid: number;
22
+ ppid: number;
23
+ /** Elapsed seconds since the process started. */
24
+ elapsedSec: number;
25
+ /**
26
+ * Stable start-time fingerprint from {@link captureProcessStartTime}.
27
+ * `null` means "could not capture" — the planner must fail closed.
28
+ */
29
+ startTime: string | null;
30
+ /** True when this process's executable path matches the installed helper. */
31
+ isHelper: boolean;
32
+ }
33
+ /**
34
+ * Tracked state for a stuck `agents` parent that has a live helper child.
35
+ * Keyed by parent PID. The two-sweep debounce avoids acting on a racy `ps`
36
+ * snapshot; the `stage` field drives child-first kill then parent escalation.
37
+ */
38
+ export interface StuckParentCandidate {
39
+ pid: number;
40
+ startTime: string | null;
41
+ helperPid: number;
42
+ helperStartTime: string | null;
43
+ firstSeenAt: number;
44
+ stage: 'watch' | 'escalate';
45
+ }
46
+ /** Result of one planning pass. */
47
+ export interface ReapPlan {
48
+ /** PIDs that should be killed this tick. */
49
+ kill: number[];
50
+ /** Candidates to carry forward to the next sweep. */
51
+ nextCandidates: Map<number, StuckParentCandidate>;
52
+ }
53
+ /**
54
+ * Pure predicate: decide which processes to kill given a `ps`-like snapshot.
55
+ *
56
+ * Mirrors the shape of {@link isExpiredPoolStray} in `lib/crabbox/lease.ts`:
57
+ * a side-effect-free classifier that the impure driver shells `ps` for. Two
58
+ * conservative reap classes:
59
+ *
60
+ * 1. Orphaned helper: PPID == 1, path-matches the helper, alive longer than
61
+ * {@link ORPHAN_GRACE_SEC}.
62
+ * 2. Stuck `agents` parent: helper child alive longer than
63
+ * {@link STUCK_GRACE_SEC}. Recorded on first sight, child killed on the
64
+ * second consecutive sweep with the same PID + startTime, parent killed on
65
+ * the third sweep if the helper child is still present.
66
+ *
67
+ * Never reaps a process whose start time could not be captured, whose path does
68
+ * not match the helper, or whose parent is no longer in the snapshot.
69
+ */
70
+ export declare function planKeychainReap(snapshots: KeychainProcessSnapshot[], now: number, prevCandidates: ReadonlyMap<number, StuckParentCandidate>): ReapPlan;
71
+ /** Test seam: reset the persisted candidate state. */
72
+ export declare function resetKeychainReaperCandidatesForTest(): void;
73
+ /**
74
+ * Impure driver: snapshot all processes once with `ps`, plan the reap, then
75
+ * execute kills through {@link killTree}.
76
+ *
77
+ * Path-matching uses the full helper path (proc_pidpath-style), not just the
78
+ * executable name, so an unrelated binary named "Agents CLI" is never targeted.
79
+ *
80
+ * Returns on non-darwin platforms without shelling anything — the helper only
81
+ * exists on macOS.
82
+ */
83
+ export declare function reapOrphanedKeychainProcesses(): {
84
+ reaped: number;
85
+ details: string[];
86
+ plan: ReapPlan;
87
+ };