@phnx-labs/agents-cli 1.20.40 → 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 +17 -0
- package/dist/commands/computer-actions.d.ts +2 -0
- package/dist/commands/computer-actions.js +60 -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/focus.d.ts +31 -0
- package/dist/commands/focus.js +150 -0
- package/dist/commands/go.d.ts +34 -11
- package/dist/commands/go.js +50 -65
- package/dist/commands/secrets.js +49 -10
- package/dist/commands/sessions.d.ts +9 -0
- package/dist/commands/sessions.js +77 -20
- 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/hooks/cache.d.ts +1 -1
- package/dist/lib/hooks/cache.js +4 -2
- package/dist/lib/hosts/option.js +1 -0
- package/dist/lib/hosts/passthrough.d.ts +3 -3
- package/dist/lib/hosts/passthrough.js +14 -4
- package/dist/lib/hosts/remote-cmd.d.ts +7 -1
- package/dist/lib/hosts/remote-cmd.js +8 -1
- package/dist/lib/menubar/install-menubar.js +2 -2
- package/dist/lib/secrets/agent.d.ts +18 -7
- package/dist/lib/secrets/agent.js +32 -15
- package/dist/lib/secrets/bundles.d.ts +8 -6
- package/dist/lib/secrets/bundles.js +14 -8
- 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/shims.d.ts +1 -1
- package/dist/lib/shims.js +32 -10
- 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/dist/lib/types.d.ts +3 -3
- package/package.json +1 -1
package/dist/commands/go.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `agents sessions go [id]` —
|
|
2
|
+
* `agents sessions go [id]` — DEPRECATED alias for `agents sessions focus --attach-only`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* filtered to sessions that are running right now.
|
|
7
|
-
* With id -> jump directly.
|
|
4
|
+
* `go` was "attach or refuse" (never fork/resume). `focus --attach-only` is exactly
|
|
5
|
+
* that behavior, so `go` now prints a deprecation notice and delegates to `focusAction`.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
7
|
+
* This file still owns the shared reach engine that `focus` imports:
|
|
8
|
+
* - `gatherLiveTargets` / `pickLiveTarget` / `buildLivePool` — live-session discovery + picker
|
|
9
|
+
* - `jumpTo` — the side-effecting jump: attach the already-running terminal
|
|
10
|
+
* local tmux -> attach (switch-client when already inside tmux)
|
|
11
|
+
* local Ghostty -> focus its tab (Cmd+<n> via System Events; tab # from ghostty-tabs)
|
|
12
|
+
* remote tmux -> ssh -tt + tmux attach (pane->session resolved on the remote)
|
|
13
|
+
* otherwise -> hand off to the `UnreachableFallback` (attach-only refuses; focus resumes)
|
|
14
|
+
* - `refuseFallback` — the attach-only fallback (remote -> login shell; local -> refuse)
|
|
15
15
|
*/
|
|
16
16
|
import chalk from 'chalk';
|
|
17
17
|
import path from 'path';
|
|
@@ -21,8 +21,8 @@ import { getActiveSessions, findSessionFileForKind } from '../lib/session/active
|
|
|
21
21
|
import { gatherRemoteActive } from '../lib/session/remote-active.js';
|
|
22
22
|
import { discoverSessions } from '../lib/session/discover.js';
|
|
23
23
|
import { dedupeByMachineSession, mergeLocalFirst, pickSessionInteractive } from './sessions.js';
|
|
24
|
+
import { focusAction } from './focus.js';
|
|
24
25
|
import { machineId } from '../lib/session/sync/config.js';
|
|
25
|
-
import { isInteractiveTerminal } from './utils.js';
|
|
26
26
|
import { attachTmux, runTmux } from '../lib/tmux/binary.js';
|
|
27
27
|
import { getDefaultSocketPath } from '../lib/tmux/paths.js';
|
|
28
28
|
import { sshStream, assertValidSshTarget, shellQuote } from '../lib/ssh-exec.js';
|
|
@@ -33,20 +33,21 @@ export function registerGoCommand(program) {
|
|
|
33
33
|
.command('go')
|
|
34
34
|
.argument('[id]', 'Short/full session id to jump to; omit for an interactive picker')
|
|
35
35
|
.option('--local', 'Only this machine (skip the cross-host sweep)')
|
|
36
|
-
.description('
|
|
36
|
+
.description('Deprecated alias for `sessions focus --attach-only`')
|
|
37
37
|
.action(async (id, opts) => {
|
|
38
|
-
|
|
38
|
+
console.error(chalk.yellow('`sessions go` is deprecated — use `sessions focus --attach-only`'));
|
|
39
|
+
await focusAction(id, { local: opts.local, attachOnly: true });
|
|
39
40
|
});
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
+
/** Live jump targets (local + remote), keyed by session id. Cloud excluded (no pid). */
|
|
43
|
+
export async function gatherLiveTargets(local) {
|
|
42
44
|
const self = machineId();
|
|
43
|
-
// Live jump targets (local + remote), keyed by session id.
|
|
44
45
|
const localActive = await getActiveSessions();
|
|
45
46
|
for (const s of localActive)
|
|
46
47
|
if (!s.machine)
|
|
47
48
|
s.machine = self;
|
|
48
49
|
let active = localActive;
|
|
49
|
-
if (!
|
|
50
|
+
if (!local) {
|
|
50
51
|
try {
|
|
51
52
|
const remote = await gatherRemoteActive();
|
|
52
53
|
active = dedupeByMachineSession([...localActive, ...remote.sessions]);
|
|
@@ -57,54 +58,24 @@ async function goAction(id, opts) {
|
|
|
57
58
|
for (const s of active)
|
|
58
59
|
if (s.context !== 'cloud' && s.sessionId)
|
|
59
60
|
activeById.set(s.sessionId, s);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// Direct jump by id — no picker.
|
|
65
|
-
if (id) {
|
|
66
|
-
const q = id.toLowerCase();
|
|
67
|
-
const matches = [...activeById.values()].filter((s) => s.sessionId.toLowerCase().startsWith(q));
|
|
68
|
-
if (matches.length === 0) {
|
|
69
|
-
console.error(chalk.red(`No live session matching "${id}".`));
|
|
70
|
-
process.exitCode = 1;
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
if (matches.length > 1) {
|
|
74
|
-
console.error(chalk.red(`"${id}" is ambiguous (${matches.length} matches). Use more of the id.`));
|
|
75
|
-
process.exitCode = 1;
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
await jumpTo(matches[0], self);
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
if (!isInteractiveTerminal()) {
|
|
82
|
-
console.error(chalk.red('go needs an interactive terminal, or pass a session id.'));
|
|
83
|
-
process.exitCode = 1;
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
// Reuse the rich `sessions` picker over the live sessions' full SessionMeta.
|
|
61
|
+
return { self, activeById };
|
|
62
|
+
}
|
|
63
|
+
/** Interactive pick over the live sessions' rich SessionMeta; returns the chosen live session. */
|
|
64
|
+
export async function pickLiveTarget(activeById, self, message, enterHint) {
|
|
87
65
|
const pool = await buildLivePool(activeById, self);
|
|
88
|
-
if (pool.length === 0)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
const picked = await pickSessionInteractive(pool, 'Jump to a live session:', undefined, 0, 'jump');
|
|
66
|
+
if (pool.length === 0)
|
|
67
|
+
return null;
|
|
68
|
+
const picked = await pickSessionInteractive(pool, message, undefined, 0, enterHint);
|
|
93
69
|
if (!picked)
|
|
94
|
-
return;
|
|
95
|
-
|
|
96
|
-
if (!target) {
|
|
97
|
-
console.log(chalk.yellow(`${picked.session.shortId} is no longer live — try: `) + chalk.gray(`agents sessions resume ${picked.session.shortId}`));
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
await jumpTo(target, self);
|
|
70
|
+
return null;
|
|
71
|
+
return activeById.get(picked.session.id) ?? null;
|
|
101
72
|
}
|
|
102
73
|
/**
|
|
103
74
|
* Map each live session to its rich SessionMeta (worktree/PR/changes/tools/tests
|
|
104
75
|
* via the shared picker), reusing `discoverSessions`. Remote or unindexed live
|
|
105
76
|
* sessions get a minimal synthesized meta so they still appear and jump.
|
|
106
77
|
*/
|
|
107
|
-
async function buildLivePool(activeById, self) {
|
|
78
|
+
export async function buildLivePool(activeById, self) {
|
|
108
79
|
let metas = [];
|
|
109
80
|
try {
|
|
110
81
|
metas = await discoverSessions({ all: true, since: '30d', limit: 1000 });
|
|
@@ -151,9 +122,14 @@ export function describeWhere(s, self) {
|
|
|
151
122
|
const remote = s.machine && s.machine !== self ? s.machine : undefined;
|
|
152
123
|
const mux = s.provenance?.mux;
|
|
153
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
|
+
: '';
|
|
154
130
|
return remote
|
|
155
131
|
? { label: `tmux ${mux.pane} on ${remote}`, action: `ssh + attach on ${remote}` }
|
|
156
|
-
: { label: `tmux ${mux.pane}`, action: 'attach its tmux' };
|
|
132
|
+
: { label: `tmux ${mux.pane}${view}`, action: 'attach its tmux' };
|
|
157
133
|
}
|
|
158
134
|
if (!remote && s.host === 'ghostty')
|
|
159
135
|
return { label: 'Ghostty', action: 'focus its Ghostty tab' };
|
|
@@ -161,7 +137,17 @@ export function describeWhere(s, self) {
|
|
|
161
137
|
return { label: `${s.host ?? 'shell'} on ${remote}`, action: `open a shell on ${remote}` };
|
|
162
138
|
return { label: s.host ?? 'unknown terminal', action: 'resume it (no live attach rail)' };
|
|
163
139
|
}
|
|
164
|
-
|
|
140
|
+
/** Default (attach-only): open a login shell on the remote, or refuse locally. */
|
|
141
|
+
export async function refuseFallback(s, remote) {
|
|
142
|
+
if (remote) {
|
|
143
|
+
console.log(chalk.yellow(`${shortId(s)} on ${remote} isn't inside tmux — opening a shell on ${remote} instead.`));
|
|
144
|
+
assertValidSshTarget(remote);
|
|
145
|
+
process.exit(sshStream(remote, 'exec "${SHELL:-/bin/sh}" -l', { tty: true }));
|
|
146
|
+
}
|
|
147
|
+
console.log(chalk.yellow(`Can't jump to ${shortId(s)} — it's in ${s.host ?? 'an unknown terminal'} with no attach rail (not tmux/Ghostty).`) +
|
|
148
|
+
chalk.gray(`\nTry: agents sessions resume ${shortId(s)}`));
|
|
149
|
+
}
|
|
150
|
+
export async function jumpTo(s, self, fallback = refuseFallback) {
|
|
165
151
|
const remote = s.machine && s.machine !== self ? s.machine : undefined;
|
|
166
152
|
const mux = s.provenance?.mux;
|
|
167
153
|
// Path C: remote tmux — ssh in and attach, resolving the pane's session on the remote.
|
|
@@ -177,9 +163,9 @@ async function jumpTo(s, self) {
|
|
|
177
163
|
console.log(chalk.gray(`Attaching ${shortId(s)} on ${remote} over SSH — Ctrl-b d to detach.`));
|
|
178
164
|
process.exit(sshStream(remote, remoteCmd, { tty: true }));
|
|
179
165
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
166
|
+
// Remote, not in tmux → hand off to the fallback (go: shell; focus: resume in a tab).
|
|
167
|
+
await fallback(s, remote);
|
|
168
|
+
return;
|
|
183
169
|
}
|
|
184
170
|
// Path B: local tmux — attach (or switch-client if we're already inside tmux).
|
|
185
171
|
if (mux?.kind === 'tmux' && mux.pane) {
|
|
@@ -218,9 +204,8 @@ async function jumpTo(s, self) {
|
|
|
218
204
|
chalk.gray(tab != null ? ` — switch to tab ${tab} (Cmd+${tab}).` : " — couldn't pinpoint its tab (same-repo forks are ambiguous); switch tabs manually."));
|
|
219
205
|
return;
|
|
220
206
|
}
|
|
221
|
-
// Path D:
|
|
222
|
-
|
|
223
|
-
chalk.gray(`\nTry: agents sessions resume ${shortId(s)}`));
|
|
207
|
+
// Path D: no attach rail (headless / plain terminal) → hand off to the fallback.
|
|
208
|
+
await fallback(s, undefined);
|
|
224
209
|
}
|
|
225
210
|
/** Resolve a local tmux pane id to its session name + window index. */
|
|
226
211
|
async function resolveLocalPane(socket, pane) {
|
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';
|
|
@@ -490,7 +491,7 @@ export function registerSecretsCommands(program) {
|
|
|
490
491
|
# See what's in the bundle (values masked); shows its prompt policy
|
|
491
492
|
agents secrets view prod
|
|
492
493
|
|
|
493
|
-
# Stop a noisy automation bundle from prompting every run: ask once a
|
|
494
|
+
# Stop a noisy automation bundle from prompting every run: ask once a week
|
|
494
495
|
agents secrets policy prod daily
|
|
495
496
|
|
|
496
497
|
# Eval the bundle into your current shell
|
|
@@ -510,15 +511,16 @@ export function registerSecretsCommands(program) {
|
|
|
510
511
|
Touch ID noise: macOS pops a prompt per bundle per process. Each bundle has
|
|
511
512
|
a prompt policy, shown in the POLICY column of 'agents secrets list':
|
|
512
513
|
daily (default) ask once, then hold it silently in the local agent up
|
|
513
|
-
to ~
|
|
514
|
+
to ~7 days, until sleep / logout or 'lock' (a bare
|
|
515
|
+
screen-lock does NOT drop it). Name is historical.
|
|
514
516
|
always ask for Touch ID every time — never auto-held.
|
|
515
|
-
The default is 'daily' (one Touch ID per ~
|
|
517
|
+
The default is 'daily' (one Touch ID per ~7 days); change it globally with
|
|
516
518
|
'secrets.policy' in agents.yaml, or per bundle with 'agents secrets policy
|
|
517
519
|
<bundle> always'. 'agents secrets unlock <bundle>' holds any bundle after one
|
|
518
520
|
prompt regardless of policy. Nothing on disk.
|
|
519
521
|
|
|
520
522
|
See also:
|
|
521
|
-
agents secrets policy <bundle> daily ask once a
|
|
523
|
+
agents secrets policy <bundle> daily ask once a week, not every run
|
|
522
524
|
agents secrets unlock <bundle> hold a bundle after one Touch ID
|
|
523
525
|
agents secrets lock wipe held bundles (re-prompt next read)
|
|
524
526
|
agents secrets status show held bundles + when they lock
|
|
@@ -623,7 +625,7 @@ export function registerSecretsCommands(program) {
|
|
|
623
625
|
}
|
|
624
626
|
else {
|
|
625
627
|
console.log(bundlePolicy(bundle) === 'daily'
|
|
626
|
-
? chalk.gray('policy: daily (ask once, then held ~
|
|
628
|
+
? chalk.gray('policy: daily (ask once, then held ~7 days until sleep / logout — screen-lock does not drop it)')
|
|
627
629
|
: chalk.gray('policy: always (asks for Touch ID every time — never auto-held)'));
|
|
628
630
|
}
|
|
629
631
|
if (bundle.created_at)
|
|
@@ -657,6 +659,26 @@ export function registerSecretsCommands(program) {
|
|
|
657
659
|
catch {
|
|
658
660
|
// Fall through to masked output on cancellation / batch failure.
|
|
659
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
|
+
}
|
|
660
682
|
}
|
|
661
683
|
for (const e of entries) {
|
|
662
684
|
if (e.kind === 'keychain') {
|
|
@@ -702,6 +724,9 @@ export function registerSecretsCommands(program) {
|
|
|
702
724
|
// so `$(agents secrets get NAME)` captures it cleanly); diagnostics go
|
|
703
725
|
// to stderr so they never pollute the captured value.
|
|
704
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' });
|
|
705
730
|
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
|
|
706
731
|
}
|
|
707
732
|
catch {
|
|
@@ -733,6 +758,8 @@ export function registerSecretsCommands(program) {
|
|
|
733
758
|
// so `agents secrets get` can read them back without a password sheet;
|
|
734
759
|
// on Linux it goes through secret-tool / encrypted-file fallback.
|
|
735
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' });
|
|
736
763
|
console.error(chalk.green(`Stored keychain item '${item}'.`));
|
|
737
764
|
}
|
|
738
765
|
catch (err) {
|
|
@@ -747,7 +774,7 @@ export function registerSecretsCommands(program) {
|
|
|
747
774
|
.description('Create an empty bundle')
|
|
748
775
|
.option('--description <text>', 'Free-form description')
|
|
749
776
|
.option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
|
|
750
|
-
.option('--policy <policy>', 'prompt policy: daily (default, ask once a
|
|
777
|
+
.option('--policy <policy>', 'prompt policy: daily (default, ask once a week), always (ask every time), or never (silent, NO biometry ACL — needs --i-understand)')
|
|
751
778
|
.addOption(new Option('--tier <policy>', 'deprecated alias for --policy').hideHelp())
|
|
752
779
|
.option('--i-understand', 'Confirm creating a "never"-policy bundle (no biometry ACL) without an interactive prompt')
|
|
753
780
|
.option('--backend <backend>', 'storage backend: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
|
|
@@ -1528,7 +1555,7 @@ Examples:
|
|
|
1528
1555
|
cmd
|
|
1529
1556
|
.command('unlock [names...]')
|
|
1530
1557
|
.description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS).')
|
|
1531
|
-
.option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h). Default
|
|
1558
|
+
.option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
|
|
1532
1559
|
.option('--all', 'Unlock every configured bundle')
|
|
1533
1560
|
.action(async (names, opts) => {
|
|
1534
1561
|
if (process.platform !== 'darwin') {
|
|
@@ -1544,7 +1571,7 @@ Examples:
|
|
|
1544
1571
|
if (opts.ttl) {
|
|
1545
1572
|
const secs = parseDuration(opts.ttl);
|
|
1546
1573
|
if (!secs) {
|
|
1547
|
-
console.error(chalk.red(`Invalid --ttl '${opts.ttl}'. Use e.g. 30m, 2h, 8h.`));
|
|
1574
|
+
console.error(chalk.red(`Invalid --ttl '${opts.ttl}'. Use e.g. 30m, 2h, 8h, 3d.`));
|
|
1548
1575
|
process.exit(1);
|
|
1549
1576
|
}
|
|
1550
1577
|
ttlMs = secs * 1000;
|
|
@@ -1628,7 +1655,7 @@ Examples:
|
|
|
1628
1655
|
cmd
|
|
1629
1656
|
.command('policy <bundle> [policy]')
|
|
1630
1657
|
.alias('tier')
|
|
1631
|
-
.description("Show or set a bundle's prompt policy: daily (default, ask once a
|
|
1658
|
+
.description("Show or set a bundle's prompt policy: daily (default, ask once a week), always (ask every time), or never (silent, NO biometry ACL).")
|
|
1632
1659
|
.option('--i-understand', 'Confirm switching to the "never" policy (no biometry ACL) without an interactive prompt')
|
|
1633
1660
|
.action(async (bundleName, policyArg, opts) => {
|
|
1634
1661
|
try {
|
|
@@ -1644,11 +1671,23 @@ Examples:
|
|
|
1644
1671
|
console.error(chalk.yellow('Aborted.'));
|
|
1645
1672
|
return;
|
|
1646
1673
|
}
|
|
1674
|
+
const wasDaily = bundlePolicy(bundle) === 'daily';
|
|
1647
1675
|
bundle.policy = next;
|
|
1648
1676
|
writeBundle(bundle);
|
|
1677
|
+
// Tightening daily -> always/never must take effect NOW, not up to the
|
|
1678
|
+
// ~7d hold later. If the broker is already serving this bundle silently
|
|
1679
|
+
// (auto-cached under the old `daily` policy), evict it so the next read
|
|
1680
|
+
// re-prompts (`always`) or reads its no-ACL item directly (`never`).
|
|
1681
|
+
// macOS-only + best-effort; agentLock no-ops off darwin / with no broker.
|
|
1682
|
+
if (wasDaily && next !== 'daily') {
|
|
1683
|
+
try {
|
|
1684
|
+
await agentLock(bundle.name);
|
|
1685
|
+
}
|
|
1686
|
+
catch { /* broker down — nothing held */ }
|
|
1687
|
+
}
|
|
1649
1688
|
console.log(chalk.green(`${bundle.name} policy set to ${next}.`));
|
|
1650
1689
|
if (next === 'daily') {
|
|
1651
|
-
console.log(chalk.gray('Held by the secrets-agent for ~
|
|
1690
|
+
console.log(chalk.gray('Held by the secrets-agent for ~7 days after one unlock (auto-cache is on by default; disable with `secrets.agent.auto: false` in agents.yaml).'));
|
|
1652
1691
|
}
|
|
1653
1692
|
else if (next === 'always') {
|
|
1654
1693
|
console.log(chalk.gray('Asks for Touch ID every time — never auto-held.'));
|
|
@@ -98,6 +98,15 @@ export declare function dedupeByMachineSession(sessions: ActiveSession[]): Activ
|
|
|
98
98
|
* `localMachine` is injected so the ordering is testable without os.hostname().
|
|
99
99
|
*/
|
|
100
100
|
export declare function mergeLocalFirst(sessions: SessionMeta[], localMachine: string): SessionMeta[];
|
|
101
|
+
/**
|
|
102
|
+
* Serialize a `SessionMeta[]` to the clean JSON shape the `--json` listing
|
|
103
|
+
* emits: strip the internal-only scoring/provenance fields (`_matchedTerms`,
|
|
104
|
+
* `_bm25Score`, `_remote`) that are search/fan-out bookkeeping, never part of
|
|
105
|
+
* the public record, then pretty-print as a 2-space array with a trailing
|
|
106
|
+
* newline. The single seam shared by the local `--json` path and the
|
|
107
|
+
* `--json --host` remote fan-out so both emit byte-identical row shapes.
|
|
108
|
+
*/
|
|
109
|
+
export declare function serializeSessionsJson(sessions: SessionMeta[]): string;
|
|
101
110
|
/**
|
|
102
111
|
* Whether the local machine's sessions belong in an `--active` view. Local is
|
|
103
112
|
* included by default; an explicit `--host`/`--device` list scopes the view to
|
|
@@ -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';
|
|
@@ -40,6 +41,7 @@ import { registerSessionsTailCommand } from './sessions-tail.js';
|
|
|
40
41
|
import { registerSessionsSyncCommand } from './sessions-sync.js';
|
|
41
42
|
import { registerSessionsResumeCommand } from './sessions-resume.js';
|
|
42
43
|
import { registerGoCommand } from './go.js';
|
|
44
|
+
import { registerFocusCommand } from './focus.js';
|
|
43
45
|
import { registerSessionsInjectCommand } from './sessions-inject.js';
|
|
44
46
|
const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
|
|
45
47
|
/**
|
|
@@ -313,6 +315,16 @@ function locatorBadge(s) {
|
|
|
313
315
|
parts.push(chalk.red('ssh'));
|
|
314
316
|
if (p?.mux?.kind === 'tmux' && (s.tmuxTarget || p.mux.pane)) {
|
|
315
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
|
+
}
|
|
316
328
|
}
|
|
317
329
|
else if (p?.mux?.kind === 'screen') {
|
|
318
330
|
parts.push(chalk.green('screen'));
|
|
@@ -507,6 +519,40 @@ export function mergeLocalFirst(sessions, localMachine) {
|
|
|
507
519
|
});
|
|
508
520
|
return keys.flatMap((k) => byMachine.get(k));
|
|
509
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* Serialize a `SessionMeta[]` to the clean JSON shape the `--json` listing
|
|
524
|
+
* emits: strip the internal-only scoring/provenance fields (`_matchedTerms`,
|
|
525
|
+
* `_bm25Score`, `_remote`) that are search/fan-out bookkeeping, never part of
|
|
526
|
+
* the public record, then pretty-print as a 2-space array with a trailing
|
|
527
|
+
* newline. The single seam shared by the local `--json` path and the
|
|
528
|
+
* `--json --host` remote fan-out so both emit byte-identical row shapes.
|
|
529
|
+
*/
|
|
530
|
+
export function serializeSessionsJson(sessions) {
|
|
531
|
+
const serializable = sessions.map((s) => {
|
|
532
|
+
const { _matchedTerms, _bm25Score, _remote, ...rest } = s;
|
|
533
|
+
return rest;
|
|
534
|
+
});
|
|
535
|
+
return JSON.stringify(serializable, null, 2) + '\n';
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* `agents sessions --json --host <h>` — fan the RECENT (non-active) listing out
|
|
539
|
+
* to the named host(s) and emit ONE clean merged `SessionMeta[]` JSON array,
|
|
540
|
+
* the same shape the local `--json` path emits. Reuses `gatherRemoteList` (the
|
|
541
|
+
* exact SSH fan-out the interactive cross-machine listing already uses) and
|
|
542
|
+
* serializes the merged, machine-tagged rows — instead of `runRemoteSessions`,
|
|
543
|
+
* which streams each remote's raw stdout under a per-host banner and so can
|
|
544
|
+
* never be JSON.parsed. A dead host contributes `[]` (with a stderr note from
|
|
545
|
+
* the fan-out), so stdout is always a valid array and the exit stays 0.
|
|
546
|
+
*/
|
|
547
|
+
async function runRemoteSessionsJson(hosts) {
|
|
548
|
+
// Forward the caller's own filters (query, --limit, --since, …) minus --host,
|
|
549
|
+
// and guarantee --json so each peer answers with a parseable array.
|
|
550
|
+
const forwarded = buildForwardedArgs(process.argv, new Set(hosts));
|
|
551
|
+
if (!forwarded.includes('--json'))
|
|
552
|
+
forwarded.push('--json');
|
|
553
|
+
const { sessions } = await gatherRemoteList(forwarded, hosts);
|
|
554
|
+
process.stdout.write(serializeSessionsJson(sessions));
|
|
555
|
+
}
|
|
510
556
|
/**
|
|
511
557
|
* `running N · idle N · waiting N · queued N` for a bucket of sessions (zero
|
|
512
558
|
* buckets omitted). Same bucketing as the grand-total summary so per-group
|
|
@@ -582,20 +628,27 @@ async function enrichLocalLocators(local) {
|
|
|
582
628
|
}
|
|
583
629
|
}
|
|
584
630
|
catch { /* non-fatal */ }
|
|
585
|
-
// tmux attach targets, one batched query per
|
|
631
|
+
// tmux attach targets + "viewing in <app> tab N", one batched query per socket.
|
|
586
632
|
try {
|
|
587
633
|
const tmux = local.filter(s => s.provenance?.mux?.kind === 'tmux' && s.provenance.mux.pane);
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
for (const
|
|
594
|
-
|
|
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)
|
|
595
642
|
continue;
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
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
|
+
}
|
|
599
652
|
}
|
|
600
653
|
}
|
|
601
654
|
}
|
|
@@ -715,10 +768,17 @@ async function sessionsAction(query, options) {
|
|
|
715
768
|
if (options.device && options.device.length > 0) {
|
|
716
769
|
options.host = [...(options.host ?? []), ...options.device];
|
|
717
770
|
}
|
|
718
|
-
// --host WITHOUT --active
|
|
719
|
-
//
|
|
720
|
-
//
|
|
771
|
+
// --host WITHOUT --active. `--json` fans the recent listing out and emits ONE
|
|
772
|
+
// clean merged SessionMeta[] array (same shape as the local --json path), for
|
|
773
|
+
// scripts/extensions that JSON.parse a remote's history. Without --json it
|
|
774
|
+
// keeps the legacy per-host stream (each remote's raw stdout under a
|
|
775
|
+
// `── host ──` banner). With --active, the hosts are folded into the merged
|
|
776
|
+
// machine-grouped view instead (handled below).
|
|
721
777
|
if (options.host && options.host.length > 0 && !options.active) {
|
|
778
|
+
if (options.json) {
|
|
779
|
+
await runRemoteSessionsJson(options.host);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
722
782
|
try {
|
|
723
783
|
runRemoteSessions(options.host);
|
|
724
784
|
}
|
|
@@ -858,11 +918,7 @@ async function sessionsAction(query, options) {
|
|
|
858
918
|
}
|
|
859
919
|
if (options.json) {
|
|
860
920
|
const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
|
|
861
|
-
|
|
862
|
-
const { _matchedTerms, _bm25Score, _remote, ...rest } = s;
|
|
863
|
-
return rest;
|
|
864
|
-
});
|
|
865
|
-
process.stdout.write(JSON.stringify(serializable, null, 2) + '\n');
|
|
921
|
+
process.stdout.write(serializeSessionsJson(filtered));
|
|
866
922
|
return;
|
|
867
923
|
}
|
|
868
924
|
// Cross-machine fan-out: unless --local (or we ARE a peer answering a
|
|
@@ -2014,6 +2070,7 @@ export function registerSessionsCommands(program) {
|
|
|
2014
2070
|
registerSessionsSyncCommand(sessionsCmd);
|
|
2015
2071
|
registerSessionsResumeCommand(sessionsCmd);
|
|
2016
2072
|
registerGoCommand(sessionsCmd);
|
|
2073
|
+
registerFocusCommand(sessionsCmd);
|
|
2017
2074
|
registerSessionsInjectCommand(sessionsCmd);
|
|
2018
2075
|
}
|
|
2019
2076
|
function formatNoSessionsMessage(showAll, project) {
|
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
|
/**
|