@phnx-labs/agents-cli 1.20.76 → 1.20.77

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 (85) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/exec.js +58 -16
  4. package/dist/commands/routines.js +271 -13
  5. package/dist/commands/secrets.js +59 -17
  6. package/dist/commands/sessions-browser.js +11 -1
  7. package/dist/commands/sessions-picker.d.ts +14 -1
  8. package/dist/commands/sessions-picker.js +168 -15
  9. package/dist/commands/sessions.d.ts +23 -4
  10. package/dist/commands/sessions.js +128 -38
  11. package/dist/commands/ssh.d.ts +13 -0
  12. package/dist/commands/ssh.js +39 -2
  13. package/dist/index.js +2 -1
  14. package/dist/lib/activity.d.ts +5 -4
  15. package/dist/lib/activity.js +450 -36
  16. package/dist/lib/cloud/session-index.js +2 -2
  17. package/dist/lib/daemon.d.ts +10 -1
  18. package/dist/lib/daemon.js +99 -12
  19. package/dist/lib/devices/fleet.d.ts +16 -1
  20. package/dist/lib/devices/fleet.js +10 -2
  21. package/dist/lib/events.d.ts +1 -1
  22. package/dist/lib/exec.d.ts +16 -0
  23. package/dist/lib/exec.js +27 -4
  24. package/dist/lib/feed.d.ts +7 -1
  25. package/dist/lib/feed.js +96 -6
  26. package/dist/lib/heal.d.ts +7 -4
  27. package/dist/lib/heal.js +10 -22
  28. package/dist/lib/hosts/dispatch.d.ts +9 -0
  29. package/dist/lib/hosts/dispatch.js +24 -4
  30. package/dist/lib/hosts/passthrough.js +7 -2
  31. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  32. package/dist/lib/hosts/remote-cmd.js +31 -8
  33. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  34. package/dist/lib/hosts/remote-session-id.js +84 -0
  35. package/dist/lib/hosts/run-target.d.ts +4 -0
  36. package/dist/lib/hosts/run-target.js +5 -1
  37. package/dist/lib/hosts/session-index.js +3 -3
  38. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  39. package/dist/lib/menubar/install-menubar.js +14 -0
  40. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  41. package/dist/lib/menubar/notify-desktop.js +78 -0
  42. package/dist/lib/overdue.d.ts +4 -5
  43. package/dist/lib/overdue.js +8 -41
  44. package/dist/lib/routine-notify.d.ts +76 -0
  45. package/dist/lib/routine-notify.js +190 -0
  46. package/dist/lib/routines-placement.d.ts +38 -0
  47. package/dist/lib/routines-placement.js +79 -0
  48. package/dist/lib/routines-project.d.ts +97 -0
  49. package/dist/lib/routines-project.js +349 -0
  50. package/dist/lib/routines.d.ts +68 -0
  51. package/dist/lib/routines.js +74 -7
  52. package/dist/lib/runner.d.ts +12 -2
  53. package/dist/lib/runner.js +150 -25
  54. package/dist/lib/sandbox.js +10 -0
  55. package/dist/lib/secrets/account-token.d.ts +20 -0
  56. package/dist/lib/secrets/account-token.js +64 -0
  57. package/dist/lib/secrets/agent.d.ts +9 -4
  58. package/dist/lib/secrets/agent.js +30 -20
  59. package/dist/lib/secrets/bundles.d.ts +20 -6
  60. package/dist/lib/secrets/bundles.js +70 -34
  61. package/dist/lib/secrets/index.d.ts +11 -2
  62. package/dist/lib/secrets/index.js +21 -9
  63. package/dist/lib/secrets/session-store.d.ts +6 -2
  64. package/dist/lib/secrets/session-store.js +65 -15
  65. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  66. package/dist/lib/secrets/unlock-hints.js +36 -0
  67. package/dist/lib/session/active.d.ts +10 -0
  68. package/dist/lib/session/active.js +67 -4
  69. package/dist/lib/session/db.d.ts +6 -0
  70. package/dist/lib/session/db.js +83 -6
  71. package/dist/lib/session/discover.d.ts +13 -1
  72. package/dist/lib/session/discover.js +91 -3
  73. package/dist/lib/session/parse.js +11 -2
  74. package/dist/lib/session/prompt.d.ts +7 -0
  75. package/dist/lib/session/prompt.js +37 -0
  76. package/dist/lib/session/provenance.d.ts +7 -0
  77. package/dist/lib/session/render.js +18 -16
  78. package/dist/lib/session/state.d.ts +4 -0
  79. package/dist/lib/session/state.js +93 -11
  80. package/dist/lib/session/types.d.ts +25 -1
  81. package/dist/lib/session/types.js +8 -0
  82. package/dist/lib/state.d.ts +7 -3
  83. package/dist/lib/state.js +20 -5
  84. package/dist/lib/types.d.ts +10 -0
  85. package/package.json +1 -1
