@phnx-labs/agents-cli 1.20.76 → 1.20.78

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 (119) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +87 -19
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +275 -15
  9. package/dist/commands/secrets.js +65 -17
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +128 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +28 -1
  15. package/dist/commands/sessions-picker.js +222 -15
  16. package/dist/commands/sessions.d.ts +117 -4
  17. package/dist/commands/sessions.js +350 -62
  18. package/dist/commands/ssh.d.ts +13 -0
  19. package/dist/commands/ssh.js +48 -3
  20. package/dist/index.js +5 -6
  21. package/dist/lib/activity.d.ts +5 -4
  22. package/dist/lib/activity.js +450 -36
  23. package/dist/lib/agents.d.ts +0 -21
  24. package/dist/lib/agents.js +0 -37
  25. package/dist/lib/auto-pull.d.ts +16 -8
  26. package/dist/lib/auto-pull.js +23 -28
  27. package/dist/lib/cloud/session-index.js +2 -2
  28. package/dist/lib/daemon.d.ts +9 -32
  29. package/dist/lib/daemon.js +70 -84
  30. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  31. package/dist/lib/devices/fleet-divergence.js +188 -0
  32. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  33. package/dist/lib/devices/fleet-inventory.js +57 -0
  34. package/dist/lib/devices/fleet.d.ts +16 -1
  35. package/dist/lib/devices/fleet.js +10 -2
  36. package/dist/lib/devices/health-report.d.ts +10 -2
  37. package/dist/lib/devices/health-report.js +32 -1
  38. package/dist/lib/events.d.ts +1 -1
  39. package/dist/lib/exec.d.ts +16 -0
  40. package/dist/lib/exec.js +27 -4
  41. package/dist/lib/feed.d.ts +7 -1
  42. package/dist/lib/feed.js +96 -6
  43. package/dist/lib/git.d.ts +13 -0
  44. package/dist/lib/git.js +102 -4
  45. package/dist/lib/heal.d.ts +7 -4
  46. package/dist/lib/heal.js +10 -22
  47. package/dist/lib/hosts/dispatch.d.ts +9 -0
  48. package/dist/lib/hosts/dispatch.js +24 -4
  49. package/dist/lib/hosts/passthrough.js +7 -2
  50. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  51. package/dist/lib/hosts/remote-cmd.js +33 -8
  52. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  53. package/dist/lib/hosts/remote-session-id.js +84 -0
  54. package/dist/lib/hosts/run-target.d.ts +4 -0
  55. package/dist/lib/hosts/run-target.js +5 -1
  56. package/dist/lib/hosts/session-index.js +3 -3
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  61. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  62. package/dist/lib/menubar/install-menubar.js +14 -0
  63. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  64. package/dist/lib/menubar/notify-desktop.js +78 -0
  65. package/dist/lib/overdue.d.ts +4 -5
  66. package/dist/lib/overdue.js +8 -41
  67. package/dist/lib/routine-notify.d.ts +76 -0
  68. package/dist/lib/routine-notify.js +190 -0
  69. package/dist/lib/routines-placement.d.ts +38 -0
  70. package/dist/lib/routines-placement.js +79 -0
  71. package/dist/lib/routines-project.d.ts +97 -0
  72. package/dist/lib/routines-project.js +349 -0
  73. package/dist/lib/routines.d.ts +68 -0
  74. package/dist/lib/routines.js +74 -7
  75. package/dist/lib/runner.d.ts +12 -2
  76. package/dist/lib/runner.js +156 -28
  77. package/dist/lib/sandbox.js +0 -6
  78. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  79. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  80. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  83. package/dist/lib/secrets/agent.d.ts +11 -4
  84. package/dist/lib/secrets/agent.js +46 -24
  85. package/dist/lib/secrets/bundles.d.ts +21 -7
  86. package/dist/lib/secrets/bundles.js +82 -38
  87. package/dist/lib/secrets/index.d.ts +11 -2
  88. package/dist/lib/secrets/index.js +21 -9
  89. package/dist/lib/secrets/scope.d.ts +26 -0
  90. package/dist/lib/secrets/scope.js +29 -0
  91. package/dist/lib/secrets/session-store.d.ts +16 -2
  92. package/dist/lib/secrets/session-store.js +118 -15
  93. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  94. package/dist/lib/secrets/unlock-hints.js +36 -0
  95. package/dist/lib/session/active.d.ts +17 -0
  96. package/dist/lib/session/active.js +105 -7
  97. package/dist/lib/session/db.d.ts +43 -0
  98. package/dist/lib/session/db.js +191 -6
  99. package/dist/lib/session/digest.js +126 -21
  100. package/dist/lib/session/discover.d.ts +28 -3
  101. package/dist/lib/session/discover.js +110 -14
  102. package/dist/lib/session/origin-machine.d.ts +18 -0
  103. package/dist/lib/session/origin-machine.js +34 -0
  104. package/dist/lib/session/parse.js +11 -2
  105. package/dist/lib/session/prompt.d.ts +7 -0
  106. package/dist/lib/session/prompt.js +37 -0
  107. package/dist/lib/session/provenance.d.ts +7 -0
  108. package/dist/lib/session/render.js +18 -16
  109. package/dist/lib/session/state.d.ts +5 -0
  110. package/dist/lib/session/state.js +94 -12
  111. package/dist/lib/session/sync/config.js +2 -2
  112. package/dist/lib/session/types.d.ts +25 -1
  113. package/dist/lib/session/types.js +8 -0
  114. package/dist/lib/smart-launch.d.ts +86 -0
  115. package/dist/lib/smart-launch.js +172 -0
  116. package/dist/lib/state.d.ts +7 -3
  117. package/dist/lib/state.js +20 -5
  118. package/dist/lib/types.d.ts +10 -0
  119. package/package.json +1 -1
