@phnx-labs/agents-cli 1.20.89 → 1.20.91
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 +361 -0
- package/README.md +6 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +7 -1
- package/dist/commands/feed.js +77 -4
- package/dist/commands/harness.d.ts +27 -0
- package/dist/commands/harness.js +120 -13
- package/dist/commands/hooks.js +22 -6
- package/dist/commands/perf.d.ts +14 -0
- package/dist/commands/perf.js +221 -0
- package/dist/commands/profiles.d.ts +3 -0
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/routines.d.ts +19 -0
- package/dist/commands/routines.js +58 -30
- package/dist/commands/secrets.d.ts +52 -4
- package/dist/commands/secrets.js +234 -37
- package/dist/commands/send.d.ts +5 -1
- package/dist/commands/send.js +1 -1
- package/dist/commands/sessions-browser.d.ts +4 -0
- package/dist/commands/sessions-browser.js +51 -9
- package/dist/commands/sessions-favorite.d.ts +20 -0
- package/dist/commands/sessions-favorite.js +120 -0
- package/dist/commands/sessions-picker.js +70 -1
- package/dist/commands/sessions.d.ts +103 -20
- package/dist/commands/sessions.js +356 -62
- package/dist/commands/setup-secrets.d.ts +7 -0
- package/dist/commands/setup-secrets.js +12 -9
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.d.ts +14 -1
- package/dist/commands/view.js +103 -128
- package/dist/index.js +18 -3
- package/dist/lib/activity.d.ts +11 -1
- package/dist/lib/activity.js +1 -0
- package/dist/lib/agents.d.ts +4 -2
- package/dist/lib/agents.js +21 -6
- package/dist/lib/catchup.d.ts +105 -0
- package/dist/lib/catchup.js +160 -0
- package/dist/lib/channels/providers/desktop.d.ts +49 -0
- package/dist/lib/channels/providers/desktop.js +132 -0
- package/dist/lib/channels/providers/index.js +2 -0
- package/dist/lib/daemon.js +74 -13
- package/dist/lib/events.d.ts +12 -0
- package/dist/lib/events.js +122 -9
- package/dist/lib/exec.js +10 -0
- package/dist/lib/feed-broadcast.d.ts +47 -0
- package/dist/lib/feed-broadcast.js +65 -1
- package/dist/lib/feed-post.d.ts +10 -0
- package/dist/lib/feed-post.js +1 -1
- package/dist/lib/feed.d.ts +47 -1
- package/dist/lib/feed.js +38 -0
- package/dist/lib/hooks/cache.d.ts +2 -0
- package/dist/lib/hooks/cache.js +24 -4
- package/dist/lib/hosts/dispatch.js +19 -1
- package/dist/lib/hq/floor.js +12 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/overdue.d.ts +14 -0
- package/dist/lib/overdue.js +37 -1
- package/dist/lib/perf/db.d.ts +25 -0
- package/dist/lib/perf/db.js +290 -0
- package/dist/lib/perf/spool.d.ts +18 -0
- package/dist/lib/perf/spool.js +79 -0
- package/dist/lib/perf/types.d.ts +45 -0
- package/dist/lib/perf/types.js +2 -0
- package/dist/lib/picker.d.ts +27 -2
- package/dist/lib/picker.js +71 -7
- package/dist/lib/profiles.d.ts +48 -0
- package/dist/lib/profiles.js +67 -0
- package/dist/lib/rotate.d.ts +24 -2
- package/dist/lib/rotate.js +63 -6
- package/dist/lib/routines-project.js +6 -0
- package/dist/lib/routines.d.ts +30 -1
- package/dist/lib/routines.js +11 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/list-filter.d.ts +94 -0
- package/dist/lib/secrets/list-filter.js +245 -0
- package/dist/lib/session/active.d.ts +109 -3
- package/dist/lib/session/active.js +269 -13
- package/dist/lib/session/db.d.ts +14 -0
- package/dist/lib/session/db.js +35 -0
- package/dist/lib/session/digest.d.ts +7 -0
- package/dist/lib/session/digest.js +29 -1
- package/dist/lib/session/discover.d.ts +1 -2
- package/dist/lib/session/discover.js +7 -24
- package/dist/lib/session/favorites.d.ts +39 -0
- package/dist/lib/session/favorites.js +101 -0
- package/dist/lib/session/highlights.d.ts +82 -0
- package/dist/lib/session/highlights.js +251 -0
- package/dist/lib/session/host-link.d.ts +68 -0
- package/dist/lib/session/host-link.js +64 -0
- package/dist/lib/session/parse.js +23 -1
- package/dist/lib/session/presence.d.ts +85 -0
- package/dist/lib/session/presence.js +150 -0
- package/dist/lib/session/relative-time.d.ts +14 -0
- package/dist/lib/session/relative-time.js +36 -0
- package/dist/lib/session/remote-list.d.ts +10 -0
- package/dist/lib/session/remote-list.js +47 -9
- package/dist/lib/session/render.d.ts +7 -0
- package/dist/lib/session/render.js +87 -17
- package/dist/lib/session/types.d.ts +4 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +9 -0
- package/dist/lib/state.js +11 -0
- package/dist/lib/tmux/binary.d.ts +7 -0
- package/dist/lib/tmux/binary.js +11 -1
- package/dist/lib/types.d.ts +4 -3
- package/dist/lib/usage-backoff.d.ts +29 -0
- package/dist/lib/usage-backoff.js +165 -0
- package/dist/lib/usage.d.ts +112 -5
- package/dist/lib/usage.js +464 -46
- package/dist/lib/watchdog/runner.d.ts +13 -0
- package/dist/lib/watchdog/runner.js +16 -1
- package/package.json +3 -1
package/dist/commands/secrets.js
CHANGED
|
@@ -18,7 +18,8 @@ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
|
|
|
18
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
|
-
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';
|
|
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, SECRET_TYPES, } from '../lib/secrets/bundles.js';
|
|
22
|
+
import { parseListFilters, bundleMatchesFilter, bundleExpiry, filterIsActive, describeFilter, parseSortField, sortBundles, SORT_FIELDS, REF_KINDS, DEFAULT_EXPIRING_DAYS, } from '../lib/secrets/list-filter.js';
|
|
22
23
|
import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
|
|
23
24
|
import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
|
|
24
25
|
import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
|
|
@@ -274,6 +275,35 @@ export function buildRemoteUnlockArgs(names, opts) {
|
|
|
274
275
|
...(opts.durable ? ['--durable'] : []),
|
|
275
276
|
];
|
|
276
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Build the remote `agents secrets list` argv for `list --host`. Every filter
|
|
280
|
+
* must be forwarded: `browseRemote` sends this argv verbatim, so a flag left out
|
|
281
|
+
* here is not an error — the remote just lists everything, and
|
|
282
|
+
* `secrets list --host zion --expired` reports every bundle on zion as expired.
|
|
283
|
+
* Silent and wrong beats loud and wrong only for the person who wrote the bug.
|
|
284
|
+
*
|
|
285
|
+
* Values pass through unparsed so the REMOTE validates them under its own rules,
|
|
286
|
+
* matching how `--ttl` is forwarded by buildRemoteUnlockArgs.
|
|
287
|
+
*/
|
|
288
|
+
export function buildRemoteListArgs(opts, query) {
|
|
289
|
+
return [
|
|
290
|
+
'list',
|
|
291
|
+
...(query ? [query] : []),
|
|
292
|
+
...(opts.json ? ['--json'] : []),
|
|
293
|
+
...(opts.policy ? ['--policy', opts.policy] : []),
|
|
294
|
+
...(opts.backend ? ['--backend', opts.backend] : []),
|
|
295
|
+
...(opts.type ? ['--type', opts.type] : []),
|
|
296
|
+
...(opts.kind ? ['--kind', opts.kind] : []),
|
|
297
|
+
...(opts.held ? ['--held'] : []),
|
|
298
|
+
...(opts.notHeld ? ['--not-held'] : []),
|
|
299
|
+
...(opts.expired ? ['--expired'] : []),
|
|
300
|
+
// `--expiring` is optional-value: `true` means the bare flag was passed.
|
|
301
|
+
...(opts.expiring === true ? ['--expiring'] : opts.expiring ? ['--expiring', String(opts.expiring)] : []),
|
|
302
|
+
...(opts.unused ? ['--unused', opts.unused] : []),
|
|
303
|
+
...(opts.sort ? ['--sort', opts.sort] : []),
|
|
304
|
+
...(opts.limit ? ['--limit', opts.limit] : []),
|
|
305
|
+
];
|
|
306
|
+
}
|
|
277
307
|
// SSH target validation is defined canonically in src/lib/ssh-exec.ts and
|
|
278
308
|
// re-exported here for back-compat with existing importers of these symbols.
|
|
279
309
|
export { SSH_TARGET_RE, assertValidSshTarget };
|
|
@@ -446,11 +476,11 @@ function humanAge(iso) {
|
|
|
446
476
|
return age;
|
|
447
477
|
return `${age} ago`;
|
|
448
478
|
}
|
|
449
|
-
/** Compact
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
479
|
+
/** Compact span for a fixed duration: "45m" / "19h" / "2d". Shared by
|
|
480
|
+
* `compactRemaining` (a countdown) and the POLICY column's hold-window
|
|
481
|
+
* annotation (a fixed length) so the two can never round onto different unit
|
|
482
|
+
* thresholds and disagree about the same number of milliseconds. */
|
|
483
|
+
export function compactDurationMs(ms) {
|
|
454
484
|
const mins = Math.round(ms / 60000);
|
|
455
485
|
if (mins < 60)
|
|
456
486
|
return `${mins}m`;
|
|
@@ -459,19 +489,46 @@ function compactRemaining(expiresAt) {
|
|
|
459
489
|
return `${hours}h`;
|
|
460
490
|
return `${Math.round(hours / 24)}d`;
|
|
461
491
|
}
|
|
462
|
-
/**
|
|
463
|
-
|
|
464
|
-
|
|
492
|
+
/** Compact remaining-time for the list POLICY column: "19h" / "45m" / "2d". */
|
|
493
|
+
function compactRemaining(expiresAt) {
|
|
494
|
+
const ms = expiresAt - Date.now();
|
|
495
|
+
if (ms <= 0)
|
|
496
|
+
return 'expired';
|
|
497
|
+
return compactDurationMs(ms);
|
|
498
|
+
}
|
|
499
|
+
/** The POLICY column for `secrets list`. `hold` is a duration, not a mode — it
|
|
500
|
+
* means "prompt once, then stay silent for this long" — so the column states
|
|
501
|
+
* the window (`hold 7d`) rather than the bare tier name, and appends `· held Nd`
|
|
502
|
+
* while the secrets-agent is actually caching the bundle. `always` and `never`
|
|
503
|
+
* carry no window and gain nothing here. `holdMs` is the configured global hold
|
|
504
|
+
* (`secretsHoldMs()`), passed in rather than read so this stays pure; `held`
|
|
465
505
|
* maps bundle name → expiry epoch-ms (from agentStatus()). */
|
|
466
|
-
export function renderPolicyCol(b, held) {
|
|
506
|
+
export function renderPolicyCol(b, holdMs, held) {
|
|
467
507
|
// `never` is loud on purpose — it's the only tier with no user-presence gate.
|
|
468
508
|
if (bundlePolicy(b) === 'never')
|
|
469
509
|
return chalk.red.bold('never · no prompt');
|
|
470
510
|
if (bundlePolicy(b) === 'always')
|
|
471
511
|
return chalk.yellow('always · prompt');
|
|
472
|
-
const
|
|
473
|
-
|
|
512
|
+
const window = compactDurationMs(holdMs);
|
|
513
|
+
// A lapsed entry is not held. Without this guard a stale broker row renders
|
|
514
|
+
// the countdown's `expired` sentinel as if it were a live hold.
|
|
515
|
+
const exp = liveHold(held?.get(b.name));
|
|
516
|
+
return exp !== null
|
|
517
|
+
? chalk.green(`hold ${window} · held ${compactRemaining(exp)}`)
|
|
518
|
+
: chalk.gray(`hold ${window}`);
|
|
519
|
+
}
|
|
520
|
+
/** The hold-window line at the top of `secrets status`. Names the `hold` policy
|
|
521
|
+
* the window belongs to — the rename in #1604 left this surface still saying
|
|
522
|
+
* "daily", the one name the CLI no longer accepts in its own help. Pure so the
|
|
523
|
+
* vocabulary is pinned by a test rather than re-drifting on the next rename. */
|
|
524
|
+
export function renderHoldSummary(holdStr, configured) {
|
|
525
|
+
const source = configured ? ' (secrets.agent.holdMs)' : ' (default)';
|
|
526
|
+
return `hold: ${holdStr}${source} — a bundle on the hold policy prompts once, then stays silent for this long or until sleep/logout.`;
|
|
474
527
|
}
|
|
528
|
+
/** The empty-broker line under the hold summary. Named here, beside
|
|
529
|
+
* `renderHoldSummary`, for the same reason: it is the second line the rename
|
|
530
|
+
* left saying `daily`, and a test pins both. */
|
|
531
|
+
export const NO_BUNDLES_HELD_LINE = 'No bundles held. The next read of each hold-policy bundle will prompt once, then hold.';
|
|
475
532
|
/** Human-readable hold window for `secrets status`. Sub-hour values render in
|
|
476
533
|
* minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
|
|
477
534
|
* hours up to 2 days, whole days beyond. Pure — unit-tested. */
|
|
@@ -490,12 +547,21 @@ export function formatHoldWindow(ms) {
|
|
|
490
547
|
}
|
|
491
548
|
/** Below this width the fixed date columns no longer fit; `list` uses cards. */
|
|
492
549
|
const SECRETS_WIDE = 96;
|
|
550
|
+
/** A broker hold expiry, or null once it has lapsed. One definition of "held",
|
|
551
|
+
* shared by the POLICY column, the `--held` filter, and the JSON payload, so the
|
|
552
|
+
* three can never disagree about the same bundle. */
|
|
553
|
+
export function liveHold(expiresAt, now = Date.now()) {
|
|
554
|
+
return expiresAt !== undefined && expiresAt > now ? expiresAt : null;
|
|
555
|
+
}
|
|
556
|
+
/** Width of the POLICY column. The widest cell is `hold 30d · held 30d` (19
|
|
557
|
+
* visible chars) — `clampHoldMs` caps the window at 30d and `renderPolicyCol`
|
|
558
|
+
* drops lapsed holds, so nothing longer can be produced. */
|
|
559
|
+
const POLICY_COL_WIDTH = 20;
|
|
493
560
|
/** Format a single bundle as a table row for the `secrets list` output. */
|
|
494
|
-
function renderBundleRow(b, held, cols = terminalWidth()) {
|
|
561
|
+
function renderBundleRow(b, holdMs, held, cols = terminalWidth()) {
|
|
495
562
|
const entries = describeBundle(b);
|
|
496
563
|
const keys = entries.length;
|
|
497
|
-
const
|
|
498
|
-
const expiring = expiringCount > 0 ? chalk.yellow(String(expiringCount)) : chalk.gray('-');
|
|
564
|
+
const expiring = renderExpiringCol(b);
|
|
499
565
|
// Timestamp distinction:
|
|
500
566
|
// "?" -> legacy bundle, never written under the timestamping code.
|
|
501
567
|
// "never" -> bundle has been written but the action never happened
|
|
@@ -509,7 +575,7 @@ function renderBundleRow(b, held, cols = terminalWidth()) {
|
|
|
509
575
|
: (b.created_at ? chalk.gray('never') : chalk.gray('?'));
|
|
510
576
|
const head = `${chalk.cyan(b.name.padEnd(20))} ` +
|
|
511
577
|
`${String(keys).padEnd(5)} ` +
|
|
512
|
-
`${padVisible(renderPolicyCol(b, held),
|
|
578
|
+
`${padVisible(renderPolicyCol(b, holdMs, held), POLICY_COL_WIDTH)} ` +
|
|
513
579
|
`${padVisible(expiring, 9)} ` +
|
|
514
580
|
`${padVisible(created, 9)} ` +
|
|
515
581
|
`${padVisible(updated, 9)} ` +
|
|
@@ -530,7 +596,7 @@ function renderBundleRow(b, held, cols = terminalWidth()) {
|
|
|
530
596
|
return trailer ? `${head} ${trailer}` : head.trimEnd();
|
|
531
597
|
}
|
|
532
598
|
/** Narrow-terminal card: name + compact meta on one line, description below. */
|
|
533
|
-
function renderBundleCard(b, held, cols) {
|
|
599
|
+
function renderBundleCard(b, holdMs, held, cols) {
|
|
534
600
|
const keys = describeBundle(b).length;
|
|
535
601
|
const used = b.last_used ? relativeAge(b.last_used) : (b.created_at ? 'never' : '?');
|
|
536
602
|
const tag = b.backend === 'file'
|
|
@@ -538,7 +604,7 @@ function renderBundleCard(b, held, cols) {
|
|
|
538
604
|
: b.backend === 'vault'
|
|
539
605
|
? chalk.blue(' [synced]')
|
|
540
606
|
: '';
|
|
541
|
-
const meta = chalk.gray(`${keys} key${keys === 1 ? '' : 's'} · `) + renderPolicyCol(b, held) + chalk.gray(` · used ${used}`);
|
|
607
|
+
const meta = chalk.gray(`${keys} key${keys === 1 ? '' : 's'} · `) + renderPolicyCol(b, holdMs, held) + chalk.gray(` · used ${used}`);
|
|
542
608
|
const line1 = `${chalk.cyan(b.name)} ${meta}${tag}`;
|
|
543
609
|
if (!b.description)
|
|
544
610
|
return line1;
|
|
@@ -650,7 +716,10 @@ function renderMetaLine(meta, reveal) {
|
|
|
650
716
|
return '';
|
|
651
717
|
return ` ${parts.join(' ')}`;
|
|
652
718
|
}
|
|
653
|
-
/** Count entries in `meta` whose `expires` falls in the next 30 days.
|
|
719
|
+
/** Count entries in `meta` whose `expires` falls in the next 30 days. Excludes
|
|
720
|
+
* keys that have ALREADY expired — those are counted by `bundleExpiry().expired`
|
|
721
|
+
* and rendered separately, since a lapsed key is a different problem from one
|
|
722
|
+
* coming due. */
|
|
654
723
|
function countExpiringSoon(meta) {
|
|
655
724
|
if (!meta)
|
|
656
725
|
return 0;
|
|
@@ -664,6 +733,23 @@ function countExpiringSoon(meta) {
|
|
|
664
733
|
}
|
|
665
734
|
return n;
|
|
666
735
|
}
|
|
736
|
+
/**
|
|
737
|
+
* The EXPIRING cell. Counts keys needing attention — already expired plus due
|
|
738
|
+
* within 30 days — and colours by the worst of the two: red once anything has
|
|
739
|
+
* lapsed, yellow while everything is merely upcoming.
|
|
740
|
+
*
|
|
741
|
+
* Expired keys used to be invisible here. `countExpiringSoon` requires
|
|
742
|
+
* `d >= 0`, so a bundle whose token died last month rendered `-`, identical to
|
|
743
|
+
* one with no expiry at all; the only places it surfaced were `secrets view`
|
|
744
|
+
* and a hard abort at inject time, i.e. after it had already broken something.
|
|
745
|
+
*/
|
|
746
|
+
export function renderExpiringCol(b, now = Date.now()) {
|
|
747
|
+
const { expired, soon } = bundleExpiry(b, now);
|
|
748
|
+
const total = expired + soon;
|
|
749
|
+
if (total === 0)
|
|
750
|
+
return chalk.gray('-');
|
|
751
|
+
return expired > 0 ? chalk.red(String(total)) : chalk.yellow(String(total));
|
|
752
|
+
}
|
|
667
753
|
/**
|
|
668
754
|
* Resolve an existing import target bundle (inheriting its backend) or create a
|
|
669
755
|
* new one with the requested backend. Refuses to silently downgrade a
|
|
@@ -789,23 +875,62 @@ export function registerSecretsCommands(program) {
|
|
|
789
875
|
{ title: 'Sync commands', names: ['push', 'pull', 'remote-list'] },
|
|
790
876
|
{ title: 'Utilities', names: ['exec', 'mcp', 'generate', 'migrate-acl'] },
|
|
791
877
|
]);
|
|
792
|
-
cmd
|
|
793
|
-
.command('list')
|
|
878
|
+
const listCmd = cmd
|
|
879
|
+
.command('list [query]')
|
|
794
880
|
.alias('ls')
|
|
795
|
-
.description('List configured secrets bundles (use --host/--hosts
|
|
881
|
+
.description('List configured secrets bundles, optionally filtered (use --host/--hosts for other machines over SSH)')
|
|
796
882
|
.option('--host <target>', 'List bundles on a remote host over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
|
|
797
883
|
.option('--hosts <list>', 'Comma-separated hosts to list in one shot, e.g. yosemite-s0,yosemite-s1')
|
|
798
884
|
.option('--device <target>', 'Alias for --host')
|
|
799
885
|
.option('--devices <list>', 'Alias for --hosts')
|
|
800
886
|
.option('--json', 'Emit machine-readable JSON (bundle metadata only — never secret values) instead of the table')
|
|
801
|
-
.
|
|
887
|
+
.option('--policy <list>', "Only these prompt policies (comma-separated): hold, always, never. '--policy never' is the audit for bundles that read with no Touch ID at all")
|
|
888
|
+
.option('--backend <list>', 'Only these backends (comma-separated): keychain, file, vault')
|
|
889
|
+
.option('--type <list>', `Only bundles carrying a key of these types (comma-separated): ${SECRET_TYPES.join(', ')}`)
|
|
890
|
+
.option('--kind <list>', `Only bundles carrying a value of these ref kinds (comma-separated): ${REF_KINDS.join(', ')}. '--kind literal' finds raw values stored inline; '--kind exec' finds bundles that shell out`)
|
|
891
|
+
.option('--held', 'Only bundles the secrets-agent is holding right now (read silently, no Touch ID). macOS only')
|
|
892
|
+
.option('--not-held', 'Only bundles the agent is NOT holding — the next read of each will prompt. macOS only')
|
|
893
|
+
.option('--expired', 'Only bundles with at least one key whose expiry has already passed')
|
|
894
|
+
.option('--expiring [days]', `Only bundles with a key falling due within N days (default ${DEFAULT_EXPIRING_DAYS})`)
|
|
895
|
+
.option('--unused <duration>', 'Only bundles not read since this far back (e.g. 30d, 4w, 3mo). Never-used bundles always match')
|
|
896
|
+
.addOption(new Option('--sort <field>', 'Sort by: name (default), used, created, updated, expiry').choices([...SORT_FIELDS]))
|
|
897
|
+
.option('-n, --limit <n>', 'Show at most this many bundles (after filtering and sorting)')
|
|
898
|
+
.action(async (query, opts) => {
|
|
802
899
|
const targets = parseHostsOption(opts);
|
|
803
900
|
if (targets.length > 0) {
|
|
804
|
-
|
|
901
|
+
// Forward the filters — browseRemote sends this argv verbatim, so a
|
|
902
|
+
// dropped flag would make the remote silently list everything.
|
|
903
|
+
await browseRemote(targets, buildRemoteListArgs(opts, query), false);
|
|
805
904
|
return;
|
|
806
905
|
}
|
|
807
|
-
|
|
808
|
-
//
|
|
906
|
+
// Parse before touching the keychain so a typo'd flag fails instantly,
|
|
907
|
+
// and render the message rather than a stack trace (the `policy` pattern).
|
|
908
|
+
let filter;
|
|
909
|
+
let sortField;
|
|
910
|
+
let limit;
|
|
911
|
+
try {
|
|
912
|
+
filter = parseListFilters(opts, query);
|
|
913
|
+
sortField = parseSortField(opts.sort);
|
|
914
|
+
limit = opts.limit === undefined ? undefined : Number(opts.limit);
|
|
915
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
|
|
916
|
+
throw new Error(`Invalid --limit '${opts.limit}'. Use a whole number of bundles, e.g. --limit 10.`);
|
|
917
|
+
}
|
|
918
|
+
// The broker is macOS-only, so off darwin `held` is always empty and a
|
|
919
|
+
// hold-state filter would answer from no data — every bundle would look
|
|
920
|
+
// not-held. Refuse rather than return a confidently wrong list.
|
|
921
|
+
if (filter.held !== undefined && process.platform !== 'darwin') {
|
|
922
|
+
throw new Error('--held/--not-held need the secrets-agent, which is macOS-only. Run it on a Mac, or with --host <mac>.');
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
catch (err) {
|
|
926
|
+
console.error(chalk.red(err.message));
|
|
927
|
+
process.exit(1);
|
|
928
|
+
}
|
|
929
|
+
const all = listBundles();
|
|
930
|
+
// The configured hold window, read once for the whole listing — it is
|
|
931
|
+
// global, so every `hold` bundle renders the same span.
|
|
932
|
+
const holdMs = secretsHoldMs();
|
|
933
|
+
// Cross-reference the secrets-agent so `hold` bundles that are currently
|
|
809
934
|
// held can show "· held Nh". Soft-fails to no hint if the broker is down.
|
|
810
935
|
const held = new Map();
|
|
811
936
|
if (process.platform === 'darwin') {
|
|
@@ -817,6 +942,12 @@ export function registerSecretsCommands(program) {
|
|
|
817
942
|
/* broker not running — render policy without the countdown */
|
|
818
943
|
}
|
|
819
944
|
}
|
|
945
|
+
// Filter, sort, then cap — before the --json branch, so the machine
|
|
946
|
+
// payload is the exact twin of the table (the sessions ordering rule).
|
|
947
|
+
const now = Date.now();
|
|
948
|
+
const matched = all.filter((b) => bundleMatchesFilter(b, filter, { held, now }));
|
|
949
|
+
const sorted = sortBundles(matched, sortField);
|
|
950
|
+
const bundles = limit === undefined ? sorted : sorted.slice(0, limit);
|
|
820
951
|
if (opts.json) {
|
|
821
952
|
// Discovery payload for agents: metadata only, no secret values. Gated on
|
|
822
953
|
// the explicit --json flag (not stdout.isTTY) so piping the human table to
|
|
@@ -825,36 +956,96 @@ export function registerSecretsCommands(program) {
|
|
|
825
956
|
name: b.name,
|
|
826
957
|
keys: describeBundle(b).length,
|
|
827
958
|
policy: bundlePolicy(b),
|
|
959
|
+
// The window `policy: "hold"` is shorthand for. Null on always/never,
|
|
960
|
+
// which have no window at all.
|
|
961
|
+
holdMs: bundlePolicy(b) === 'hold' ? holdMs : null,
|
|
828
962
|
backend: b.backend === 'file' ? 'file' : 'keychain',
|
|
829
963
|
allowExec: Boolean(b.allow_exec),
|
|
830
964
|
expiringSoon: countExpiringSoon(b.meta),
|
|
965
|
+
// Already-lapsed keys, which `expiringSoon` deliberately excludes. A
|
|
966
|
+
// machine caller polling for rotation needs both numbers.
|
|
967
|
+
expired: bundleExpiry(b, now).expired,
|
|
831
968
|
description: b.description ?? null,
|
|
832
969
|
createdAt: b.created_at ?? null,
|
|
833
970
|
updatedAt: b.updated_at ?? null,
|
|
834
971
|
lastUsed: b.last_used ?? null,
|
|
835
|
-
|
|
972
|
+
// Same liveness rule the POLICY column and --held use: a broker entry
|
|
973
|
+
// past its expiry is not held. Without the check this field reported a
|
|
974
|
+
// stale past timestamp as if the bundle were still warm, disagreeing
|
|
975
|
+
// with the table and the filter about the very same bundle.
|
|
976
|
+
heldExpiresAt: liveHold(held.get(b.name), now),
|
|
836
977
|
}));
|
|
837
978
|
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
838
979
|
return;
|
|
839
980
|
}
|
|
840
981
|
if (bundles.length === 0) {
|
|
841
|
-
|
|
842
|
-
|
|
982
|
+
// Distinguish "you have none" from "your filter excluded all of them",
|
|
983
|
+
// and name the axes that did it — otherwise the only way to find out
|
|
984
|
+
// which flag emptied the list is to remove them one at a time.
|
|
985
|
+
if (filterIsActive(filter)) {
|
|
986
|
+
console.log(chalk.gray(`No bundles ${describeFilter(filter)}. ${all.length} bundle${all.length === 1 ? '' : 's'} total.`));
|
|
987
|
+
console.log(chalk.gray('Try: agents secrets list (no filters)'));
|
|
988
|
+
}
|
|
989
|
+
else {
|
|
990
|
+
console.log(chalk.gray('No secrets bundles configured.'));
|
|
991
|
+
console.log(chalk.gray('Try: agents secrets create <name>'));
|
|
992
|
+
}
|
|
843
993
|
return;
|
|
844
994
|
}
|
|
845
995
|
const cols = terminalWidth();
|
|
846
996
|
if (cols >= SECRETS_WIDE) {
|
|
847
|
-
console.log(chalk.bold(`${'NAME'.padEnd(20)} ${'KEYS'.padEnd(5)} ${'POLICY'.padEnd(
|
|
997
|
+
console.log(chalk.bold(`${'NAME'.padEnd(20)} ${'KEYS'.padEnd(5)} ${'POLICY'.padEnd(POLICY_COL_WIDTH)} ${'EXPIRING'.padEnd(9)} ${'CREATED'.padEnd(9)} ${'UPDATED'.padEnd(9)} ${'USED'.padEnd(7)} DESCRIPTION`));
|
|
848
998
|
for (const b of bundles) {
|
|
849
|
-
console.log(renderBundleRow(b, held, cols));
|
|
999
|
+
console.log(renderBundleRow(b, holdMs, held, cols));
|
|
850
1000
|
}
|
|
851
1001
|
}
|
|
852
1002
|
else {
|
|
853
1003
|
for (const b of bundles) {
|
|
854
|
-
console.log(renderBundleCard(b, held, cols));
|
|
1004
|
+
console.log(renderBundleCard(b, holdMs, held, cols));
|
|
855
1005
|
}
|
|
856
1006
|
}
|
|
857
1007
|
});
|
|
1008
|
+
setHelpSections(listCmd, {
|
|
1009
|
+
examples: `
|
|
1010
|
+
# Everything, newest-used first
|
|
1011
|
+
agents secrets list --sort used
|
|
1012
|
+
|
|
1013
|
+
# Find one by name or description
|
|
1014
|
+
agents secrets list github
|
|
1015
|
+
|
|
1016
|
+
# The security audit: which bundles read with NO Touch ID at all?
|
|
1017
|
+
agents secrets list --policy never
|
|
1018
|
+
|
|
1019
|
+
# Which still store a raw value inline, or can shell out?
|
|
1020
|
+
agents secrets list --kind literal
|
|
1021
|
+
agents secrets list --kind exec
|
|
1022
|
+
|
|
1023
|
+
# What has already lapsed, and what is about to
|
|
1024
|
+
agents secrets list --expired
|
|
1025
|
+
agents secrets list --expiring 7 --sort expiry
|
|
1026
|
+
|
|
1027
|
+
# What can I delete? Untouched in three months
|
|
1028
|
+
agents secrets list --unused 3mo --sort used
|
|
1029
|
+
|
|
1030
|
+
# Which bundles will prompt me on the next read (macOS)
|
|
1031
|
+
agents secrets list --not-held
|
|
1032
|
+
|
|
1033
|
+
# Combine axes — every filter narrows further
|
|
1034
|
+
agents secrets list --policy hold --backend file --expiring
|
|
1035
|
+
|
|
1036
|
+
# Same filters, on another machine, machine-readable
|
|
1037
|
+
agents secrets list --expired --host mac-mini --json
|
|
1038
|
+
`,
|
|
1039
|
+
notes: `
|
|
1040
|
+
- Filters compose: every flag you add narrows the list further.
|
|
1041
|
+
- An unknown value is an error, not an empty list — '--policy hodl' names the valid set.
|
|
1042
|
+
- --held/--not-held read live broker state, so they need macOS. Use --host <mac> from elsewhere.
|
|
1043
|
+
- --expired and --expiring are different questions: already lapsed vs coming due. The EXPIRING column counts both and turns red once anything has lapsed.
|
|
1044
|
+
- --unused matches bundles never read at all, not just old ones.
|
|
1045
|
+
- Filters apply before --json, so the JSON is the exact twin of the table.
|
|
1046
|
+
- Filters are forwarded over --host, so a remote list narrows the same way.
|
|
1047
|
+
`,
|
|
1048
|
+
});
|
|
858
1049
|
cmd
|
|
859
1050
|
.command('view [name]')
|
|
860
1051
|
.alias('show')
|
|
@@ -959,6 +1150,9 @@ export function registerSecretsCommands(program) {
|
|
|
959
1150
|
name: bundle.name,
|
|
960
1151
|
description: bundle.description ?? null,
|
|
961
1152
|
policy: bundlePolicy(bundle),
|
|
1153
|
+
// The window `policy: "hold"` is shorthand for. Null on
|
|
1154
|
+
// always/never, which have no window at all.
|
|
1155
|
+
holdMs: bundlePolicy(bundle) === 'hold' ? secretsHoldMs() : null,
|
|
962
1156
|
backend: bundle.backend === 'file' ? 'file' : 'keychain',
|
|
963
1157
|
allowExec: Boolean(bundle.allow_exec),
|
|
964
1158
|
createdAt: bundle.created_at ?? null,
|
|
@@ -983,7 +1177,10 @@ export function registerSecretsCommands(program) {
|
|
|
983
1177
|
}
|
|
984
1178
|
else {
|
|
985
1179
|
console.log(bundlePolicy(bundle) === 'hold'
|
|
986
|
-
|
|
1180
|
+
// The window comes from secretsHoldMs(), never a literal — this
|
|
1181
|
+
// line used to hardcode "7d by default" and so misstated the
|
|
1182
|
+
// window for anyone who had configured secrets.agent.holdMs.
|
|
1183
|
+
? chalk.gray(`policy: hold (ask once, then held for ${formatHoldWindow(secretsHoldMs())} — until sleep / logout; screen-lock does not drop it)`)
|
|
987
1184
|
: chalk.gray('policy: always (asks for Touch ID every time — never auto-held)'));
|
|
988
1185
|
}
|
|
989
1186
|
if (bundle.created_at)
|
|
@@ -1174,7 +1371,7 @@ export function registerSecretsCommands(program) {
|
|
|
1174
1371
|
const resolvedName = name ?? (await promptBundleName());
|
|
1175
1372
|
validateBundleName(resolvedName);
|
|
1176
1373
|
// Leave policy unset unless the user explicitly chose one, so the bundle
|
|
1177
|
-
// inherits the configured default (`
|
|
1374
|
+
// inherits the configured default (`hold`) instead of being pinned.
|
|
1178
1375
|
const policyOpt = opts.policy ?? opts.tier;
|
|
1179
1376
|
const policy = policyOpt ? parsePolicyOpt(policyOpt) : undefined;
|
|
1180
1377
|
const backend = opts.synced ? 'vault' : resolveBackendOpt(opts.backend);
|
|
@@ -2266,7 +2463,7 @@ Examples:
|
|
|
2266
2463
|
(brokerUp
|
|
2267
2464
|
? chalk.green('running') + chalk.gray(isDaemonRunning() ? ' (hosted by the daemon)' : ' (standalone)')
|
|
2268
2465
|
: chalk.yellow('not running — starts on demand, or run `agents secrets start` to bring the daemon up now')));
|
|
2269
|
-
// Diagnostic: version skew is the top reason a `
|
|
2466
|
+
// Diagnostic: version skew is the top reason a `hold` bundle keeps
|
|
2270
2467
|
// re-prompting — a broker on an older build gets torn down when the CLI
|
|
2271
2468
|
// version changes (e.g. `agents-cli-update`), wiping every held bundle.
|
|
2272
2469
|
const onDisk = getCliVersionFresh();
|
|
@@ -2286,11 +2483,11 @@ Examples:
|
|
|
2286
2483
|
catch {
|
|
2287
2484
|
return false;
|
|
2288
2485
|
} })();
|
|
2289
|
-
console.log(chalk.gray(
|
|
2486
|
+
console.log(chalk.gray(renderHoldSummary(holdStr, configured)));
|
|
2290
2487
|
const entries = await agentStatus();
|
|
2291
2488
|
const held = new Set(entries.map((e) => e.name));
|
|
2292
2489
|
if (entries.length === 0) {
|
|
2293
|
-
console.log(chalk.gray(
|
|
2490
|
+
console.log(chalk.gray(NO_BUNDLES_HELD_LINE));
|
|
2294
2491
|
console.log(chalk.gray('Pre-warm now with: agents secrets unlock <bundle> (or --all)'));
|
|
2295
2492
|
}
|
|
2296
2493
|
else {
|
package/dist/commands/send.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `agents send <text> --channel <name> --to <target>` — deliver a message over
|
|
3
|
-
* any registered channel (mailbox, telegram, imessage, slack, discord, apps).
|
|
3
|
+
* any registered channel (mailbox, telegram, imessage, slack, discord, desktop, apps).
|
|
4
|
+
*
|
|
5
|
+
* `desktop` is the local machine's notification centre — the only channel with no
|
|
6
|
+
* external dependency, so it still reaches the operator at their Mac when a
|
|
7
|
+
* messaging gateway is down.
|
|
4
8
|
*
|
|
5
9
|
* `agents notify <text>` — owner-facing alias. Fills channel + target from the
|
|
6
10
|
* `notify.owner` block in agents.yaml when not passed, so routines/agents ping
|
package/dist/commands/send.js
CHANGED
|
@@ -29,7 +29,7 @@ async function doSend(text, channel, to, opts) {
|
|
|
29
29
|
export function registerSendCommand(program) {
|
|
30
30
|
program
|
|
31
31
|
.command('send <text>')
|
|
32
|
-
.description('Send a message through a channel provider (mailbox, telegram, imessage, slack, discord).')
|
|
32
|
+
.description('Send a message through a channel provider (mailbox, telegram, imessage, slack, discord, desktop).')
|
|
33
33
|
.requiredOption('--channel <name>', 'channel / provider name')
|
|
34
34
|
.requiredOption('--to <target>', 'channel-specific recipient id')
|
|
35
35
|
.option('--thread <id>', 'channel thread id / timestamp')
|
|
@@ -26,6 +26,8 @@ export interface BrowserFilter {
|
|
|
26
26
|
device?: string;
|
|
27
27
|
/** filter to one team's lineage, or all — the `T` key / `--in-team`. */
|
|
28
28
|
team?: string;
|
|
29
|
+
/** favorited-only — the `f` key / `--favorites`. */
|
|
30
|
+
favorites: boolean;
|
|
29
31
|
/** this-repo subtree vs every directory — the `P` key / `--all`. */
|
|
30
32
|
projectScope: 'repo' | 'all';
|
|
31
33
|
/** time window (undefined = all time) — the `W` key / `--since`. */
|
|
@@ -77,6 +79,7 @@ export declare function activeBrowserSeed(opts: {
|
|
|
77
79
|
host?: string[];
|
|
78
80
|
since?: string;
|
|
79
81
|
all?: boolean;
|
|
82
|
+
favorites?: boolean;
|
|
80
83
|
}): Partial<BrowserFilter>;
|
|
81
84
|
/**
|
|
82
85
|
* The initial filter for the bare interactive listing: current-repo subtree by
|
|
@@ -92,6 +95,7 @@ export declare function bareBrowserSeed(opts: {
|
|
|
92
95
|
since?: string;
|
|
93
96
|
host?: string[];
|
|
94
97
|
inTeam?: string;
|
|
98
|
+
favorites?: boolean;
|
|
95
99
|
}): Partial<BrowserFilter>;
|
|
96
100
|
/**
|
|
97
101
|
* A live session's stable row key: its session id when the agent reported one,
|
|
@@ -17,9 +17,10 @@ import { isSessionTrackedAgent } from '../lib/session/types.js';
|
|
|
17
17
|
import { discoverSessions } from '../lib/session/discover.js';
|
|
18
18
|
import { gatherRemoteList } from '../lib/session/remote-list.js';
|
|
19
19
|
import { enrichTeamOrigins, safeTeamText } from '../lib/session/team-filter.js';
|
|
20
|
+
import { listFavorites, toggleFavorite } from '../lib/session/favorites.js';
|
|
20
21
|
import { machineId, normalizeHost } from '../lib/session/sync/config.js';
|
|
21
22
|
import { buildPreview } from './sessions-picker.js';
|
|
22
|
-
import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, } from './sessions.js';
|
|
23
|
+
import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, formatLiveStatusHeadline, } from './sessions.js';
|
|
23
24
|
/**
|
|
24
25
|
* Complete a seed into the filter the picker actually runs on.
|
|
25
26
|
*
|
|
@@ -37,6 +38,7 @@ export function buildInitialFilter(initial) {
|
|
|
37
38
|
return {
|
|
38
39
|
running: initial.running ?? false,
|
|
39
40
|
teams: initial.teams ?? false,
|
|
41
|
+
favorites: initial.favorites ?? false,
|
|
40
42
|
agent: initial.agent,
|
|
41
43
|
device: initial.device,
|
|
42
44
|
team: initial.team,
|
|
@@ -107,6 +109,8 @@ export function browserFilterToArgv(f, query = '') {
|
|
|
107
109
|
a.push('--active');
|
|
108
110
|
if (f.teams)
|
|
109
111
|
a.push('--teams');
|
|
112
|
+
if (f.favorites)
|
|
113
|
+
a.push('--favorites');
|
|
110
114
|
if (f.agent)
|
|
111
115
|
a.push('-a', f.agent);
|
|
112
116
|
if (f.device)
|
|
@@ -140,6 +144,7 @@ export function activeBrowserSeed(opts) {
|
|
|
140
144
|
return {
|
|
141
145
|
running: true,
|
|
142
146
|
teams: !!opts.teams,
|
|
147
|
+
favorites: !!opts.favorites,
|
|
143
148
|
agent: opts.agent,
|
|
144
149
|
projectScope: 'all',
|
|
145
150
|
device: normalizeDeviceSeed(opts.host?.[0]),
|
|
@@ -170,6 +175,7 @@ export function bareBrowserSeed(opts) {
|
|
|
170
175
|
const wholeTeam = !!opts.inTeam;
|
|
171
176
|
return {
|
|
172
177
|
teams: !!opts.teams,
|
|
178
|
+
favorites: !!opts.favorites,
|
|
173
179
|
agent: opts.agent,
|
|
174
180
|
// The filter carries one device; seed it only when the scope names exactly
|
|
175
181
|
// one, so a two-device scope isn't narrowed to the first of them.
|
|
@@ -359,9 +365,14 @@ export function shouldShowHostColumn(f, live, rows) {
|
|
|
359
365
|
return false;
|
|
360
366
|
return rows.some((r) => liveHostLabel(live.get(r.id)) !== '');
|
|
361
367
|
}
|
|
362
|
-
/** Apply the cheap in-memory filters (agent / device / project / running). */
|
|
363
|
-
function applyFilters(rows, live, f, self) {
|
|
368
|
+
/** Apply the cheap in-memory filters (agent / device / project / running / favorites). */
|
|
369
|
+
function applyFilters(rows, live, f, self, favorites) {
|
|
364
370
|
let out = rows;
|
|
371
|
+
// A projected live row is keyed by pid/task when it has no session id, and a
|
|
372
|
+
// favorite is always keyed by a real session id — so an id-less row can never
|
|
373
|
+
// be favorited and correctly drops out here.
|
|
374
|
+
if (f.favorites)
|
|
375
|
+
out = out.filter((r) => favorites.has(r.id));
|
|
365
376
|
if (f.agent)
|
|
366
377
|
out = out.filter((r) => r.agent === f.agent);
|
|
367
378
|
if (f.device)
|
|
@@ -398,13 +409,15 @@ function headerFor(f) {
|
|
|
398
409
|
bits.push('running');
|
|
399
410
|
if (f.teams)
|
|
400
411
|
bits.push('teams');
|
|
412
|
+
if (f.favorites)
|
|
413
|
+
bits.push('favorites');
|
|
401
414
|
return bits.join(' · ');
|
|
402
415
|
}
|
|
403
416
|
function helpFor(_f, mode) {
|
|
404
417
|
if (mode === 'search') {
|
|
405
418
|
return 'type to filter · ↑↓ navigate · esc exit search · ⏎ resume';
|
|
406
419
|
}
|
|
407
|
-
return 's search · r running · c teams · t team · a agent · d device · p project · w window · tab preview · y copy-cmd · ⏎ resume · esc quit';
|
|
420
|
+
return 's search · r running · f favorites · * star · c teams · t team · a agent · d device · p project · w window · tab preview · y copy-cmd · ⏎ resume · esc quit';
|
|
408
421
|
}
|
|
409
422
|
/**
|
|
410
423
|
* Launch the interactive session browser. `initial` seeds the filter (e.g.
|
|
@@ -432,6 +445,10 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
432
445
|
// The live index is slow (a full ps/tmux scan) and only the running filter
|
|
433
446
|
// needs it — fetch it once, lazily, the first time running is toggled on.
|
|
434
447
|
let liveCache = null;
|
|
448
|
+
// Re-read every load (it's an mtime-memoized parse of one small file), so the
|
|
449
|
+
// `*` key's reload picks up the star it just wrote — and so does a favorite
|
|
450
|
+
// starred by another session on this machine.
|
|
451
|
+
let favorites = new Set();
|
|
435
452
|
// Generation guard: two quick keypresses can start overlapping loads whose
|
|
436
453
|
// SSH fan-outs settle out of order. dynamicPicker's own gen ref guards which
|
|
437
454
|
// rows become `items`, but the shared closure state below (cols / cycle pools /
|
|
@@ -489,9 +506,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
489
506
|
...rows.map((r) => safeTeamText(r.spawnedTeam)),
|
|
490
507
|
...rows.map((r) => safeTeamText(r.teamOrigin?.team)),
|
|
491
508
|
]);
|
|
492
|
-
|
|
509
|
+
favorites = listFavorites();
|
|
510
|
+
const filtered = applyFilters(rows, live ?? new Map(), f, self, favorites);
|
|
493
511
|
cols = pickerColumnsFor(filtered);
|
|
494
512
|
cols.showHost = shouldShowHostColumn(f, live, filtered);
|
|
513
|
+
// Status rides the same gate as the host column: both come from the live
|
|
514
|
+
// scan, so both belong to the running view and neither should widen a plain
|
|
515
|
+
// transcript listing that has no live rows to fill them.
|
|
516
|
+
cols.showStatus = !!f.running && !!live;
|
|
495
517
|
return filtered;
|
|
496
518
|
};
|
|
497
519
|
const picked = await dynamicPicker({
|
|
@@ -499,9 +521,17 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
499
521
|
initialFilter,
|
|
500
522
|
load,
|
|
501
523
|
keyFor: (s) => s.id,
|
|
502
|
-
labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id))),
|
|
524
|
+
labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id)), favorites.has(s.id), liveCache?.get(s.id)),
|
|
503
525
|
matches: sessionMatchesQuery,
|
|
504
|
-
|
|
526
|
+
// Lead the preview with the live status banner — the one place a `crashed` /
|
|
527
|
+
// `orphaned` session gets a sentence instead of a glyph. `buildPreview` is
|
|
528
|
+
// memoized per session, so the volatile live half is prepended here rather
|
|
529
|
+
// than baked into the cached body.
|
|
530
|
+
buildPreview: (s) => {
|
|
531
|
+
const headline = formatLiveStatusHeadline(liveCache?.get(s.id), favorites.has(s.id));
|
|
532
|
+
const body = buildPreview(s);
|
|
533
|
+
return headline ? `${headline}\n${body}` : body;
|
|
534
|
+
},
|
|
505
535
|
headerFor: (f) => unreachable.length > 0
|
|
506
536
|
? `${headerFor(f)} · ${chalk.yellow(`${unreachable.join(', ')}: unreachable`)}`
|
|
507
537
|
: headerFor(f),
|
|
@@ -511,6 +541,7 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
511
541
|
loadingMessage: local ? 'Loading…' : 'Loading (reaching other machines)…',
|
|
512
542
|
keyBindings: {
|
|
513
543
|
r: (f) => ({ ...f, running: !f.running }),
|
|
544
|
+
f: (f) => ({ ...f, favorites: !f.favorites }),
|
|
514
545
|
c: (f) => ({ ...f, teams: !f.teams }),
|
|
515
546
|
a: (f) => ({ ...f, agent: cycle(f.agent, agentsInPool) }),
|
|
516
547
|
d: (f) => ({ ...f, device: cycle(f.device, devicesInPool) }),
|
|
@@ -521,8 +552,19 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
521
552
|
p: (f) => (hosts ? f : { ...f, projectScope: f.projectScope === 'repo' ? 'all' : 'repo' }),
|
|
522
553
|
w: (f) => ({ ...f, window: cycleWindow(f.window) }),
|
|
523
554
|
},
|
|
524
|
-
onKey: (name, f,
|
|
525
|
-
if (name === '
|
|
555
|
+
onKey: (name, f, active, query) => {
|
|
556
|
+
if (name === '*') {
|
|
557
|
+
// Only a row with a real session id can be starred: a projected live row
|
|
558
|
+
// with no id is keyed by pid, which is gone the moment the process is.
|
|
559
|
+
if (!active || active.id.startsWith(LIVE_ROW_PREFIX))
|
|
560
|
+
return 'nothing to star on this row';
|
|
561
|
+
const on = toggleFavorite(active.id);
|
|
562
|
+
// reload so the row's star is repainted — labels are memoized per row.
|
|
563
|
+
return { flash: on ? `★ favorited ${active.shortId}` : `☆ unfavorited ${active.shortId}`, reload: true };
|
|
564
|
+
}
|
|
565
|
+
// Both cases: `hotkeyToken` hands `onKey` the literal character, and this
|
|
566
|
+
// key worked with caps lock on before it existed.
|
|
567
|
+
if (name === 'y' || name === 'Y') {
|
|
526
568
|
// Thread the live search query so the copied command reproduces the
|
|
527
569
|
// exact view — the human→agent bridge must include the search term.
|
|
528
570
|
const cmd = 'ag ' + browserFilterToArgv(f, query).join(' ');
|