@@ -29,6 +29,7 @@ import { backgroundSpawnOptions } from './platform/process.js';
29
29
  import { walkForFiles } from './fs-walk.js';
30
30
  import { getBinaryPath, getVersionHomePath, isVersionInstalled, resolveVersion } from './versions.js';
31
31
  import { claudeHomeHasOwnCredential } from './agents.js';
32
+ import { resolveAccountSetupToken } from './secrets/account-token.js';
32
33
  import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
33
34
  import { readAuthHealth, isDeadVerdict } from './auth-health.js';
34
35
  import { machineId } from './machine-id.js';
@@ -399,18 +400,28 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone) {
399
400
  if (v !== undefined)
400
401
  out[k] = v;
401
402
  }
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;
403
+ // A headless routine should authenticate the rotation-pinned account via its
404
+ // long-lived, NON-rotating setup-token, not the interactive OAuth session.
405
+ // Claude Code's interactive session uses single-use rotating refresh tokens: one
406
+ // fleet machine refreshing invalidates that account on every other machine, so
407
+ // they 401 and log out (Claude Code #25609 / #56339) the fleet-wide daily
408
+ // logout. If the daemon injected this account's per-account
409
+ // CLAUDE_CODE_OAUTH_TOKEN_<slug> (from a headless-readable no-ACL bundle), use it;
410
+ // that also works on macOS, where the drop path below is inert
411
+ // (claudeHomeHasOwnCredential is false on darwin, agents.ts).
412
+ //
413
+ // Fallback (RUSH-1979): with no per-account token, keep the prior behavior — drop
414
+ // the single ambient CLAUDE_CODE_OAUTH_TOKEN when the pinned account has its own
415
+ // on-disk credential (Linux) so it isn't shadowed; keep it otherwise.
416
+ if (agent === 'claude' && version && out.CLAUDE_CONFIG_DIR) {
417
+ const home = getVersionHomePath('claude', version);
418
+ const accountToken = resolveAccountSetupToken(baseEnv, home);
419
+ if (accountToken) {
420
+ out.CLAUDE_CODE_OAUTH_TOKEN = accountToken;
421
+ }
422
+ else if (claudeHomeHasOwnCredential(home)) {
423
+ delete out.CLAUDE_CODE_OAUTH_TOKEN;
424
+ }
414
425
  }
415
426
  if (timezone)
416
427
  out.TZ = timezone;
@@ -512,11 +523,19 @@ export async function executeJob(config, deps) {
512
523
  if (eligibility) {
513
524
  throw new Error(eligibility.message);
514
525
  }
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 });
526
+ // Placement (hostStrategy / bare host:) body may run on another machine
527
+ // over SSH or in the cloud; local version selection / sandbox / spawn then
528
+ // do not apply. Sync callers (manual `routines run`, catchup) follow the
529
+ // remote run to completion when possible.
530
+ {
531
+ const { resolvePlacementTarget } = await import('./routines-placement.js');
532
+ const target = resolvePlacementTarget(config);
533
+ if (target.mode === 'host') {
534
+ return executeJobOnHost({ ...config, host: target.host }, { detached: false });
535
+ }
536
+ if (target.mode === 'cloud') {
537
+ return executeJobOnCloud(config, { detached: false });
538
+ }
520
539
  }
521
540
  // Command-mode: run a plain shell command directly (no agent, no rotation,
522
541
  // no pinning, no sandbox overlay). Reuses the run-record machinery so