@@ -27,8 +27,7 @@ import { loadTask as loadHostTask } from './hosts/tasks.js';
27
27
  import { reconcileTask as reconcileHostTask } from './hosts/reconcile.js';
28
28
  import { backgroundSpawnOptions } from './platform/process.js';
29
29
  import { walkForFiles } from './fs-walk.js';
30
- import { getBinaryPath, getVersionHomePath, isVersionInstalled, resolveVersion } from './versions.js';
31
- import { claudeHomeHasOwnCredential } from './agents.js';
30
+ import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js';
32
31
  import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
33
32
  import { readAuthHealth, isDeadVerdict } from './auth-health.js';
34
33
  import { machineId } from './machine-id.js';
@@ -399,19 +398,12 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone) {
399
398
  if (v !== undefined)
400
399
  out[k] = v;
401
400
  }
402
- // RUSH-1979: the daemon injects one ambient CLAUDE_CODE_OAUTH_TOKEN (RUSH-1759)
403
- // so a token-less default account still authenticates. When balanced rotation
404
- // pins THIS spawn to a specific account's CLAUDE_CONFIG_DIR that holds its own
405
- // credential, that ambient token would shadow the account (Claude and the Linux
406
- // shim both prefer the env var) making the pool inert and 401ing on the one
407
- // stale token. Drop it here so the pinned account authenticates itself; keep it
408
- // when the account has no on-disk credential (the RUSH-1759 default).
409
- if (agent === 'claude' &&
410
- version &&
411
- out.CLAUDE_CONFIG_DIR &&
412
- claudeHomeHasOwnCredential(getVersionHomePath('claude', version))) {
413
- delete out.CLAUDE_CODE_OAUTH_TOKEN;
414
- }
401
+ // No Claude token is injected here. A routine authenticates through the pinned
402
+ // account's own CLAUDE_CONFIG_DIR login (buildExecEnv points it at the
403
+ // per-account version home), identical to interactive `agents run`. Claude
404
+ // Code's interactive session refreshes itself per-device; keeping the daemon
405
+ // out of the credential entirely is what avoids the fleet-wide rotation logout
406
+ // a shared/rotating token was the cause, not the fix.
415
407
  if (timezone)
416
408
  out.TZ = timezone;
417
409
  return out;
