@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/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.43
6
+
7
+ - **NEW: `agents run --name <slug>` — a durable, human/agent-friendly handle for any run.** An agent that dispatches another agent had no cheap status handle: the host-task id was never even printed (the `--no-follow` tip showed a literal `<id>` placeholder), and only Claude's session id is known up front (pre-minted `--session-id`) — every other agent's id is discovered later by scanning transcripts, so callers fell back to `agents logs`, which dumps the raw, token-heavy transcript. `--name` is chosen at launch, agent-agnostic, and stored on the structures that already back these views: a first-class `name` column on `sessions.db` (schema v9, additive, no rescan) parallel to `label` — `agents sessions <ref>` resolves against **both** name and label; the HostTask sidecar (forwarded to the remote run, so `agents hosts ps` gains a NAME column and `agents hosts logs <name>` resolves by name); and a run-name sidecar (`~/.agents/.cache/run-names/`) that joins a local run's name onto the index by id every scan via `syncNames` — the same idempotent pattern as `/rename` label sync. The `name` column is deliberately left out of the upsert `ON CONFLICT … SET` clause, so a discovery rescan can never null an existing name (regression-tested in `db.names.test.ts`). Omitting `--name` is a strict no-op: `name` stays unset and every id-based path is unchanged. The `--no-follow` dispatch tip now prints the real handle and steers to the compact `agents sessions` digest over the raw log. Source: `apps/cli/src/commands/exec.ts`, `apps/cli/src/lib/session/{db,run-names,discover}.ts`, `apps/cli/src/lib/hosts/{dispatch,tasks}.ts`.
8
+ - **New terminals (and teammates) no longer launch into a rate-limited account; `balanced` is now the default run strategy.** Two coupled fixes. (1) A bare `agents run <agent>` — every new agent terminal the extension spawns, and every non-version-pinned `agents teams add`/`start` teammate, since both route through bare `agents run` — used to default to the `available` strategy, which *prefers the pinned default version when it looks healthy*. But "healthy" was judged by the router's `getRoutingUsedPercent`, which **excluded the 5-hour session window** and looked at weekly usage only. So a session-maxed account with weekly headroom (e.g. session 100% / week 60%) was deemed eligible and kept getting launched — while `agents view` showed it "rate-limited" (its badge, `deriveUsageStatusFromSnapshot`, *counts* the session window). The router and the badge disagreed. Now `hasUsageAvailable` shares the badge's exact signal: an account maxed on **any** blocking window (session or weekly) is ineligible and skipped by both `available` and `balanced` — you never spin up an agent on an account that can't serve the next request. Capacity *weighting* still ranks eligible accounts by weekly headroom, so a brief session spike doesn't distort long-run routing. (2) The default strategy is now `balanced` (was `available`): a bare run spreads load across all healthy accounts by remaining headroom instead of sticking to the pinned default. Override per-workspace with `run.<agent>.strategy` in `agents.yaml`, or per-invocation with `--strategy` / `-b`. Source: `apps/cli/src/lib/rotate.ts`, `apps/cli/src/lib/usage.ts`, `apps/cli/src/commands/exec.ts`.
9
+ - **[browser] Logins survive browser restarts: sandboxed profiles keep memory-only session cookies, without restoring tabs.** Sites that issue login cookies with `expires=-1` (idealista, many banking/classifieds sites) logged the profile out on every browser restart, because Chromium purges memory-only session cookies at startup unless the session-restore preference is set — a constraint that had already leaked into agent designs as "sessions can't survive restarts". Every launch now pins `session.restore_on_startup: 1` ("continue where you left off") in the profile's `Default/Preferences`, which is the switch Chromium's cookie purge actually keys off — and pairs it with `--no-startup-window` so the *visible* side of restore never happens: no window exists at startup for restore to fill, no ghost tabs from the last task reopen, and the task flow creates its own tab over CDP exactly as before. Verified live on Windows/Comet: a memory-only cookie planted pre-restart was still present after a full stop/start, with OS-level window enumeration confirming a single window and zero restored tabs. The Preferences patch runs pre-spawn (browser down, so Chromium can't overwrite it on exit), stamps the profile name only on first launch, skips malformed files untouched, and is a no-op when already set. Electron profiles keep the old name-only seeding — they manage their own storage and need their startup window (the CDP driver binds to it). Bare `agents browser start` (no `--url`) recreates the old startup-window affordance by opening a blank page target when none exists, unregistered on the task like the startup window always was. Server-side session TTLs still apply — this removes the restart logout, not the site's own expiry. Source: `apps/cli/src/lib/browser/chrome.ts` (`ensureProfilePreferences`, launch args), `apps/cli/src/lib/browser/service.ts`.
10
+ - **Security fix: `agents sessions --host <target>` no longer accepts a leading-dash target (SSH argv-flag smuggling).** `session/remote.ts` carried its own copy of `assertValidSshTarget` that omitted the `host.startsWith('-')` guard every other SSH path enforces, so a bare flag like `-l` or `-F/path` — which passes the character allowlist — was handed straight to `ssh` as an argument (`-oProxyCommand=…`-class injection) before any connection. The duplicate validator (and its `SSH_TARGET_RE`) is deleted; `runRemoteSessions` now routes through the canonical `assertValidSshTarget` in `ssh-exec.ts`, whose dash guard is already regression-tested (`ssh-exec.test.ts`). Source: `apps/cli/src/lib/session/remote.ts`.
11
+
12
+ ## 1.20.42
13
+
14
+ - **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`.
15
+ - **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`.
16
+ - **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`.
17
+ - **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`.
18
+
5
19
  ## 1.20.41
6
20
 
7
21
  - **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.
@@ -218,11 +218,13 @@ export function registerRunCommand(program) {
218
218
  .option('-i, --interactive', 'Force interactive mode even when a prompt is provided. Mutually exclusive with --headless.')
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
+ .option('--name <slug>', 'Give the run a durable name — a stable handle you can check on later with `agents sessions <name>` (and `agents hosts logs <name>` for --host runs), instead of an opaque id. Optional; omitting it keeps today\'s id-only behavior.')
221
222
  .option('--verbose', 'Show detailed execution logs')
223
+ .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
224
  .option('--timeout <duration>', 'Kill the agent after this duration (e.g., 30m, 1h, 2h30m)')
223
225
  .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
226
  .option('-b, --balanced', 'Shortcut for --strategy balanced. Ignored when @version is pinned.')
225
- .option('--strategy <strategy>', 'Version/account selection strategy: pinned | available | balanced. Defaults to run.<agent>.strategy, then pinned. (Legacy `rotate` accepted as alias for `balanced`.)')
227
+ .option('--strategy <strategy>', 'Version/account selection strategy: pinned | available | balanced. Defaults to run.<agent>.strategy, then balanced (spreads load across healthy accounts and skips any that are rate-limited). (Legacy `rotate` accepted as alias for `balanced`.)')
226
228
  .option('--acp', 'Route through the Agent Client Protocol instead of direct exec. Supported for gemini, claude (via @zed-industries/claude-code-acp adapter). Unified event stream; emits ndjson when --json.')
227
229
  .option('-y, --yes', 'Skip the interactive budget-confirm prompt (require_confirm_over). Never skips a hard budget block.', false)
228
230
  .option('--loop', 'Re-inject the prompt/entrypoint each iteration until a stop condition (issue #332). Guards (--max-iterations, --budget, --until) are enforced outside the agent. Writes a checkpoint after every iteration for --resume-checkpoint.')
@@ -274,9 +276,10 @@ export function registerRunCommand(program) {
274
276
  Legacy 'full' is silently rewritten to 'skip'.
275
277
 
276
278
  Run strategy (set via --strategy or run.<agent>.strategy in agents.yaml):
277
- pinned use the workspace/global pinned version (default)
278
- available use pinned if usage available; otherwise switch to another signed-in version
279
- balanced distribute load across healthy accounts by remaining capacity
279
+ pinned use the workspace/global pinned version
280
+ available use pinned if it can run right now; otherwise switch to another signed-in version
281
+ balanced distribute load across healthy accounts by remaining capacity (default)
282
+ A version/account is skipped when it is rate-limited right now — any usage window (incl. the 5-hour session window) at 100%, matching the 'agents view' badge.
280
283
  --balanced is shorthand for --strategy balanced. Ignored when @version is pinned, when a profile is used, or with --fallback.
281
284
 
282
285
  Fallback: --fallback codex,gemini retries on rate-limit failure via /continue handoff. Each entry accepts @version.
@@ -403,15 +406,22 @@ export function registerRunCommand(program) {
403
406
  model: options.model,
404
407
  remoteCwd: options.remoteCwd,
405
408
  sessionId: hostSessionId,
409
+ name: options.name,
406
410
  resume: resumeId,
407
411
  follow: options.follow !== false,
408
412
  });
409
413
  // Register the dispatched run in the LOCAL session index so it shows
410
- // up in `agents sessions` and resolves by id, even though its
414
+ // up in `agents sessions` and resolves by id/name, even though its
411
415
  // transcript lives on the host. No-op when no session id was captured.
412
416
  registerHostSession(task, { cwd: process.cwd(), prompt });
413
417
  if (options.follow === false) {
414
- console.log(chalk.green(`Dispatched to ${host.name}.`) + chalk.gray(' Track: agents hosts ps · Follow: agents hosts logs <id> -f'));
418
+ // The handle the caller uses to check on the run: the name if given,
419
+ // else the real host-task id (never the old literal `<id>`). Steer
420
+ // to the compact `agents sessions` digest over the raw log first.
421
+ const handle = task.name ?? task.id;
422
+ console.log(chalk.green(`Dispatched to ${host.name}${task.name ? ` as "${task.name}"` : ''}.`) + '\n' +
423
+ chalk.gray(` Status: agents sessions ${handle}`) + chalk.gray(' (compact digest — use this)') + '\n' +
424
+ chalk.gray(` Raw log: agents hosts logs ${handle} -f`) + chalk.gray(' (heavy, only if needed)'));
415
425
  process.exit(0);
416
426
  }
417
427
  // -1 = the follow window closed but the run continues on the host (the
@@ -1061,8 +1071,10 @@ export function registerRunCommand(program) {
1061
1071
  json: options.json,
1062
1072
  headless: options.headless,
1063
1073
  sessionId: resumeSessionId ?? options.sessionId,
1074
+ name: options.name,
1064
1075
  resume: resumeNative,
1065
1076
  verbose: options.verbose,
1077
+ raw: options.raw,
1066
1078
  timeout: options.timeout,
1067
1079
  env,
1068
1080
  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' };
@@ -15,7 +15,7 @@ import { sshTargetFor } from '../lib/hosts/types.js';
15
15
  import { listSshConfigHosts, listKnownHosts, isSshConfigHost } from '../lib/hosts/ssh-config.js';
16
16
  import { probeHost, remoteAgentsVersion, bootstrapAgentsCli, localCliVersion, } from '../lib/hosts/ready.js';
17
17
  import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
18
- import { listTasks } from '../lib/hosts/tasks.js';
18
+ import { listTasks, loadTask, findTaskByName, findTaskBySessionId } from '../lib/hosts/tasks.js';
19
19
  import { reconcileRunningTasks } from '../lib/hosts/reconcile.js';
20
20
  import { showHostTaskLog } from '../lib/hosts/logs.js';
21
21
  /** Parse `user@host` or `host` into its pieces. */
@@ -179,18 +179,22 @@ async function doPs(json) {
179
179
  return;
180
180
  }
181
181
  const cols = terminalWidth();
182
- console.log(chalk.bold('ID').padEnd(11) + chalk.bold('HOST').padEnd(16) + chalk.bold('AGENT').padEnd(10) + chalk.bold('STATUS').padEnd(11) + chalk.bold('PROMPT'));
182
+ console.log(chalk.bold('ID').padEnd(11) + chalk.bold('NAME').padEnd(16) + chalk.bold('HOST').padEnd(16) + chalk.bold('AGENT').padEnd(10) + chalk.bold('STATUS').padEnd(11) + chalk.bold('PROMPT'));
183
183
  for (const t of tasks) {
184
184
  const status = t.status === 'completed' ? chalk.green(t.status) : t.status === 'failed' ? chalk.red(t.status) : chalk.yellow(t.status);
185
+ const nameCol = truncateToWidth(t.name ?? chalk.gray('-'), 15).padEnd(16);
185
186
  // Prompt fills the remaining width instead of a fixed 50-char byte slice (98-char rows).
186
- const promptCol = truncateToWidth(t.prompt, Math.max(12, cols - (11 + 16 + 10 + 11)));
187
- console.log(t.id.padEnd(11) + t.host.padEnd(16) + t.agent.padEnd(10) + status.padEnd(11) + promptCol);
187
+ const promptCol = truncateToWidth(t.prompt, Math.max(12, cols - (11 + 16 + 16 + 10 + 11)));
188
+ console.log(t.id.padEnd(11) + nameCol + t.host.padEnd(16) + t.agent.padEnd(10) + status.padEnd(11) + promptCol);
188
189
  }
189
190
  }
190
- async function doLogs(id, follow) {
191
+ async function doLogs(ref, follow) {
192
+ // Resolve the ref as a task id first, then fall back to a `--name` handle so
193
+ // `agents hosts logs <name>` works, not just the opaque id.
194
+ const id = loadTask(ref) ? ref : (findTaskByName(ref)?.id ?? findTaskBySessionId(ref)?.id ?? ref);
191
195
  const res = await showHostTaskLog(id, follow);
192
196
  if (!res.found) {
193
- console.log(chalk.red(`Unknown task "${id}".`));
197
+ console.log(chalk.red(`Unknown task "${ref}".`));
194
198
  process.exitCode = 1;
195
199
  return;
196
200
  }
@@ -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
  }
@@ -36,6 +36,28 @@ export declare function getRunningChromeInfo(profileName: string): {
36
36
  pid: number;
37
37
  port: number;
38
38
  } | null;
39
+ /**
40
+ * Prepare `<userDataDir>/Default/Preferences` before launch.
41
+ *
42
+ * Two concerns, one write:
43
+ * - First launch (file absent): stamp the agents-cli profile name so
44
+ * Chromium's UI shows "<profile>" instead of its default "Person 1".
45
+ * Cosmetic; existing files keep whatever Chrome wrote in the meantime.
46
+ * - Every launch (when `persistSessionCookies`): pin
47
+ * `session.restore_on_startup: 1` ("continue where you left off").
48
+ * Chromium purges memory-only session cookies at startup UNLESS this
49
+ * preference says the session will be restored — it keys the purge off
50
+ * the pref, not off tabs actually reopening. Sites like idealista issue
51
+ * login cookies with `expires=-1`, so without this every browser restart
52
+ * silently logs the profile out. The visible tab-restore side effect is
53
+ * suppressed separately via `--no-startup-window` (see launchBrowser).
54
+ *
55
+ * Runs only while the browser is down (called before spawn), so Chromium
56
+ * can't overwrite the patch on exit. Best-effort: a malformed existing file
57
+ * is left untouched (Chromium recovers its own state better than we can),
58
+ * and any I/O hiccup is silently ignored.
59
+ */
60
+ export declare function ensureProfilePreferences(userDataDir: string, profileName: string, persistSessionCookies: boolean): void;
39
61
  /**
40
62
  * Is a TCP port currently bound? `lsof` on POSIX, `netstat -ano` on Windows
41
63
  * (lsof doesn't exist there). Returns false on any tooling error so port
@@ -212,11 +212,11 @@ isElectron = false) {
212
212
  const runtimeDir = getProfileRuntimeDir(profileName);
213
213
  const userDataDir = path.join(runtimeDir, 'chrome-data');
214
214
  fs.mkdirSync(userDataDir, { recursive: true });
215
- // First-launch seed: stamp the user-data-dir's Default/Preferences with
216
- // the agents-cli profile name so Chromium's UI shows "<profile>" instead
217
- // of its default "Person 1". Done only when the file doesn't exist
218
- // subsequent launches inherit whatever Chrome wrote in the meantime.
219
- seedDefaultProfileName(userDataDir, profileName);
215
+ // Pre-launch Preferences pass: first-launch profile-name stamp, plus (for
216
+ // real browsers, not Electron apps) the session-cookie persistence pin.
217
+ // Electron apps manage their own storage and don't read Chromium's
218
+ // `session.*` prefs, so they get the name stamp only.
219
+ ensureProfilePreferences(userDataDir, profileName, !isElectron);
220
220
  // Chromium on macOS coordinates instances via the SingletonLock file
221
221
  // *inside* each user-data-dir. Direct binary spawn with a fresh
222
222
  // --user-data-dir creates a fully independent process — the user's
@@ -242,6 +242,14 @@ isElectron = false) {
242
242
  // remote-debugging transport is active. That property is the loudest
243
243
  // signal Cloudflare Turnstile, hCaptcha, and similar checks read.
244
244
  '--disable-blink-features=AutomationControlled',
245
+ // Companion to `session.restore_on_startup: 1` (see
246
+ // ensureProfilePreferences): the pref keeps session cookies alive across
247
+ // restarts, but on its own it would also reopen last session's tabs at
248
+ // startup. Suppressing the startup window leaves restore nothing to fill —
249
+ // cookies survive, no ghost tabs — and the task flow creates its own tab
250
+ // over CDP anyway. Electron apps need their window to appear (the CDP
251
+ // driver binds to it), so they skip the flag.
252
+ ...(isElectron ? [] : ['--no-startup-window']),
245
253
  ...(options.headless ? ['--headless=new'] : []),
246
254
  `--window-size=${viewport.width},${viewport.height}`,
247
255
  ...(viewport.x !== undefined && viewport.y !== undefined
@@ -325,20 +333,52 @@ export function getRunningChromeInfo(profileName) {
325
333
  return { pid: rt.pid, port: rt.port };
326
334
  }
327
335
  /**
328
- * Stamp `<userDataDir>/Default/Preferences` with our profile name so
329
- * Chrome's UI labels the window with the agents-cli name rather than the
330
- * default "Person 1". Only writes when the file is absent (first launch).
331
- * Best-effort: any I/O hiccup is silently ignored; missing the rename is
332
- * cosmetic, not functional.
336
+ * Prepare `<userDataDir>/Default/Preferences` before launch.
337
+ *
338
+ * Two concerns, one write:
339
+ * - First launch (file absent): stamp the agents-cli profile name so
340
+ * Chromium's UI shows "<profile>" instead of its default "Person 1".
341
+ * Cosmetic; existing files keep whatever Chrome wrote in the meantime.
342
+ * - Every launch (when `persistSessionCookies`): pin
343
+ * `session.restore_on_startup: 1` ("continue where you left off").
344
+ * Chromium purges memory-only session cookies at startup UNLESS this
345
+ * preference says the session will be restored — it keys the purge off
346
+ * the pref, not off tabs actually reopening. Sites like idealista issue
347
+ * login cookies with `expires=-1`, so without this every browser restart
348
+ * silently logs the profile out. The visible tab-restore side effect is
349
+ * suppressed separately via `--no-startup-window` (see launchBrowser).
350
+ *
351
+ * Runs only while the browser is down (called before spawn), so Chromium
352
+ * can't overwrite the patch on exit. Best-effort: a malformed existing file
353
+ * is left untouched (Chromium recovers its own state better than we can),
354
+ * and any I/O hiccup is silently ignored.
333
355
  */
334
- function seedDefaultProfileName(userDataDir, profileName) {
356
+ export function ensureProfilePreferences(userDataDir, profileName, persistSessionCookies) {
335
357
  const defaultDir = path.join(userDataDir, 'Default');
336
358
  const prefsPath = path.join(defaultDir, 'Preferences');
337
- if (fs.existsSync(prefsPath))
359
+ let prefs;
360
+ try {
361
+ prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf8'));
362
+ if (typeof prefs !== 'object' || prefs === null)
363
+ return; // not ours to fix
364
+ }
365
+ catch (err) {
366
+ if (err?.code !== 'ENOENT')
367
+ return; // unreadable/malformed: leave alone
368
+ }
369
+ const firstLaunch = prefs === undefined;
370
+ if (firstLaunch)
371
+ prefs = { profile: { name: profileName } };
372
+ let dirty = firstLaunch;
373
+ if (persistSessionCookies && prefs.session?.restore_on_startup !== 1) {
374
+ prefs.session = { ...prefs.session, restore_on_startup: 1 };
375
+ dirty = true;
376
+ }
377
+ if (!dirty)
338
378
  return;
339
379
  try {
340
380
  fs.mkdirSync(defaultDir, { recursive: true });
341
- fs.writeFileSync(prefsPath, JSON.stringify({ profile: { name: profileName } }));
381
+ fs.writeFileSync(prefsPath, JSON.stringify(prefs));
342
382
  }
343
383
  catch { /* not critical */ }
344
384
  }
@@ -310,6 +310,19 @@ export class BrowserService {
310
310
  conn = await this.connectProfile(effectiveProfile, resolved.target);
311
311
  this.connections.set(composite, conn);
312
312
  }
313
+ // Browsers launch with --no-startup-window (session-cookie persistence,
314
+ // see launchBrowser), so a bare `start` with no --url would otherwise
315
+ // leave the user staring at a process with zero windows. Recreate the
316
+ // old startup-window affordance: if no page target exists, open a blank
317
+ // one. Deliberately NOT registered on the task — the startup window
318
+ // never was either, and tasks track only tabs they created.
319
+ if (!opts.url && !conn.electron) {
320
+ const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
321
+ if (!targetInfos.some((t) => t.type === 'page')) {
322
+ await conn.cdp.send('Target.createTarget', { url: 'about:blank' });
323
+ this.invalidateTargetCache(conn);
324
+ }
325
+ }
313
326
  const task = {
314
327
  id: taskId,
315
328
  name: taskName,
@@ -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
  }
@@ -80,6 +80,13 @@ export interface ExecOptions {
80
80
  addDirs?: string[];
81
81
  timeout?: string;
82
82
  sessionId?: string;
83
+ /**
84
+ * Durable `agents run --name <slug>` handle. Exported to the agent's env as
85
+ * `AGENT_SESSION_NAME` (companion to `AGENT_SESSION_ID`) and, when a session
86
+ * id is known at launch, recorded in the run-name index so `agents sessions
87
+ * <name>` resolves the run. Absent for unnamed runs — no behavior change.
88
+ */
89
+ name?: string;
83
90
  /**
84
91
  * Resume the conversation named by `sessionId` using the agent's NATIVE resume
85
92
  * form (claude `--resume`, codex `resume`) instead of the default `--session-id`
@@ -109,6 +116,12 @@ export interface ExecOptions {
109
116
  mcpConfigPath?: string;
110
117
  /** Raw args captured after `--` on the command line, forwarded verbatim to the underlying agent CLI. */
111
118
  passthroughArgs?: string[];
119
+ /**
120
+ * Escape hatch for the interactive tmux spawn-wrap (see shouldWrapInTmux):
121
+ * when true, spawn the agent directly instead of inside a shared-socket tmux
122
+ * session. Also forced off by AGENTS_NO_TMUX=1. No effect on headless runs.
123
+ */
124
+ raw?: boolean;
112
125
  }
113
126
  /**
114
127
  * Resolve interactive vs headless. Explicit flags are definitive and win over
@@ -213,6 +226,52 @@ export declare function resolveShimSpawn(platform: NodeJS.Platform, binary: stri
213
226
  * keeping version resolution in one place instead of reimplementing it in batch.
214
227
  */
215
228
  export declare function execShimPassthrough(agent: AgentId, rawArgs: string[], cwd: string, pinnedVersion?: string): Promise<number>;
229
+ /** Inputs that decide whether an interactive spawn is wrapped in a shared-socket tmux session. */
230
+ export interface TmuxWrapContext {
231
+ /** resolveInteractive() result — only interactive REPL launches are wrapped. */
232
+ interactive: boolean;
233
+ /** process.platform — Windows has no tmux path, always spawns bare. */
234
+ platform: NodeJS.Platform;
235
+ /** True when the launcher itself already runs inside tmux ($TMUX set) — never double-wrap. */
236
+ inTmux: boolean;
237
+ /** The `--raw` escape hatch. */
238
+ raw: boolean;
239
+ /** The AGENTS_NO_TMUX=1 escape hatch. */
240
+ noTmuxEnv: boolean;
241
+ /** Whether a tmux binary is on PATH. */
242
+ tmuxAvailable: boolean;
243
+ }
244
+ /**
245
+ * Decide whether to run an interactive agent INSIDE a detached tmux session on
246
+ * the shared socket (then attach the current TTY) instead of a bare spawn.
247
+ *
248
+ * tmux-wrapping gives every interactive agent an exact, unique `%pane` handle so
249
+ * `agents sessions --active` can tell co-located agents apart, and lets `agents
250
+ * focus` re-attach a live session without forking it. Pure so the gate is unit-
251
+ * tested independently of the (side-effecting) spawn.
252
+ *
253
+ * All five guards must pass:
254
+ * - interactive — a headless `-p` run has no TTY to attach; keep bare spawn.
255
+ * - not Windows — no tmux path on win32.
256
+ * - not already in tmux — nesting tmux-in-tmux is pointless and confusing.
257
+ * - not --raw — explicit opt-out.
258
+ * - not AGENTS_NO_TMUX=1 — env opt-out (CI, scripts, the shim passthrough path).
259
+ * - tmux installed — otherwise there is nothing to wrap with.
260
+ */
261
+ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
262
+ /**
263
+ * Build the shell command that runs an agent inside a tmux pane with the exact
264
+ * env the bare spawn would use. tmux runs it via `sh -c <cmd>`; we `exec env
265
+ * K=V … <agent> <args…>` so:
266
+ * - `env` materializes the full agent env INTO the pane, independent of the
267
+ * (possibly stale, shared) tmux server environment — additive, so tmux's own
268
+ * $TMUX / $TMUX_PANE still reach the agent for provenance detection;
269
+ * - `exec` replaces the shell so the agent is the pane's leaf process (clean
270
+ * `#{pane_pid}`, clean signal delivery on detach/kill).
271
+ * Keys are filtered to valid identifiers so exported shell functions
272
+ * (`BASH_FUNC_*%%`) can't make `env` choke.
273
+ */
274
+ export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv): string;
216
275
  /** Exit code spawnAgent resolves with when a run is killed for crossing a budget cap. */
217
276
  export declare const BUDGET_KILL_EXIT_CODE = 7;
218
277
  /**