@phnx-labs/agents-cli 1.20.41 → 1.20.43

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 (46) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/commands/computer-actions.js +1 -1
  3. package/dist/commands/computer.d.ts +2 -2
  4. package/dist/commands/computer.js +4 -4
  5. package/dist/commands/exec.js +18 -6
  6. package/dist/commands/go.js +6 -1
  7. package/dist/commands/hosts.js +10 -6
  8. package/dist/commands/secrets.js +26 -0
  9. package/dist/commands/sessions.js +30 -12
  10. package/dist/lib/browser/chrome.d.ts +22 -0
  11. package/dist/lib/browser/chrome.js +53 -13
  12. package/dist/lib/browser/service.js +13 -0
  13. package/dist/lib/computer-rpc.js +3 -3
  14. package/dist/lib/exec.d.ts +59 -0
  15. package/dist/lib/exec.js +163 -0
  16. package/dist/lib/hosts/dispatch.d.ts +5 -0
  17. package/dist/lib/hosts/dispatch.js +4 -0
  18. package/dist/lib/hosts/session-index.js +1 -0
  19. package/dist/lib/hosts/tasks.d.ts +15 -0
  20. package/dist/lib/hosts/tasks.js +16 -0
  21. package/dist/lib/menubar/install-menubar.js +2 -2
  22. package/dist/lib/rotate.d.ts +11 -6
  23. package/dist/lib/rotate.js +25 -11
  24. package/dist/lib/secrets/bundles.js +5 -3
  25. package/dist/lib/secrets/remote.js +14 -0
  26. package/dist/lib/secrets/sync.js +13 -0
  27. package/dist/lib/session/active.d.ts +49 -3
  28. package/dist/lib/session/active.js +139 -10
  29. package/dist/lib/session/db.d.ts +11 -0
  30. package/dist/lib/session/db.js +129 -50
  31. package/dist/lib/session/discover.d.ts +5 -0
  32. package/dist/lib/session/discover.js +14 -3
  33. package/dist/lib/session/remote.d.ts +4 -6
  34. package/dist/lib/session/remote.js +5 -12
  35. package/dist/lib/session/run-names.d.ts +32 -0
  36. package/dist/lib/session/run-names.js +63 -0
  37. package/dist/lib/session/types.d.ts +8 -0
  38. package/dist/lib/session/viewing-in.d.ts +54 -0
  39. package/dist/lib/session/viewing-in.js +155 -0
  40. package/dist/lib/ssh-tunnel.d.ts +1 -1
  41. package/dist/lib/ssh-tunnel.js +3 -3
  42. package/dist/lib/tmux/session.d.ts +46 -0
  43. package/dist/lib/tmux/session.js +84 -2
  44. package/dist/lib/usage.d.ts +5 -3
  45. package/dist/lib/usage.js +5 -3
  46. package/package.json +1 -1
package/dist/lib/exec.js CHANGED
@@ -17,8 +17,11 @@ import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js'
17
17
  import { sanitizeProcessEnv } from './secrets/bundles.js';
18
18
  import { getShimsDir } from './state.js';
19
19
  import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
20
+ import { recordRunName } from './session/run-names.js';
20
21
  import { mailboxDir, isValidMailboxId } from './mailbox.js';
21
22
  import { composeWin32CommandLine } from './platform/index.js';
