@phnx-labs/agents-cli 1.20.77 → 1.20.82
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 +589 -0
- package/README.md +27 -25
- package/dist/bin/agents +0 -0
- package/dist/commands/activity.d.ts +9 -0
- package/dist/commands/activity.js +153 -24
- package/dist/commands/apply.js +11 -4
- package/dist/commands/cli.js +1 -1
- package/dist/commands/commands.d.ts +1 -1
- package/dist/commands/commands.js +7 -6
- package/dist/commands/defaults.js +3 -11
- package/dist/commands/doctor.d.ts +74 -0
- package/dist/commands/doctor.js +718 -40
- package/dist/commands/events.js +1 -1
- package/dist/commands/exec.js +53 -8
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +87 -11
- package/dist/commands/harness.js +2 -2
- package/dist/commands/hooks.js +1 -1
- package/dist/commands/import.js +8 -4
- package/dist/commands/logs.js +6 -0
- package/dist/commands/models.js +1 -1
- package/dist/commands/packages.js +6 -6
- package/dist/commands/permissions.js +1 -1
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/pull.d.ts +1 -1
- package/dist/commands/pull.js +4 -4
- package/dist/commands/repo.d.ts +78 -0
- package/dist/commands/repo.js +195 -11
- package/dist/commands/routines.js +16 -4
- package/dist/commands/rules.js +13 -12
- package/dist/commands/secrets.d.ts +3 -1
- package/dist/commands/secrets.js +108 -34
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +118 -12
- package/dist/commands/sessions-export.d.ts +3 -0
- package/dist/commands/sessions-export.js +13 -10
- package/dist/commands/sessions-inject.d.ts +7 -4
- package/dist/commands/sessions-inject.js +18 -18
- package/dist/commands/sessions-picker.d.ts +15 -1
- package/dist/commands/sessions-picker.js +90 -7
- package/dist/commands/sessions-resume.d.ts +16 -0
- package/dist/commands/sessions-resume.js +14 -13
- package/dist/commands/sessions.d.ts +126 -1
- package/dist/commands/sessions.js +365 -42
- package/dist/commands/set.d.ts +15 -0
- package/dist/commands/set.js +79 -0
- package/dist/commands/setup-fleet.d.ts +21 -0
- package/dist/commands/setup-fleet.js +201 -0
- package/dist/commands/setup-secrets.d.ts +20 -0
- package/dist/commands/setup-secrets.js +223 -0
- package/dist/commands/setup.js +17 -3
- package/dist/commands/skills.js +1 -1
- package/dist/commands/ssh.js +9 -1
- package/dist/commands/sync.js +7 -2
- package/dist/commands/teams-picker.js +0 -1
- package/dist/commands/teams.d.ts +33 -1
- package/dist/commands/teams.js +208 -44
- package/dist/commands/versions.js +10 -5
- package/dist/commands/view.d.ts +1 -0
- package/dist/commands/view.js +11 -0
- package/dist/index.js +8 -13
- package/dist/lib/acp/harnesses.js +0 -7
- package/dist/lib/activity.d.ts +156 -0
- package/dist/lib/activity.js +282 -0
- package/dist/lib/agents.d.ts +9 -23
- package/dist/lib/agents.js +72 -100
- package/dist/lib/auto-pull.d.ts +16 -8
- package/dist/lib/auto-pull.js +23 -28
- package/dist/lib/browser/service.js +3 -0
- package/dist/lib/browser/types.d.ts +7 -0
- package/dist/lib/capabilities.js +6 -2
- package/dist/lib/crabbox/lease.js +2 -2
- package/dist/lib/crabbox/setup-copy.d.ts +4 -4
- package/dist/lib/crabbox/setup-copy.js +4 -4
- package/dist/lib/daemon.d.ts +26 -40
- package/dist/lib/daemon.js +63 -123
- package/dist/lib/devices/fleet-divergence.d.ts +101 -0
- package/dist/lib/devices/fleet-divergence.js +188 -0
- package/dist/lib/devices/fleet-inventory.d.ts +19 -0
- package/dist/lib/devices/fleet-inventory.js +57 -0
- package/dist/lib/devices/fleet.d.ts +1 -1
- package/dist/lib/devices/fleet.js +1 -1
- package/dist/lib/devices/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/doctor-diff.d.ts +20 -0
- package/dist/lib/doctor-diff.js +6 -1
- package/dist/lib/drift.d.ts +31 -11
- package/dist/lib/drift.js +58 -7
- package/dist/lib/events.d.ts +8 -1
- package/dist/lib/events.js +10 -1
- package/dist/lib/exec.js +35 -8
- package/dist/lib/feed-post.d.ts +28 -1
- package/dist/lib/feed-post.js +110 -2
- package/dist/lib/fleet/apply.js +8 -1
- package/dist/lib/fleet/auth-sync.d.ts +18 -1
- package/dist/lib/fleet/auth-sync.js +25 -11
- package/dist/lib/fleet/remote-login.js +5 -0
- package/dist/lib/git.d.ts +27 -14
- package/dist/lib/git.js +138 -43
- package/dist/lib/hooks.d.ts +42 -0
- package/dist/lib/hooks.js +137 -93
- package/dist/lib/hosts/dispatch.d.ts +28 -1
- package/dist/lib/hosts/dispatch.js +33 -5
- package/dist/lib/hosts/option.js +2 -2
- package/dist/lib/hosts/passthrough.d.ts +22 -1
- package/dist/lib/hosts/passthrough.js +238 -13
- package/dist/lib/hosts/remote-cmd.js +2 -0
- package/dist/lib/hosts/run-target.d.ts +2 -0
- package/dist/lib/hosts/run-target.js +1 -0
- package/dist/lib/hq/floor.d.ts +1 -1
- package/dist/lib/hq/floor.js +6 -1
- package/dist/lib/mcp.js +3 -71
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/menubar/notify-desktop.d.ts +15 -0
- package/dist/lib/menubar/notify-desktop.js +41 -5
- package/dist/lib/merged-resources.d.ts +11 -0
- package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
- package/dist/lib/permissions.d.ts +0 -32
- package/dist/lib/permissions.js +4 -157
- package/dist/lib/picker.js +1 -1
- package/dist/lib/platform/process.d.ts +16 -5
- package/dist/lib/platform/process.js +54 -0
- package/dist/lib/plugins.d.ts +0 -8
- package/dist/lib/plugins.js +4 -110
- package/dist/lib/project-resources.js +4 -1
- package/dist/lib/refresh.d.ts +5 -3
- package/dist/lib/refresh.js +7 -5
- package/dist/lib/resources/commands.js +4 -1
- package/dist/lib/resources/mcp.js +0 -4
- package/dist/lib/resources/permissions.d.ts +1 -1
- package/dist/lib/resources/permissions.js +1 -5
- package/dist/lib/resources/rules.js +4 -1
- package/dist/lib/resources/skills.js +4 -1
- package/dist/lib/resources/subagents.js +4 -0
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/routines.d.ts +20 -0
- package/dist/lib/routines.js +6 -0
- package/dist/lib/run-defaults.d.ts +1 -0
- package/dist/lib/run-defaults.js +9 -0
- package/dist/lib/runner.d.ts +0 -12
- package/dist/lib/runner.js +71 -29
- package/dist/lib/sandbox.js +0 -16
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/secrets/agent.d.ts +18 -0
- package/dist/lib/secrets/agent.js +131 -17
- package/dist/lib/secrets/audit.d.ts +46 -0
- package/dist/lib/secrets/audit.js +56 -0
- package/dist/lib/secrets/bundles.d.ts +12 -9
- package/dist/lib/secrets/bundles.js +47 -31
- package/dist/lib/secrets/index.d.ts +2 -1
- package/dist/lib/secrets/index.js +8 -2
- package/dist/lib/secrets/remote.d.ts +75 -2
- package/dist/lib/secrets/remote.js +139 -5
- package/dist/lib/secrets/scope.d.ts +26 -0
- package/dist/lib/secrets/scope.js +29 -0
- package/dist/lib/secrets/session-store.d.ts +10 -0
- package/dist/lib/secrets/session-store.js +64 -11
- package/dist/lib/secrets/sync.js +3 -3
- package/dist/lib/session/active.d.ts +117 -26
- package/dist/lib/session/active.js +199 -53
- package/dist/lib/session/actor-sidecar.d.ts +23 -0
- package/dist/lib/session/actor-sidecar.js +101 -0
- package/dist/lib/session/db.d.ts +41 -1
- package/dist/lib/session/db.js +173 -8
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +19 -2
- package/dist/lib/session/discover.js +32 -11
- package/dist/lib/session/hook-sessions.d.ts +9 -0
- package/dist/lib/session/hook-sessions.js +57 -8
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/pid-registry.d.ts +13 -0
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/state.d.ts +1 -0
- package/dist/lib/session/state.js +1 -1
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/session/types.d.ts +16 -0
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/dist/lib/staleness/detectors/commands.js +2 -2
- package/dist/lib/staleness/detectors/permissions.js +1 -45
- package/dist/lib/staleness/writers/commands.js +4 -5
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +1 -2
- package/dist/lib/startup/command-registry.js +2 -4
- package/dist/lib/subagents-registry.js +5 -12
- package/dist/lib/subagents.d.ts +0 -10
- package/dist/lib/subagents.js +0 -12
- package/dist/lib/teams/agents.d.ts +0 -2
- package/dist/lib/teams/agents.js +10 -27
- package/dist/lib/teams/index.d.ts +1 -1
- package/dist/lib/teams/index.js +1 -1
- package/dist/lib/types.d.ts +17 -9
- package/dist/lib/usage.d.ts +28 -5
- package/dist/lib/usage.js +271 -8
- package/dist/lib/versions.js +7 -1
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +25 -13
- package/package.json +1 -2
- package/dist/commands/check.d.ts +0 -15
- package/dist/commands/check.js +0 -180
- package/dist/commands/resources.d.ts +0 -5
- package/dist/lib/secrets/account-token.d.ts +0 -20
- package/dist/lib/secrets/account-token.js +0 -64
package/dist/commands/secrets.js
CHANGED
|
@@ -15,19 +15,21 @@ import * as path from 'path';
|
|
|
15
15
|
import { SSH_TARGET_RE, assertValidSshTarget, sshExec } from '../lib/ssh-exec.js';
|
|
16
16
|
import { quoteWin32ExecArg, composeWin32CommandLine } from '../lib/platform/index.js';
|
|
17
17
|
import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
|
|
18
|
-
import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, } from '../lib/secrets/remote.js';
|
|
18
|
+
import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, verifyRemoteKeychainPush, keychainWriteFailureMessage, } from '../lib/secrets/remote.js';
|
|
19
19
|
import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
|
|
20
20
|
import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
|
|
21
21
|
import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, readBundleIfDecryptable, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, } from '../lib/secrets/bundles.js';
|
|
22
22
|
import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
|
|
23
23
|
import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
|
|
24
24
|
import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
|
|
25
|
+
import { GLOBAL_HARNESS } from '../lib/secrets/scope.js';
|
|
25
26
|
import { secretsHoldMs, secretsAgentDurable, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
|
|
26
27
|
import { saveSession, deleteBundleSessions, deleteAllSessions } from '../lib/secrets/session-store.js';
|
|
27
28
|
import { getCliVersionFresh } from '../lib/version.js';
|
|
28
29
|
import { readMeta } from '../lib/state.js';
|
|
29
30
|
import { parseDuration } from '../lib/hooks/cache.js';
|
|
30
31
|
import { emit, query } from '../lib/events.js';
|
|
32
|
+
import { emitSecretAudit } from '../lib/secrets/audit.js';
|
|
31
33
|
import { frequentlyPromptedBundles } from '../lib/secrets/unlock-hints.js';
|
|
32
34
|
import { registerCommandGroups, setHelpSections } from '../lib/help.js';
|
|
33
35
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
@@ -467,7 +469,7 @@ export function renderPolicyCol(b, held) {
|
|
|
467
469
|
if (bundlePolicy(b) === 'always')
|
|
468
470
|
return chalk.yellow('always · prompt');
|
|
469
471
|
const exp = held?.get(b.name);
|
|
470
|
-
return exp ? chalk.green(`
|
|
472
|
+
return exp ? chalk.green(`hold · held ${compactRemaining(exp)}`) : chalk.gray('hold');
|
|
471
473
|
}
|
|
472
474
|
/** Human-readable hold window for `secrets status`. Sub-hour values render in
|
|
473
475
|
* minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
|
|
@@ -668,7 +670,7 @@ function countExpiringSoon(meta) {
|
|
|
668
670
|
* guard can't drift between them.
|
|
669
671
|
*/
|
|
670
672
|
function resolveImportBundle(name, backendOpt, synced = false) {
|
|
671
|
-
const requestedBackend = synced ? 'vault' :
|
|
673
|
+
const requestedBackend = synced ? 'vault' : resolveBackendOpt(backendOpt);
|
|
672
674
|
if (bundleExists(name)) {
|
|
673
675
|
const bundle = readBundle(name);
|
|
674
676
|
if (requestedBackend !== 'keychain' && bundle.backend !== requestedBackend) {
|
|
@@ -734,7 +736,7 @@ export function registerSecretsCommands(program) {
|
|
|
734
736
|
agents secrets view prod
|
|
735
737
|
|
|
736
738
|
# Stop a noisy automation bundle from prompting every run: ask once a week
|
|
737
|
-
agents secrets policy prod
|
|
739
|
+
agents secrets policy prod hold
|
|
738
740
|
|
|
739
741
|
# Eval the bundle into your current shell
|
|
740
742
|
eval "$(agents secrets export prod --plaintext)"
|
|
@@ -752,17 +754,21 @@ export function registerSecretsCommands(program) {
|
|
|
752
754
|
|
|
753
755
|
Touch ID noise: macOS pops a prompt per bundle per process. Each bundle has
|
|
754
756
|
a prompt policy, shown in the POLICY column of 'agents secrets list':
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
757
|
+
hold (default) ask once, then serve it silently from the local agent
|
|
758
|
+
for the hold window ('secrets.agent.holdMs', 7d by
|
|
759
|
+
default), until sleep / logout or 'lock' (a bare
|
|
760
|
+
screen-lock does NOT drop it). Accepts the older names
|
|
761
|
+
'daily' and 'session'.
|
|
758
762
|
always ask for Touch ID every time — never auto-held.
|
|
759
|
-
|
|
763
|
+
never no biometry ACL at all — reads are silent forever, even
|
|
764
|
+
if the agent is down. Automation credentials only.
|
|
765
|
+
The default is 'hold' (one Touch ID per hold window); change it globally with
|
|
760
766
|
'secrets.policy' in agents.yaml, or per bundle with 'agents secrets policy
|
|
761
767
|
<bundle> always'. 'agents secrets unlock <bundle>' holds any bundle after one
|
|
762
768
|
prompt regardless of policy. Nothing on disk.
|
|
763
769
|
|
|
764
770
|
See also:
|
|
765
|
-
agents secrets policy <bundle>
|
|
771
|
+
agents secrets policy <bundle> hold ask once per hold window (7d by default), not every run
|
|
766
772
|
agents secrets unlock <bundle> hold a bundle after one Touch ID
|
|
767
773
|
agents secrets lock wipe held bundles (re-prompt next read)
|
|
768
774
|
agents secrets status show held bundles + when they lock
|
|
@@ -788,6 +794,8 @@ export function registerSecretsCommands(program) {
|
|
|
788
794
|
.description('List configured secrets bundles (use --host/--hosts to list bundles on other machines over SSH)')
|
|
789
795
|
.option('--host <target>', 'List bundles on a remote host over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
|
|
790
796
|
.option('--hosts <list>', 'Comma-separated hosts to list in one shot, e.g. yosemite-s0,yosemite-s1')
|
|
797
|
+
.option('--device <target>', 'Alias for --host')
|
|
798
|
+
.option('--devices <list>', 'Alias for --hosts')
|
|
791
799
|
.option('--json', 'Emit machine-readable JSON (bundle metadata only — never secret values) instead of the table')
|
|
792
800
|
.action(async (opts) => {
|
|
793
801
|
const targets = parseHostsOption(opts);
|
|
@@ -855,6 +863,8 @@ export function registerSecretsCommands(program) {
|
|
|
855
863
|
.option('--json', 'Emit machine-readable JSON (values masked unless --reveal) instead of the human view')
|
|
856
864
|
.option('--host <target>', 'Show a bundle on a remote host over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
|
|
857
865
|
.option('--hosts <list>', 'Comma-separated hosts to show in one shot, e.g. yosemite-s0,yosemite-s1')
|
|
866
|
+
.option('--device <target>', 'Alias for --host')
|
|
867
|
+
.option('--devices <list>', 'Alias for --hosts')
|
|
858
868
|
.action(async (name, opts) => {
|
|
859
869
|
try {
|
|
860
870
|
const targets = parseHostsOption(opts);
|
|
@@ -912,8 +922,8 @@ export function registerSecretsCommands(program) {
|
|
|
912
922
|
}
|
|
913
923
|
const exposed = revealed.size + entries.filter((e) => e.kind === 'literal').length;
|
|
914
924
|
if (exposed > 0) {
|
|
915
|
-
|
|
916
|
-
|
|
925
|
+
emitSecretAudit({
|
|
926
|
+
event: 'secrets.get',
|
|
917
927
|
bundle: bundle.name,
|
|
918
928
|
operation: 'view --reveal --json',
|
|
919
929
|
source: 'reveal',
|
|
@@ -971,8 +981,8 @@ export function registerSecretsCommands(program) {
|
|
|
971
981
|
console.log(chalk.red.bold('policy: never — NO biometry ACL; reads are silent (no Touch ID, no user-presence check). Automation-only.'));
|
|
972
982
|
}
|
|
973
983
|
else {
|
|
974
|
-
console.log(bundlePolicy(bundle) === '
|
|
975
|
-
? chalk.gray('policy:
|
|
984
|
+
console.log(bundlePolicy(bundle) === 'hold'
|
|
985
|
+
? chalk.gray('policy: hold (ask once, then held for the hold window — 7d by default — until sleep / logout; screen-lock does not drop it)')
|
|
976
986
|
: chalk.gray('policy: always (asks for Touch ID every time — never auto-held)'));
|
|
977
987
|
}
|
|
978
988
|
if (bundle.created_at)
|
|
@@ -1020,8 +1030,8 @@ export function registerSecretsCommands(program) {
|
|
|
1020
1030
|
const literalCount = entries.filter((e) => e.kind === 'literal').length;
|
|
1021
1031
|
const exposedCount = revealedValues.size + literalCount;
|
|
1022
1032
|
if (exposedCount > 0) {
|
|
1023
|
-
|
|
1024
|
-
|
|
1033
|
+
emitSecretAudit({
|
|
1034
|
+
event: 'secrets.get',
|
|
1025
1035
|
bundle: bundle.name,
|
|
1026
1036
|
operation: 'view --reveal',
|
|
1027
1037
|
source: 'reveal',
|
|
@@ -1078,7 +1088,7 @@ export function registerSecretsCommands(program) {
|
|
|
1078
1088
|
const value = getKeychainToken(item);
|
|
1079
1089
|
// Raw item reads bypass readAndResolveBundleEnv, so audit here too.
|
|
1080
1090
|
// `item` is the keychain service name, never the value.
|
|
1081
|
-
|
|
1091
|
+
emitSecretAudit({ event: 'secrets.get', item, source: 'raw-item', status: 'success' });
|
|
1082
1092
|
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
|
|
1083
1093
|
}
|
|
1084
1094
|
catch {
|
|
@@ -1152,10 +1162,10 @@ export function registerSecretsCommands(program) {
|
|
|
1152
1162
|
.description('Create an empty bundle')
|
|
1153
1163
|
.option('--description <text>', 'Free-form description')
|
|
1154
1164
|
.option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
|
|
1155
|
-
.option('--policy <policy>',
|
|
1165
|
+
.option('--policy <policy>', "prompt policy: hold (default, ask once per hold window — secrets.agent.holdMs, 7d by default), always (ask every time), or never (silent, NO biometry ACL — needs --i-understand). 'daily'/'session' are accepted aliases for 'hold'.")
|
|
1156
1166
|
.addOption(new Option('--tier <policy>', 'deprecated alias for --policy').hideHelp())
|
|
1157
1167
|
.option('--i-understand', 'Confirm creating a "never"-policy bundle (no biometry ACL) without an interactive prompt')
|
|
1158
|
-
.option('--backend <backend>', 'storage backend: keychain
|
|
1168
|
+
.option('--backend <backend>', 'storage backend: keychain or file (defaults to agents.yaml secrets.backend)')
|
|
1159
1169
|
.option('--synced', 'Store this bundle in the age-encrypted synced secrets file for user-managed cross-machine file sync')
|
|
1160
1170
|
.option('--force', 'Overwrite an existing bundle')
|
|
1161
1171
|
.action(async (name, opts) => {
|
|
@@ -1166,7 +1176,7 @@ export function registerSecretsCommands(program) {
|
|
|
1166
1176
|
// inherits the configured default (`daily`) instead of being pinned.
|
|
1167
1177
|
const policyOpt = opts.policy ?? opts.tier;
|
|
1168
1178
|
const policy = policyOpt ? parsePolicyOpt(policyOpt) : undefined;
|
|
1169
|
-
const backend = opts.synced ? 'vault' :
|
|
1179
|
+
const backend = opts.synced ? 'vault' : resolveBackendOpt(opts.backend);
|
|
1170
1180
|
if (bundleExists(resolvedName) && !opts.force) {
|
|
1171
1181
|
console.error(chalk.red(`Bundle '${resolvedName}' already exists. Use --force to overwrite.`));
|
|
1172
1182
|
process.exit(1);
|
|
@@ -1187,8 +1197,8 @@ export function registerSecretsCommands(program) {
|
|
|
1187
1197
|
vars: {},
|
|
1188
1198
|
};
|
|
1189
1199
|
writeBundle(bundle);
|
|
1190
|
-
const policyTag = bundlePolicy(bundle) === '
|
|
1191
|
-
? 'policy:
|
|
1200
|
+
const policyTag = bundlePolicy(bundle) === 'hold'
|
|
1201
|
+
? 'policy: hold'
|
|
1192
1202
|
: bundlePolicy(bundle) === 'always'
|
|
1193
1203
|
? 'policy: always ask'
|
|
1194
1204
|
: 'policy: never (NO biometry ACL)';
|
|
@@ -1571,7 +1581,7 @@ Examples:
|
|
|
1571
1581
|
.addOption(new Option('--from-1password', 'deprecated alias for --from 1password:<vault>').hideHelp())
|
|
1572
1582
|
.addOption(new Option('--vault <name>', 'deprecated: name the vault in --from 1password:<vault>').hideHelp())
|
|
1573
1583
|
.option('--all-plaintext', 'Store every imported value as a literal in the bundle metadata (skip keychain item creation)')
|
|
1574
|
-
.option('--backend <backend>', 'When creating the bundle: keychain
|
|
1584
|
+
.option('--backend <backend>', 'When creating the bundle: keychain or file (defaults to agents.yaml secrets.backend)')
|
|
1575
1585
|
.option('--synced', 'When creating the bundle, store it in the age-encrypted synced secrets file')
|
|
1576
1586
|
.option('--force', 'Overwrite an existing key in the bundle')
|
|
1577
1587
|
.option('--purge', 'With --from icloud: delete the iCloud copies after a successful import (iCloud propagates the deletion to your other devices)')
|
|
@@ -1622,7 +1632,7 @@ Examples:
|
|
|
1622
1632
|
if (opts.from1password) {
|
|
1623
1633
|
console.log(chalk.yellow('--from-1password is deprecated; use --from 1password:<vault>.'));
|
|
1624
1634
|
}
|
|
1625
|
-
const requestedBackend = opts.synced ? 'vault' :
|
|
1635
|
+
const requestedBackend = opts.synced ? 'vault' : resolveBackendOpt(opts.backend);
|
|
1626
1636
|
if (source.kind === 'icloud') {
|
|
1627
1637
|
await importFromICloud(bundleName, {
|
|
1628
1638
|
force: opts.force,
|
|
@@ -1673,12 +1683,18 @@ Examples:
|
|
|
1673
1683
|
.option('--to-1password', 'Push every key in the bundle as a PASSWORD item in a 1Password vault')
|
|
1674
1684
|
.option('--vault <name>', '1Password vault name (used with --to-1password)')
|
|
1675
1685
|
.option('--host <target...>', 'Push the bundle over SSH to this target (host alias or user@host); repeatable for multiple machines')
|
|
1686
|
+
.option('--device <target...>', 'Alias for --host; repeatable')
|
|
1676
1687
|
.option('--remote-backend <backend>', 'Backend for the bundle on the remote (with --host): keychain (default) or file (passphrase-encrypted, headless-readable). file forwards AGENTS_SECRETS_PASSPHRASE over stdin.', 'keychain')
|
|
1677
1688
|
.option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --host)')
|
|
1678
1689
|
.option('--format <shell|json>', 'Output for --plaintext export: shell (default) or json (lossless, machine-readable; used by remote resolve)', 'shell')
|
|
1679
1690
|
.option('--to-file <path>', 'Write the bundle as an AES-256-GCM encrypted offline file (needs AGENTS_SECRETS_PASSPHRASE; symmetric counterpart of import --from-file)')
|
|
1680
1691
|
.action(async (bundleName, opts) => {
|
|
1681
1692
|
try {
|
|
1693
|
+
// `--device` is an alias for `--host` (fleet vocabulary parity with
|
|
1694
|
+
// `agents activity`/`run --device`); fold it into the host list up front so
|
|
1695
|
+
// every downstream branch sees one resolved target list.
|
|
1696
|
+
if (opts.device?.length)
|
|
1697
|
+
opts.host = [...(opts.host ?? []), ...opts.device];
|
|
1682
1698
|
const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
|
|
1683
1699
|
const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
|
|
1684
1700
|
if (opts.toFile) {
|
|
@@ -1769,6 +1785,29 @@ Examples:
|
|
|
1769
1785
|
console.error(chalk.red(`${host}: remote import failed (exit ${res.code})${msg ? `: ${msg}` : ''}`));
|
|
1770
1786
|
continue;
|
|
1771
1787
|
}
|
|
1788
|
+
// A keychain-backed push to a macOS remote over headless SSH can land
|
|
1789
|
+
// the bundle metadata but no READABLE value items: the remote login
|
|
1790
|
+
// keychain is locked in the non-interactive SSH context, so Security
|
|
1791
|
+
// accepts the write but the biometry-ACL'd item is unreadable, and the
|
|
1792
|
+
// remote `import` still exits 0. Read the bundle back the same way a
|
|
1793
|
+
// release will (drops the plaintext, keeps only key presence; the
|
|
1794
|
+
// remote's headless `agentOnly` guard fails fast, so no Touch ID) and
|
|
1795
|
+
// FAIL LOUDLY if the keys didn't materialize — rather than leave a
|
|
1796
|
+
// metadata-only bundle that breaks later with "stored item not found".
|
|
1797
|
+
// The file backend is headless-readable by construction, so skip it.
|
|
1798
|
+
if (remoteBackend === 'keychain') {
|
|
1799
|
+
const verdict = verifyRemoteKeychainPush(host, resolvedBundleName, Object.keys(env), { osLookupName: host });
|
|
1800
|
+
if (!verdict.ok) {
|
|
1801
|
+
failures++;
|
|
1802
|
+
if (verdict.kind === 'locked-keychain') {
|
|
1803
|
+
console.error(chalk.red(keychainWriteFailureMessage(host, resolvedBundleName, verdict.reason)));
|
|
1804
|
+
}
|
|
1805
|
+
else {
|
|
1806
|
+
console.error(chalk.red(`${host}: pushed '${resolvedBundleName}' but could not verify it on the remote: ${verdict.reason}`));
|
|
1807
|
+
}
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1772
1811
|
const remoteMsg = (res.stdout || '').trim().split('\n').map((l) => l.trim()).filter(Boolean).pop();
|
|
1773
1812
|
console.log(chalk.green(`${host} -> '${resolvedBundleName}': ${remoteMsg || `${keyCount} key(s) exported`}`));
|
|
1774
1813
|
}
|
|
@@ -2057,7 +2096,7 @@ Examples:
|
|
|
2057
2096
|
.description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS). With --host, unlock FILE-backed bundle(s) on a remote (the passphrase prompt surfaces over the SSH TTY); keychain/biometry bundles are GUI-only and can\'t be remote-unlocked.')
|
|
2058
2097
|
.option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
|
|
2059
2098
|
.option('--durable', 'Keep the unlock across sleep + reboot too (default: survives upgrade/restart but re-locks on sleep). Set secrets.agent.durable in agents.yaml to make this the default.')
|
|
2060
|
-
.option('--for <agent>', '
|
|
2099
|
+
.option('--for <agent>', 'Narrow the unlock to ONE harness type (for example claude, codex, or kimi). Default: the grant is global — every harness and a plain shell can read it, so one Touch ID covers them all.')
|
|
2061
2100
|
.option('--all', 'Unlock every configured bundle')
|
|
2062
2101
|
.option('--host <target>', 'Unlock the bundle(s) on this remote machine over SSH instead of locally (file-backed bundles only — the remote\'s passphrase prompt surfaces on your terminal over a -tt session). Single-valued (NOT variadic) so it never swallows the bundle name: `unlock <name> --host <machine>`.')
|
|
2063
2102
|
.action(async (names, opts) => {
|
|
@@ -2130,7 +2169,12 @@ Examples:
|
|
|
2130
2169
|
// (single-instance start lock, #414) and best-effort — never blocks unlock.
|
|
2131
2170
|
ensureDaemonStarted();
|
|
2132
2171
|
let loaded = 0;
|
|
2133
|
-
|
|
2172
|
+
// An unlock is a deliberate act, so it grants GLOBALLY unless `--for`
|
|
2173
|
+
// narrows it to one harness. It must NOT inherit the ambient
|
|
2174
|
+
// AGENTS_AGENT_NAME: that silently scoped a terminal unlock to whichever
|
|
2175
|
+
// agent happened to launch the shell, leaving the grant unreadable to
|
|
2176
|
+
// every other reader for its whole TTL.
|
|
2177
|
+
const harness = opts.for || GLOBAL_HARNESS;
|
|
2134
2178
|
for (const name of targets) {
|
|
2135
2179
|
try {
|
|
2136
2180
|
// noAgent: read the real keychain (one Touch ID) rather than the
|
|
@@ -2146,13 +2190,29 @@ Examples:
|
|
|
2146
2190
|
loaded++;
|
|
2147
2191
|
// Persist a durable session snapshot so the unlock survives a daemon
|
|
2148
2192
|
// restart / upgrade (and sleep too, with --durable). session-store.ts.
|
|
2193
|
+
const durable = opts.durable ?? secretsAgentDurable();
|
|
2149
2194
|
saveSession(name, {
|
|
2150
2195
|
bundle,
|
|
2151
2196
|
env,
|
|
2152
2197
|
expiresAt: Date.now() + ttlMs,
|
|
2153
|
-
sleepPersist:
|
|
2198
|
+
sleepPersist: durable,
|
|
2154
2199
|
harness,
|
|
2155
2200
|
});
|
|
2201
|
+
// Audit the GRANT itself — the broker + durable session now serve this
|
|
2202
|
+
// bundle prompt-free for the whole TTL, to every reader in `harness`
|
|
2203
|
+
// scope. The pre-read above emits `secrets.get`; this records the
|
|
2204
|
+
// longer-lived unlock a `secrets.get` does not capture. Key NAMES only.
|
|
2205
|
+
emitSecretAudit({
|
|
2206
|
+
event: 'secrets.unlocked',
|
|
2207
|
+
bundle: name,
|
|
2208
|
+
operation: 'unlock',
|
|
2209
|
+
source: durable ? 'broker+durable' : 'broker',
|
|
2210
|
+
status: 'success',
|
|
2211
|
+
keyCount: Object.keys(env).length,
|
|
2212
|
+
keys: Object.keys(env).sort(),
|
|
2213
|
+
agent: harness,
|
|
2214
|
+
ttlMs,
|
|
2215
|
+
});
|
|
2156
2216
|
console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(Date.now() + ttlMs)})`)}`);
|
|
2157
2217
|
}
|
|
2158
2218
|
else {
|
|
@@ -2272,7 +2332,7 @@ Examples:
|
|
|
2272
2332
|
cmd
|
|
2273
2333
|
.command('policy <bundle> [policy]')
|
|
2274
2334
|
.alias('tier')
|
|
2275
|
-
.description("Show or set a bundle's prompt policy:
|
|
2335
|
+
.description("Show or set a bundle's prompt policy: hold (default, ask once per hold window — secrets.agent.holdMs, 7d by default), always (ask every time), or never (silent, NO biometry ACL). 'daily'/'session' are accepted aliases for 'hold'.")
|
|
2276
2336
|
.option('--i-understand', 'Confirm switching to the "never" policy (no biometry ACL) without an interactive prompt')
|
|
2277
2337
|
.action(async (bundleName, policyArg, opts) => {
|
|
2278
2338
|
try {
|
|
@@ -2289,12 +2349,12 @@ Examples:
|
|
|
2289
2349
|
return;
|
|
2290
2350
|
}
|
|
2291
2351
|
bundle.policy = next;
|
|
2292
|
-
// writeBundle evicts any broker-held copy, so tightening
|
|
2352
|
+
// writeBundle evicts any broker-held copy, so tightening hold ->
|
|
2293
2353
|
// always/never takes effect NOW: the next read re-prompts (`always`)
|
|
2294
2354
|
// or reads its no-ACL item directly (`never`).
|
|
2295
2355
|
writeBundle(bundle);
|
|
2296
2356
|
console.log(chalk.green(`${bundle.name} policy set to ${next}.`));
|
|
2297
|
-
if (next === '
|
|
2357
|
+
if (next === 'hold') {
|
|
2298
2358
|
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).'));
|
|
2299
2359
|
}
|
|
2300
2360
|
else if (next === 'always') {
|
|
@@ -2369,11 +2429,11 @@ export function parsePolicyOpt(raw) {
|
|
|
2369
2429
|
const v = (raw ?? 'always').toLowerCase();
|
|
2370
2430
|
if (v === 'always' || v === 'biometry')
|
|
2371
2431
|
return 'always';
|
|
2372
|
-
if (v === 'daily' || v === 'session')
|
|
2373
|
-
return '
|
|
2432
|
+
if (v === 'hold' || v === 'daily' || v === 'session')
|
|
2433
|
+
return 'hold';
|
|
2374
2434
|
if (v === 'never' || v === 'none')
|
|
2375
2435
|
return 'never';
|
|
2376
|
-
throw new Error(`Invalid policy '${raw}'. Use 'always', '
|
|
2436
|
+
throw new Error(`Invalid policy '${raw}'. Use 'always', 'hold', or 'never'.`);
|
|
2377
2437
|
}
|
|
2378
2438
|
/**
|
|
2379
2439
|
* Gate a create/switch to the `never` prompt-policy behind an explicit,
|
|
@@ -2408,13 +2468,27 @@ async function confirmNeverPolicyInteractive(bundleName) {
|
|
|
2408
2468
|
return confirm({ message: `Store '${bundleName}' WITHOUT biometry protection?`, default: false });
|
|
2409
2469
|
}
|
|
2410
2470
|
/** Validate a --backend value, exiting with a clear message on a bad one. */
|
|
2411
|
-
function
|
|
2412
|
-
const
|
|
2471
|
+
export function secretsDefaultBackend() {
|
|
2472
|
+
const raw = readMeta().secrets?.backend;
|
|
2473
|
+
if (raw === undefined)
|
|
2474
|
+
return 'keychain';
|
|
2475
|
+
if (raw === 'keychain' || raw === 'file' || raw === 'vault')
|
|
2476
|
+
return raw;
|
|
2477
|
+
console.error(chalk.red(`Invalid secrets.backend '${raw}'. Use 'keychain', 'file', or 'vault'.`));
|
|
2478
|
+
process.exit(1);
|
|
2479
|
+
}
|
|
2480
|
+
function parseBackendOpt(raw, defaultBackend = 'keychain') {
|
|
2481
|
+
const v = (raw ?? defaultBackend).toLowerCase();
|
|
2482
|
+
if (!raw && v === 'vault')
|
|
2483
|
+
return 'vault';
|
|
2413
2484
|
if (v === 'keychain' || v === 'file')
|
|
2414
2485
|
return v;
|
|
2415
2486
|
console.error(chalk.red(`Invalid --backend '${raw}'. Use 'keychain' or 'file'. For cross-machine file sync, pass --synced.`));
|
|
2416
2487
|
process.exit(1);
|
|
2417
2488
|
}
|
|
2489
|
+
function resolveBackendOpt(raw) {
|
|
2490
|
+
return parseBackendOpt(raw, raw === undefined ? secretsDefaultBackend() : 'keychain');
|
|
2491
|
+
}
|
|
2418
2492
|
/** Human-readable "locks in 3 hours" / "locks in 5 minutes" from an epoch-ms expiry. */
|
|
2419
2493
|
function humanRemaining(expiresAt) {
|
|
2420
2494
|
const ms = expiresAt - Date.now();
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* Built on {@link dynamicPicker}; every data source (discover, fleet, live index,
|
|
10
10
|
* preview, resume dispatch) is reused from the existing sessions plumbing.
|
|
11
11
|
*/
|
|
12
|
-
import type
|
|
12
|
+
import { type SessionMeta } from '../lib/session/types.js';
|
|
13
|
+
import type { ActiveSession } from '../lib/session/active.js';
|
|
13
14
|
/**
|
|
14
15
|
* The single canonical filter state. Every field has a flag equivalent, so the
|
|
15
16
|
* same view is reachable interactively (hotkeys) or from the command line (flags).
|
|
@@ -59,11 +60,14 @@ export declare function activeBrowserSeed(opts: {
|
|
|
59
60
|
agent?: string;
|
|
60
61
|
host?: string[];
|
|
61
62
|
since?: string;
|
|
63
|
+
all?: boolean;
|
|
62
64
|
}): Partial<BrowserFilter>;
|
|
63
65
|
/**
|
|
64
66
|
* The initial filter for the bare interactive listing: current-repo subtree by
|
|
65
|
-
* default (matches the static overview's cwd scoping)
|
|
66
|
-
* directory
|
|
67
|
+
* default (matches the static overview's cwd scoping). `--all` sets every
|
|
68
|
+
* non-status filter to its "all" value — every directory (project) AND all-time
|
|
69
|
+
* (window) — so one flag maxes the view; `--since` still overrides the window and
|
|
70
|
+
* `-a`/`--device` still narrow their axis.
|
|
67
71
|
*/
|
|
68
72
|
export declare function bareBrowserSeed(opts: {
|
|
69
73
|
teams?: boolean;
|
|
@@ -71,6 +75,44 @@ export declare function bareBrowserSeed(opts: {
|
|
|
71
75
|
all?: boolean;
|
|
72
76
|
since?: string;
|
|
73
77
|
}): Partial<BrowserFilter>;
|
|
78
|
+
/**
|
|
79
|
+
* A live session's stable row key: its session id when the agent reported one,
|
|
80
|
+
* else a per-machine handle so an id-less live session (a just-booted harness, a
|
|
81
|
+
* cloud task) still gets exactly one row instead of being dropped. Cloud rows key
|
|
82
|
+
* on the task id because they have no pid — keying them all on the machine alone
|
|
83
|
+
* would collapse two cloud tasks into one row, the same silent-drop this whole
|
|
84
|
+
* change exists to remove.
|
|
85
|
+
*/
|
|
86
|
+
export declare function liveRowKey(a: ActiveSession, self: string): string;
|
|
87
|
+
/** Index live sessions by {@link liveRowKey} — the join key between the live scan
|
|
88
|
+
* and the transcript pool. Id-carrying rows key on the session id, so a live row
|
|
89
|
+
* and its transcript row collapse to one. */
|
|
90
|
+
export declare function indexLiveRows(rows: ActiveSession[], self: string): Map<string, ActiveSession>;
|
|
91
|
+
/**
|
|
92
|
+
* Project a live session onto the picker's row shape, for a session the transcript
|
|
93
|
+
* pool doesn't carry — a peer's session, a transcript outside the current window,
|
|
94
|
+
* or an agent that has not written one yet. `filePath` is the live transcript path
|
|
95
|
+
* when the scan resolved one and empty otherwise, which is what
|
|
96
|
+
* {@link handlePickedSession} keys "there is nothing to open yet" off.
|
|
97
|
+
*/
|
|
98
|
+
export declare function liveSessionToMeta(a: ActiveSession, self: string): SessionMeta;
|
|
99
|
+
/**
|
|
100
|
+
* Fold the live scan INTO the transcript pool. The running filter used to be a
|
|
101
|
+
* pure intersection (`pool ∩ live`), which meant a session had to already be in
|
|
102
|
+
* the pool to be shown as running — so every session on another machine, and
|
|
103
|
+
* every local one outside the pool's window, was invisible in the browser while
|
|
104
|
+
* `--active --json` listed it. Live sessions the pool lacks are appended as their
|
|
105
|
+
* own rows, then the whole set is grouped local-machine-first.
|
|
106
|
+
*/
|
|
107
|
+
export declare function mergeLiveIntoPool(rows: SessionMeta[], live: Map<string, ActiveSession>, self: string): SessionMeta[];
|
|
108
|
+
/**
|
|
109
|
+
* Whether to render the host-program column. It belongs to the running view
|
|
110
|
+
* only, so this gates on the FILTER — not on the live index being populated.
|
|
111
|
+
* `liveCache` outlives a toggle of the `r` hotkey, so testing `live` alone would
|
|
112
|
+
* keep the column after running is turned back off, widening a plain transcript
|
|
113
|
+
* listing that has no live rows to explain it.
|
|
114
|
+
*/
|
|
115
|
+
export declare function shouldShowHostColumn(f: BrowserFilter, live: Map<string, ActiveSession> | null, rows: SessionMeta[]): boolean;
|
|
74
116
|
/**
|
|
75
117
|
* Launch the interactive session browser. `initial` seeds the filter (e.g.
|
|
76
118
|
* `{ running: true }` for `--active`). Resolves after the user resumes a session
|
|
@@ -9,14 +9,15 @@
|
|
|
9
9
|
* Built on {@link dynamicPicker}; every data source (discover, fleet, live index,
|
|
10
10
|
* preview, resume dispatch) is reused from the existing sessions plumbing.
|
|
11
11
|
*/
|
|
12
|
+
import path from 'path';
|
|
12
13
|
import { spawnSync } from 'child_process';
|
|
13
14
|
import { dynamicPicker } from '../lib/picker.js';
|
|
14
|
-
import {
|
|
15
|
+
import { isSessionTrackedAgent } from '../lib/session/types.js';
|
|
15
16
|
import { discoverSessions } from '../lib/session/discover.js';
|
|
16
17
|
import { gatherRemoteList } from '../lib/session/remote-list.js';
|
|
17
18
|
import { machineId, normalizeHost } from '../lib/session/sync/config.js';
|
|
18
19
|
import { buildPreview } from './sessions-picker.js';
|
|
19
|
-
import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst,
|
|
20
|
+
import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, } from './sessions.js';
|
|
20
21
|
/** Ordered window cycle for the `W` key. `undefined` = all time. */
|
|
21
22
|
const WINDOW_CYCLE = [undefined, '1d', '7d', '30d'];
|
|
22
23
|
/** Return the next value in `[undefined, ...options]`, wrapping. */
|
|
@@ -102,20 +103,25 @@ export function activeBrowserSeed(opts) {
|
|
|
102
103
|
agent: opts.agent,
|
|
103
104
|
projectScope: 'all',
|
|
104
105
|
device: normalizeDeviceSeed(opts.host?.[0]),
|
|
105
|
-
window
|
|
106
|
+
// --all widens the window to all-time (project is already 'all' here);
|
|
107
|
+
// --since still overrides.
|
|
108
|
+
window: opts.since ?? (opts.all ? undefined : '30d'),
|
|
106
109
|
};
|
|
107
110
|
}
|
|
108
111
|
/**
|
|
109
112
|
* The initial filter for the bare interactive listing: current-repo subtree by
|
|
110
|
-
* default (matches the static overview's cwd scoping)
|
|
111
|
-
* directory
|
|
113
|
+
* default (matches the static overview's cwd scoping). `--all` sets every
|
|
114
|
+
* non-status filter to its "all" value — every directory (project) AND all-time
|
|
115
|
+
* (window) — so one flag maxes the view; `--since` still overrides the window and
|
|
116
|
+
* `-a`/`--device` still narrow their axis.
|
|
112
117
|
*/
|
|
113
118
|
export function bareBrowserSeed(opts) {
|
|
114
119
|
return {
|
|
115
120
|
teams: !!opts.teams,
|
|
116
121
|
agent: opts.agent,
|
|
122
|
+
// --all maxes every non-status filter: all dirs AND all-time. --since wins.
|
|
117
123
|
projectScope: opts.all ? 'all' : 'repo',
|
|
118
|
-
window: opts.since ?? '30d',
|
|
124
|
+
window: opts.since ?? (opts.all ? undefined : '30d'),
|
|
119
125
|
};
|
|
120
126
|
}
|
|
121
127
|
/** Copy text to the OS clipboard (best-effort; silently no-ops if unavailable). */
|
|
@@ -182,6 +188,99 @@ async function fetchRawPool(f, self, local, hosts) {
|
|
|
182
188
|
}
|
|
183
189
|
return { key: `${since ?? 'all'}|${f.teams}`, rows };
|
|
184
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* A live session's stable row key: its session id when the agent reported one,
|
|
193
|
+
* else a per-machine handle so an id-less live session (a just-booted harness, a
|
|
194
|
+
* cloud task) still gets exactly one row instead of being dropped. Cloud rows key
|
|
195
|
+
* on the task id because they have no pid — keying them all on the machine alone
|
|
196
|
+
* would collapse two cloud tasks into one row, the same silent-drop this whole
|
|
197
|
+
* change exists to remove.
|
|
198
|
+
*/
|
|
199
|
+
export function liveRowKey(a, self) {
|
|
200
|
+
if (a.sessionId)
|
|
201
|
+
return a.sessionId;
|
|
202
|
+
const handle = a.cloudTaskId ?? (a.pid != null ? String(a.pid) : 'unknown');
|
|
203
|
+
return `${LIVE_ROW_PREFIX}${a.machine ?? self}:${handle}`;
|
|
204
|
+
}
|
|
205
|
+
/** Index live sessions by {@link liveRowKey} — the join key between the live scan
|
|
206
|
+
* and the transcript pool. Id-carrying rows key on the session id, so a live row
|
|
207
|
+
* and its transcript row collapse to one. */
|
|
208
|
+
export function indexLiveRows(rows, self) {
|
|
209
|
+
const byKey = new Map();
|
|
210
|
+
for (const a of rows)
|
|
211
|
+
byKey.set(liveRowKey(a, self), a);
|
|
212
|
+
return byKey;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Project a live session onto the picker's row shape, for a session the transcript
|
|
216
|
+
* pool doesn't carry — a peer's session, a transcript outside the current window,
|
|
217
|
+
* or an agent that has not written one yet. `filePath` is the live transcript path
|
|
218
|
+
* when the scan resolved one and empty otherwise, which is what
|
|
219
|
+
* {@link handlePickedSession} keys "there is nothing to open yet" off.
|
|
220
|
+
*/
|
|
221
|
+
export function liveSessionToMeta(a, self) {
|
|
222
|
+
const machine = a.machine ?? self;
|
|
223
|
+
const started = a.startedAtMs ?? a.lastActivityMs;
|
|
224
|
+
const topic = a.topic ?? a.preview;
|
|
225
|
+
return {
|
|
226
|
+
id: liveRowKey(a, self),
|
|
227
|
+
// No session id to short — name the row by what it IS (a cloud task, a pid),
|
|
228
|
+
// so the id column still identifies the process you'd go looking for.
|
|
229
|
+
shortId: a.sessionId
|
|
230
|
+
? a.sessionId.slice(0, 8)
|
|
231
|
+
: a.cloudTaskId
|
|
232
|
+
? a.cloudTaskId.slice(0, 8)
|
|
233
|
+
: `p:${a.pid ?? '?'}`,
|
|
234
|
+
agent: isSessionTrackedAgent(a.kind) ? a.kind : 'claude',
|
|
235
|
+
timestamp: new Date(started ?? Date.now()).toISOString(),
|
|
236
|
+
lastActivity: a.lastActivityMs ? new Date(a.lastActivityMs).toISOString() : undefined,
|
|
237
|
+
project: a.cwd ? path.basename(a.cwd) : undefined,
|
|
238
|
+
cwd: a.cwd,
|
|
239
|
+
filePath: a.sessionFile ?? '',
|
|
240
|
+
// A live topic is raw transcript text: a newline in it would break the row
|
|
241
|
+
// into two and misalign every column after it. Indexed rows are already
|
|
242
|
+
// cleaned at scan time, so this puts projected rows on the same footing.
|
|
243
|
+
topic: topic ? cleanPreview(topic) : undefined,
|
|
244
|
+
label: a.label ? cleanPreview(a.label) : undefined,
|
|
245
|
+
machine,
|
|
246
|
+
// Reading/resuming a peer's session hops back over SSH — same contract the
|
|
247
|
+
// cross-machine listing sets, so handlePickedSession routes it correctly.
|
|
248
|
+
_remote: machine !== self,
|
|
249
|
+
prUrl: a.pr?.url,
|
|
250
|
+
prNumber: a.pr?.number,
|
|
251
|
+
ticketId: a.ticket?.id,
|
|
252
|
+
worktreeSlug: a.worktree?.slug,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Fold the live scan INTO the transcript pool. The running filter used to be a
|
|
257
|
+
* pure intersection (`pool ∩ live`), which meant a session had to already be in
|
|
258
|
+
* the pool to be shown as running — so every session on another machine, and
|
|
259
|
+
* every local one outside the pool's window, was invisible in the browser while
|
|
260
|
+
* `--active --json` listed it. Live sessions the pool lacks are appended as their
|
|
261
|
+
* own rows, then the whole set is grouped local-machine-first.
|
|
262
|
+
*/
|
|
263
|
+
export function mergeLiveIntoPool(rows, live, self) {
|
|
264
|
+
const known = new Set(rows.map((r) => r.id));
|
|
265
|
+
const extra = [];
|
|
266
|
+
for (const [key, a] of live) {
|
|
267
|
+
if (!known.has(key))
|
|
268
|
+
extra.push(liveSessionToMeta(a, self));
|
|
269
|
+
}
|
|
270
|
+
return extra.length === 0 ? rows : mergeLocalFirst([...rows, ...extra], self);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Whether to render the host-program column. It belongs to the running view
|
|
274
|
+
* only, so this gates on the FILTER — not on the live index being populated.
|
|
275
|
+
* `liveCache` outlives a toggle of the `r` hotkey, so testing `live` alone would
|
|
276
|
+
* keep the column after running is turned back off, widening a plain transcript
|
|
277
|
+
* listing that has no live rows to explain it.
|
|
278
|
+
*/
|
|
279
|
+
export function shouldShowHostColumn(f, live, rows) {
|
|
280
|
+
if (!f.running || !live)
|
|
281
|
+
return false;
|
|
282
|
+
return rows.some((r) => liveHostLabel(live.get(r.id)) !== '');
|
|
283
|
+
}
|
|
185
284
|
/** Apply the cheap in-memory filters (agent / device / project / running). */
|
|
186
285
|
function applyFilters(rows, live, f, self) {
|
|
187
286
|
let out = rows;
|
|
@@ -269,11 +368,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
269
368
|
return []; // superseded — don't touch shared state
|
|
270
369
|
pool = fetched;
|
|
271
370
|
}
|
|
272
|
-
// Only pay for the live scan when the running filter needs it.
|
|
371
|
+
// Only pay for the live scan when the running filter needs it. Same fleet
|
|
372
|
+
// sweep the static `--active` view uses, so the two never disagree about
|
|
373
|
+
// what is running.
|
|
273
374
|
let live = liveCache;
|
|
274
375
|
if (f.running && !live) {
|
|
275
376
|
try {
|
|
276
|
-
|
|
377
|
+
const { sessions } = await gatherActiveSessions({ local, hosts });
|
|
378
|
+
live = indexLiveRows(sessions, self);
|
|
277
379
|
}
|
|
278
380
|
catch {
|
|
279
381
|
live = new Map();
|
|
@@ -286,10 +388,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
286
388
|
rawCache = pool;
|
|
287
389
|
if (live)
|
|
288
390
|
liveCache = live;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const
|
|
391
|
+
// Live sessions the transcript pool lacks become rows of their own, so the
|
|
392
|
+
// running view lists every active session, not just the ones already indexed.
|
|
393
|
+
const rows = f.running && live ? mergeLiveIntoPool(pool.rows, live, self) : pool.rows;
|
|
394
|
+
agentsInPool = distinct(rows.map((r) => r.agent));
|
|
395
|
+
devicesInPool = distinct(rows.map((r) => r.machine ?? self));
|
|
396
|
+
const filtered = applyFilters(rows, live ?? new Map(), f, self);
|
|
292
397
|
cols = pickerColumnsFor(filtered);
|
|
398
|
+
cols.showHost = shouldShowHostColumn(f, live, filtered);
|
|
293
399
|
return filtered;
|
|
294
400
|
};
|
|
295
401
|
const picked = await dynamicPicker({
|
|
@@ -297,7 +403,7 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
297
403
|
initialFilter,
|
|
298
404
|
load,
|
|
299
405
|
keyFor: (s) => s.id,
|
|
300
|
-
labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id)),
|
|
406
|
+
labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id))),
|
|
301
407
|
matches: sessionMatchesQuery,
|
|
302
408
|
buildPreview,
|
|
303
409
|
headerFor,
|
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
|
+
import type { SessionMeta } from '../lib/session/types.js';
|
|
2
3
|
export declare function registerSessionsExportCommand(sessionsCmd: Command): void;
|
|
4
|
+
/** ids > query > everything-in-scope. Exported for the id-resolution test. */
|
|
5
|
+
export declare function selectSessions(metas: SessionMeta[], selectors: string[]): SessionMeta[];
|