@phnx-labs/agents-cli 1.20.41 → 1.20.42

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.42
6
+
7
+ - **Fix: exiting a split pane inside an interactive `ag run` session kicked you out of tmux entirely.** When you split the window of an interactive agent session (`ag run claude`) with Ctrl-b `"`/`%` and then `exit`ed *your* split, the whole tmux client detached and dumped you back to the parent shell — even though the agent was still running in the other pane. Cause: `runInTmux` installed a session-wide `pane-died` hook (`detach-client`) meant to fire only when the AGENT pane exits (so the attach returns and the exit status is read), but with no `#{hook_pane}` guard it fired for *any* pane's death. The hook is now scoped to the agent pane; a user split that exits is closed in place (`kill-pane`, no lingering dead husk) and the agent keeps running full-window. Source: `apps/cli/src/lib/exec.ts`, `apps/cli/src/lib/tmux/session.test.ts`.
8
+ - **Every secret-value read is now audited, not just the ones that flowed through the resolver.** `agents events --module secrets` (or `--event secrets.get`) is meant to show "every secret accessed or revealed", but several paths read plaintext values without going through `readAndResolveBundleEnv` (the only place that emitted `secrets.get`), so they were invisible: `secrets push` (which reads the whole bundle to upload it — the most sensitive silent read), `secrets view --reveal`, the raw `secrets get <item>`, `secrets set <item>` (a raw write, no `secrets.set`), and the *initiating* side of `secrets exec --host` / `run --secrets bundle@host` (only the remote host logged it). Each now emits with a `source` telling you HOW it was read — `keychain`, `agent` (served from the unlocked broker), `reveal`, `raw-item`, `sync-push`, or `remote` (with the target `host`) — alongside the bundle, caller, keyCount, and OS-user/host/transport. The resolved **value is never written to the log**, only names and counts. All `secrets.*` events are now tagged `module: 'secrets'` so `--module secrets` actually surfaces the value reads (previously it matched only the coarse command events). Note: the event log has a 7-day retention, so export what you need for long-term records. Source: `src/lib/secrets/bundles.ts`, `src/lib/secrets/sync.ts`, `src/lib/secrets/remote.ts`, `src/commands/secrets.ts`, `docs/06-observability.md`.
9
+ - **Fix: `sessions --active` showed the SAME preview + topic for every co-located session.** Multiple Claude sessions in one cwd (e.g. several editor tabs, or two worktree siblings) all rendered identical activity — they looked like duplicate cards. `findClaudeSessionFile` fell back to the newest `.jsonl` in the cwd whenever a session's `<id>.jsonl` wasn't found, so every distinct session collapsed onto ONE file's preview/topic. The stale-id trigger: an editor caches the launch uuid in `live-terminals.json`, but Claude rotates its transcript uuid on resume/compact, so the cached id no longer matches any file. Now the terminal path resolves each tab's EXACT id from the pid registry (mirroring the headless path), the newest-file fallback is gated to the no-id case (`pickSessionFile`), and an unresolvable file reads as `idle` rather than `running`. Source: `apps/cli/src/lib/session/active.ts`.
10
+ - **Fix: one malformed Kimi session blanked the WHOLE `agents sessions` listing.** A Kimi `state.json` with neither `createdAt` nor `updatedAt` made `readKimiMeta` return an `undefined` timestamp, which binds `NULL` into the `timestamp TEXT NOT NULL` column and aborts the entire batch index — so a single bad session took down the listing for every session, not just itself. Two layers: `readKimiMeta` now coerces the timestamp to never-null, falling back to the `state.json` mtime (matching how the listing already ranks Kimi via `last_activity`, like every other parser); and `upsertSessionsBatch` wraps each row in a per-row guard so a future constraint-violating row skips itself (ledger deliberately not stamped, so the next scan re-tries it) instead of rolling back the whole batch. Source: `apps/cli/src/lib/session/discover.ts`, `apps/cli/src/lib/session/db.ts`.
11
+
5
12
  ## 1.20.41
6
13
 