@@ -735,6 +754,86 @@ export async function executeJob(config, deps) {
735
754
  timer.end({ status: 'failed', exitCode: 1, runId });
736
755
  return { meta, reportPath: null };
737
756
  }
757
+ /**
758
+ * Dispatch a routine to the agent's native cloud provider (or the configured
759
+ * default). Writes a local run record with `cloudTaskId` so list/runs still
760
+ * work; does not wait for cloud completion on the detached path.
761
+ */
762
+ async function executeJobOnCloud(config, opts) {
763
+ if (config.workflow) {
764
+ throw new Error(`Routine '${config.name}' runs a workflow bundle, which can't execute in the cloud yet — remove 'hostStrategy: cloud' or 'workflow:'.`);
765
+ }
766
+ if (config.loop) {
767
+ throw new Error(`Routine '${config.name}' uses 'loop:', which can't execute in the cloud yet — remove 'hostStrategy: cloud' or 'loop:'.`);
768
+ }
769
+ if (config.command) {
770
+ throw new Error(`Routine '${config.name}' uses 'command:', which can't execute in the cloud yet — remove 'hostStrategy: cloud' or 'command:'.`);
771
+ }
772
+ if (!config.agent) {
773
+ throw new Error(`Routine '${config.name}' hostStrategy: cloud requires an agent`);
774
+ }
775
+ const { resolveProvider } = await import('./cloud/registry.js');
776
+ const { insertTask } = await import('./cloud/store.js');
777
+ const provider = resolveProvider(undefined, config.agent);
778
+ const timer = createTimer('agent.run', {
779
+ agent: config.agent,
780
+ jobName: config.name,
781
+ mode: config.mode,
782
+ placement: 'cloud',
783
+ ...redactPrompt(config.prompt),
784
+ schedule: config.schedule,
785
+ });
786
+ const runId = generateRunId();
787
+ const runDir = getRunDir(config.name, runId);
788
+ fs.mkdirSync(runDir, { recursive: true });
789
+ const meta = {
790
+ jobName: config.name,
791
+ runId,
792
+ agent: config.agent,
793
+ pid: null,
794
+ spawnedAt: Date.now(),
795
+ status: 'running',
796
+ startedAt: new Date().toISOString(),
797
+ completedAt: null,
798
+ exitCode: null,
799
+ };
800
+ writeRunMeta(meta);
801
+ try {
802
+ const task = await provider.dispatch({
803
+ prompt: resolveJobPrompt(config),
804
+ agent: config.agent,
805
+ repo: config.repo,
806
+ timeout: config.timeout,
807
+ model: config.config?.model,
808
+ });
809
+ try {
810
+ insertTask(task);
811
+ }
812
+ catch { /* store is best-effort */ }
813
+ meta.cloudTaskId = task.id;
814
+ meta.cloudProvider = task.provider;
815
+ // Terminal cloud responses (e.g. antigravity) finalize immediately.
816
+ // Async providers leave the run `running` with the cloud task id for
817
+ // the user to follow via `agents cloud status`.
818
+ if (task.status === 'completed') {
819
+ finalizeRunMeta(meta, 'completed', 0);
820
+ }
821
+ else if (task.status === 'failed' || task.status === 'cancelled') {
822
+ finalizeRunMeta(meta, 'failed', 1, { errorMessage: task.summary ?? `cloud ${task.status}` });
823
+ }
824
+ // Non-terminal statuses stay `running` for both detached and sync paths;
825
+ // the user follows via `agents cloud status <id>`.
826
+ writeRunMeta(meta);
827
+ timer.end({ status: meta.status, exitCode: meta.exitCode ?? undefined, runId });
828
+ return { meta, reportPath: null };
829
+ }
830
+ catch (err) {
831
+ finalizeRunMeta(meta, 'failed', 1, { errorMessage: err.message });
832
+ writeRunMeta(meta);
833
+ timer.end({ status: 'failed', exitCode: 1, runId, error: err.message });
834
+ throw err;
835
+ }
836
+ }
738
837
  async function executeJobOnHost(config, opts) {
739
838
  if (config.workflow) {
740
839
  throw new Error(`Routine '${config.name}' runs a workflow bundle, which can't execute on a host yet — remove 'host:' or 'workflow:'.`);
@@ -877,24 +976,44 @@ async function executeCommandJobForeground(config) {
877
976
  });
878
977
  return { meta, reportPath: null };
879
978
  }