23
+ import { isTmuxInstalled } from './tmux/binary.js';
24
+ import { shellQuote } from './ssh-exec.js';
22
25
  /**
23
26
  * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
24
27
  *
@@ -255,6 +258,12 @@ export function buildExecEnv(options) {
255
258
  if (options.sessionId && isValidMailboxId(options.sessionId)) {
256
259
  result.AGENTS_MAILBOX_DIR = mailboxDir(options.sessionId);
257
260
  }
261
+ // Export the run's durable name (companion to AGENT_SESSION_ID) so a
262
+ // SessionStart hook / the agent can associate its transcript with the handle
263
+ // the user gave the run. Only set when --name was passed.
264
+ if (options.name) {
265
+ result.AGENT_SESSION_NAME = options.name;
266
+ }
258
267
  return {
259
268
  ...result,
260
269
  ...options.env,
@@ -753,6 +762,131 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
753
762
  });
754
763
  });
755
764
  }
765
+ /**
766
+ * Decide whether to run an interactive agent INSIDE a detached tmux session on
767
+ * the shared socket (then attach the current TTY) instead of a bare spawn.
768
+ *
769
+ * tmux-wrapping gives every interactive agent an exact, unique `%pane` handle so
770
+ * `agents sessions --active` can tell co-located agents apart, and lets `agents
771
+ * focus` re-attach a live session without forking it. Pure so the gate is unit-
772
+ * tested independently of the (side-effecting) spawn.
773
+ *
774
+ * All five guards must pass:
775
+ * - interactive — a headless `-p` run has no TTY to attach; keep bare spawn.
776
+ * - not Windows — no tmux path on win32.
777
+ * - not already in tmux — nesting tmux-in-tmux is pointless and confusing.
778
+ * - not --raw — explicit opt-out.
779
+ * - not AGENTS_NO_TMUX=1 — env opt-out (CI, scripts, the shim passthrough path).
780
+ * - tmux installed — otherwise there is nothing to wrap with.
781
+ */
782
+ export function shouldWrapInTmux(ctx) {
783
+ if (!ctx.interactive)
784
+ return false;
785
+ if (ctx.platform === 'win32')
786
+ return false;
787
+ if (ctx.inTmux)
788
+ return false;
789
+ if (ctx.raw)
790
+ return false;
791
+ if (ctx.noTmuxEnv)
792
+ return false;
793
+ if (!ctx.tmuxAvailable)
794
+ return false;
795
+ return true;
796
+ }
797
+ /**
798
+ * Build the shell command that runs an agent inside a tmux pane with the exact
799
+ * env the bare spawn would use. tmux runs it via `sh -c <cmd>`; we `exec env
800
+ * K=V … <agent> <args…>` so:
801
+ * - `env` materializes the full agent env INTO the pane, independent of the
802
+ * (possibly stale, shared) tmux server environment — additive, so tmux's own
803
+ * $TMUX / $TMUX_PANE still reach the agent for provenance detection;
804
+ * - `exec` replaces the shell so the agent is the pane's leaf process (clean
805
+ * `#{pane_pid}`, clean signal delivery on detach/kill).
806
+ * Keys are filtered to valid identifiers so exported shell functions
807
+ * (`BASH_FUNC_*%%`) can't make `env` choke.
808
+ */
809
+ export function buildTmuxAgentCommand(executable, args, env) {
810
+ const envPrefix = Object.entries(env)
811
+ .filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
812
+ .map(([k, v]) => `${k}=${shellQuote(String(v))}`)
813
+ .join(' ');
814
+ const agentCmd = [executable, ...args].map(shellQuote).join(' ');
815
+ return `exec env ${envPrefix} ${agentCmd}`;
816
+ }
817
+ /**
818
+ * Run an interactive agent inside a detached tmux session on the shared socket,
819
+ * attach the current TTY, and propagate the wrapped agent's exit code.
820
+ *
821
+ * Lifecycle:
822
+ * 1. createSession() launches `sh -c 'exec env … agent'` detached, remain-on-exit
823
+ * on (global), and returns the pane id.
824
+ * 2. A per-session `pane-died` hook detaches the attach client the instant the
825
+ * AGENT pane exits, so attach returns instead of parking on a dead pane. The
826
+ * hook is guarded on `#{hook_pane}` so it fires ONLY for the agent pane —
827
+ * user-created splits (Ctrl-b " / %) that the user exits are closed in place
828
+ * (`kill-pane`) instead of tearing down the whole client, so exiting one
829
+ * split leaves the agent running full-window rather than kicking you out.
830
+ * 3. We record the agent pane's pid → session mapping (WITH the tmux pane) so the
831
+ * headless active-scan attributes it, then attach the TTY (blocking).
832
+ * 4. On return: if the pane is dead the agent exited — read its status, tear the
833
+ * session down, return that code. If the pane is still alive the user detached
834
+ * (Ctrl-b d) — return 0 and LEAVE the session for `agents focus` to re-attach.
835
+ */
836
+ async function runInTmux(options, executable, args) {
837
+ const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName } = await import('./tmux/session.js');
838
+ const { getDefaultSocketPath } = await import('./tmux/paths.js');
839
+ const { attachTmux, runTmux } = await import('./tmux/binary.js');
840
+ const socket = getDefaultSocketPath();
841
+ const cwd = options.cwd || process.cwd();
842
+ const idSeed = (options.sessionId ?? randomUUID()).slice(0, 8);
843
+ const name = slugifyName(`ag-${options.agent}-${idSeed}`);
844
+ const cmd = buildTmuxAgentCommand(executable, args, buildExecEnv(options));
845
+ const labels = { agent: options.agent };
846
+ if (options.sessionId)
847
+ labels.sessionId = options.sessionId;
848
+ const meta = await createSession({ name, cmd, cwd, socket, source: 'cli', labels });
849
+ const pane = meta.pane;
850
+ if (pane) {
851
+ // When the AGENT pane dies, detach the client (don't kill) so the session
852
+ // survives just long enough to read the dead pane's exit status below. The
853
+ // `#{hook_pane}` guard scopes this to the agent pane only: if the user splits
854
+ // the window and exits one of THEIR panes, the else-branch `kill-pane` closes
855
+ // that split in place instead of detaching everyone (the pane-died hook runs
856
+ // in the dead pane's context, so bare `kill-pane` targets it). Without the
857
+ // guard, exiting any split kicked the user clean out of tmux.
858
+ await setSessionHook(name, 'pane-died', `if -F '#{==:#{hook_pane},${pane}}' 'detach-client -s =${name}' 'kill-pane'`, socket);
859
+ // Record the agent's OS pid (the pane leaf, thanks to `exec`) WITH its tmux
860
+ // pane so the active-scan attributes it exactly and shows the %pane.
861
+ let panePid = 0;
862
+ try {
863
+ const r = await runTmux({ socket, args: ['display-message', '-pt', pane, '-p', '#{pane_pid}'], throwOnError: false });
864
+ panePid = parseInt(r.stdout.trim(), 10) || 0;
865
+ }
866
+ catch { /* best-effort */ }
867
+ writePidSessionEntry({
868
+ pid: panePid,
869
+ agent: options.agent,
870
+ sessionId: options.sessionId,
871
+ cwd,
872
+ tmuxPane: pane,
873
+ startedAtMs: Date.now(),
874
+ });
875
+ }
876
+ // The agent could exit before we attach (fast failure). Don't attach to an
877
+ // already-dead pane — read its status directly and tear down.
878
+ const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
879
+ if (!before.dead) {
880
+ await attachTmux({ socket, args: ['attach-session', '-t', name] });
881
+ }
882
+ const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
883
+ if (after.dead) {
884
+ await killSession(name, socket).catch(() => { });
885
+ return { exitCode: after.status ?? 0, stderr: '' };
886
+ }
887
+ // Pane still alive → the user detached; keep the session for `agents focus`.
888
+ return { exitCode: 0, stderr: '' };
889
+ }
756
890
  /**
757
891
  * Spawn an agent process and return its exit code plus a tee'd copy of stderr.
758
892
  *
@@ -773,6 +907,12 @@ async function spawnAgent(options) {
773
907
  if (options.agent === 'claude' && !options.resume && !options.sessionId) {
774
908
  options = { ...options, sessionId: randomUUID() };
775
909
  }
910
+ // Record the run's --name against its session id (when both are known at
911
+ // launch) so `agents sessions <name>` resolves it. Best-effort; unnamed runs
912
+ // and agents whose id isn't known up front simply skip this.
913
+ if (options.name && options.sessionId) {
914
+ recordRunName({ sessionId: options.sessionId, name: options.name, agent: options.agent, cwd: options.cwd });
915
+ }
776
916
  const cmd = buildExecCommand(options);
777
917
  const [executable, ...args] = cmd;
778
918
  const timeoutMs = options.timeout ? parseTimeout(options.timeout) : undefined;
@@ -800,6 +940,29 @@ async function spawnAgent(options) {
800
940
  command: executable,
801
941
  args: redactArgs(args.slice(0, 10)),
802
942
  });
943
+ // Interactive spawn-wrap: on macOS/Linux, run the agent INSIDE a shared-socket
944
+ // tmux session (then attach this TTY) so it gets a unique, addressable %pane.
945
+ // Headless runs, Windows, already-in-tmux, --raw, and AGENTS_NO_TMUX=1 keep the
946
+ // bare spawn below. See shouldWrapInTmux / runInTmux.
947
+ if (shouldWrapInTmux({
948
+ interactive,
949
+ platform: process.platform,
950
+ inTmux: !!process.env.TMUX,
951
+ raw: options.raw === true,
952
+ noTmuxEnv: process.env.AGENTS_NO_TMUX === '1',
953
+ tmuxAvailable: isTmuxInstalled(),
954
+ })) {
955
+ timer.mark('startup');
956
+ try {
957
+ const result = await runInTmux(options, executable, args);
958
+ timer.end({ exitCode: result.exitCode, status: result.exitCode === 0 ? 'success' : 'failed' });
959
+ return result;
960
+ }
961
+ catch (err) {
962
+ timer.end({ error: err.message, exitCode: -1, status: 'error' });
963
+ throw err;
964
+ }
965
+ }
803
966
  return new Promise((resolve, reject) => {
804
967
  // Interactive mode inherits all stdio so the CLI owns the TTY (TUI
805
968
  // rendering, raw-mode keystrokes, colored output). Headless mode pipes
@@ -27,6 +27,11 @@ export interface DispatchOptions {
27
27
  * resumable by id. Mutually exclusive with `resume`.
28
28
  */