7
14
  - **NEW: `agents sessions focus [id]`** — one command to get back to a session, however it's reachable. It **attaches** a live session in place (tmux `switch-client`/`attach-session`, a remote tmux over `ssh -tt`, or a Ghostty tab — joining the live process without forking); where there's **no live terminal to attach**, it **opens a new tab and resumes** the session — locally, or on the remote peer over SSH (`runOnPeer`, so the peer resolves the version-pinned binary). No id opens the rich live-session picker (this-machine first). Reuses the live-session detection and the terminal launch engine (`openSurfaces`), and folds `go`'s attach paths in. Source: `src/commands/focus.ts`, `src/commands/go.ts`.
@@ -200,7 +200,7 @@ function warnIfNotFrontmost(res) {
200
200
  }
201
201
  }
202
202
  function reportMissingHelper() {
203
- console.error('helper not built. Run: ./packages/computer-helper/scripts/build.sh debug');
203
+ console.error('helper not built. Run: ./native/computer-mac/scripts/build.sh debug');
204
204
  process.exit(1);
205
205
  }
206
206
  // Open a client, run fn, always close. Fails fast if no helper is present.
@@ -22,9 +22,9 @@ export declare function detectImageFormat(buf: Buffer): '.png' | '.jpg' | null;
22
22
  * Make the screenshot filename honest about its bytes. The two helper backends
23
23
  * encode DIFFERENT formats and neither re-encodes to match the requested name:
24
24
  * the macOS helper (ScreenCaptureKit) returns JPEG
25
- * (packages/computer-helper/Sources/ComputerHelper/Screenshot.swift:207,212),
25
+ * (native/computer-mac/Sources/ComputerHelper/Screenshot.swift:207,212),
26
26
  * the Windows helper returns PNG
27
- * (packages/computer-helper-win/Screenshot.cs:33). So a fixed default extension
27
+ * (native/computer-win/Screenshot.cs:33). So a fixed default extension
28
28
  * cannot be correct for both — the only honest path is to sniff the real format
29
29
  * and swap the extension to match. Pure so it's unit-testable.
30
30
  *