979
+ /** Invoke a lifecycle hook without letting a caller error break run finalization. */
980
+ function safeHook(fn) {
981
+ if (!fn)
982
+ return;
983
+ try {
984
+ fn();
985
+ }
986
+ catch { /* hooks are best-effort */ }
987
+ }
880
988
  /** 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) {
989
+ export async function executeJobDetached(config, hooks) {
883
990
  const eligibility = checkJobDeviceEligibility(config);
884
991
  if (eligibility) {
885
992
  process.stderr.write(`[agents] daemon: skipping '${config.name}' — ${eligibility.message}\n`);
886
993
  throw new Error(eligibility.message);
887
994
  }
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;
995
+ // Placement (hostStrategy / bare host:) — dispatch off-box and return; the
996
+ // monitor finalizes host: runs, cloud runs stay terminal when dispatch ends.
997
+ // Either way the in-process onFinish hook does not fire for off-box routines
998
+ // (the monitor tick observes an already-finalized record), so notify-desktop
999
+ // sends the finish notification only for local detached runs below (RUSH-2030).
1000
+ {
1001
+ const { resolvePlacementTarget } = await import('./routines-placement.js');
1002
+ const target = resolvePlacementTarget(config);
1003
+ if (target.mode === 'host') {
1004
+ const { meta } = await executeJobOnHost({ ...config, host: target.host }, { detached: true });
1005
+ return meta;
1006
+ }
1007
+ if (target.mode === 'cloud') {
1008
+ const { meta } = await executeJobOnCloud(config, { detached: true });
1009
+ return meta;
1010
+ }
892
1011
  }
893
1012
  // Command-mode: fire a plain shell command detached (no agent, no rotation,
894
1013
  // no pinning, no sandbox overlay). Still writes a run record so the daemon,
895
1014
  // list/runs, and overdue tracking keep working.
896
1015
  if (config.command) {
897
- return executeCommandJobDetached(config);
1016
+ return executeCommandJobDetached(config, hooks);
898
1017
  }
899
1018
  // Pre-flight: pick a healthy version/account so the daemon does not launch
900
1019
  // into a credit-exhausted install. Detached cannot mid-run failover (no exit
@@ -962,6 +1081,9 @@ export async function executeJobDetached(config) {
962
1081
  const isAuthFailure = !!errorMessage && errorMessage.startsWith('auth_failed:');
963
1082
  if (status !== 'timeout' && !isAuthFailure)
964
1083
  extractAndSaveReport(stdoutPath, effectiveAgent, runDir);
1084
+ // Fire the finish/output notification AFTER the report is written so the hook
1085
+ // can read report.md (RUSH-2030). Best-effort; never breaks finalization.
1086
+ safeHook(hooks?.onFinish ? () => hooks.onFinish(meta) : undefined);
965
1087
  };
966
1088
  child.on('exit', (code) => {
967
1089
  let logText = '';
@@ -1008,7 +1130,7 @@ export async function executeJobDetached(config) {
1008
1130
  * un-sandboxed, unref, then record the pid. The daemon does not wait for exit;
1009
1131
  * `monitorRunningJobs` reaps the record on the next tick.
1010
1132
  */
