@phnx-labs/agents-cli 1.20.88 → 1.20.90
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 +503 -0
- package/README.md +15 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/commands.js +7 -7
- package/dist/commands/exec.js +7 -1
- package/dist/commands/factory.js +26 -2
- package/dist/commands/funnel.js +16 -1
- package/dist/commands/harness.d.ts +27 -0
- package/dist/commands/harness.js +120 -13
- package/dist/commands/menubar.js +117 -34
- 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 +51 -7
- package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
- package/dist/commands/secrets-rotate-passphrase.js +96 -0
- package/dist/commands/secrets.d.ts +10 -1
- package/dist/commands/secrets.js +20 -6
- 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.d.ts +110 -21
- package/dist/commands/sessions.js +395 -74
- 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/commands/webhook.js +7 -2
- package/dist/lib/agents.d.ts +4 -2
- package/dist/lib/agents.js +21 -6
- package/dist/lib/commands.js +9 -1
- package/dist/lib/daemon.d.ts +29 -0
- package/dist/lib/daemon.js +58 -4
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/factory/snapshot.d.ts +78 -0
- package/dist/lib/factory/snapshot.js +209 -0
- package/dist/lib/fs-atomic.d.ts +14 -1
- package/dist/lib/fs-atomic.js +35 -3
- package/dist/lib/funnel.d.ts +1 -0
- package/dist/lib/funnel.js +8 -0
- 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/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
- package/dist/lib/menubar/install-menubar.d.ts +53 -2
- package/dist/lib/menubar/install-menubar.js +183 -28
- package/dist/lib/picker.d.ts +27 -2
- package/dist/lib/picker.js +71 -7
- package/dist/lib/platform/process.d.ts +2 -0
- package/dist/lib/platform/process.js +5 -3
- package/dist/lib/profiles.d.ts +48 -0
- package/dist/lib/profiles.js +67 -0
- package/dist/lib/resources.d.ts +8 -0
- package/dist/lib/resources.js +34 -1
- package/dist/lib/rotate.d.ts +24 -2
- package/dist/lib/rotate.js +63 -6
- package/dist/lib/routines-placement.d.ts +2 -1
- package/dist/lib/routines-placement.js +8 -4
- package/dist/lib/routines.d.ts +57 -1
- package/dist/lib/routines.js +74 -1
- package/dist/lib/runner.d.ts +2 -0
- package/dist/lib/runner.js +21 -8
- 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/bundles.js +9 -34
- package/dist/lib/secrets/filestore.d.ts +152 -34
- package/dist/lib/secrets/filestore.js +676 -123
- 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/favorites.d.ts +39 -0
- package/dist/lib/session/favorites.js +101 -0
- package/dist/lib/session/host-link.d.ts +68 -0
- package/dist/lib/session/host-link.js +64 -0
- package/dist/lib/session/presence.d.ts +85 -0
- package/dist/lib/session/presence.js +150 -0
- package/dist/lib/session/remote-active.d.ts +4 -1
- package/dist/lib/session/remote-active.js +8 -2
- package/dist/lib/session/remote-list.d.ts +10 -0
- package/dist/lib/session/remote-list.js +47 -9
- package/dist/lib/session/viewing-in.d.ts +31 -0
- package/dist/lib/session/viewing-in.js +47 -0
- package/dist/lib/state.d.ts +17 -0
- package/dist/lib/state.js +30 -2
- package/dist/lib/tmux/binary.d.ts +7 -0
- package/dist/lib/tmux/binary.js +11 -1
- package/dist/lib/triggers/handlers.d.ts +95 -0
- package/dist/lib/triggers/handlers.js +384 -0
- package/dist/lib/triggers/webhook.d.ts +10 -2
- package/dist/lib/triggers/webhook.js +65 -11
- 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 +1 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents secrets rotate-passphrase` — rotate the encrypted file store's
|
|
3
|
+
* machine-local master passphrase (RUSH-1975).
|
|
4
|
+
*
|
|
5
|
+
* Re-encrypts every `<item>.enc` under a freshly generated key and rewrites the
|
|
6
|
+
* 0600 key file in place, atomically: the new store is staged, verified
|
|
7
|
+
* (round-trip + count), fsync'd, then swapped by directory rename, so a crash
|
|
8
|
+
* leaves the old store intact and readable. Headless-safe and Linux-first — the
|
|
9
|
+
* remediation path for a compromised passphrase (RUSH-1968), where the two
|
|
10
|
+
* supported alternatives are unacceptable (a hand-rolled non-atomic script, or
|
|
11
|
+
* export-to-plaintext which is the exposure being fixed).
|
|
12
|
+
*
|
|
13
|
+
* Dry-run by default, matching `import-keyring`; `--commit` performs the swap.
|
|
14
|
+
*/
|
|
15
|
+
import type { Command } from 'commander';
|
|
16
|
+
/** Register `agents secrets rotate-passphrase` on the parent secrets Command. */
|
|
17
|
+
export declare function registerSecretsRotatePassphraseCommand(secrets: Command): void;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents secrets rotate-passphrase` — rotate the encrypted file store's
|
|
3
|
+
* machine-local master passphrase (RUSH-1975).
|
|
4
|
+
*
|
|
5
|
+
* Re-encrypts every `<item>.enc` under a freshly generated key and rewrites the
|
|
6
|
+
* 0600 key file in place, atomically: the new store is staged, verified
|
|
7
|
+
* (round-trip + count), fsync'd, then swapped by directory rename, so a crash
|
|
8
|
+
* leaves the old store intact and readable. Headless-safe and Linux-first — the
|
|
9
|
+
* remediation path for a compromised passphrase (RUSH-1968), where the two
|
|
10
|
+
* supported alternatives are unacceptable (a hand-rolled non-atomic script, or
|
|
11
|
+
* export-to-plaintext which is the exposure being fixed).
|
|
12
|
+
*
|
|
13
|
+
* Dry-run by default, matching `import-keyring`; `--commit` performs the swap.
|
|
14
|
+
*/
|
|
15
|
+
import chalk from 'chalk';
|
|
16
|
+
import { machinePassphraseExists, rotatePassphrase } from '../lib/secrets/filestore.js';
|
|
17
|
+
import { agentStatus } from '../lib/secrets/agent.js';
|
|
18
|
+
/** Register `agents secrets rotate-passphrase` on the parent secrets Command. */
|
|
19
|
+
export function registerSecretsRotatePassphraseCommand(secrets) {
|
|
20
|
+
secrets
|
|
21
|
+
.command('rotate-passphrase')
|
|
22
|
+
.description('Re-key the encrypted file store under a new machine-local passphrase (atomic, headless-safe). Dry-run by default.')
|
|
23
|
+
.option('--commit', 'Perform the rotation (default is dry-run reporting only)')
|
|
24
|
+
.option('--dry-run', 'Report bundle count and round-trip result without re-keying (the default). Still heals an interrupted rotation — that is the one thing it writes.')
|
|
25
|
+
.option('--force', 'Override the safety refusals (held broker unlocks, or a passphrase exported in the environment)')
|
|
26
|
+
.addHelpText('after', `
|
|
27
|
+
Rotates the auto-provisioned file-store key at ~/.agents/.secrets-key/passphrase:
|
|
28
|
+
decrypts every item under the current key, re-encrypts under a new one, verifies
|
|
29
|
+
every item round-trips, then swaps the store and key file atomically. A crash
|
|
30
|
+
before the swap leaves the old store readable with the old key; a crash inside the
|
|
31
|
+
swap self-heals on the next rotate-passphrase run (not on an ordinary get). No
|
|
32
|
+
plaintext secret value or passphrase is ever written to disk, argv, or a log.
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
# Report what would rotate (no writes)
|
|
36
|
+
agents secrets rotate-passphrase
|
|
37
|
+
|
|
38
|
+
# Perform the rotation
|
|
39
|
+
agents secrets rotate-passphrase --commit`)
|
|
40
|
+
.action(async (opts) => {
|
|
41
|
+
try {
|
|
42
|
+
if (opts.commit && opts.dryRun) {
|
|
43
|
+
throw new Error('--commit and --dry-run are mutually exclusive.');
|
|
44
|
+
}
|
|
45
|
+
const dryRun = !opts.commit;
|
|
46
|
+
if (!machinePassphraseExists()) {
|
|
47
|
+
throw new Error('No machine-local passphrase is provisioned on this box, so there is nothing to rotate. ' +
|
|
48
|
+
'This command re-keys the file store\'s auto-provisioned key at ~/.agents/.secrets-key/passphrase.');
|
|
49
|
+
}
|
|
50
|
+
// Guard 1: refuse while the secrets-agent holds live unlocks (macOS), so a
|
|
51
|
+
// concurrent read cannot land against a half-swapped store. --force overrides.
|
|
52
|
+
if (!dryRun && !opts.force) {
|
|
53
|
+
const held = await agentStatus();
|
|
54
|
+
if (held.length > 0) {
|
|
55
|
+
throw new Error(`The secrets-agent is holding ${held.length} unlocked bundle(s) (${held.map((e) => e.name).join(', ')}). ` +
|
|
56
|
+
'Lock them first (`agents secrets lock --all`) so no concurrent read races the rotation, or pass --force.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Guard 2: a passphrase exported in the environment shadows the on-disk key
|
|
60
|
+
// file with a now-stale value after the rotation, breaking every read. This
|
|
61
|
+
// is the exact RUSH-1968 footgun — refuse the commit and point at the fix.
|
|
62
|
+
if (!dryRun && !opts.force && (process.env.AGENTS_SECRETS_PASSPHRASE ?? '').length > 0) {
|
|
63
|
+
throw new Error('AGENTS_SECRETS_PASSPHRASE is set in this environment; it would shadow the rotated key file ' +
|
|
64
|
+
'with a stale value and break every read. Unset it (this is the RUSH-1968 fix) before rotating, or pass --force.');
|
|
65
|
+
}
|
|
66
|
+
const report = rotatePassphrase({ dryRun });
|
|
67
|
+
console.log(`${chalk.bold(String(report.bundleCount))} item(s) decrypt under the current key and ` +
|
|
68
|
+
`${report.roundTripOk ? chalk.green('round-trip cleanly') : chalk.red('failed to round-trip')} under a new key.`);
|
|
69
|
+
if (report.skipped.length > 0) {
|
|
70
|
+
console.log(chalk.gray(`skipped ${report.skipped.length} orphan file(s) (not re-keyed):`));
|
|
71
|
+
for (const s of report.skipped)
|
|
72
|
+
console.log(chalk.gray(` - ${s}`));
|
|
73
|
+
}
|
|
74
|
+
if (report.dryRun) {
|
|
75
|
+
if (report.recoveredInterruptedRotation) {
|
|
76
|
+
// Be precise: this run DID write. Recovery is deliberately not gated on
|
|
77
|
+
// --commit, because healing is how a crashed store becomes readable
|
|
78
|
+
// again without re-keying it.
|
|
79
|
+
console.log(chalk.yellow('Recovered an interrupted rotation: the store was healed back to a single ' +
|
|
80
|
+
'readable state. That is the only thing this dry run wrote — no re-keying happened.'));
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.log(chalk.gray('Dry-run: nothing written.'));
|
|
84
|
+
}
|
|
85
|
+
console.log(chalk.gray(`Pass --commit to re-encrypt the store and swap the key file (${report.keyFilePath}).`));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
console.log(chalk.green(`Rotated: re-encrypted ${report.bundleCount} item(s) and rewrote ${report.keyFilePath} (mode 0600).`));
|
|
89
|
+
console.log(chalk.gray('The previous key and ciphertext were removed. Any process still holding the old passphrase in its environment must be restarted.'));
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
console.error(chalk.red(err.message));
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -80,10 +80,19 @@ export declare function exportBundleToFile(env: Record<string, string>, filePath
|
|
|
80
80
|
*/
|
|
81
81
|
export declare function importBundleFromFile(filePath: string, passphrase: string): Record<string, string>;
|
|
82
82
|
/** The POLICY column for `secrets list`: the prompt policy, plus a concise
|
|
83
|
-
* state hint. `
|
|
83
|
+
* state hint. `hold` shows `held Nh` when the secrets-agent is currently
|
|
84
84
|
* caching the bundle; `always` and `never` show whether they prompt. `held`
|
|
85
85
|
* maps bundle name → expiry epoch-ms (from agentStatus()). */
|
|
86
86
|
export declare function renderPolicyCol(b: SecretsBundle, held?: Map<string, number>): string;
|
|
87
|
+
/** The hold-window line at the top of `secrets status`. Names the `hold` policy
|
|
88
|
+
* the window belongs to — the rename in #1604 left this surface still saying
|
|
89
|
+
* "daily", the one name the CLI no longer accepts in its own help. Pure so the
|
|
90
|
+
* vocabulary is pinned by a test rather than re-drifting on the next rename. */
|
|
91
|
+
export declare function renderHoldSummary(holdStr: string, configured: boolean): string;
|
|
92
|
+
/** The empty-broker line under the hold summary. Named here, beside
|
|
93
|
+
* `renderHoldSummary`, for the same reason: it is the second line the rename
|
|
94
|
+
* left saying `daily`, and a test pins both. */
|
|
95
|
+
export declare const NO_BUNDLES_HELD_LINE = "No bundles held. The next read of each hold-policy bundle will prompt once, then hold.";
|
|
87
96
|
/** Human-readable hold window for `secrets status`. Sub-hour values render in
|
|
88
97
|
* minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
|
|
89
98
|
* hours up to 2 days, whole days beyond. Pure — unit-tested. */
|
package/dist/commands/secrets.js
CHANGED
|
@@ -38,6 +38,7 @@ import { getVaultSession, vaultExists } from '../lib/secrets/vault.js';
|
|
|
38
38
|
import { registerSecretsSyncCommands } from './secrets-sync.js';
|
|
39
39
|
import { registerSecretsMigrateAclCommand } from './secrets-migrate.js';
|
|
40
40
|
import { registerSecretsImportKeyringCommand } from './secrets-import.js';
|
|
41
|
+
import { registerSecretsRotatePassphraseCommand } from './secrets-rotate-passphrase.js';
|
|
41
42
|
/** Prompt the user for a secret value with masked input. Requires an interactive TTY. */
|
|
42
43
|
async function promptForSecret(message) {
|
|
43
44
|
if (!isInteractiveTerminal()) {
|
|
@@ -459,7 +460,7 @@ function compactRemaining(expiresAt) {
|
|
|
459
460
|
return `${Math.round(hours / 24)}d`;
|
|
460
461
|
}
|
|
461
462
|
/** The POLICY column for `secrets list`: the prompt policy, plus a concise
|
|
462
|
-
* state hint. `
|
|
463
|
+
* state hint. `hold` shows `held Nh` when the secrets-agent is currently
|
|
463
464
|
* caching the bundle; `always` and `never` show whether they prompt. `held`
|
|
464
465
|
* maps bundle name → expiry epoch-ms (from agentStatus()). */
|
|
465
466
|
export function renderPolicyCol(b, held) {
|
|
@@ -471,6 +472,18 @@ export function renderPolicyCol(b, held) {
|
|
|
471
472
|
const exp = held?.get(b.name);
|
|
472
473
|
return exp ? chalk.green(`hold · held ${compactRemaining(exp)}`) : chalk.gray('hold');
|
|
473
474
|
}
|
|
475
|
+
/** The hold-window line at the top of `secrets status`. Names the `hold` policy
|
|
476
|
+
* the window belongs to — the rename in #1604 left this surface still saying
|
|
477
|
+
* "daily", the one name the CLI no longer accepts in its own help. Pure so the
|
|
478
|
+
* vocabulary is pinned by a test rather than re-drifting on the next rename. */
|
|
479
|
+
export function renderHoldSummary(holdStr, configured) {
|
|
480
|
+
const source = configured ? ' (secrets.agent.holdMs)' : ' (default)';
|
|
481
|
+
return `hold: ${holdStr}${source} — a bundle on the hold policy prompts once, then stays silent for this long or until sleep/logout.`;
|
|
482
|
+
}
|
|
483
|
+
/** The empty-broker line under the hold summary. Named here, beside
|
|
484
|
+
* `renderHoldSummary`, for the same reason: it is the second line the rename
|
|
485
|
+
* left saying `daily`, and a test pins both. */
|
|
486
|
+
export const NO_BUNDLES_HELD_LINE = 'No bundles held. The next read of each hold-policy bundle will prompt once, then hold.';
|
|
474
487
|
/** Human-readable hold window for `secrets status`. Sub-hour values render in
|
|
475
488
|
* minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
|
|
476
489
|
* hours up to 2 days, whole days beyond. Pure — unit-tested. */
|
|
@@ -804,7 +817,7 @@ export function registerSecretsCommands(program) {
|
|
|
804
817
|
return;
|
|
805
818
|
}
|
|
806
819
|
const bundles = listBundles();
|
|
807
|
-
// Cross-reference the secrets-agent so `
|
|
820
|
+
// Cross-reference the secrets-agent so `hold` bundles that are currently
|
|
808
821
|
// held can show "· held Nh". Soft-fails to no hint if the broker is down.
|
|
809
822
|
const held = new Map();
|
|
810
823
|
if (process.platform === 'darwin') {
|
|
@@ -1173,7 +1186,7 @@ export function registerSecretsCommands(program) {
|
|
|
1173
1186
|
const resolvedName = name ?? (await promptBundleName());
|
|
1174
1187
|
validateBundleName(resolvedName);
|
|
1175
1188
|
// Leave policy unset unless the user explicitly chose one, so the bundle
|
|
1176
|
-
// inherits the configured default (`
|
|
1189
|
+
// inherits the configured default (`hold`) instead of being pinned.
|
|
1177
1190
|
const policyOpt = opts.policy ?? opts.tier;
|
|
1178
1191
|
const policy = policyOpt ? parsePolicyOpt(policyOpt) : undefined;
|
|
1179
1192
|
const backend = opts.synced ? 'vault' : resolveBackendOpt(opts.backend);
|
|
@@ -2265,7 +2278,7 @@ Examples:
|
|
|
2265
2278
|
(brokerUp
|
|
2266
2279
|
? chalk.green('running') + chalk.gray(isDaemonRunning() ? ' (hosted by the daemon)' : ' (standalone)')
|
|
2267
2280
|
: chalk.yellow('not running — starts on demand, or run `agents secrets start` to bring the daemon up now')));
|
|
2268
|
-
// Diagnostic: version skew is the top reason a `
|
|
2281
|
+
// Diagnostic: version skew is the top reason a `hold` bundle keeps
|
|
2269
2282
|
// re-prompting — a broker on an older build gets torn down when the CLI
|
|
2270
2283
|
// version changes (e.g. `agents-cli-update`), wiping every held bundle.
|
|
2271
2284
|
const onDisk = getCliVersionFresh();
|
|
@@ -2285,11 +2298,11 @@ Examples:
|
|
|
2285
2298
|
catch {
|
|
2286
2299
|
return false;
|
|
2287
2300
|
} })();
|
|
2288
|
-
console.log(chalk.gray(
|
|
2301
|
+
console.log(chalk.gray(renderHoldSummary(holdStr, configured)));
|
|
2289
2302
|
const entries = await agentStatus();
|
|
2290
2303
|
const held = new Set(entries.map((e) => e.name));
|
|
2291
2304
|
if (entries.length === 0) {
|
|
2292
|
-
console.log(chalk.gray(
|
|
2305
|
+
console.log(chalk.gray(NO_BUNDLES_HELD_LINE));
|
|
2293
2306
|
console.log(chalk.gray('Pre-warm now with: agents secrets unlock <bundle> (or --all)'));
|
|
2294
2307
|
}
|
|
2295
2308
|
else {
|
|
@@ -2419,6 +2432,7 @@ Examples:
|
|
|
2419
2432
|
registerSecretsSyncCommands(cmd);
|
|
2420
2433
|
registerSecretsMigrateAclCommand(cmd);
|
|
2421
2434
|
registerSecretsImportKeyringCommand(cmd);
|
|
2435
|
+
registerSecretsRotatePassphraseCommand(cmd);
|
|
2422
2436
|
}
|
|
2423
2437
|
/** Validate a prompt-policy value, throwing a clear message on a bad one (the
|
|
2424
2438
|
* caller's try/catch renders it and exits). Accepts the legacy `biometry` /
|
|
@@ -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(' ');
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents sessions favorite` — the non-TTY half of the star.
|
|
3
|
+
*
|
|
4
|
+
* The `*` hotkey in the interactive browser is how a human stars a session; this
|
|
5
|
+
* is how a script, an agent, or a machine without a TTY does the same thing, and
|
|
6
|
+
* it is what makes the feature testable end to end without driving a terminal UI.
|
|
7
|
+
* Both write the one store in `lib/session/favorites.ts`.
|
|
8
|
+
*/
|
|
9
|
+
import type { Command } from 'commander';
|
|
10
|
+
/**
|
|
11
|
+
* Resolve one user-typed id (usually the 8-char short id the listing prints) to
|
|
12
|
+
* a full session id. Ambiguity is an ERROR, not a silent first-match: starring
|
|
13
|
+
* the wrong session is invisible until the user wonders where their star went.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveFavoriteTarget(idQuery: string): {
|
|
16
|
+
id: string;
|
|
17
|
+
} | {
|
|
18
|
+
error: string;
|
|
19
|
+
};
|
|
20
|
+
export declare function registerSessionsFavoriteCommand(sessionsCmd: Command): void;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents sessions favorite` — the non-TTY half of the star.
|
|
3
|
+
*
|
|
4
|
+
* The `*` hotkey in the interactive browser is how a human stars a session; this
|
|
5
|
+
* is how a script, an agent, or a machine without a TTY does the same thing, and
|
|
6
|
+
* it is what makes the feature testable end to end without driving a terminal UI.
|
|
7
|
+
* Both write the one store in `lib/session/favorites.ts`.
|
|
8
|
+
*/
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { setHelpSections } from '../lib/help.js';
|
|
11
|
+
import { findSessionsById } from '../lib/session/db.js';
|
|
12
|
+
import { isCompleteSessionId } from '../lib/session/discover.js';
|
|
13
|
+
import { isFavorite, listFavorites, setFavorite } from '../lib/session/favorites.js';
|
|
14
|
+
/**
|
|
15
|
+
* Resolve one user-typed id (usually the 8-char short id the listing prints) to
|
|
16
|
+
* a full session id. Ambiguity is an ERROR, not a silent first-match: starring
|
|
17
|
+
* the wrong session is invisible until the user wonders where their star went.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveFavoriteTarget(idQuery) {
|
|
20
|
+
const matches = findSessionsById(idQuery);
|
|
21
|
+
// A COMPLETE id needs no index entry: the id is the key the store is built on,
|
|
22
|
+
// and requiring a transcript row would refuse exactly the newest sessions — a
|
|
23
|
+
// live one that has not been indexed yet. The browser's `*` stars those from
|
|
24
|
+
// the live row, so demanding a DB hit here would make the two disagree.
|
|
25
|
+
if (matches.length === 0) {
|
|
26
|
+
return isCompleteSessionId(idQuery.trim())
|
|
27
|
+
? { id: idQuery.trim() }
|
|
28
|
+
: { error: `No session matches "${idQuery}".` };
|
|
29
|
+
}
|
|
30
|
+
if (matches.length > 1) {
|
|
31
|
+
const ids = matches.slice(0, 5).map((m) => m.shortId).join(', ');
|
|
32
|
+
return { error: `"${idQuery}" matches ${matches.length} sessions (${ids}…) — use a longer id.` };
|
|
33
|
+
}
|
|
34
|
+
return { id: matches[0].id };
|
|
35
|
+
}
|
|
36
|
+
export function registerSessionsFavoriteCommand(sessionsCmd) {
|
|
37
|
+
const cmd = sessionsCmd
|
|
38
|
+
.command('favorite')
|
|
39
|
+
.argument('[ids...]', 'Session ids to star (full or short id prefix)')
|
|
40
|
+
.description('Star sessions so they are easy to find again — list them with --favorites, or `f` in the browser.')
|
|
41
|
+
.option('--remove', 'Unstar the given sessions instead of starring them')
|
|
42
|
+
.option('--list', 'List the starred sessions (the default when no ids are given)')
|
|
43
|
+
.option('--json', 'Output JSON');
|
|
44
|
+
setHelpSections(cmd, {
|
|
45
|
+
examples: `
|
|
46
|
+
# Star a session by its short id (the 8 chars the listing prints)
|
|
47
|
+
agents sessions favorite 26c27162
|
|
48
|
+
|
|
49
|
+
# See what is starred
|
|
50
|
+
agents sessions favorite --list
|
|
51
|
+
|
|
52
|
+
# Browse only the starred ones
|
|
53
|
+
agents sessions --favorites
|
|
54
|
+
|
|
55
|
+
# Unstar it again
|
|
56
|
+
agents sessions favorite 26c27162 --remove
|
|
57
|
+
`,
|
|
58
|
+
notes: `
|
|
59
|
+
In the interactive browser (\`agents sessions\`), \`*\` stars the highlighted
|
|
60
|
+
session and \`f\` filters the list down to the starred ones.
|
|
61
|
+
|
|
62
|
+
Stars live in ~/.agents/.history/favorites.json, keyed by session id, so
|
|
63
|
+
they survive a reindex of the session cache. They are per-machine: session
|
|
64
|
+
sync carries transcripts, not this file.
|
|
65
|
+
`,
|
|
66
|
+
});
|
|
67
|
+
cmd.action((ids, options, self) => {
|
|
68
|
+
// `--json` has to come from the merged view, not `options`. The parent
|
|
69
|
+
// `sessions` command declares `--json` AND takes a positional `[query]`, so
|
|
70
|
+
// commander keeps parsing parent-known options past the subcommand name and
|
|
71
|
+
// binds `--json` to the PARENT — `options.json` is silently undefined here
|
|
72
|
+
// while `--remove`/`--list` (unknown to the parent) arrive fine.
|
|
73
|
+
// `optsWithGlobals` is commander's own answer for reading an option a parent
|
|
74
|
+
// owns; it is still declared on this command so `--help` documents it.
|
|
75
|
+
const json = self.optsWithGlobals().json === true;
|
|
76
|
+
if (options.list || ids.length === 0) {
|
|
77
|
+
const starred = [...listFavorites()].sort();
|
|
78
|
+
if (json) {
|
|
79
|
+
process.stdout.write(JSON.stringify({ favorites: starred }, null, 2) + '\n');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (starred.length === 0) {
|
|
83
|
+
console.log(chalk.gray('No favorited sessions. Star one with `agents sessions favorite <id>`.'));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
for (const id of starred)
|
|
87
|
+
console.log(`${chalk.yellow('★')} ${id}`);
|
|
88
|
+
console.log(chalk.gray(`\n${starred.length} favorite${starred.length === 1 ? '' : 's'}.`));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const on = !options.remove;
|
|
92
|
+
const results = [];
|
|
93
|
+
for (const idQuery of ids) {
|
|
94
|
+
const resolved = resolveFavoriteTarget(idQuery);
|
|
95
|
+
if ('error' in resolved) {
|
|
96
|
+
results.push({ query: idQuery, error: resolved.error });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// Unstarring something that was never starred, or starring it twice, is a
|
|
100
|
+
// no-op the store already short-circuits — report the resulting state.
|
|
101
|
+
setFavorite(resolved.id, on);
|
|
102
|
+
results.push({ query: idQuery, id: resolved.id, favorite: isFavorite(resolved.id) });
|
|
103
|
+
}
|
|
104
|
+
if (json) {
|
|
105
|
+
process.stdout.write(JSON.stringify({ results }, null, 2) + '\n');
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
for (const r of results) {
|
|
109
|
+
if (r.error)
|
|
110
|
+
console.error(chalk.red(r.error));
|
|
111
|
+
else
|
|
112
|
+
console.log(`${r.favorite ? chalk.yellow('★ favorited') : chalk.gray('☆ unfavorited')} ${r.id}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// A failed lookup is a failed command — a script must not read "starred" from
|
|
116
|
+
// a zero exit when nothing was starred.
|
|
117
|
+
if (results.some((r) => r.error))
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
});
|
|
120
|
+
}
|