@@ -53,9 +53,9 @@ export function detectImageFormat(buf) {
53
53
  * Make the screenshot filename honest about its bytes. The two helper backends
54
54
  * encode DIFFERENT formats and neither re-encodes to match the requested name:
55
55
  * the macOS helper (ScreenCaptureKit) returns JPEG
56
- * (packages/computer-helper/Sources/ComputerHelper/Screenshot.swift:207,212),
56
+ * (native/computer-mac/Sources/ComputerHelper/Screenshot.swift:207,212),
57
57
  * the Windows helper returns PNG
58
- * (packages/computer-helper-win/Screenshot.cs:33). So a fixed default extension
58
+ * (native/computer-win/Screenshot.cs:33). So a fixed default extension
59
59
  * cannot be correct for both — the only honest path is to sniff the real format
60
60
  * and swap the extension to match. Pure so it's unit-testable.
61
61
  *
@@ -354,7 +354,7 @@ function registerSetupCommand(program) {
354
354
  }
355
355
  const srcApp = resolveHelperApp();
356
356
  if (!srcApp || !fs.existsSync(srcApp)) {
357
- console.error('helper not built. Run: ./packages/computer-helper/scripts/build.sh debug');
357
+ console.error('helper not built. Run: ./native/computer-mac/scripts/build.sh debug');
358
358
  process.exit(1);
359
359
  }
360
360
  const home = os.homedir();
@@ -392,7 +392,7 @@ function registerSetupCommand(program) {
392
392
  }
393
393
  catch {
394
394
  console.error('codesign verify FAILED. The destination .app is unsigned or its signature was stripped.');
395
- console.error('rebuild the helper with a Developer ID cert: ./packages/computer-helper/scripts/build.sh release');
395
+ console.error('rebuild the helper with a Developer ID cert: ./native/computer-mac/scripts/build.sh release');
396
396
  process.exit(1);
397
397
  }
398
398
  // 3. Ensure socket + log parent dirs exist.
@@ -219,6 +219,7 @@ export function registerRunCommand(program) {
219
219
  .option('--resume [id]', 'Resume a previous conversation. Accepts a full or partial session id (prefix-matched against the index); omit the id to pick from recent sessions interactively. Resumes under the version that started the session. claude/codex resume natively; other agents replay via a /continue first message. Pair with a prompt to continue headlessly.')
220
220
  .option('--session-id <id>', 'Force a NEW conversation to use this exact session UUID (Claude only). This CREATES a session — to resume an existing one, use --resume.')
221
221
  .option('--verbose', 'Show detailed execution logs')
222
+ .option('--raw', 'Interactive runs on macOS/Linux launch inside a shared tmux session (for %pane addressing + re-attach). Pass --raw to spawn the agent directly instead. Also disabled by AGENTS_NO_TMUX=1.')
222
223
  .option('--timeout <duration>', 'Kill the agent after this duration (e.g., 30m, 1h, 2h30m)')
223
224
  .option('--fallback <agents>', 'Comma-separated agents to try on rate-limit failure. Each entry accepts an optional @version pin (e.g., codex@0.116.0,gemini). The primary runs first; if it exits with a rate-limit error, the next agent picks up via /continue handoff.')
224
225
  .option('-b, --balanced', 'Shortcut for --strategy balanced. Ignored when @version is pinned.')
@@ -1063,6 +1064,7 @@ export function registerRunCommand(program) {
1063
1064
  sessionId: resumeSessionId ?? options.sessionId,
1064
1065
  resume: resumeNative,
1065
1066
  verbose: options.verbose,
1067
+ raw: options.raw,
1066
1068
  timeout: options.timeout,
1067
1069
  env,
1068
1070
  toolsRestrict: workflowToolsRestrict,
@@ -122,9 +122,14 @@ export function describeWhere(s, self) {
122
122
  const remote = s.machine && s.machine !== self ? s.machine : undefined;
123
123
  const mux = s.provenance?.mux;
124
124
  if (mux?.kind === 'tmux' && mux.pane) {
125
+ // When the renderer has resolved the current viewer, fold it into the label
126
+ // so `focus` reports "tmux %3 (viewing in codium tab 2)" / "(detached)".
127
+ const view = s.viewingIn
128
+ ? ` (viewing in ${s.viewingIn.app}${s.viewingIn.tab != null ? ` tab ${s.viewingIn.tab}` : ''})`
129
+ : '';
125
130
  return remote
126
131
  ? { label: `tmux ${mux.pane} on ${remote}`, action: `ssh + attach on ${remote}` }
127
- : { label: `tmux ${mux.pane}`, action: 'attach its tmux' };
132
+ : { label: `tmux ${mux.pane}${view}`, action: 'attach its tmux' };
128
133
  }
129
134
  if (!remote && s.host === 'ghostty')
130
135
  return { label: 'Ghostty', action: 'focus its Ghostty tab' };
@@ -18,6 +18,7 @@ import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainI
18
18
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
19
19
  import { DEFAULT_TTL_MS, agentLoad, agentLock, agentStatus, ensureAgentRunning, installSecretsAgentService, runAgentLoadFromStdin, runSecretsAgent, secretsAgentServiceInstalled, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
20
20
  import { parseDuration } from '../lib/hooks/cache.js';
21
+ import { emit } from '../lib/events.js';
21
22
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
22
23
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
23
24
  import { registerSecretsSyncCommands } from './secrets-sync.js';
@@ -658,6 +659,26 @@ export function registerSecretsCommands(program) {
658
659
  catch {
659
660
  // Fall through to masked output on cancellation / batch failure.
660
661
  }
662
+ // Revealing plaintext bypasses readAndResolveBundleEnv (the usual
663
+ // audit chokepoint), so emit here — a `--reveal` exposes real values
664
+ // and must show up in `agents events --module secrets`. Count both the
665
+ // keychain values actually decrypted AND the inline literals (which
666
+ // `--reveal` always prints, even for a literal-only bundle with no
667
+ // keychain refs — see the entries loop below). Values are never
668
+ // included, only how many keys were exposed. Emit only when something
669
+ // was actually shown (a cancelled Touch ID + no literals reveals none).
670
+ const literalCount = entries.filter((e) => e.kind === 'literal').length;
671
+ const exposedCount = revealedValues.size + literalCount;
672
+ if (exposedCount > 0) {
673
+ emit('secrets.get', {
674
+ module: 'secrets',
675
+ bundle: bundle.name,
676
+ caller: 'view --reveal',
677
+ source: 'reveal',
678
+ status: 'success',
679
+ keyCount: exposedCount,
680
+ });
681
+ }
661
682
  }
662
683
  for (const e of entries) {
663
684
  if (e.kind === 'keychain') {
@@ -703,6 +724,9 @@ export function registerSecretsCommands(program) {
703
724
  // so `$(agents secrets get NAME)` captures it cleanly); diagnostics go
704
725
  // to stderr so they never pollute the captured value.
705
726
  const value = getKeychainToken(item);
727
+ // Raw item reads bypass readAndResolveBundleEnv, so audit here too.
728
+ // `item` is the keychain service name, never the value.
729
+ emit('secrets.get', { module: 'secrets', item, source: 'raw-item', status: 'success' });
706
730
  process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
707
731
  }
708
732
  catch {
@@ -734,6 +758,8 @@ export function registerSecretsCommands(program) {
734
758
  // so `agents secrets get` can read them back without a password sheet;
735
759
  // on Linux it goes through secret-tool / encrypted-file fallback.
736
760
  setKeychainToken(item, value);
761
+ // Raw item writes bypass writeBundle (the usual secrets.set chokepoint).
762
+ emit('secrets.set', { module: 'secrets', item, source: 'raw-item' });
737
763
  console.error(chalk.green(`Stored keychain item '${item}'.`));
738
764
  }
739
765
  catch (err) {
@@ -18,7 +18,8 @@ import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session
18
18
  import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecutable } from '../lib/platform/index.js';
19
19
  import { getActiveSessions } from '../lib/session/active.js';
20
20
  import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
21
- import { mapPanesToTargets } from '../lib/tmux/session.js';
21
+ import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
22
+ import { resolveViewingIn } from '../lib/session/viewing-in.js';
22
23
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
23
24
  import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
24
25
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
@@ -314,6 +315,16 @@ function locatorBadge(s) {
314
315
  parts.push(chalk.red('ssh'));
315
316
  if (p?.mux?.kind === 'tmux' && (s.tmuxTarget || p.mux.pane)) {
316
317
  parts.push(chalk.green(s.tmuxTarget ?? p.mux.pane));
318
+ // For a tmux-hosted session, say which app+tab is looking at it right now
319
+ // (or that it's running detached). Only meaningful for tmux (the pane is the
320
+ // durable handle; the viewer is transient).
321
+ if (s.viewingIn) {
322
+ const tab = s.viewingIn.tab != null ? ` tab ${s.viewingIn.tab}` : '';
323
+ parts.push(chalk.gray(`viewing in ${s.viewingIn.app}${tab}`));
324
+ }
325
+ else {
326
+ parts.push(chalk.gray('detached'));
327
+ }
317
328
  }
318
329
  else if (p?.mux?.kind === 'screen') {
319
330
  parts.push(chalk.green('screen'));
@@ -617,20 +628,27 @@ async function enrichLocalLocators(local) {
617
628
  }
618
629
  }
619
630
  catch { /* non-fatal */ }
620
- // tmux attach targets, one batched query per distinct socket.
631
+ // tmux attach targets + "viewing in <app> tab N", one batched query per socket.
621
632
  try {
622
633
  const tmux = local.filter(s => s.provenance?.mux?.kind === 'tmux' && s.provenance.mux.pane);
623
- const sockets = new Set(tmux.map(s => s.provenance.mux.socket));
624
- for (const socket of sockets) {
625
- const paneMap = await mapPanesToTargets(socket);
626
- if (paneMap.size === 0)
627
- continue;
628
- for (const s of tmux) {
629
- if (s.provenance.mux.socket !== socket)
634
+ if (tmux.length > 0) {
635
+ // One Ghostty enumeration shared across every socket's viewing-in resolve
636
+ // (a tmux client can be attached from a Ghostty tab).
637
+ const surfaces = await enumerateGhosttyTabs();
638
+ const sockets = new Set(tmux.map(s => s.provenance.mux.socket));
639
+ for (const socket of sockets) {
640
+ const paneMap = await mapPanesToTargets(socket);
641
+ if (paneMap.size === 0)
630
642
  continue;
631
- const target = paneMap.get(s.provenance.mux.pane);
632
- if (target)
633
- s.tmuxTarget = target;
643
+ const clients = await listClients(socket);
644
+ for (const s of tmux) {
645
+ if (s.provenance.mux.socket !== socket)
646
+ continue;
647
+ const target = paneMap.get(s.provenance.mux.pane);
648
+ if (target)
649
+ s.tmuxTarget = target;
650
+ s.viewingIn = await resolveViewingIn(s, clients, { paneToTarget: paneMap, ghosttySurfaces: surfaces });
651
+ }
634
652
  }
635
653
  }
636
654
  }
@@ -168,8 +168,8 @@ export function writeComputerPeers(allowedExecPaths) {
168
168
  export function resolveHelperExec() {
169
169
  const here = path.dirname(fileURLToPath(import.meta.url));
170
170
  const candidates = [
171
- // Local build (running from the agents-cli checkout).
172
- path.resolve(here, '..', '..', 'packages', 'computer-helper', 'dist', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
171
+ // Local build (running from the agents-cli checkout). apps/cli/dist/lib -> repo root (4 up) -> native/computer-mac.
172
+ path.resolve(here, '..', '..', '..', '..', 'native', 'computer-mac', 'dist', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
173
173
  // Bundled with the npm package (later: CDN download lands here).
174
174
  path.resolve(here, '..', 'computer-helper', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
175
175
  ];
@@ -218,7 +218,7 @@ export function openComputerClient() {
218
218
  }
219
219
  const helperExec = resolveHelperExec();
220
220
  if (!helperExec) {
221
- throw new Error('helper not built. Run: ./packages/computer-helper/scripts/build.sh debug');
221
+ throw new Error('helper not built. Run: ./native/computer-mac/scripts/build.sh debug');
222
222
  }
223
223
  return new StdioClient(helperExec);
224
224
  }
@@ -109,6 +109,12 @@ export interface ExecOptions {
109
109
  mcpConfigPath?: string;
110
110
  /** Raw args captured after `--` on the command line, forwarded verbatim to the underlying agent CLI. */
111
111
  passthroughArgs?: string[];
112
+ /**
113
+ * Escape hatch for the interactive tmux spawn-wrap (see shouldWrapInTmux):
114
+ * when true, spawn the agent directly instead of inside a shared-socket tmux
115
+ * session. Also forced off by AGENTS_NO_TMUX=1. No effect on headless runs.
116
+ */
117
+ raw?: boolean;
112
118
  }
113
119
  /**
114
120
  * Resolve interactive vs headless. Explicit flags are definitive and win over
@@ -213,6 +219,52 @@ export declare function resolveShimSpawn(platform: NodeJS.Platform, binary: stri
213
219
  * keeping version resolution in one place instead of reimplementing it in batch.
214
220
  */
215
221
  export declare function execShimPassthrough(agent: AgentId, rawArgs: string[], cwd: string, pinnedVersion?: string): Promise<number>;
222
+ /** Inputs that decide whether an interactive spawn is wrapped in a shared-socket tmux session. */
223
+ export interface TmuxWrapContext {
224
+ /** resolveInteractive() result — only interactive REPL launches are wrapped. */
225
+ interactive: boolean;
226
+ /** process.platform — Windows has no tmux path, always spawns bare. */
227
+ platform: NodeJS.Platform;
228
+ /** True when the launcher itself already runs inside tmux ($TMUX set) — never double-wrap. */
229
+ inTmux: boolean;
230
+ /** The `--raw` escape hatch. */
231
+ raw: boolean;
232
+ /** The AGENTS_NO_TMUX=1 escape hatch. */
233
+ noTmuxEnv: boolean;
234
+ /** Whether a tmux binary is on PATH. */
235
+ tmuxAvailable: boolean;
236
+ }
237
+ /**
238
+ * Decide whether to run an interactive agent INSIDE a detached tmux session on
239
+ * the shared socket (then attach the current TTY) instead of a bare spawn.
240
+ *
241
+ * tmux-wrapping gives every interactive agent an exact, unique `%pane` handle so
242
+ * `agents sessions --active` can tell co-located agents apart, and lets `agents
243
+ * focus` re-attach a live session without forking it. Pure so the gate is unit-
244
+ * tested independently of the (side-effecting) spawn.
245
+ *
246
+ * All five guards must pass:
247
+ * - interactive — a headless `-p` run has no TTY to attach; keep bare spawn.
248
+ * - not Windows — no tmux path on win32.
249
+ * - not already in tmux — nesting tmux-in-tmux is pointless and confusing.
250
+ * - not --raw — explicit opt-out.
251
+ * - not AGENTS_NO_TMUX=1 — env opt-out (CI, scripts, the shim passthrough path).
252
+ * - tmux installed — otherwise there is nothing to wrap with.
253
+ */
254
+ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
255
+ /**
256
+ * Build the shell command that runs an agent inside a tmux pane with the exact
257
+ * env the bare spawn would use. tmux runs it via `sh -c <cmd>`; we `exec env
258
+ * K=V … <agent> <args…>` so:
259
+ * - `env` materializes the full agent env INTO the pane, independent of the
260
+ * (possibly stale, shared) tmux server environment — additive, so tmux's own
261
+ * $TMUX / $TMUX_PANE still reach the agent for provenance detection;
262
+ * - `exec` replaces the shell so the agent is the pane's leaf process (clean
263
+ * `#{pane_pid}`, clean signal delivery on detach/kill).
264
+ * Keys are filtered to valid identifiers so exported shell functions
265
+ * (`BASH_FUNC_*%%`) can't make `env` choke.
266
+ */
267
+ export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv): string;
216
268
  /** Exit code spawnAgent resolves with when a run is killed for crossing a budget cap. */
217
269
  export declare const BUDGET_KILL_EXIT_CODE = 7;
218
270
  /**
package/dist/lib/exec.js CHANGED
@@ -19,6 +19,8 @@ import { getShimsDir } from './state.js';
19
19
  import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
20
20
  import { mailboxDir, isValidMailboxId } from './mailbox.js';
21
21
  import { composeWin32CommandLine } from './platform/index.js';
22
+ import { isTmuxInstalled } from './tmux/binary.js';
23
+ import { shellQuote } from './ssh-exec.js';
22
24
  /**
23
25
  * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
24
26
  *
@@ -753,6 +755,131 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
753
755
  });
754
756
  });
755
757
  }
758
+ /**
759
+ * Decide whether to run an interactive agent INSIDE a detached tmux session on
760
+ * the shared socket (then attach the current TTY) instead of a bare spawn.
761
+ *
762
+ * tmux-wrapping gives every interactive agent an exact, unique `%pane` handle so
763
+ * `agents sessions --active` can tell co-located agents apart, and lets `agents
764
+ * focus` re-attach a live session without forking it. Pure so the gate is unit-
765
+ * tested independently of the (side-effecting) spawn.
766
+ *
767
+ * All five guards must pass:
768
+ * - interactive — a headless `-p` run has no TTY to attach; keep bare spawn.
769
+ * - not Windows — no tmux path on win32.
770
+ * - not already in tmux — nesting tmux-in-tmux is pointless and confusing.
771
+ * - not --raw — explicit opt-out.
772
+ * - not AGENTS_NO_TMUX=1 — env opt-out (CI, scripts, the shim passthrough path).
773
+ * - tmux installed — otherwise there is nothing to wrap with.
774
+ */
775
+ export function shouldWrapInTmux(ctx) {
776
+ if (!ctx.interactive)
777
+ return false;
778
+ if (ctx.platform === 'win32')
779
+ return false;
780
+ if (ctx.inTmux)
781
+ return false;
782
+ if (ctx.raw)
783
+ return false;
784
+ if (ctx.noTmuxEnv)
785
+ return false;
786
+ if (!ctx.tmuxAvailable)
787
+ return false;
788
+ return true;
789
+ }
790
+ /**
791
+ * Build the shell command that runs an agent inside a tmux pane with the exact
792
+ * env the bare spawn would use. tmux runs it via `sh -c <cmd>`; we `exec env
793
+ * K=V … <agent> <args…>` so:
794
+ * - `env` materializes the full agent env INTO the pane, independent of the
795
+ * (possibly stale, shared) tmux server environment — additive, so tmux's own
796
+ * $TMUX / $TMUX_PANE still reach the agent for provenance detection;
797
+ * - `exec` replaces the shell so the agent is the pane's leaf process (clean
798
+ * `#{pane_pid}`, clean signal delivery on detach/kill).
799
+ * Keys are filtered to valid identifiers so exported shell functions
800
+ * (`BASH_FUNC_*%%`) can't make `env` choke.
801
+ */
802
+ export function buildTmuxAgentCommand(executable, args, env) {
803
+ const envPrefix = Object.entries(env)
804
+ .filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
805
+ .map(([k, v]) => `${k}=${shellQuote(String(v))}`)
806
+ .join(' ');
807
+ const agentCmd = [executable, ...args].map(shellQuote).join(' ');
808
+ return `exec env ${envPrefix} ${agentCmd}`;
809
+ }
810
+ /**
811
+ * Run an interactive agent inside a detached tmux session on the shared socket,
812
+ * attach the current TTY, and propagate the wrapped agent's exit code.
813
+ *
814
+ * Lifecycle:
815
+ * 1. createSession() launches `sh -c 'exec env … agent'` detached, remain-on-exit
816
+ * on (global), and returns the pane id.
817
+ * 2. A per-session `pane-died` hook detaches the attach client the instant the
818
+ * AGENT pane exits, so attach returns instead of parking on a dead pane. The
819
+ * hook is guarded on `#{hook_pane}` so it fires ONLY for the agent pane —
820
+ * user-created splits (Ctrl-b " / %) that the user exits are closed in place
821
+ * (`kill-pane`) instead of tearing down the whole client, so exiting one
822
+ * split leaves the agent running full-window rather than kicking you out.
823
+ * 3. We record the agent pane's pid → session mapping (WITH the tmux pane) so the
824
+ * headless active-scan attributes it, then attach the TTY (blocking).
825
+ * 4. On return: if the pane is dead the agent exited — read its status, tear the
826
+ * session down, return that code. If the pane is still alive the user detached
827
+ * (Ctrl-b d) — return 0 and LEAVE the session for `agents focus` to re-attach.
828
+ */
829
+ async function runInTmux(options, executable, args) {
830
+ const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName } = await import('./tmux/session.js');
831
+ const { getDefaultSocketPath } = await import('./tmux/paths.js');
832
+ const { attachTmux, runTmux } = await import('./tmux/binary.js');
833
+ const socket = getDefaultSocketPath();
834
+ const cwd = options.cwd || process.cwd();
835
+ const idSeed = (options.sessionId ?? randomUUID()).slice(0, 8);
836
+ const name = slugifyName(`ag-${options.agent}-${idSeed}`);
837
+ const cmd = buildTmuxAgentCommand(executable, args, buildExecEnv(options));
838
+ const labels = { agent: options.agent };
839
+ if (options.sessionId)
840
+ labels.sessionId = options.sessionId;
841
+ const meta = await createSession({ name, cmd, cwd, socket, source: 'cli', labels });
842
+ const pane = meta.pane;
843
+ if (pane) {
844
+ // When the AGENT pane dies, detach the client (don't kill) so the session
845
+ // survives just long enough to read the dead pane's exit status below. The
846
+ // `#{hook_pane}` guard scopes this to the agent pane only: if the user splits
847
+ // the window and exits one of THEIR panes, the else-branch `kill-pane` closes
848
+ // that split in place instead of detaching everyone (the pane-died hook runs
849
+ // in the dead pane's context, so bare `kill-pane` targets it). Without the
850
+ // guard, exiting any split kicked the user clean out of tmux.
851
+ await setSessionHook(name, 'pane-died', `if -F '#{==:#{hook_pane},${pane}}' 'detach-client -s =${name}' 'kill-pane'`, socket);
852
+ // Record the agent's OS pid (the pane leaf, thanks to `exec`) WITH its tmux
853
+ // pane so the active-scan attributes it exactly and shows the %pane.
854
+ let panePid = 0;
855
+ try {
856
+ const r = await runTmux({ socket, args: ['display-message', '-pt', pane, '-p', '#{pane_pid}'], throwOnError: false });
857
+ panePid = parseInt(r.stdout.trim(), 10) || 0;
858
+ }
859
+ catch { /* best-effort */ }
860
+ writePidSessionEntry({
861
+ pid: panePid,
862
+ agent: options.agent,
863
+ sessionId: options.sessionId,
864
+ cwd,
865
+ tmuxPane: pane,
866
+ startedAtMs: Date.now(),
867
+ });
868
+ }
869
+ // The agent could exit before we attach (fast failure). Don't attach to an
870
+ // already-dead pane — read its status directly and tear down.
871
+ const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
872
+ if (!before.dead) {
873
+ await attachTmux({ socket, args: ['attach-session', '-t', name] });
874
+ }
875
+ const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
876
+ if (after.dead) {
877
+ await killSession(name, socket).catch(() => { });
878
+ return { exitCode: after.status ?? 0, stderr: '' };
879
+ }
880
+ // Pane still alive → the user detached; keep the session for `agents focus`.
881
+ return { exitCode: 0, stderr: '' };
882
+ }
756
883
  /**
757
884
  * Spawn an agent process and return its exit code plus a tee'd copy of stderr.
758
885
  *
@@ -800,6 +927,29 @@ async function spawnAgent(options) {
800
927
  command: executable,
801
928
  args: redactArgs(args.slice(0, 10)),
802
929
  });
930
+ // Interactive spawn-wrap: on macOS/Linux, run the agent INSIDE a shared-socket
931
+ // tmux session (then attach this TTY) so it gets a unique, addressable %pane.
932
+ // Headless runs, Windows, already-in-tmux, --raw, and AGENTS_NO_TMUX=1 keep the
933
+ // bare spawn below. See shouldWrapInTmux / runInTmux.
934
+ if (shouldWrapInTmux({
935
+ interactive,
936
+ platform: process.platform,
937
+ inTmux: !!process.env.TMUX,
938
+ raw: options.raw === true,
939
+ noTmuxEnv: process.env.AGENTS_NO_TMUX === '1',
940
+ tmuxAvailable: isTmuxInstalled(),
941
+ })) {
942
+ timer.mark('startup');
943
+ try {
944
+ const result = await runInTmux(options, executable, args);
945
+ timer.end({ exitCode: result.exitCode, status: result.exitCode === 0 ? 'success' : 'failed' });
946
+ return result;
947
+ }
948
+ catch (err) {
949
+ timer.end({ error: err.message, exitCode: -1, status: 'error' });
950
+ throw err;
951
+ }
952
+ }
803
953
  return new Promise((resolve, reject) => {
804
954
  // Interactive mode inherits all stdio so the CLI owns the TTY (TUI
805
955
  // rendering, raw-mode keystrokes, colored output). Headless mode pipes
@@ -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 */
@@ -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 };