29
29
  sessionId?: string;
30
+ /**
31
+ * Durable `--name <slug>` handle, forwarded to the remote `agents run` and
32
+ * recorded on the local task so `agents hosts logs/ps <name>` resolve it.
33
+ */
34
+ name?: string;
30
35
  /** Resume an existing session on the host by id (via `agents run --resume`). */
31
36
  resume?: string;
32
37
  /** Stream progress and block until completion (default true). */
@@ -62,6 +62,7 @@ async function launchDetached(host, target, opts) {
62
62
  prompt: opts.promptLabel,
63
63
  pid: Number.isFinite(pid) ? pid : undefined,
64
64
  sessionId: opts.sessionId,
65
+ name: opts.name,
65
66
  remoteLog,
66
67
  remoteExit,
67
68
  status: 'running',
@@ -96,6 +97,8 @@ export function buildRunForwardedArgs(opts) {
96
97
  args.push('--mode', opts.mode);
97
98
  if (opts.model)
98
99
  args.push('--model', opts.model);
100
+ if (opts.name)
101
+ args.push('--name', opts.name);
99
102
  if (opts.resume)
100
103
  args.push('--resume', opts.resume);
101
104
  else if (opts.sessionId)
@@ -115,6 +118,7 @@ export async function dispatchToHost(host, opts) {
115
118
  timeoutMs: opts.timeoutMs,
116
119
  agentLabel: opts.agent,
117
120
  promptLabel: opts.prompt,
121
+ name: opts.name,
118
122
  // On resume the remote session keeps its existing id; record that id so the
119
123
  // task stays mapped to the same session.
120
124
  sessionId: opts.resume ?? opts.sessionId,
@@ -36,6 +36,7 @@ export function hostSessionMeta(task, ctx) {
36
36
  filePath: '',
37
37
  topic: ctx.prompt.split('\n')[0]?.slice(0, 120) || undefined,
38
38
  label: `[host/${task.host}]`,
39
+ name: task.name,
39
40
  };
40
41
  }
41
42
  /**
@@ -15,6 +15,14 @@ export interface HostTask {
15
15
  agent: string;
16
16
  prompt: string;
17
17
  pid?: number;
18
+ /**
19
+ * The durable `agents run --name <slug>` handle for this dispatch, if given.
20
+ * Chosen at launch and agent-agnostic (unlike sessionId), so `agents hosts
21
+ * ps/logs <name>` and the dispatch tip can reference the run by a stable name
22
+ * even for agents that never expose a session id up front. Absent when the
23
+ * run was launched without `--name`.
24
+ */
25
+ name?: string;
18
26
  /**
19
27
  * The agent session id the remote run was launched with (Claude only — the
20
28
  * only agent that accepts `--session-id` to force a NEW session's id). Lets
@@ -52,3 +60,10 @@ export declare function listTasks(): HostTask[];
52
60
  * with the same forced id resolves to the most recent dispatch.
53
61
  */
54
62
  export declare function findTaskBySessionId(sessionId: string): HostTask | null;
63
+ /**
64
+ * Find the newest host task launched with `--name <name>`, so `agents hosts
65
+ * logs/ps <name>` and resolve-by-handle can address a run by its durable name.
66
+ * Case-insensitive; newest wins (listTasks is createdAt-desc) when a name was
67
+ * reused across dispatches.
68
+ */
69
+ export declare function findTaskByName(name: string): HostTask | null;
@@ -85,3 +85,19 @@ export function findTaskBySessionId(sessionId) {
85
85
  }
86
86
  return null;
87
87
  }
88
+ /**
89
+ * Find the newest host task launched with `--name <name>`, so `agents hosts
90
+ * logs/ps <name>` and resolve-by-handle can address a run by its durable name.
91
+ * Case-insensitive; newest wins (listTasks is createdAt-desc) when a name was
92
+ * reused across dispatches.
93
+ */
94
+ export function findTaskByName(name) {
95
+ if (!name)
96
+ return null;
97
+ const wanted = name.toLowerCase();
98
+ for (const task of listTasks()) {
99
+ if (task.name && task.name.toLowerCase() === wanted)
100
+ return task;
101
+ }
102
+ return null;
103
+ }
@@ -77,7 +77,7 @@ export function menubarServiceInstalled() {
77
77
  * Locate the source `.app` shipped alongside the compiled JS.
78
78
  * 1. dist/lib/menubar/MenubarHelper.app — npm install layout (sibling of this file)
79
79
  * 2. <repo>/bin/MenubarHelper.app — raw working tree (tsx/dev)
80
- * 3. <repo>/packages/menubar-helper/dist/MenubarHelper.app — fresh local build
80
+ * 3. apps/cli/menubar/dist/MenubarHelper.app — fresh local build
81
81
  */
82
82
  function sourceAppPath() {
83
83
  const candidates = [];
@@ -85,7 +85,7 @@ function sourceAppPath() {
85
85
  const here = path.dirname(fileURLToPath(import.meta.url));
86
86
  candidates.push(path.join(here, APP_BUNDLE_NAME));
87
87
  candidates.push(path.resolve(here, '..', '..', '..', 'bin', APP_BUNDLE_NAME));
88
- candidates.push(path.resolve(here, '..', '..', '..', 'packages', 'menubar-helper', 'dist', APP_BUNDLE_NAME));
88
+ candidates.push(path.resolve(here, '..', '..', '..', 'menubar', 'dist', APP_BUNDLE_NAME));
89
89
  }
90
90
  catch {
91
91
  /* import.meta.url unavailable */
@@ -47,9 +47,11 @@ export declare function getProjectRunStrategy(agent: AgentId, startPath: string)
47
47
  * Resolve the configured strategy. Lookup order:
48
48
  * 1. project-local agents.yaml (nearest to `startPath`)
49
49
  * 2. ~/.agents/.system/agents.yaml
50
- * 3. default: `available` (use the pinned default version when healthy,
51
- * otherwise fall through to a healthy account so a single rate-limited
52
- * account doesn't block the run).
50
+ * 3. default: `balanced` (weighted-random across all healthy accounts by
51
+ * remaining headroom, skipping any that are currently rate-limited). A
52
+ * bare `agents run <agent>` — e.g. every new terminal the extension spawns
53
+ * — should spread load and never launch into a throttled account, rather
54
+ * than stick to the pinned default even when it's maxed.
53
55
  */
54
56
  export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: string): RunStrategy;
55
57
  /** Persist the global run strategy used by bare `agents run <agent>`. */
@@ -65,9 +67,12 @@ export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrate
65
67
  * headroom, with no stampede on the lowest-usage one. Stateless — parallel
66
68
  * callers naturally fan out via the random roll.
67
69
  *
68
- * Eligibility: signed in (email present), auth valid, and usage available
69
- * (any non-session window strictly under 100%, or local flag not exhausted
70
- * when no live snapshot exists).
70
+ * Eligibility: signed in (email present), auth valid, and not currently
71
+ * rate-limited — no blocking window (session OR weekly) at 100%, matching the
72
+ * `agents view` badge; or the local cached status is usable when no live
73
+ * snapshot exists. Note the split: eligibility considers the session window
74
+ * (a session-maxed account can't run now), but the capacity *weight* above is
75
+ * driven by weekly headroom so a brief session spike doesn't distort routing.
71
76
  *
72
77
  * Dedupe: when multiple versions share an email, collapse to one candidate
73
78
  * per email (the least-recently-active version). Prevents two parallel pods
@@ -10,7 +10,7 @@ import { getAccountInfo } from './agents.js';
10
10
  import { readMeta, writeMeta, getHelpersDir } from './state.js';
11
11
  import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
12
12
  import { getProjectRunConfigs } from './run-config.js';
13
- import { getUsageInfoByIdentity, getUsageLookupKey, } from './usage.js';
13
+ import { getUsageInfoByIdentity, getUsageLookupKey, deriveUsageStatusFromSnapshot, } from './usage.js';
14
14
  function getRotateDir() {
15
15
  const dir = path.join(getHelpersDir(), 'rotate');
16
16
  fs.mkdirSync(dir, { recursive: true });
@@ -44,14 +44,16 @@ export function getProjectRunStrategy(agent, startPath) {
44
44
  * Resolve the configured strategy. Lookup order:
45
45
  * 1. project-local agents.yaml (nearest to `startPath`)
46
46
  * 2. ~/.agents/.system/agents.yaml
47
- * 3. default: `available` (use the pinned default version when healthy,
48
- * otherwise fall through to a healthy account so a single rate-limited
49
- * account doesn't block the run).
47
+ * 3. default: `balanced` (weighted-random across all healthy accounts by
48
+ * remaining headroom, skipping any that are currently rate-limited). A
49
+ * bare `agents run <agent>` — e.g. every new terminal the extension spawns
50
+ * — should spread load and never launch into a throttled account, rather
51
+ * than stick to the pinned default even when it's maxed.
50
52
  */
51
53
  export function getConfiguredRunStrategy(agent, startPath = process.cwd()) {
52
54
  return getProjectRunStrategy(agent, startPath)
53
55
  ?? normalizeRunStrategy(readMeta().run?.[agent]?.strategy)
54
- ?? 'available';
56
+ ?? 'balanced';
55
57
  }
56
58
  /** Persist the global run strategy used by bare `agents run <agent>`. */
57
59
  export function setGlobalRunStrategy(agent, strategy) {
@@ -72,10 +74,19 @@ function isAvailableEligible(candidate) {
72
74
  && hasUsageAvailable(candidate);
73
75
  }
74
76
  function hasUsageAvailable(candidate) {
75
- const usedPercent = getRoutingUsedPercent(candidate.usageSnapshot);
76
- if (usedPercent !== null) {
77
- return usedPercent < 100;
77
+ const snapshot = candidate.usageSnapshot;
78
+ if (snapshot && snapshot.windows.length > 0) {
79
+ // Eligibility mirrors the `agents view` throttle badge exactly
80
+ // (deriveUsageStatusFromSnapshot): an account maxed on ANY blocking window —
81
+ // including the 5-hour session window — cannot serve the next request, so it
82
+ // must not be picked. Previously this checked only non-session windows
83
+ // (getRoutingUsedPercent), so a session-maxed account with weekly headroom
84
+ // stayed "eligible" and the router kept launching into it while `ag view`
85
+ // showed it rate-limited. Capacity *weighting* still ranks eligible accounts
86
+ // by weekly headroom; this gate only decides can-it-run-right-now.
87
+ return deriveUsageStatusFromSnapshot(snapshot) !== 'rate_limited';
78
88
  }
89
+ // No live snapshot: fall back to the coarse cached status.
79
90
  if (candidate.usageStatus === 'out_of_credits' || candidate.usageStatus === 'rate_limited') {
80
91
  return false;
81
92
  }
@@ -140,9 +151,12 @@ function dedupeAndSortCandidates(candidates) {
140
151
  * headroom, with no stampede on the lowest-usage one. Stateless — parallel
141
152
  * callers naturally fan out via the random roll.
142
153
  *
143
- * Eligibility: signed in (email present), auth valid, and usage available
144
- * (any non-session window strictly under 100%, or local flag not exhausted
145
- * when no live snapshot exists).
154
+ * Eligibility: signed in (email present), auth valid, and not currently
155
+ * rate-limited — no blocking window (session OR weekly) at 100%, matching the
156
+ * `agents view` badge; or the local cached status is usable when no live
157
+ * snapshot exists. Note the split: eligibility considers the session window
158
+ * (a session-maxed account can't run now), but the capacity *weight* above is
159
+ * driven by weekly headroom so a brief session spike doesn't distort routing.
146
160
  *
147
161
  * Dedupe: when multiple versions share an email, collapse to one candidate
148
162
  * per email (the least-recently-active version). Prevents two parallel pods
@@ -336,13 +336,13 @@ export function writeBundle(bundle) {
336
336
  // of the tier. On an un-updated pinned helper this write fails loudly (the
337
337
  // no-ACL command is missing) rather than silently landing an ACL'd item.
338
338
  itemStore(backend).set(bundleMetaItem(bundle.name), json, { noAcl: bundle.policy === 'never' });
339
- emit('secrets.set', { bundle: bundle.name });
339
+ emit('secrets.set', { module: 'secrets', bundle: bundle.name });
340
340
  }
341
341
  export function deleteBundle(name) {
342
342
  validateBundleName(name);
343
343
  const deleted = itemStore(bundleBackend(name)).delete(bundleMetaItem(name));
344
344
  if (deleted) {
345
- emit('secrets.delete', { bundle: name });
345
+ emit('secrets.delete', { module: 'secrets', bundle: name });
346
346
  }
347
347
  return deleted;
348
348
  }
@@ -692,6 +692,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
692
692
  const filtered = filterAgentHitBySubsetAndExpiry(hit, opts);
693
693
  stampLastUsed(filtered.bundle);
694
694
  emit('secrets.get', {
695
+ module: 'secrets',
695
696
  bundle: name,
696
697
  caller: opts.caller,
697
698
  status: 'success',
@@ -781,6 +782,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
781
782
  keychainKeys.sort();
782
783
  const emitReadAudit = (status, err) => {
783
784
  emit('secrets.get', {
785
+ module: 'secrets',
784
786
  bundle: bundle.name,
785
787
  caller: opts.caller,
786
788
  status,
@@ -943,7 +945,7 @@ export function renameBundle(oldName, newName, opts = {}) {
943
945
  store.delete(oldItem);
944
946
  }
945
947
  deleteBundle(oldName);
946
- emit('secrets.rename', { from: oldName, to: newName });
948
+ emit('secrets.rename', { module: 'secrets', from: oldName, to: newName });
947
949
  }
948
950
  /**
949
951
  * The store (keychain or encrypted file) that carries a bundle's items. The
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { sshExec, assertValidSshTarget } from '../ssh-exec.js';
19
19
  import { resolveHost } from '../hosts/registry.js';
20
+ import { emit } from '../events.js';
20
21
  import { sshTargetFor } from '../hosts/types.js';
21
22
  import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
22
23
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
@@ -136,5 +137,18 @@ export async function remoteResolveEnv(target, bundle) {
136
137
  for (const [k, v] of Object.entries(parsed)) {
137
138
  env[k] = typeof v === 'string' ? v : String(v);
138
139
  }
140
+ // The remote host audits its own `secrets export` read; this emit records the
141
+ // event on the INITIATING host too (values were pulled into this process and
142
+ // injected locally). Covers `secrets exec --host` and `run --secrets b@host`.
143
+ // Values never enter the payload — only the bundle, target host, and count.
144
+ emit('secrets.get', {
145
+ module: 'secrets',
146
+ bundle,
147
+ caller: 'remote resolve',
148
+ source: 'remote',
149
+ host: target,
150
+ status: 'success',
151
+ keyCount: Object.keys(env).length,
152
+ });
139
153
  return env;
140
154
  }
@@ -14,6 +14,7 @@ import * as crypto from 'crypto';
14
14
  import { deleteKeychainToken, getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from './index.js';
15
15
  import { readBundle, writeBundle, keychainItemsForBundle, validateBundleName, } from './bundles.js';
16
16
  import { rushSyncBackend } from './drivers/rush.js';
17
+ import { emit } from '../events.js';
17
18
  // PBKDF2 cost. 600k SHA-256 iters matches OWASP 2023+ guidance and keeps a
18
19
  // passphrase prompt under a second on the hardware the CLI targets.
19
20
  const PBKDF2_ITER = 600_000;
@@ -185,6 +186,18 @@ function rollbackFailureMessage(name, phase, err, dirty) {
185
186
  export async function pushBundle(name, opts) {
186
187
  validateBundleName(name);
187
188
  const snap = snapshotBundle(name);
189
+ // Push reads every plaintext value and uploads the (client-side-encrypted)
190
+ // bundle off-machine — the most sensitive read there is. It bypasses
191
+ // readAndResolveBundleEnv, so audit it explicitly. Values never enter the
192
+ // payload; only the bundle name and how many keys were read.
193
+ emit('secrets.get', {
194
+ module: 'secrets',
195
+ bundle: name,
196
+ caller: 'sync push',
197
+ source: 'sync-push',
198
+ status: 'success',
199
+ keyCount: Object.keys(snap.secrets).length,
200
+ });
188
201
  const envelope = encryptBlob(JSON.stringify(snap), opts.passphrase);
189
202
  const updated_at = new Date().toISOString();
190
203
  const payload = { envelope, updated_at };
@@ -20,6 +20,8 @@ export interface ActiveSession {
20
20
  cwd?: string;
21
21
  /** User-given name from /rename command. */
22
22
  label?: string;
23
+ /** Durable `agents run --name` launch handle, when the run was named. */
24
+ name?: string;
23
25
  /** First meaningful line of the initial prompt (extracted topic). */
24
26
  topic?: string;
25
27
  /** Live preview: the latest turn (agent message or tool action), from the state engine. */
@@ -84,11 +86,35 @@ export interface ActiveSession {
84
86
  * (after the --json/--waiting gates) — NOT emitted on the discovery path.
85
87
  */
86
88
  tmuxTarget?: string;
89
+ /**
90
+ * Which host app + tab a tmux-hosted session is currently being VIEWED in,
91
+ * resolved from the attached tmux client (its terminal PID -> app via
92
+ * HOST_MATCHERS, its tab via the per-app resolver). `undefined` means no
93
+ * client is attached — the session is running detached. Transient,
94
+ * renderer-set (see src/lib/session/viewing-in.ts) — NOT on the discovery path.
95
+ */
96
+ viewingIn?: {
97
+ app: string;
98
+ tab?: number;
99
+ };
87
100
  }
88
101
  export interface ActiveQueryOptions {
89
102
  /** Skip the `ps` scan for ad-hoc headless agents. */
90
103
  skipHeadless?: boolean;
91
104
  }
105
+ /**
106
+ * Pick a Claude transcript file within a project dir.
107
+ *
108
+ * With a CONCRETE session id: return that id's `<id>.jsonl` or undefined — NEVER a
109
+ * sibling's. Falling back to the newest file here is the bug that made N distinct
110
+ * co-located sessions (e.g. several editor tabs in one cwd, or two worktree siblings)
111
+ * all collapse onto ONE file and render identical preview + topic (they look like
112
+ * duplicate cards). The mtime fallback is only sound when NO id is known.
113
+ *
114
+ * With NO id: return the newest `.jsonl` by mtime (the legitimate single-session
115
+ * heuristic for a directly-launched agent with no registry entry).
116
+ */
117
+ export declare function pickSessionFile(projectDir: string, sessionId?: string): string | undefined;
92
118
  /**
93
119
  * Locate the live transcript for an agent process. Claude files are keyed by
94
120
  * cwd (+ optional session uuid); Codex files are date-partitioned, so we resolve
@@ -117,6 +143,14 @@ export declare function parseWin32ProcessCsv(out: string): ProcRow[];
117
143
  * for testing the bound; production always uses the real `lsof`-backed probe.
118
144
  */
119
145
  export declare function resolveCwds(pids: number[], probe?: (pid: number) => Promise<string | undefined>): Promise<(string | undefined)[]>;
146
+ /**
147
+ * Resolve the host app for a single pid by walking its process ancestry with the
148
+ * same HOST_MATCHERS logic `detectHost` uses. Reads the whole process table per
149
+ * call, so it's for the low-cardinality renderer path (one tmux client per
150
+ * session), not a hot loop. Returns undefined when nothing above the pid is a
151
+ * recognised UI. Exported for the "viewing in <app>" resolver.
152
+ */
153
+ export declare function hostFromPid(pid: number): Promise<string | undefined>;
120
154
  export interface AgentCandidate {
121
155
  pid: number;
122
156
  kind: string;
@@ -155,9 +189,21 @@ export declare function foldSubordinateAgents(candidates: AgentCandidate[], ppid
155
189
  */
156
190
  export declare function listUnattributedActive(attributed: Set<number>): Promise<ActiveSession[]>;
157
191
  /**
158
- * Union of all four sources. Teams and terminals spawn actual CLI processes
159
- * that also show up in `ps`, so headless attribution runs last with the
160
- * already-attributed PIDs removed.
192
+ * Agents hosted in the shared-socket tmux server the authoritative source for
193
+ * tmux-wrapped interactive spawns (see src/lib/exec.ts `runInTmux`). Enumerates
194
+ * every pane on the shared socket and keeps those whose session meta was stamped
195
+ * with `labels.agent` + `labels.sessionId` by the spawn-wrap. Because tmux (not a
196
+ * per-window `live-terminals.json`) is the source of truth, a tmux-hosted agent is
197
+ * ALWAYS captured with its exact `%pane` even when the extension registry is stale
198
+ * or absent. `source: 'teams'` is skipped — teammates are surfaced by listTeamsActive.
199
+ */
200
+ export declare function listTmuxAgentSessions(): Promise<ActiveSession[]>;
201
+ /**
202
+ * Union of all sources. Teams and terminals spawn actual CLI processes that
203
+ * also show up in `ps`, so headless attribution runs last with the already-
204
+ * attributed PIDs removed. The tmux source goes FIRST into the dedupe so a
205
+ * tmux-hosted agent's row (which carries the exact `%pane`) wins over a staler
206
+ * terminal/headless row for the same session id.
161
207
  */
162
208
  export declare function getActiveSessions(opts?: ActiveQueryOptions): Promise<ActiveSession[]>;
163
209
  export {};