1011
- function executeCommandJobDetached(config) {
1133
+ function executeCommandJobDetached(config, hooks) {
1012
1134
  const runId = generateRunId();
1013
1135
  const runDir = getRunDir(config.name, runId);
1014
1136
  fs.mkdirSync(runDir, { recursive: true });
@@ -1053,6 +1175,9 @@ function executeCommandJobDetached(config) {
1053
1175
  settled = true;
1054
1176
  finalizeRunMeta(meta, status, exitCode, errorMessage ? { errorMessage } : undefined);
1055
1177
  writeRunMeta(meta);
1178
+ // Finish notification (RUSH-2030). For command routines the threshold only
1179
+ // surfaces failures, decided in routine-notify.ts. Best-effort.
1180
+ safeHook(hooks?.onFinish ? () => hooks.onFinish(meta) : undefined);
1056
1181
  };
1057
1182
  child.on('exit', (code) => settle(code === 0 ? 'completed' : 'failed', code ?? 1));
1058
1183
  child.on('error', (err) => {
@@ -75,6 +75,16 @@ export function buildSpawnEnv(overlayHome, extraEnv) {
75
75
  env[key] = process.env[key];
76
76
  }
77
77
  }
78
+ // Per-account Claude setup-tokens: the daemon injects a CLAUDE_CODE_OAUTH_TOKEN_<slug>
79
+ // for each account so a routine authenticates its rotation-pinned account via a
80
+ // long-lived, non-rotating token (see runner.ts buildRoutineSpawnEnv). Forward every
81
+ // such key by prefix — same trust tier as CLAUDE_CODE_OAUTH_TOKEN above; still no API
82
+ // keys or other provider secrets.
83
+ for (const key of Object.keys(process.env)) {
84
+ if (key.startsWith('CLAUDE_CODE_OAUTH_TOKEN_') && process.env[key]) {
85
+ env[key] = process.env[key];
86
+ }
87
+ }
78
88
  if (extraEnv) {
79
89
  Object.assign(env, extraEnv);
80
90
  }
@@ -0,0 +1,20 @@
1
+ /** The `CLAUDE_CODE_OAUTH_TOKEN_<slug>` env key for a given account email/id. */
2
+ export declare function accountTokenKey(account: string): string;
3
+ /**
4
+ * Read the signed-in account email for a Claude version home from
5
+ * `oauthAccount.emailAddress`. Sync, no keychain, no network. Tries both stores
6
+ * the canonical resolver uses (agents.ts getAccountInfo): the shim-set
7
+ * `CLAUDE_CONFIG_DIR` location `<home>/.claude/.claude.json` first, then the
8
+ * home-level `<home>/.claude.json` (an account signed in via the IDE / direct
9
+ * binary without the shim writes there). Returns null when neither has a usable
10
+ * email.
11
+ */
12
+ export declare function readClaudeAccountEmail(home: string): string | null;
13
+ /**
14
+ * Resolve the per-account setup-token for the account pinned to `home`, looking
15
+ * it up in the provided env (the daemon injects the `claude` bundle's
16
+ * `CLAUDE_CODE_OAUTH_TOKEN_*` keys). Returns null when the home has no known
17
+ * account, or no matching per-account token is present — callers then leave the
18
+ * existing ambient/interactive credential untouched (a safe no-op).
19
+ */
20
+ export declare function resolveAccountSetupToken(env: Record<string, string | undefined>, home: string): string | null;
@@ -0,0 +1,64 @@
1
+ // Per-account Claude setup-token resolution.
2
+ //
3
+ // A headless/unattended Claude run should authenticate with the account's
4
+ // long-lived `claude setup-token` (exported as a per-account
5
+ // `CLAUDE_CODE_OAUTH_TOKEN_<slug>` env var), NOT the interactive OAuth session.
6
+ // Claude Code's interactive session uses single-use ROTATING refresh tokens: when
7
+ // one machine refreshes, the server invalidates the old refresh token globally, so
8
+ // every other machine on that account 401s and gets logged out (Claude Code
9
+ // #25609 / #56339). The 1-year setup-token sits earlier in the auth precedence
10
+ // (`CLAUDE_CODE_OAUTH_TOKEN`, before subscription login) and does not participate
11
+ // in that rotation — so per-account setup-tokens keep the fleet signed in.
12
+ //
13
+ // The per-account env key encodes the account email. The convention (verified
14
+ // against live bundles, e.g. `muqsit@getrush.ai` →
15
+ // `CLAUDE_CODE_OAUTH_TOKEN_MUQSIT_AT_GETRUSH_DOT_AI`): upper-case, `@`→`_AT_`,
16
+ // `.`→`_DOT_`, any other non-alphanumeric → `_`.
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ /** The `CLAUDE_CODE_OAUTH_TOKEN_<slug>` env key for a given account email/id. */
20
+ export function accountTokenKey(account) {
21
+ const slug = account
22
+ .trim()
23
+ .toUpperCase()
24
+ .replace(/@/g, '_AT_')
25
+ .replace(/\./g, '_DOT_')
26
+ .replace(/[^A-Z0-9_]/g, '_');
27
+ return `CLAUDE_CODE_OAUTH_TOKEN_${slug}`;
28
+ }
29
+ /**
30
+ * Read the signed-in account email for a Claude version home from
31
+ * `oauthAccount.emailAddress`. Sync, no keychain, no network. Tries both stores
32
+ * the canonical resolver uses (agents.ts getAccountInfo): the shim-set
33
+ * `CLAUDE_CONFIG_DIR` location `<home>/.claude/.claude.json` first, then the
34
+ * home-level `<home>/.claude.json` (an account signed in via the IDE / direct
35
+ * binary without the shim writes there). Returns null when neither has a usable
36
+ * email.
37
+ */
38
+ export function readClaudeAccountEmail(home) {
39
+ for (const p of [path.join(home, '.claude', '.claude.json'), path.join(home, '.claude.json')]) {
40
+ try {
41
+ const email = JSON.parse(fs.readFileSync(p, 'utf-8')).oauthAccount?.emailAddress;
42
+ if (typeof email === 'string' && email.trim().length > 0)
43
+ return email.trim();
44
+ }
45
+ catch {
46
+ // Missing/unreadable/malformed at this location — try the next.
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+ /**
52
+ * Resolve the per-account setup-token for the account pinned to `home`, looking
53
+ * it up in the provided env (the daemon injects the `claude` bundle's
54
+ * `CLAUDE_CODE_OAUTH_TOKEN_*` keys). Returns null when the home has no known
55
+ * account, or no matching per-account token is present — callers then leave the
56
+ * existing ambient/interactive credential untouched (a safe no-op).
57
+ */
58
+ export function resolveAccountSetupToken(env, home) {
59
+ const email = readClaudeAccountEmail(home);
60
+ if (!email)
61
+ return null;
62
+ const token = env[accountTokenKey(email)];
63
+ return typeof token === 'string' && token.trim().length > 0 ? token.trim() : null;
64
+ }
@@ -69,12 +69,14 @@ export interface StoredBundle {
69
69
  env: Record<string, string>;
70
70
  /** epoch ms; the entry is gone once Date.now() passes this. */
71
71
  expiresAt: number;
72
+ harness: string;
72
73
  }
73
74
  /** One unlocked bundle as reported by `status`. */
74
75
  export interface AgentStatusEntry {
75
76
  name: string;
76
77
  expiresAt: number;
77
78
  keyCount: number;
79
+ harness: string;
78
80
  }
79
81
  /**
80
82
  * Read the current broker capability token, or null if none is present. Clients
@@ -106,10 +108,12 @@ export type Request = {
106
108
  } | {
107
109
  cmd: 'get';
108
110
  name: string;
111
+ harness?: string;
109
112
  token?: string;
110
113
  } | {
111
114
  cmd: 'load';
112
115
  name: string;
116
+ harness?: string;
113
117
  bundle: SecretsBundle;
114
118
  env: Record<string, string>;
115
119
  ttlMs: number;
@@ -166,6 +170,7 @@ export type Response = {
166
170
  * exiting. Pure + exported for unit testing.
167
171
  */
168
172
  export declare function realBundleCount(store: Map<string, StoredBundle>): number;
173
+ export declare function scopedBundleKey(name: string, harness: string): string;
169
174
  export declare function handleAgentRequest(store: Map<string, StoredBundle>, req: Request, now?: number): Response;
170
175
  /**
171
176
  * Decide whether a `watch-lock` helper line should wipe the in-memory store.
@@ -270,7 +275,7 @@ export declare function syncClientLaunch(sub: string[], agentsBin?: string): {
270
275
  * null if the agent isn't running / doesn't hold this bundle / anything fails
271
276
  * (soft — caller falls through to the real keychain). macOS only.
272
277
  */
273
- export declare function agentGetSync(name: string): {
278
+ export declare function agentGetSync(name: string, harness?: string): {
274
279
  bundle: SecretsBundle;
275
280
  env: Record<string, string>;
276
281
  } | null;
@@ -308,7 +313,7 @@ export declare function agentReachableSync(): boolean;
308
313
  export declare function agentEvictSync(name: string): void;
309
314
  /** Body of `__secrets-get <name>`. Prints `{bundle, env}` as JSON on a
310
315
  * cache hit. Exit 0 = hit, 3 = miss or broker down. */
311
- export declare function runAgentGetSync(name: string): Promise<number>;
316
+ export declare function runAgentGetSync(name: string, harness?: string): Promise<number>;
312
317
  /** Body of `__secrets-ping`. Exit 0 = a broker is listening and speaking
313
318
  * our protocol, 3 = nothing there. Deliberately does NOT gate on
314
319
  * PROTOCOL_VERSION: this only decides whether the auto-cache may take the
@@ -387,7 +392,7 @@ export declare function clampHoldMs(v: unknown): number;
387
392
  * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
388
393
  * than a tight inline retry loop. Best-effort; never throws. macOS only.
389
394
  */
390
- export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): void;
395
+ export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string): void;
391
396
  /**
392
397
  * Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
393
398
  * env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
@@ -402,7 +407,7 @@ export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, e
402
407
  */
403
408
  export declare function runAgentLoadFromStdin(): Promise<void>;
404
409
  /** 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>;
410
+ export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string): Promise<boolean>;
406
411
  /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
407
412
  * wiped, or 0 when no broker is running. */
408
413
  export declare function agentLock(name?: string): Promise<number>;
@@ -38,7 +38,7 @@ import { rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
38
38
  import { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
39
39
  /** Bumped when the wire protocol changes; a client that pings a mismatched
40
40
  * server kills and respawns it rather than talking a stale dialect. */
41
- const PROTOCOL_VERSION = 1;
41
+ const PROTOCOL_VERSION = 2;
42
42
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
43
43
  export const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d
44
44
  /**
@@ -133,7 +133,8 @@ function onDarwin() {
133
133
  function rehydrateStore(now = Date.now()) {
134
134
  const store = new Map();
135
135
  for (const { name, entry } of rehydrateSessions(now)) {
136
- store.set(name, { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt });
136
+ const harness = entry.harness || 'cli';
137
+ store.set(scopedBundleKey(name, harness), { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt, harness });
137
138
  }
138
139
  return store;
139
140
  }
@@ -291,11 +292,14 @@ export async function uninstallSecretsAgentService() {
291
292
  */
292
293
  export function realBundleCount(store) {
293
294
  let n = 0;
294
- for (const name of store.keys())
295
- if (!name.startsWith(META_CACHE_PREFIX))
295
+ for (const e of store.values())
296
+ if (!e.bundle.name.startsWith(META_CACHE_PREFIX))
296
297
  n++;
297
298
  return n;
298
299
  }
300
+ export function scopedBundleKey(name, harness) {
301
+ return `${harness}:${name}`;
302
+ }
299
303
  export function handleAgentRequest(store, req, now = Date.now()) {
300
304
  switch (req.cmd) {
301
305
  case 'ping':
@@ -305,20 +309,26 @@ export function handleAgentRequest(store, req, now = Date.now()) {
305
309
  // the broker is running pre-upgrade code and should be restarted.
306
310
  return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION, cliVersion: getCliVersion() };
307
311
  case 'get': {
308
- const e = store.get(req.name);
312
+ const key = scopedBundleKey(req.name, req.harness || 'cli');
313
+ const e = store.get(key);
309
314
  if (!e || now >= e.expiresAt) {
310
315
  if (e)
311
- store.delete(req.name); // drop expired on read
316
+ store.delete(key); // drop expired on read
312
317
  return { ok: true, cmd: 'get', hit: false };
313
318
  }
314
319
  return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
315
320
  }
316
321
  case 'load':
317
- store.set(req.name, { bundle: req.bundle, env: req.env, expiresAt: now + req.ttlMs });
322
+ const harness = req.harness || 'cli';
323
+ store.set(scopedBundleKey(req.name, harness), { bundle: req.bundle, env: req.env, expiresAt: now + req.ttlMs, harness });
318
324
  return { ok: true, cmd: 'load' };
319
325
  case 'lock': {
320
326
  if (req.name) {
321
- return { ok: true, cmd: 'lock', wiped: store.delete(req.name) ? 1 : 0 };
327
+ let wiped = 0;
328
+ for (const [key, entry] of store)
329
+ if (entry.bundle.name === req.name && store.delete(key))
330
+ wiped++;
331
+ return { ok: true, cmd: 'lock', wiped };
322
332
  }
323
333
  const wiped = store.size;
324
334
  store.clear();
@@ -329,9 +339,9 @@ export function handleAgentRequest(store, req, now = Date.now()) {
329
339
  for (const [name, e] of store) {
330
340
  if (now >= e.expiresAt)
331
341
  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 });
342
+ if (e.bundle.name.startsWith(META_CACHE_PREFIX))
343
+ continue; // internal list cache
344
+ entries.push({ name: e.bundle.name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length, harness: e.harness });
335
345
  }
336
346
  return { ok: true, cmd: 'status', entries };
337
347
  }
@@ -824,10 +834,10 @@ function syncClient(sub, timeout) {
824
834
  * null if the agent isn't running / doesn't hold this bundle / anything fails
825
835
  * (soft — caller falls through to the real keychain). macOS only.
826
836
  */
827
- export function agentGetSync(name) {
837
+ export function agentGetSync(name, harness = 'cli') {
828
838
  if (!agentSocketExists())
829
839
  return null;
830
- const r = syncClient([SYNC_GET_CMD, name], SYNC_GET_TIMEOUT_MS);
840
+ const r = syncClient([SYNC_GET_CMD, name, harness], SYNC_GET_TIMEOUT_MS);
831
841
  if (!r || r.status !== 0 || !r.stdout)
832
842
  return null;
833
843
  try {
@@ -904,8 +914,8 @@ export function agentEvictSync(name) {
904
914
  // so a cache hit never runs checkForUpdates() or forks a detached sync.
905
915
  /** Body of `__secrets-get <name>`. Prints `{bundle, env}` as JSON on a
906
916
  * 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);
917
+ export async function runAgentGetSync(name, harness = 'cli') {
918
+ const r = await request({ cmd: 'get', name, harness }, SOCKET_GET_TIMEOUT_MS);
909
919
  if (r?.ok === true && r.cmd === 'get' && r.hit) {
910
920
  // Trailing newline: the parent reads the LAST line (see lastLine), so the
911
921
  // payload must terminate the stream even if anything ever precedes it.
@@ -1056,10 +1066,10 @@ export function clampHoldMs(v) {
1056
1066
  * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
1057
1067
  * than a tight inline retry loop. Best-effort; never throws. macOS only.
1058
1068
  */
1059
- export function agentAutoLoadSync(name, bundle, env, ttlMs) {
1069
+ export function agentAutoLoadSync(name, bundle, env, ttlMs, harness = 'cli') {
1060
1070
  if (!onDarwin())
1061
1071
  return;
1062
- const payload = JSON.stringify({ name, bundle, env, ttlMs });
1072
+ const payload = JSON.stringify({ name, bundle, env, ttlMs, harness });
1063
1073
  // Broker actually LISTENING → deterministic synchronous warm (bounded; the read
1064
1074
  // already paid a Touch ID, so <1s here is invisible). We gate on a real liveness
1065
1075
  // ping, NOT mere socket-file existence: a broker that died leaving its socket
@@ -1126,13 +1136,13 @@ export async function runAgentLoadFromStdin() {
1126
1136
  process.exitCode = 1; // broker couldn't be brought up — did NOT load
1127
1137
  return;
1128
1138
  }
1129
- const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS);
1139
+ const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ?? 'cli');
1130
1140
  if (!loaded)
1131
1141
  process.exitCode = 1; // transport failed — did NOT load
1132
1142
  }
1133
1143
  /** 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 });
1144
+ export async function agentLoad(name, bundle, env, ttlMs, harness = 'cli') {
1145
+ const r = await request({ cmd: 'load', name, bundle, env, ttlMs, harness });
1136
1146
  return r?.ok === true && r.cmd === 'load';
1137
1147
  }
1138
1148
  /** 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) /
@@ -293,7 +303,10 @@ export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveB
293
303
  * fast with an actionable error instead of hijacking Touch ID. This generalizes
294
304
  * the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
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 {