@@ -512,11 +504,19 @@ export async function executeJob(config, deps) {
512
504
  if (eligibility) {
513
505
  throw new Error(eligibility.message);
514
506
  }
515
- // `host:` placement the job body runs on another machine over SSH; local
516
- // version selection / sandbox / spawn do not apply. Sync callers (manual
517
- // `routines run`, catchup) follow the remote run to completion.
518
- if (config.host) {
519
- return executeJobOnHost(config, { detached: false });
507
+ // Placement (hostStrategy / bare host:) body may run on another machine
508
+ // over SSH or in the cloud; local version selection / sandbox / spawn then
509
+ // do not apply. Sync callers (manual `routines run`, catchup) follow the
510
+ // remote run to completion when possible.
511
+ {
512
+ const { resolvePlacementTarget } = await import('./routines-placement.js');
513
+ const target = resolvePlacementTarget(config);
514
+ if (target.mode === 'host') {
515
+ return executeJobOnHost({ ...config, host: target.host }, { detached: false });
516
+ }
517
+ if (target.mode === 'cloud') {
518
+ return executeJobOnCloud(config, { detached: false });
519
+ }
520
520
  }
521
521
  // Command-mode: run a plain shell command directly (no agent, no rotation,
522
522
  // no pinning, no sandbox overlay). Reuses the run-record machinery so
@@ -735,6 +735,86 @@ export async function executeJob(config, deps) {
735
735
  timer.end({ status: 'failed', exitCode: 1, runId });
736
736
  return { meta, reportPath: null };
737
737
  }
738
+ /**
739
+ * Dispatch a routine to the agent's native cloud provider (or the configured
740
+ * default). Writes a local run record with `cloudTaskId` so list/runs still
741
+ * work; does not wait for cloud completion on the detached path.
742
+ */
743
+ async function executeJobOnCloud(config, opts) {
744
+ if (config.workflow) {
745
+ throw new Error(`Routine '${config.name}' runs a workflow bundle, which can't execute in the cloud yet — remove 'hostStrategy: cloud' or 'workflow:'.`);
746
+ }
747
+ if (config.loop) {
748
+ throw new Error(`Routine '${config.name}' uses 'loop:', which can't execute in the cloud yet — remove 'hostStrategy: cloud' or 'loop:'.`);
749
+ }
750
+ if (config.command) {
751
+ throw new Error(`Routine '${config.name}' uses 'command:', which can't execute in the cloud yet — remove 'hostStrategy: cloud' or 'command:'.`);
752
+ }
753
+ if (!config.agent) {
754
+ throw new Error(`Routine '${config.name}' hostStrategy: cloud requires an agent`);
755
+ }
756
+ const { resolveProvider } = await import('./cloud/registry.js');
757
+ const { insertTask } = await import('./cloud/store.js');
758
+ const provider = resolveProvider(undefined, config.agent);
759
+ const timer = createTimer('agent.run', {
760
+ agent: config.agent,
761
+ jobName: config.name,
762
+ mode: config.mode,
763
+ placement: 'cloud',
764
+ ...redactPrompt(config.prompt),
765
+ schedule: config.schedule,
766
+ });
767
+ const runId = generateRunId();
768
+ const runDir = getRunDir(config.name, runId);
769
+ fs.mkdirSync(runDir, { recursive: true });
770
+ const meta = {
771
+ jobName: config.name,
772
+ runId,
773
+ agent: config.agent,
774
+ pid: null,
775
+ spawnedAt: Date.now(),
776
+ status: 'running',
777
+ startedAt: new Date().toISOString(),
778
+ completedAt: null,
779
+ exitCode: null,
780
+ };
781
+ writeRunMeta(meta);
782
+ try {
783
+ const task = await provider.dispatch({
784
+ prompt: resolveJobPrompt(config),
785
+ agent: config.agent,
786
+ repo: config.repo,
787
+ timeout: config.timeout,
788
+ model: config.config?.model,
789
+ });
790
+ try {
791
+ insertTask(task);
792
+ }
793
+ catch { /* store is best-effort */ }
794
+ meta.cloudTaskId = task.id;
795
+ meta.cloudProvider = task.provider;
796
+ // Terminal cloud responses (e.g. antigravity) finalize immediately.
797
+ // Async providers leave the run `running` with the cloud task id for
798
+ // the user to follow via `agents cloud status`.
799
+ if (task.status === 'completed') {
800
+ finalizeRunMeta(meta, 'completed', 0);
801
+ }
802
+ else if (task.status === 'failed' || task.status === 'cancelled') {
803
+ finalizeRunMeta(meta, 'failed', 1, { errorMessage: task.summary ?? `cloud ${task.status}` });
804
+ }
805
+ // Non-terminal statuses stay `running` for both detached and sync paths;
806
+ // the user follows via `agents cloud status <id>`.
807
+ writeRunMeta(meta);
808
+ timer.end({ status: meta.status, exitCode: meta.exitCode ?? undefined, runId });
809
+ return { meta, reportPath: null };
810
+ }
811
+ catch (err) {
812
+ finalizeRunMeta(meta, 'failed', 1, { errorMessage: err.message });
813
+ writeRunMeta(meta);
814
+ timer.end({ status: 'failed', exitCode: 1, runId, error: err.message });
815
+ throw err;
816
+ }
817
+ }
738
818
  async function executeJobOnHost(config, opts) {
739
819
  if (config.workflow) {
740
820
  throw new Error(`Routine '${config.name}' runs a workflow bundle, which can't execute on a host yet — remove 'host:' or 'workflow:'.`);
@@ -877,24 +957,44 @@ async function executeCommandJobForeground(config) {
877
957
  });
878
958
  return { meta, reportPath: null };
879
959
  }
960
+ /** Invoke a lifecycle hook without letting a caller error break run finalization. */
961
+ function safeHook(fn) {
962
+ if (!fn)
963
+ return;
964
+ try {
965
+ fn();
966
+ }
967
+ catch { /* hooks are best-effort */ }
968
+ }
880
969
  /** Spawn a job as a detached process and return immediately with run metadata. */
881
- /** Spawn a job as a detached process and return immediately with run metadata. */
882
- export async function executeJobDetached(config) {
970
+ export async function executeJobDetached(config, hooks) {
883
971
  const eligibility = checkJobDeviceEligibility(config);
884
972
  if (eligibility) {
885
973
  process.stderr.write(`[agents] daemon: skipping '${config.name}' — ${eligibility.message}\n`);
886
974
  throw new Error(eligibility.message);
887
975
  }
888
- // `host:` placement — dispatch over SSH and return; the monitor finalizes.
889
- if (config.host) {
890
- const { meta } = await executeJobOnHost(config, { detached: true });
891
- return meta;
976
+ // Placement (hostStrategy / bare host:) — dispatch off-box and return; the
977
+ // monitor finalizes host: runs, cloud runs stay terminal when dispatch ends.
978
+ // Either way the in-process onFinish hook does not fire for off-box routines
979
+ // (the monitor tick observes an already-finalized record), so notify-desktop
980
+ // sends the finish notification only for local detached runs below (RUSH-2030).
981
+ {
982
+ const { resolvePlacementTarget } = await import('./routines-placement.js');
983
+ const target = resolvePlacementTarget(config);
984
+ if (target.mode === 'host') {
985
+ const { meta } = await executeJobOnHost({ ...config, host: target.host }, { detached: true });
986
+ return meta;
987
+ }
988
+ if (target.mode === 'cloud') {
989
+ const { meta } = await executeJobOnCloud(config, { detached: true });
990
+ return meta;
991
+ }
892
992
  }
893
993
  // Command-mode: fire a plain shell command detached (no agent, no rotation,
894
994
  // no pinning, no sandbox overlay). Still writes a run record so the daemon,
895
995
  // list/runs, and overdue tracking keep working.
896
996
  if (config.command) {
897
- return executeCommandJobDetached(config);
997
+ return executeCommandJobDetached(config, hooks);
898
998
  }
899
999
  // Pre-flight: pick a healthy version/account so the daemon does not launch
900
1000
  // into a credit-exhausted install. Detached cannot mid-run failover (no exit
@@ -944,6 +1044,28 @@ export async function executeJobDetached(config) {
944
1044
  completedAt: null,
945
1045
  exitCode: null,
946
1046
  };
1047
+ // Auth preflight (mirrors executeJob): with no injected token, a daemon-fired
1048
+ // Claude routine authenticates via the pinned account's own CLAUDE_CONFIG_DIR
1049
+ // login. If the last live probe rejected that (agent, version)'s token, the run
1050
+ // is guaranteed to 401 — fail fast with a re-login hint instead of spawning a
1051
+ // doomed run + poisoned report. Cache-only; fails OPEN on any non-dead/missing
1052
+ // verdict, and agents with no live probe (codex/gemini/grok) are never blocked.
1053
+ const preflightVersion = launch.chain[0]?.version;
1054
+ if (preflightVersion) {
1055
+ const health = readAuthHealth(machineId(), effectiveAgent, preflightVersion);
1056
+ if (health && isDeadVerdict(health.verdict)) {
1057
+ const reason = `auth_preflight: ${health.verdict}`;
1058
+ process.stderr.write(`[agents] routine ${config.name}: ${effectiveAgent}@${preflightVersion} token ${health.verdict} — skipping run (re-login required)\n`);
1059
+ try {
1060
+ fs.closeSync(stdoutFd);
1061
+ }
1062
+ catch { /* already closed */ }
1063
+ finalizeRunMeta(meta, 'failed', 1, { errorMessage: reason });
1064
+ writeRunMeta(meta);
1065
+ archiveRoutineTranscripts(meta, runDir, overlayHome);
1066
+ return meta;
1067
+ }
1068
+ }
947
1069
  const child = spawn(cmd[0], cmd.slice(1), {
948
1070
  stdio: ['ignore', stdoutFd, stdoutFd],
949
1071
  ...backgroundSpawnOptions({ fdStdio: true }),
@@ -962,6 +1084,9 @@ export async function executeJobDetached(config) {
962
1084
  const isAuthFailure = !!errorMessage && errorMessage.startsWith('auth_failed:');
963
1085
  if (status !== 'timeout' && !isAuthFailure)
964
1086
  extractAndSaveReport(stdoutPath, effectiveAgent, runDir);
1087
+ // Fire the finish/output notification AFTER the report is written so the hook
1088
+ // can read report.md (RUSH-2030). Best-effort; never breaks finalization.
1089
+ safeHook(hooks?.onFinish ? () => hooks.onFinish(meta) : undefined);
965
1090
  };
966
1091
  child.on('exit', (code) => {
967
1092
  let logText = '';
@@ -1008,7 +1133,7 @@ export async function executeJobDetached(config) {
1008
1133
  * un-sandboxed, unref, then record the pid. The daemon does not wait for exit;
1009
1134
  * `monitorRunningJobs` reaps the record on the next tick.
1010
1135
  */
1011
- function executeCommandJobDetached(config) {
1136
+ function executeCommandJobDetached(config, hooks) {
1012
1137
  const runId = generateRunId();
1013
1138
  const runDir = getRunDir(config.name, runId);
1014
1139
  fs.mkdirSync(runDir, { recursive: true });
@@ -1053,6 +1178,9 @@ function executeCommandJobDetached(config) {
1053
1178
  settled = true;
1054
1179
  finalizeRunMeta(meta, status, exitCode, errorMessage ? { errorMessage } : undefined);
1055
1180
  writeRunMeta(meta);
1181
+ // Finish notification (RUSH-2030). For command routines the threshold only
1182
+ // surfaces failures, decided in routine-notify.ts. Best-effort.
1183
+ safeHook(hooks?.onFinish ? () => hooks.onFinish(meta) : undefined);
1056
1184
  };
1057
1185
  child.on('exit', (code) => settle(code === 0 ? 'completed' : 'failed', code ?? 1));
1058
1186
  child.on('error', (err) => {
@@ -44,12 +44,6 @@ const ENV_ALLOWLIST = [
44
44
  'VISUAL',
45
45
  'NO_COLOR',
46
46
  'FORCE_COLOR',
47
- // Headless Claude auth: the routines daemon injects CLAUDE_CODE_OAUTH_TOKEN
48
- // from the `claude` secrets bundle (see daemon.ts). Without this allowlist
49
- // entry, sandboxed routine spawns drop the token and look "unconfigured".
50
- // Intentionally NOT ANTHROPIC_API_KEY / other provider secrets — those stay
51
- // stripped (see tests/sandbox.test.ts "does not include sensitive env vars").
52
- 'CLAUDE_CODE_OAUTH_TOKEN',
53
47
  ];
54
48
  /** Tools safe to grant as wildcards (no filesystem access). */
55
49
  const SAFE_TOOLS = {
@@ -8,6 +8,8 @@
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>
11
13
  <key>CFBundleExecutable</key>
12
14
  <string>Agents CLI</string>
13
15
  <key>CFBundlePackageType</key>
@@ -3,9 +3,21 @@
3
3
  <plist version="1.0">
4
4
  <dict>
5
5
  <key>files</key>
6
- <dict/>
6
+ <dict>
7
+ <key>Resources/AppIcon.icns</key>
8
+ <data>
9
+ Vd5nfogg74zSRNspluv4uvDecUA=
10
+ </data>
11
+ </dict>
7
12
  <key>files2</key>
8
13
  <dict>
14
+ <key>Resources/AppIcon.icns</key>
15
+ <dict>
16
+ <key>hash2</key>
17
+ <data>
18
+ DznVe0VgYOux7+B/aiHNgGYLCvSJKzgXDTPJn2jNkNg=
19
+ </data>
20
+ </dict>
9
21
  <key>embedded.provisionprofile</key>
10
22
  <dict>
11
23
  <key>hash2</key>
@@ -25,6 +25,8 @@
25
25
  */
26
26
  import * as net from 'net';
27
27
  import type { SecretsBundle } from './bundles.js';
28
+ import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
29
+ export { GLOBAL_HARNESS, bundleScopeChain };
28
30
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
29
31
  export declare const DEFAULT_TTL_MS: number;
30
32
  /**
@@ -69,12 +71,14 @@ export interface StoredBundle {
69
71
  env: Record<string, string>;
70
72
  /** epoch ms; the entry is gone once Date.now() passes this. */
71
73
  expiresAt: number;
74
+ harness: string;
72
75
  }
73
76
  /** One unlocked bundle as reported by `status`. */
74
77
  export interface AgentStatusEntry {
75
78
  name: string;
76
79
  expiresAt: number;
77
80
  keyCount: number;
81
+ harness: string;
78
82
  }
79
83
  /**
80
84
  * Read the current broker capability token, or null if none is present. Clients
@@ -106,10 +110,12 @@ export type Request = {
106
110
  } | {
107
111
  cmd: 'get';
108
112
  name: string;
113
+ harness?: string;
109
114
  token?: string;
110
115
  } | {
111
116
  cmd: 'load';
112
117
  name: string;
118
+ harness?: string;
113
119
  bundle: SecretsBundle;
114
120
  env: Record<string, string>;
115
121
  ttlMs: number;
@@ -166,6 +172,7 @@ export type Response = {
166
172
  * exiting. Pure + exported for unit testing.
167
173
  */
168
174
  export declare function realBundleCount(store: Map<string, StoredBundle>): number;
175
+ export declare function scopedBundleKey(name: string, harness: string): string;
169
176
  export declare function handleAgentRequest(store: Map<string, StoredBundle>, req: Request, now?: number): Response;
170
177
  /**
171
178
  * Decide whether a `watch-lock` helper line should wipe the in-memory store.
@@ -270,7 +277,7 @@ export declare function syncClientLaunch(sub: string[], agentsBin?: string): {
270
277
  * null if the agent isn't running / doesn't hold this bundle / anything fails
271
278
  * (soft — caller falls through to the real keychain). macOS only.
272
279
  */
273
- export declare function agentGetSync(name: string): {
280
+ export declare function agentGetSync(name: string, harness?: string): {
274
281
  bundle: SecretsBundle;
275
282
  env: Record<string, string>;
276
283
  } | null;
@@ -308,7 +315,7 @@ export declare function agentReachableSync(): boolean;
308
315
  export declare function agentEvictSync(name: string): void;
309
316
  /** Body of `__secrets-get <name>`. Prints `{bundle, env}` as JSON on a
310
317
  * cache hit. Exit 0 = hit, 3 = miss or broker down. */
311
- export declare function runAgentGetSync(name: string): Promise<number>;
318
+ export declare function runAgentGetSync(name: string, harness?: string): Promise<number>;
312
319
  /** Body of `__secrets-ping`. Exit 0 = a broker is listening and speaking
313
320
  * our protocol, 3 = nothing there. Deliberately does NOT gate on
314
321
  * PROTOCOL_VERSION: this only decides whether the auto-cache may take the
@@ -387,7 +394,7 @@ export declare function clampHoldMs(v: unknown): number;
387
394
  * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
388
395
  * than a tight inline retry loop. Best-effort; never throws. macOS only.
389
396
  */
390
- export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): void;
397
+ export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string): void;
391
398
  /**
392
399
  * Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
393
400
  * env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
@@ -402,7 +409,7 @@ export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, e
402
409
  */
403
410
  export declare function runAgentLoadFromStdin(): Promise<void>;
404
411
  /** Store a resolved bundle in the broker. Returns false on transport failure. */
405
- export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): Promise<boolean>;
412
+ export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string): Promise<boolean>;
406
413
  /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
407
414
  * wiped, or 0 when no broker is running. */
408
415
  export declare function agentLock(name?: string): Promise<number>;
@@ -34,11 +34,17 @@ import { isAlive } from '../platform/process.js';
34
34
  import { getKeychainHelperPath } from './install-helper.js';
35
35
  import { getCliVersion, getCliVersionFresh } from '../version.js';
36
36
  import { getCliLaunch } from '../cli-entry.js';
37
+ import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
37
38
  import { rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
38
39
  import { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
40
+ // Re-exported so callers already reaching for agent.js keep one obvious home for
41
+ // the scope vocabulary; the definitions live in the leaf module scope.ts because
42
+ // agent.ts and session-store.ts import each other and a cyclic `const` read can
43
+ // hit the ESM temporal dead zone at runtime.
44
+ export { GLOBAL_HARNESS, bundleScopeChain };
39
45
  /** Bumped when the wire protocol changes; a client that pings a mismatched
40
46
  * server kills and respawns it rather than talking a stale dialect. */
41
- const PROTOCOL_VERSION = 1;
47
+ const PROTOCOL_VERSION = 2;
42
48
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
43
49
  export const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d
44
50
  /**
@@ -133,7 +139,8 @@ function onDarwin() {
133
139
  function rehydrateStore(now = Date.now()) {
134
140
  const store = new Map();
135
141
  for (const { name, entry } of rehydrateSessions(now)) {
136
- store.set(name, { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt });
142
+ const harness = entry.harness || GLOBAL_HARNESS;
143
+ store.set(scopedBundleKey(name, harness), { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt, harness });
137
144
  }
138
145
  return store;
139
146
  }
@@ -291,11 +298,14 @@ export async function uninstallSecretsAgentService() {
291
298
  */
292
299
  export function realBundleCount(store) {
293
300
  let n = 0;
294
- for (const name of store.keys())
295
- if (!name.startsWith(META_CACHE_PREFIX))
301
+ for (const e of store.values())
302
+ if (!e.bundle.name.startsWith(META_CACHE_PREFIX))
296
303
  n++;
297
304
  return n;
298
305
  }
306
+ export function scopedBundleKey(name, harness) {
307
+ return `${harness}:${name}`;
308
+ }
299
309
  export function handleAgentRequest(store, req, now = Date.now()) {
300
310
  switch (req.cmd) {
301
311
  case 'ping':
@@ -305,20 +315,32 @@ export function handleAgentRequest(store, req, now = Date.now()) {
305
315
  // the broker is running pre-upgrade code and should be restarted.
306
316
  return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION, cliVersion: getCliVersion() };
307
317
  case 'get': {
308
- const e = store.get(req.name);
309
- if (!e || now >= e.expiresAt) {
310
- if (e)
311
- store.delete(req.name); // drop expired on read
312
- return { ok: true, cmd: 'get', hit: false };
318
+ // Walk own-harness → global so a `--for` grant wins over a global one and
319
+ // an unscoped unlock serves every harness (bundleScopeChain).
320
+ for (const scope of bundleScopeChain(req.harness)) {
321
+ const key = scopedBundleKey(req.name, scope);
322
+ const e = store.get(key);
323
+ if (!e)
324
+ continue;
325
+ if (now >= e.expiresAt) {
326
+ store.delete(key);
327
+ continue;
328
+ } // drop expired on read
329
+ return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
313
330
  }
314
- return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
331
+ return { ok: true, cmd: 'get', hit: false };
315
332
  }
316
333
  case 'load':
317
- store.set(req.name, { bundle: req.bundle, env: req.env, expiresAt: now + req.ttlMs });
334
+ const harness = req.harness || GLOBAL_HARNESS;
335
+ store.set(scopedBundleKey(req.name, harness), { bundle: req.bundle, env: req.env, expiresAt: now + req.ttlMs, harness });
318
336
  return { ok: true, cmd: 'load' };
319
337
  case 'lock': {
320
338
  if (req.name) {
321
- return { ok: true, cmd: 'lock', wiped: store.delete(req.name) ? 1 : 0 };
339
+ let wiped = 0;
340
+ for (const [key, entry] of store)
341
+ if (entry.bundle.name === req.name && store.delete(key))
342
+ wiped++;
343
+ return { ok: true, cmd: 'lock', wiped };
322
344
  }
323
345
  const wiped = store.size;
324
346
  store.clear();
@@ -329,9 +351,9 @@ export function handleAgentRequest(store, req, now = Date.now()) {
329
351
  for (const [name, e] of store) {
330
352
  if (now >= e.expiresAt)
331
353
  continue;
332
- if (name.startsWith(META_CACHE_PREFIX))
333
- continue; // internal list cache, not a user bundle
334
- entries.push({ name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length });
354
+ if (e.bundle.name.startsWith(META_CACHE_PREFIX))
355
+ continue; // internal list cache
356
+ entries.push({ name: e.bundle.name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length, harness: e.harness });
335
357
  }
336
358
  return { ok: true, cmd: 'status', entries };
337
359
  }
@@ -824,10 +846,10 @@ function syncClient(sub, timeout) {
824
846
  * null if the agent isn't running / doesn't hold this bundle / anything fails
825
847
  * (soft — caller falls through to the real keychain). macOS only.
826
848
  */
827
- export function agentGetSync(name) {
849
+ export function agentGetSync(name, harness = GLOBAL_HARNESS) {
828
850
  if (!agentSocketExists())
829
851
  return null;
830
- const r = syncClient([SYNC_GET_CMD, name], SYNC_GET_TIMEOUT_MS);
852
+ const r = syncClient([SYNC_GET_CMD, name, harness], SYNC_GET_TIMEOUT_MS);
831
853
  if (!r || r.status !== 0 || !r.stdout)
832
854
  return null;
833
855
  try {
@@ -904,8 +926,8 @@ export function agentEvictSync(name) {
904
926
  // so a cache hit never runs checkForUpdates() or forks a detached sync.
905
927
  /** Body of `__secrets-get <name>`. Prints `{bundle, env}` as JSON on a
906
928
  * cache hit. Exit 0 = hit, 3 = miss or broker down. */
907
- export async function runAgentGetSync(name) {
908
- const r = await request({ cmd: 'get', name }, SOCKET_GET_TIMEOUT_MS);
929
+ export async function runAgentGetSync(name, harness = GLOBAL_HARNESS) {
930
+ const r = await request({ cmd: 'get', name, harness }, SOCKET_GET_TIMEOUT_MS);
909
931
  if (r?.ok === true && r.cmd === 'get' && r.hit) {
910
932
  // Trailing newline: the parent reads the LAST line (see lastLine), so the
911
933
  // payload must terminate the stream even if anything ever precedes it.
@@ -1056,10 +1078,10 @@ export function clampHoldMs(v) {
1056
1078
  * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
1057
1079
  * than a tight inline retry loop. Best-effort; never throws. macOS only.
1058
1080
  */
1059
- export function agentAutoLoadSync(name, bundle, env, ttlMs) {
1081
+ export function agentAutoLoadSync(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS) {
1060
1082
  if (!onDarwin())
1061
1083
  return;
1062
- const payload = JSON.stringify({ name, bundle, env, ttlMs });
1084
+ const payload = JSON.stringify({ name, bundle, env, ttlMs, harness });
1063
1085
  // Broker actually LISTENING → deterministic synchronous warm (bounded; the read
1064
1086
  // already paid a Touch ID, so <1s here is invisible). We gate on a real liveness
1065
1087
  // ping, NOT mere socket-file existence: a broker that died leaving its socket
@@ -1126,13 +1148,13 @@ export async function runAgentLoadFromStdin() {
1126
1148
  process.exitCode = 1; // broker couldn't be brought up — did NOT load
1127
1149
  return;
1128
1150
  }
1129
- const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS);
1151
+ const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ?? GLOBAL_HARNESS);
1130
1152
  if (!loaded)
1131
1153
  process.exitCode = 1; // transport failed — did NOT load
1132
1154
  }
1133
1155
  /** Store a resolved bundle in the broker. Returns false on transport failure. */
1134
- export async function agentLoad(name, bundle, env, ttlMs) {
1135
- const r = await request({ cmd: 'load', name, bundle, env, ttlMs });
1156
+ export async function agentLoad(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS) {
1157
+ const r = await request({ cmd: 'load', name, bundle, env, ttlMs, harness });
1136
1158
  return r?.ok === true && r.cmd === 'load';
1137
1159
  }
1138
1160
  /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
@@ -198,6 +198,12 @@ export interface ResolveBundleOptions {
198
198
  * about to read the bundle.
199
199
  */
200
200
  caller?: string;
201
+ /** Harness type whose unlock may be reused (claude, codex, kimi, ...). */
202
+ agent?: string;
203
+ /** Human duration rendered in the Touch ID prompt. */
204
+ duration?: string;
205
+ /** Explicitly permit this agent request to raise interactive authentication. */
206
+ interactiveUnlock?: boolean;
201
207
  /**
202
208
  * Skip the secrets-agent fast-path and read straight from the keychain
203
209
  * (popping Touch ID). Set by callers that must NOT serve a cached snapshot —
@@ -272,15 +278,19 @@ export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveB
272
278
  * True when the current process is a background / non-interactive context that
273
279
  * must NEVER raise a Keychain biometry prompt on the interactive user's screen —
274
280
  * a prompt nobody is watching. Two signals, either sufficient:
275
- * - `AGENTS_RUNTIME` is `headless` or `teams` (set on the child env by
276
- * `agents run --headless`, scheduled routines, and teammates see
277
- * exec.ts:resolveInteractive, runner.ts, teams/agents.ts).
281
+ * - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` i.e. ANY agent
282
+ * launch, interactive included, and inherited by everything spawned beneath
283
+ * one (set on the child env by `agents run --headless`, scheduled routines,
284
+ * teammates, and interactive runs — see exec.ts:430, runner.ts,
285
+ * teams/agents.ts).
278
286
  * - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
279
287
  * stdio is redirected to a log — e.g. a release script run in the
280
288
  * background as `( ... ) >log 2>&1 </dev/null`).
281
289
  * `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
282
- * even in a non-TTY context. An interactive `eval "$(agents secrets export X)"`
283
- * keeps its terminal stdin, so it is NOT classified headless and still prompts.
290
+ * even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
291
+ * PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
292
+ * prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
293
+ * broker-only — the agent, not the human, is the caller there.
284
294
  *
285
295
  * Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
286
296
  * broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
@@ -291,9 +301,12 @@ export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveB
291
301
  *
292
302
  * A read in a macOS headless context resolves broker-only (agentOnly) and fails
293
303
  * fast with an actionable error instead of hijacking Touch ID. This generalizes
294
- * the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
304
+ * the per-caller broker-only pattern used across the headless secrets readers.
295
305
  */
296
- export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
306
+ export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, tty?: {
307
+ stdin?: boolean;
308
+ stdout?: boolean;
309
+ }): boolean;
297
310
  /**
298
311
  * Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
299
312
  *
@@ -311,6 +324,7 @@ export declare function readAndResolveBundleEnv(name: string, opts?: ResolveBund
311
324
  bundle: SecretsBundle;
312
325
  env: Record<string, string>;
313
326
  };
327
+ export declare function humanUnlockDuration(ms: number): string;
314
328
  export declare function keychainRef(key: string): string;
315
329
  /** Options for rotateBundleSecret. */
316
330
  export interface RotateOptions {