@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 +7 -0
- package/dist/commands/computer-actions.js +1 -1
- package/dist/commands/computer.d.ts +2 -2
- package/dist/commands/computer.js +4 -4
- package/dist/commands/exec.js +2 -0
- package/dist/commands/go.js +6 -1
- package/dist/commands/secrets.js +26 -0
- package/dist/commands/sessions.js +30 -12
- package/dist/lib/computer-rpc.js +3 -3
- package/dist/lib/exec.d.ts +52 -0
- package/dist/lib/exec.js +150 -0
- package/dist/lib/menubar/install-menubar.js +2 -2
- package/dist/lib/secrets/bundles.js +5 -3
- package/dist/lib/secrets/remote.js +14 -0
- package/dist/lib/secrets/sync.js +13 -0
- package/dist/lib/session/active.d.ts +47 -3
- package/dist/lib/session/active.js +132 -10
- package/dist/lib/session/db.js +45 -31
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +9 -2
- package/dist/lib/session/viewing-in.d.ts +54 -0
- package/dist/lib/session/viewing-in.js +155 -0
- package/dist/lib/ssh-tunnel.d.ts +1 -1
- package/dist/lib/ssh-tunnel.js +3 -3
- package/dist/lib/tmux/session.d.ts +46 -0
- package/dist/lib/tmux/session.js +84 -2
- package/package.json +1 -1
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: ./
|
|
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
|
-
* (
|
|
25
|
+
* (native/computer-mac/Sources/ComputerHelper/Screenshot.swift:207,212),
|
|
26
26
|
* the Windows helper returns PNG
|
|
27
|
-
* (
|
|
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
|
-
* (
|
|
56
|
+
* (native/computer-mac/Sources/ComputerHelper/Screenshot.swift:207,212),
|
|
57
57
|
* the Windows helper returns PNG
|
|
58
|
-
* (
|
|
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: ./
|
|
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: ./
|
|
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.
|
package/dist/commands/exec.js
CHANGED
|
@@ -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,
|
package/dist/commands/go.js
CHANGED
|
@@ -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' };
|
package/dist/commands/secrets.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
for (const
|
|
629
|
-
|
|
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
|
|
632
|
-
|
|
633
|
-
s.
|
|
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
|
}
|
package/dist/lib/computer-rpc.js
CHANGED
|
@@ -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, '..', '..', '
|
|
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: ./
|
|
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
|
}
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -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.
|
|
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, '..', '..', '..', '
|
|
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
|
}
|
package/dist/lib/secrets/sync.js
CHANGED
|
@@ -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 };
|