@phnx-labs/agents-cli 1.22.30 → 1.22.31
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 +6 -0
- package/README.md +5 -4
- package/dist/bin/agents +0 -0
- package/dist/commands/focus.d.ts +4 -1
- package/dist/commands/focus.js +19 -4
- package/dist/commands/secrets.d.ts +20 -0
- package/dist/commands/secrets.js +59 -93
- package/dist/commands/{sessions-favorite.d.ts → sessions-bookmark.d.ts} +7 -7
- package/dist/commands/{sessions-favorite.js → sessions-bookmark.js} +38 -38
- package/dist/commands/sessions-browser.d.ts +10 -8
- package/dist/commands/sessions-browser.js +61 -32
- package/dist/commands/sessions-stats.js +1 -1
- package/dist/commands/sessions.d.ts +10 -8
- package/dist/commands/sessions.js +70 -55
- package/dist/index.js +1 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.js +6 -0
- package/dist/lib/picker.d.ts +6 -3
- package/dist/lib/picker.js +7 -2
- 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/agent.d.ts +0 -1
- package/dist/lib/secrets/agent.js +0 -4
- package/dist/lib/secrets/session-store.d.ts +0 -4
- package/dist/lib/secrets/session-store.js +0 -5
- package/dist/lib/session/{favorites.d.ts → bookmarks.d.ts} +14 -14
- package/dist/lib/session/{favorites.js → bookmarks.js} +22 -22
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.31
|
|
4
|
+
|
|
5
|
+
- **`agents secrets unlock --keys` folds in the scoped-hold surface; the `secrets lease`/`leases`/`revoke` commands are deleted (RUSH-2350).** `unlock` now takes `--keys K1,K2` to hold ONLY that subset of a bundle behind its own expiry instead of the whole bundle — the one capability `lease` had that `unlock` did not, now on the command agents already reach for. Without `--keys`, `unlock` behaves exactly as before (whole bundle). An unknown or empty key subset fails closed (`Unknown secret lease key(s): …` / `requires at least one key`), `--keys` scopes exactly one local bundle (rejected with `--all`/`--host`), and `secrets status` now names the held keys of a scoped hold; `secrets lock <name>` releases it. The duplicate `secrets lease`/`secrets leases`/`secrets revoke` trio is gone — it had zero consumers, and its jobs (list holdings, release one) are already `secrets status` and `secrets lock`. No alias or deprecation shim (per the repo's no-unasked-shims rule). The underlying lease model (`src/lib/secrets/lease.ts`) is unchanged — `unlock --keys` reuses it. Source: `apps/cli/src/commands/secrets.ts` (`scopeHeldEnv`, the `unlock`/`status` actions), `apps/cli/src/lib/secrets/{agent,session-store}.ts`, `apps/cli/src/commands/secrets.scope.test.ts`, `apps/cli/src/commands/secrets.flags.test.ts`.
|
|
6
|
+
|
|
7
|
+
- **Session previews use bookmark and focus as separate actions (RUSH-2373).** The shared interactive browser for ordinary, team, and routine sessions now uses `*` to bookmark the highlighted row, `b` / `--bookmarks` to filter bookmarks, and `f` to focus the highlighted session through the same attach-or-recover flow as `agents sessions focus`. Enter still resumes. The non-TTY surface is now `agents sessions bookmark`; its flag, JSON keys, code symbols, and durable store are bookmark-only, with a one-time migration from `~/.agents/.history/favorites.json` to `bookmarks.json`. The retired favorite command and flag are not aliases. Source: `apps/cli/src/commands/sessions-browser.ts`, `sessions-bookmark.ts`, `focus.ts`, `apps/cli/src/lib/session/bookmarks.ts`, `apps/cli/src/lib/migrate.ts`.
|
|
8
|
+
|
|
3
9
|
## 1.22.30
|
|
4
10
|
|
|
5
11
|
- **`agents secrets import --force` now repairs a bundle whose metadata record is
|
package/README.md
CHANGED
|
@@ -338,8 +338,9 @@ On a terminal, `agents sessions --active` (and a bare `agents sessions`) open th
|
|
|
338
338
|
|---|---|---|
|
|
339
339
|
| `s` | search text | `--query` / positional |
|
|
340
340
|
| `r` | running only | `--active` |
|
|
341
|
-
| `
|
|
342
|
-
| `*` |
|
|
341
|
+
| `b` | bookmarks only | `--bookmarks` |
|
|
342
|
+
| `*` | bookmark / unbookmark the highlighted session | `agents sessions bookmark <id>` |
|
|
343
|
+
| `f` | focus the highlighted session | `agents sessions focus <id>` |
|
|
343
344
|
| `c` | team sessions | `--team` (alias: `--teams`) |
|
|
344
345
|
| `a` | agent (cycles) | `-a` |
|
|
345
346
|
| `d` | device (cycles) | `--device` |
|
|
@@ -349,7 +350,7 @@ On a terminal, `agents sessions --active` (and a bare `agents sessions`) open th
|
|
|
349
350
|
| `⏎` | resume / attach | `resume` / `focus` |
|
|
350
351
|
| `y` | copy the equivalent command | `--print-cmd` |
|
|
351
352
|
|
|
352
|
-
**
|
|
353
|
+
**Bookmark the sessions you keep coming back to.** `*` marks the highlighted row (a `★` shows in the listing), `b` narrows to bookmarks, and `agents sessions bookmark <id>` / `--bookmarks` do the same outside a TTY. Press `f` to focus the highlighted row through the same attach-or-recover flow as `agents sessions focus <id>`; Enter keeps its existing resume behavior. Bookmarks live in `~/.agents/.history/bookmarks.json` keyed by session id, so they survive a reindex of the session cache. They're per-machine — session sync carries transcripts, not this file.
|
|
353
354
|
|
|
354
355
|
**A session that lost its host says so.** When an editor window or an SSH connection goes down hard, the agent it owned used to simply disappear from `--active`; when an agent outlived its window in tmux, it reported a plain `idle`. Both now carry their own status: `✗ crashed` (the host went down and took the agent with it) and `◍ orphan` (still alive, but no client is attached — nothing is showing it). Read from tmux's attached-client count and the editor window's registry heartbeat, so a deliberate `agents sessions detach` is never mistaken for one, and a session that is still *working* headlessly is left alone.
|
|
355
356
|
|
|
@@ -359,7 +360,7 @@ Filters **stack** (they AND together), the active set shows in the header, and t
|
|
|
359
360
|
| --- | --- |
|
|
360
361
|
|  |  |
|
|
361
362
|
|
|
362
|
-
Each live session resolves to `working`, `waiting_input` (with why -- a question, a plan review, or a permission prompt), `idle`, or a lifecycle state such as `orphaned`, `crashed`, `closed`, `abandoned`, `queued`, or `unknown`. Pass the matching flag (`--working`, `--idle`, `--waiting`, `--orphan`, `--crashed`, `--closed`, `--abandoned`, `--queued`, `--unknown`) directly; each implies `--active`, and several flags form a union. The fleet fan-out is already the default; `--local` opts out. `--all` instead widens historical directory and time scope. Rows also carry badges for the PR, worktree, and ticket. `agents sessions focus [selector]` accepts the same agent/version, device, time, team, project, skill/plugin,
|
|
363
|
+
Each live session resolves to `working`, `waiting_input` (with why -- a question, a plan review, or a permission prompt), `idle`, or a lifecycle state such as `orphaned`, `crashed`, `closed`, `abandoned`, `queued`, or `unknown`. Pass the matching flag (`--working`, `--idle`, `--waiting`, `--orphan`, `--crashed`, `--closed`, `--abandoned`, `--queued`, `--unknown`) directly; each implies `--active`, and several flags form a union. The fleet fan-out is already the default; `--local` opts out. `--all` instead widens historical directory and time scope. Rows also carry badges for the PR, worktree, and ticket. `agents sessions focus [selector]` accepts the same agent/version, device, time, team, project, skill/plugin, bookmark, and live-state filters as the session browser. A unique id focuses directly; an agent/version or text selector always opens the preview picker. Immediately before attach it checks the tmux pane process: a living pane is joined in place, while a dead/missing pane enters recovery instead of showing tmux's `Pane is dead` screen.
|
|
363
364
|
|
|
364
365
|
Landing on a session cold? `agents sessions <id>` prints a catch-up digest: an inferred title, files changed grouped by directory (created / modified / deleted), a histogram of which tools did the work (including parsed Bash commands -- `git`, `npm`, `ffmpeg`, `ssh`, and so on), and the last test verdict -- the signals to reload a task in seconds.
|
|
365
366
|
|
package/dist/bin/agents
CHANGED
|
Binary file
|
package/dist/commands/focus.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ export interface FocusOptions {
|
|
|
42
42
|
since?: string;
|
|
43
43
|
until?: string;
|
|
44
44
|
limit?: string;
|
|
45
|
-
|
|
45
|
+
bookmarks?: boolean;
|
|
46
46
|
unmanaged?: boolean;
|
|
47
47
|
sort?: string;
|
|
48
48
|
working?: boolean;
|
|
@@ -74,6 +74,9 @@ export declare function selectFallback(attachOnly: boolean | undefined): Unreach
|
|
|
74
74
|
export declare function focusAction(id: string | undefined, opts: FocusOptions): Promise<void>;
|
|
75
75
|
/** A retained pane is not attachable merely because tmux can still display it. */
|
|
76
76
|
export declare function isAttachableLiveSession(session: ActiveSession): boolean;
|
|
77
|
+
/** Focus one row selected by the shared session browser through the same
|
|
78
|
+
* attach/recover decision as `agents sessions focus <id>`. */
|
|
79
|
+
export declare function focusSelectedSession(meta: SessionMeta, active: ActiveSession | undefined, self: string): Promise<void>;
|
|
77
80
|
/**
|
|
78
81
|
* How a single selected live session opens in its new tab.
|
|
79
82
|
* - `attach` — a live tmux pane joined in the tab (a second client, no fork),
|
package/dist/commands/focus.js
CHANGED
|
@@ -32,7 +32,7 @@ const INHERITED_FOCUS_OPTIONS = [
|
|
|
32
32
|
'local', 'host', 'device', 'active', 'agent',
|
|
33
33
|
'claude', 'codex', 'kimi', 'antigravity', 'grok', 'opencode',
|
|
34
34
|
'all', 'teams', 'inTeam', 'routine', 'project', 'skill', 'plugin',
|
|
35
|
-
'since', 'until', 'limit', '
|
|
35
|
+
'since', 'until', 'limit', 'bookmarks', 'unmanaged', 'sort',
|
|
36
36
|
'working', 'idle', 'waiting', 'orphan', 'orphaned', 'crashed',
|
|
37
37
|
'closed', 'abandoned', 'queued', 'unknown',
|
|
38
38
|
];
|
|
@@ -93,7 +93,7 @@ export function registerFocusCommand(program) {
|
|
|
93
93
|
.option('--since <time>', 'Only sessions newer than this (for example 7d)')
|
|
94
94
|
.option('--until <time>', 'Only sessions older than this timestamp')
|
|
95
95
|
.option('-n, --limit <n>', 'Maximum candidates to load', '500')
|
|
96
|
-
.option('--
|
|
96
|
+
.option('--bookmarks', 'Only bookmarked sessions')
|
|
97
97
|
.option('--unmanaged', 'Also include native-home sessions outside managed versions')
|
|
98
98
|
.option('--sort <field>', 'Order candidates by recent, cost, or duration', 'recent')
|
|
99
99
|
.option('--working', 'Only live sessions currently doing work')
|
|
@@ -184,7 +184,7 @@ export async function focusAction(id, opts) {
|
|
|
184
184
|
agent: agentSelector ?? focusOptionAgent(opts),
|
|
185
185
|
teams: opts.teams === true,
|
|
186
186
|
team: opts.inTeam,
|
|
187
|
-
|
|
187
|
+
bookmarks: opts.bookmarks === true,
|
|
188
188
|
projectScope: opts.all || hosts.length > 0 || !!opts.project || idLookup ? 'all' : 'repo',
|
|
189
189
|
project: opts.project,
|
|
190
190
|
window: opts.since ?? (opts.all || idLookup ? undefined : '30d'),
|
|
@@ -304,7 +304,7 @@ function looksLikeIdentitySelector(selector) {
|
|
|
304
304
|
function hasFocusFilters(opts, statuses) {
|
|
305
305
|
return statuses.length > 0 || !!(opts.active || opts.local || opts.host?.length || opts.device?.length || focusOptionAgent(opts) ||
|
|
306
306
|
opts.all || opts.teams || opts.inTeam || opts.routine || opts.project || opts.skill || opts.plugin ||
|
|
307
|
-
opts.since || opts.until || opts.
|
|
307
|
+
opts.since || opts.until || opts.bookmarks || opts.unmanaged || (opts.sort && opts.sort !== 'recent'));
|
|
308
308
|
}
|
|
309
309
|
function focusSort(value) {
|
|
310
310
|
if (!value || value === 'recent')
|
|
@@ -370,6 +370,21 @@ async function focusResolvedSession(meta, liveById, self, fallback, attachOnly)
|
|
|
370
370
|
}
|
|
371
371
|
await resumeSessionInPlace(meta);
|
|
372
372
|
}
|
|
373
|
+
/** Focus one row selected by the shared session browser through the same
|
|
374
|
+
* attach/recover decision as `agents sessions focus <id>`. */
|
|
375
|
+
export async function focusSelectedSession(meta, active, self) {
|
|
376
|
+
if (active && !active.sessionId) {
|
|
377
|
+
if (isAttachableLiveSession(active)) {
|
|
378
|
+
await jumpTo(active, self, resumeInNewTab);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
console.log(chalk.yellow('This live session has no session id or living attach rail to focus.'));
|
|
382
|
+
process.exitCode = 1;
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const liveById = active ? new Map([[meta.id, active]]) : new Map();
|
|
386
|
+
await focusResolvedSession(meta, liveById, self, resumeInNewTab, false);
|
|
387
|
+
}
|
|
373
388
|
function activeFromMeta(meta) {
|
|
374
389
|
return {
|
|
375
390
|
context: 'headless',
|
|
@@ -12,6 +12,7 @@ import { bundleEnvToDotenv } from '../lib/secrets/push.js';
|
|
|
12
12
|
export { bundleEnvToDotenv };
|
|
13
13
|
import { type SecretsBackend, type SecretsBundle, type SecretsPolicy } from '../lib/secrets/bundles.js';
|
|
14
14
|
import { type SecretsListFilterOpts } from '../lib/secrets/list-filter.js';
|
|
15
|
+
import { type SecretLease } from '../lib/secrets/lease.js';
|
|
15
16
|
import { type BundleUsageSummary } from '../lib/secrets/usage-db.js';
|
|
16
17
|
/** Read all available data from stdin synchronously, trimmed. */
|
|
17
18
|
/**
|
|
@@ -51,6 +52,25 @@ export declare function parseImportSource(opts: {
|
|
|
51
52
|
* without a live SSH session.
|
|
52
53
|
*/
|
|
53
54
|
export declare function resolveUnlockTtlMs(ttl: string | undefined, until: string | undefined, now?: number): number;
|
|
55
|
+
/**
|
|
56
|
+
* Decide what an `unlock` holds: the whole bundle env, or — with --keys — only
|
|
57
|
+
* the resolved subset behind a lease that scopes the broker entry (agent.ts
|
|
58
|
+
* re-selects on load) and its own expiry. Fails closed: createSecretLease throws
|
|
59
|
+
* on an unknown or empty key subset. The unlock action's single scoping seam,
|
|
60
|
+
* exported so the whole-bundle vs scoped-subset decision is unit-testable without
|
|
61
|
+
* a live broker.
|
|
62
|
+
*/
|
|
63
|
+
export declare function scopeHeldEnv(input: {
|
|
64
|
+
bundle: string;
|
|
65
|
+
env: Record<string, string>;
|
|
66
|
+
keys: string | null;
|
|
67
|
+
ttlMs: number;
|
|
68
|
+
harness: string;
|
|
69
|
+
sleepPersist: boolean;
|
|
70
|
+
}): {
|
|
71
|
+
heldEnv: Record<string, string>;
|
|
72
|
+
lease?: SecretLease;
|
|
73
|
+
};
|
|
54
74
|
export declare function buildRemoteUnlockArgs(names: string[], opts: {
|
|
55
75
|
all?: boolean;
|
|
56
76
|
ttl?: string;
|
package/dist/commands/secrets.js
CHANGED
|
@@ -25,8 +25,8 @@ import { getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainTok
|
|
|
25
25
|
import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
|
|
26
26
|
import { GLOBAL_HARNESS } from '../lib/secrets/scope.js';
|
|
27
27
|
import { createSecretLease, selectLeasedEnv } from '../lib/secrets/lease.js';
|
|
28
|
-
import { secretsHoldMs, secretsAgentDurable, agentLoad, agentLock, agentPing,
|
|
29
|
-
import {
|
|
28
|
+
import { secretsHoldMs, secretsAgentDurable, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
|
|
29
|
+
import { saveSession, deleteBundleSessions, deleteAllSessions } from '../lib/secrets/session-store.js';
|
|
30
30
|
import { getCliVersionFresh } from '../lib/version.js';
|
|
31
31
|
import { readMeta } from '../lib/state.js';
|
|
32
32
|
import { parseDuration } from '../lib/hooks/cache.js';
|
|
@@ -287,6 +287,30 @@ export function resolveUnlockTtlMs(ttl, until, now = Date.now()) {
|
|
|
287
287
|
}
|
|
288
288
|
return secretsHoldMs();
|
|
289
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Decide what an `unlock` holds: the whole bundle env, or — with --keys — only
|
|
292
|
+
* the resolved subset behind a lease that scopes the broker entry (agent.ts
|
|
293
|
+
* re-selects on load) and its own expiry. Fails closed: createSecretLease throws
|
|
294
|
+
* on an unknown or empty key subset. The unlock action's single scoping seam,
|
|
295
|
+
* exported so the whole-bundle vs scoped-subset decision is unit-testable without
|
|
296
|
+
* a live broker.
|
|
297
|
+
*/
|
|
298
|
+
export function scopeHeldEnv(input) {
|
|
299
|
+
// null = no --keys → hold the whole bundle. An empty-string --keys is a
|
|
300
|
+
// deliberate (if empty) scope request and falls through to createSecretLease,
|
|
301
|
+
// which fails closed ('requires at least one key') rather than holding it all.
|
|
302
|
+
if (input.keys === null)
|
|
303
|
+
return { heldEnv: input.env };
|
|
304
|
+
const lease = createSecretLease({
|
|
305
|
+
bundle: input.bundle,
|
|
306
|
+
keys: input.keys.split(','),
|
|
307
|
+
availableKeys: Object.keys(input.env),
|
|
308
|
+
ttlMs: input.ttlMs,
|
|
309
|
+
harness: input.harness,
|
|
310
|
+
sleepPersist: input.sleepPersist,
|
|
311
|
+
});
|
|
312
|
+
return { heldEnv: selectLeasedEnv(lease, input.env), lease };
|
|
313
|
+
}
|
|
290
314
|
export function buildRemoteUnlockArgs(names, opts) {
|
|
291
315
|
return [
|
|
292
316
|
'unlock',
|
|
@@ -2442,89 +2466,6 @@ Examples:
|
|
|
2442
2466
|
console.log(password);
|
|
2443
2467
|
}
|
|
2444
2468
|
});
|
|
2445
|
-
cmd
|
|
2446
|
-
.command('lease <bundle>')
|
|
2447
|
-
.description('Hold only an explicit subset of a bundle until an independent expiry.')
|
|
2448
|
-
.requiredOption('--keys <keys>', 'Comma-separated key subset')
|
|
2449
|
-
.requiredOption('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d)')
|
|
2450
|
-
.option('--agent <agent>', 'Narrow the lease to one harness; default is global')
|
|
2451
|
-
.option('--durable', 'Keep the lease across sleep as well as broker restart')
|
|
2452
|
-
.action(async (name, opts) => {
|
|
2453
|
-
if (process.platform !== 'darwin') {
|
|
2454
|
-
throw new Error('Scoped lease brokering is not available on this platform yet.');
|
|
2455
|
-
}
|
|
2456
|
-
const seconds = parseDuration(opts.ttl);
|
|
2457
|
-
if (seconds === null)
|
|
2458
|
-
throw new Error(`Invalid lease duration '${opts.ttl}'.`);
|
|
2459
|
-
const ttlMs = seconds * 1000;
|
|
2460
|
-
const harness = opts.agent || GLOBAL_HARNESS;
|
|
2461
|
-
const { bundle, env } = readAndResolveBundleEnv(name, {
|
|
2462
|
-
noAgent: true,
|
|
2463
|
-
interactiveUnlock: true,
|
|
2464
|
-
caller: 'lease secrets',
|
|
2465
|
-
agent: harness,
|
|
2466
|
-
keyMode: 'storage',
|
|
2467
|
-
});
|
|
2468
|
-
const lease = createSecretLease({
|
|
2469
|
-
bundle: name,
|
|
2470
|
-
keys: opts.keys.split(','),
|
|
2471
|
-
availableKeys: Object.keys(env),
|
|
2472
|
-
ttlMs,
|
|
2473
|
-
harness,
|
|
2474
|
-
sleepPersist: opts.durable,
|
|
2475
|
-
});
|
|
2476
|
-
const leasedEnv = selectLeasedEnv(lease, env);
|
|
2477
|
-
if (!(await ensureAgentRunning()) || !(await agentLoad(name, bundle, leasedEnv, ttlMs, harness, lease))) {
|
|
2478
|
-
throw new Error('Could not load the scoped lease into the secrets broker.');
|
|
2479
|
-
}
|
|
2480
|
-
ensureDaemonStarted();
|
|
2481
|
-
saveSession(name, {
|
|
2482
|
-
bundle,
|
|
2483
|
-
env: leasedEnv,
|
|
2484
|
-
expiresAt: lease.expiresAt,
|
|
2485
|
-
sleepPersist: lease.sleepPersist,
|
|
2486
|
-
harness,
|
|
2487
|
-
lease,
|
|
2488
|
-
});
|
|
2489
|
-
emitSecretAudit({
|
|
2490
|
-
event: 'secrets.unlocked', bundle: name, operation: 'lease', source: lease.sleepPersist ? 'broker+durable' : 'broker',
|
|
2491
|
-
status: 'success', keys: lease.keys, keyCount: lease.keys.length, agent: harness, ttlMs: lease.expiresAt - lease.createdAt,
|
|
2492
|
-
});
|
|
2493
|
-
console.log(`${chalk.green('leased')} ${chalk.cyan(lease.id)} ${chalk.gray(`(${name}: ${lease.keys.join(', ')}, ${humanRemaining(lease.expiresAt)})`)}`);
|
|
2494
|
-
});
|
|
2495
|
-
cmd
|
|
2496
|
-
.command('leases')
|
|
2497
|
-
.description('List active scoped secret leases.')
|
|
2498
|
-
.action(async () => {
|
|
2499
|
-
const brokerLeases = (await agentStatus()).filter((entry) => entry.leaseId);
|
|
2500
|
-
const byId = new Map(brokerLeases.map((entry) => [entry.leaseId, entry]));
|
|
2501
|
-
for (const { name, lease } of activeLeaseSessions()) {
|
|
2502
|
-
if (!byId.has(lease.id))
|
|
2503
|
-
byId.set(lease.id, { name, expiresAt: lease.expiresAt, keyCount: lease.keys.length, harness: lease.harness, leaseId: lease.id, keys: lease.keys });
|
|
2504
|
-
}
|
|
2505
|
-
const leases = [...byId.values()];
|
|
2506
|
-
if (leases.length === 0) {
|
|
2507
|
-
console.log(chalk.gray('No active secret leases.'));
|
|
2508
|
-
return;
|
|
2509
|
-
}
|
|
2510
|
-
console.log(`${'ID'.padEnd(24)} ${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(24)} EXPIRES IN`);
|
|
2511
|
-
for (const lease of leases) {
|
|
2512
|
-
console.log(`${lease.leaseId.padEnd(24)} ${lease.name.padEnd(24)} ${(lease.keys ?? []).join(',').padEnd(24)} ${humanRemaining(lease.expiresAt)}`);
|
|
2513
|
-
}
|
|
2514
|
-
});
|
|
2515
|
-
cmd
|
|
2516
|
-
.command('revoke <lease-id>')
|
|
2517
|
-
.description('Revoke one scoped secret lease immediately.')
|
|
2518
|
-
.action(async (leaseId) => {
|
|
2519
|
-
if (agentSocketExists() && !(await agentPing()).reachable) {
|
|
2520
|
-
throw new Error('Cannot revoke while the secrets broker is using an incompatible protocol. Restart the broker and retry.');
|
|
2521
|
-
}
|
|
2522
|
-
const persisted = deleteLeaseSession(leaseId);
|
|
2523
|
-
const wiped = await agentRevoke(leaseId);
|
|
2524
|
-
if (wiped + persisted === 0)
|
|
2525
|
-
throw new Error(`Secret lease '${leaseId}' is not active.`);
|
|
2526
|
-
console.log(chalk.green(`Revoked secret lease ${leaseId}.`));
|
|
2527
|
-
});
|
|
2528
2469
|
cmd
|
|
2529
2470
|
.command('unlock [names...]')
|
|
2530
2471
|
.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.')
|
|
@@ -2532,6 +2473,7 @@ Examples:
|
|
|
2532
2473
|
.option('--until <date>', 'Hold until this absolute date or timestamp (for example 2026-08-06T12:00:00Z). Mutually exclusive with --ttl.')
|
|
2533
2474
|
.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.')
|
|
2534
2475
|
.option('--agent <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.')
|
|
2476
|
+
.option('--keys <keys>', 'Hold ONLY this comma-separated subset of the bundle\'s keys instead of the whole bundle. Scopes exactly one bundle; fails closed on an unknown key. `secrets status` shows which keys a scoped hold covers.')
|
|
2535
2477
|
.option('--all', 'Unlock every configured bundle')
|
|
2536
2478
|
.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>`.')
|
|
2537
2479
|
.action(async (names, opts) => {
|
|
@@ -2539,6 +2481,19 @@ Examples:
|
|
|
2539
2481
|
console.error(chalk.red('--ttl and --until are mutually exclusive.'));
|
|
2540
2482
|
process.exit(1);
|
|
2541
2483
|
}
|
|
2484
|
+
if (opts.keys !== undefined) {
|
|
2485
|
+
// --keys scopes ONE bundle to an explicit subset. It is local-only (a
|
|
2486
|
+
// remote --host unlock holds the whole file-backed bundle) and cannot
|
|
2487
|
+
// combine with --all, which would hold every bundle whole.
|
|
2488
|
+
if (opts.host) {
|
|
2489
|
+
console.error(chalk.red('--keys is local-only; a remote (--host) unlock holds the whole file-backed bundle.'));
|
|
2490
|
+
process.exit(1);
|
|
2491
|
+
}
|
|
2492
|
+
if (opts.all || !names || names.length !== 1) {
|
|
2493
|
+
console.error(chalk.red('--keys scopes ONE bundle to a subset of its keys: name exactly one bundle (not --all).'));
|
|
2494
|
+
process.exit(1);
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2542
2497
|
// Single-valued (not variadic): a variadic --host greedily consumes the
|
|
2543
2498
|
// positional bundle name (`unlock --host mac wztest` -> host=[mac,wztest],
|
|
2544
2499
|
// names=[]). Unlock targets one remote at a time anyway.
|
|
@@ -2623,6 +2578,7 @@ Examples:
|
|
|
2623
2578
|
// agent happened to launch the shell, leaving the grant unreadable to
|
|
2624
2579
|
// every other reader for its whole TTL.
|
|
2625
2580
|
const harness = opts.agent || GLOBAL_HARNESS;
|
|
2581
|
+
const durable = opts.durable ?? secretsAgentDurable();
|
|
2626
2582
|
for (const name of targets) {
|
|
2627
2583
|
try {
|
|
2628
2584
|
// noAgent: read the real keychain (one Touch ID) rather than the
|
|
@@ -2639,17 +2595,23 @@ Examples:
|
|
|
2639
2595
|
// ACL heal can rewrite that already-read value without another read.
|
|
2640
2596
|
maybeAutoRekey();
|
|
2641
2597
|
healKeychainBundleMetadataAclOnce(new Map([[bundle.name, JSON.stringify(bundle)]]));
|
|
2642
|
-
|
|
2598
|
+
// With --keys, hold ONLY the resolved subset behind a lease that scopes
|
|
2599
|
+
// the broker entry and its own expiry; without it, the whole bundle is
|
|
2600
|
+
// held exactly as before. scopeHeldEnv fails closed on an unknown key.
|
|
2601
|
+
const { heldEnv, lease } = scopeHeldEnv({ bundle: name, env, keys: opts.keys ?? null, ttlMs, harness, sleepPersist: durable });
|
|
2602
|
+
const heldExpiresAt = lease ? lease.expiresAt : expiresAt;
|
|
2603
|
+
if (await agentLoad(name, bundle, heldEnv, ttlMs, harness, lease)) {
|
|
2643
2604
|
loaded++;
|
|
2644
2605
|
// Persist a durable session snapshot so the unlock survives a daemon
|
|
2645
2606
|
// restart / upgrade (and sleep too, with --durable). session-store.ts.
|
|
2646
|
-
|
|
2607
|
+
// A scoped hold persists its lease so it rehydrates scoped.
|
|
2647
2608
|
saveSession(name, {
|
|
2648
2609
|
bundle,
|
|
2649
|
-
env,
|
|
2650
|
-
expiresAt,
|
|
2610
|
+
env: heldEnv,
|
|
2611
|
+
expiresAt: heldExpiresAt,
|
|
2651
2612
|
sleepPersist: durable,
|
|
2652
2613
|
harness,
|
|
2614
|
+
lease,
|
|
2653
2615
|
});
|
|
2654
2616
|
// Audit the GRANT itself — the broker + durable session now serve this
|
|
2655
2617
|
// bundle prompt-free for the whole TTL, to every reader in `harness`
|
|
@@ -2661,12 +2623,13 @@ Examples:
|
|
|
2661
2623
|
operation: 'unlock',
|
|
2662
2624
|
source: durable ? 'broker+durable' : 'broker',
|
|
2663
2625
|
status: 'success',
|
|
2664
|
-
keyCount: Object.keys(
|
|
2665
|
-
keys: Object.keys(
|
|
2626
|
+
keyCount: Object.keys(heldEnv).length,
|
|
2627
|
+
keys: Object.keys(heldEnv).sort(),
|
|
2666
2628
|
agent: harness,
|
|
2667
2629
|
ttlMs,
|
|
2668
2630
|
});
|
|
2669
|
-
|
|
2631
|
+
const scoped = lease ? chalk.gray(` scoped to ${lease.keys.join(', ')}`) : '';
|
|
2632
|
+
console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(heldEnv).length} keys, ${humanRemaining(heldExpiresAt)})`)}${scoped}`);
|
|
2670
2633
|
}
|
|
2671
2634
|
else {
|
|
2672
2635
|
console.error(chalk.red(`Failed to load '${name}' into the agent.`));
|
|
@@ -2748,7 +2711,10 @@ Examples:
|
|
|
2748
2711
|
else {
|
|
2749
2712
|
console.log(chalk.bold(`${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(5)} LOCKS IN`));
|
|
2750
2713
|
for (const e of entries) {
|
|
2751
|
-
|
|
2714
|
+
// A scoped hold (unlock --keys) carries the held key names, so name them
|
|
2715
|
+
// rather than leave the bundle looking whole.
|
|
2716
|
+
const scoped = e.keys?.length ? chalk.gray(` scoped: ${e.keys.join(', ')}`) : '';
|
|
2717
|
+
console.log(`${chalk.cyan(e.name.padEnd(24))} ${String(e.keyCount).padEnd(5)} ${humanRemaining(e.expiresAt)}${scoped}`);
|
|
2752
2718
|
}
|
|
2753
2719
|
console.log(chalk.gray('Reads of held bundles are silent; any bundle not listed prompts once on its next read.'));
|
|
2754
2720
|
}
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `agents sessions
|
|
2
|
+
* `agents sessions bookmark` — the non-TTY half of the bookmark hotkey.
|
|
3
3
|
*
|
|
4
|
-
* The `*` hotkey in the interactive browser is how a human
|
|
4
|
+
* The `*` hotkey in the interactive browser is how a human bookmarks a session;
|
|
5
5
|
* this is how a script, an agent, or a machine without a TTY does the same thing,
|
|
6
6
|
* and it is what makes the feature testable end to end without driving a terminal
|
|
7
|
-
* UI. Both write the one store in `lib/session/
|
|
7
|
+
* UI. Both write the one store in `lib/session/bookmarks.ts`.
|
|
8
8
|
*/
|
|
9
9
|
import type { Command } from 'commander';
|
|
10
10
|
/**
|
|
11
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:
|
|
13
|
-
* the wrong session is invisible until the user wonders where their
|
|
12
|
+
* a full session id. Ambiguity is an ERROR, not a silent first-match: bookmarking
|
|
13
|
+
* the wrong session is invisible until the user wonders where their bookmark went.
|
|
14
14
|
*/
|
|
15
|
-
export declare function
|
|
15
|
+
export declare function resolveBookmarkTarget(idQuery: string): {
|
|
16
16
|
id: string;
|
|
17
17
|
} | {
|
|
18
18
|
error: string;
|
|
19
19
|
};
|
|
20
|
-
export declare function
|
|
20
|
+
export declare function registerSessionsBookmarkCommand(sessionsCmd: Command): void;
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `agents sessions
|
|
2
|
+
* `agents sessions bookmark` — the non-TTY half of the bookmark hotkey.
|
|
3
3
|
*
|
|
4
|
-
* The `*` hotkey in the interactive browser is how a human
|
|
4
|
+
* The `*` hotkey in the interactive browser is how a human bookmarks a session;
|
|
5
5
|
* this is how a script, an agent, or a machine without a TTY does the same thing,
|
|
6
6
|
* and it is what makes the feature testable end to end without driving a terminal
|
|
7
|
-
* UI. Both write the one store in `lib/session/
|
|
7
|
+
* UI. Both write the one store in `lib/session/bookmarks.ts`.
|
|
8
8
|
*/
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
import { setHelpSections } from '../lib/help.js';
|
|
11
11
|
import { findSessionsById } from '../lib/session/db.js';
|
|
12
12
|
import { isCompleteSessionId } from '../lib/session/discover.js';
|
|
13
|
-
import {
|
|
13
|
+
import { isBookmarked, listBookmarks, setBookmark } from '../lib/session/bookmarks.js';
|
|
14
14
|
/**
|
|
15
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:
|
|
17
|
-
* the wrong session is invisible until the user wonders where their
|
|
16
|
+
* a full session id. Ambiguity is an ERROR, not a silent first-match: bookmarking
|
|
17
|
+
* the wrong session is invisible until the user wonders where their bookmark went.
|
|
18
18
|
*/
|
|
19
|
-
export function
|
|
19
|
+
export function resolveBookmarkTarget(idQuery) {
|
|
20
20
|
const matches = findSessionsById(idQuery);
|
|
21
21
|
// A COMPLETE id needs no index entry: the id is the key the store is built on,
|
|
22
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 `*`
|
|
23
|
+
// live one that has not been indexed yet. The browser's `*` bookmarks those
|
|
24
24
|
// from the live row, so demanding a DB hit here would make the two disagree.
|
|
25
25
|
if (matches.length === 0) {
|
|
26
26
|
return isCompleteSessionId(idQuery.trim())
|
|
@@ -33,33 +33,33 @@ export function resolveFavoriteTarget(idQuery) {
|
|
|
33
33
|
}
|
|
34
34
|
return { id: matches[0].id };
|
|
35
35
|
}
|
|
36
|
-
export function
|
|
36
|
+
export function registerSessionsBookmarkCommand(sessionsCmd) {
|
|
37
37
|
const cmd = sessionsCmd
|
|
38
|
-
.command('
|
|
39
|
-
.argument('[ids...]', 'Session ids to
|
|
40
|
-
.description('
|
|
41
|
-
.option('--remove', 'Remove the given sessions from
|
|
42
|
-
.option('--list', 'List the
|
|
38
|
+
.command('bookmark')
|
|
39
|
+
.argument('[ids...]', 'Session ids to bookmark (full or short id prefix)')
|
|
40
|
+
.description('Bookmark sessions so they are easy to find again — list them with --bookmarks, or `b` in the browser.')
|
|
41
|
+
.option('--remove', 'Remove the given sessions from bookmarks instead of adding them')
|
|
42
|
+
.option('--list', 'List the bookmarked sessions (the default when no ids are given)')
|
|
43
43
|
.option('--json', 'Output JSON');
|
|
44
44
|
setHelpSections(cmd, {
|
|
45
45
|
examples: `
|
|
46
|
-
#
|
|
47
|
-
agents sessions
|
|
46
|
+
# Bookmark a session by its short id (the 8 chars the listing prints)
|
|
47
|
+
agents sessions bookmark 26c27162
|
|
48
48
|
|
|
49
|
-
# See what is
|
|
50
|
-
agents sessions
|
|
49
|
+
# See what is bookmarked
|
|
50
|
+
agents sessions bookmark --list
|
|
51
51
|
|
|
52
|
-
# Browse only the
|
|
53
|
-
agents sessions --
|
|
52
|
+
# Browse only the bookmarked ones
|
|
53
|
+
agents sessions --bookmarks
|
|
54
54
|
|
|
55
|
-
# Remove it from
|
|
56
|
-
agents sessions
|
|
55
|
+
# Remove it from bookmarks again
|
|
56
|
+
agents sessions bookmark 26c27162 --remove
|
|
57
57
|
`,
|
|
58
58
|
notes: `
|
|
59
|
-
In the interactive browser (\`agents sessions\`), \`*\`
|
|
60
|
-
session and \`
|
|
59
|
+
In the interactive browser (\`agents sessions\`), \`*\` bookmarks the highlighted
|
|
60
|
+
session and \`b\` filters the list down to the bookmarked ones.
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
Bookmarks live in ~/.agents/.history/bookmarks.json, keyed by session id, so
|
|
63
63
|
they survive a reindex of the session cache. They are per-machine: session
|
|
64
64
|
sync carries transcripts, not this file.
|
|
65
65
|
`,
|
|
@@ -74,32 +74,32 @@ export function registerSessionsFavoriteCommand(sessionsCmd) {
|
|
|
74
74
|
// owns; it is still declared on this command so `--help` documents it.
|
|
75
75
|
const json = self.optsWithGlobals().json === true;
|
|
76
76
|
if (options.list || ids.length === 0) {
|
|
77
|
-
const
|
|
77
|
+
const bookmarked = [...listBookmarks()].sort();
|
|
78
78
|
if (json) {
|
|
79
|
-
process.stdout.write(JSON.stringify({
|
|
79
|
+
process.stdout.write(JSON.stringify({ bookmarks: bookmarked }, null, 2) + '\n');
|
|
80
80
|
return;
|
|
81
81
|
}
|
|
82
|
-
if (
|
|
83
|
-
console.log(chalk.gray('No
|
|
82
|
+
if (bookmarked.length === 0) {
|
|
83
|
+
console.log(chalk.gray('No bookmarked sessions. Bookmark one with `agents sessions bookmark <id>`.'));
|
|
84
84
|
return;
|
|
85
85
|
}
|
|
86
|
-
for (const id of
|
|
86
|
+
for (const id of bookmarked)
|
|
87
87
|
console.log(`${chalk.yellow('★')} ${id}`);
|
|
88
|
-
console.log(chalk.gray(`\n${
|
|
88
|
+
console.log(chalk.gray(`\n${bookmarked.length} bookmark${bookmarked.length === 1 ? '' : 's'}.`));
|
|
89
89
|
return;
|
|
90
90
|
}
|
|
91
91
|
const on = !options.remove;
|
|
92
92
|
const results = [];
|
|
93
93
|
for (const idQuery of ids) {
|
|
94
|
-
const resolved =
|
|
94
|
+
const resolved = resolveBookmarkTarget(idQuery);
|
|
95
95
|
if ('error' in resolved) {
|
|
96
96
|
results.push({ query: idQuery, error: resolved.error });
|
|
97
97
|
continue;
|
|
98
98
|
}
|
|
99
|
-
//
|
|
99
|
+
// Removing a bookmark that does not exist, or bookmarking it twice,
|
|
100
100
|
// is a no-op the store already short-circuits — report the resulting state.
|
|
101
|
-
|
|
102
|
-
results.push({ query: idQuery, id: resolved.id,
|
|
101
|
+
setBookmark(resolved.id, on);
|
|
102
|
+
results.push({ query: idQuery, id: resolved.id, bookmark: isBookmarked(resolved.id) });
|
|
103
103
|
}
|
|
104
104
|
if (json) {
|
|
105
105
|
process.stdout.write(JSON.stringify({ results }, null, 2) + '\n');
|
|
@@ -109,11 +109,11 @@ export function registerSessionsFavoriteCommand(sessionsCmd) {
|
|
|
109
109
|
if (r.error)
|
|
110
110
|
console.error(chalk.red(r.error));
|
|
111
111
|
else
|
|
112
|
-
console.log(`${r.
|
|
112
|
+
console.log(`${r.bookmark ? chalk.yellow('★ bookmarked') : chalk.gray('☆ unbookmarked')} ${r.id}`);
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
|
-
// A failed lookup is a failed command — a script must not read "
|
|
116
|
-
// from a zero exit when nothing was
|
|
115
|
+
// A failed lookup is a failed command — a script must not read "bookmarked"
|
|
116
|
+
// from a zero exit when nothing was bookmarked.
|
|
117
117
|
if (results.some((r) => r.error))
|
|
118
118
|
process.exitCode = 1;
|
|
119
119
|
});
|
|
@@ -27,8 +27,8 @@ export interface BrowserFilter {
|
|
|
27
27
|
device?: string;
|
|
28
28
|
/** filter to one team's lineage, or all — the `T` key / `--in-team`. */
|
|
29
29
|
team?: string;
|
|
30
|
-
/**
|
|
31
|
-
|
|
30
|
+
/** bookmarked-only — the `b` key / `--bookmarks`. */
|
|
31
|
+
bookmarks: boolean;
|
|
32
32
|
/** this-repo subtree vs every directory — the `P` key / `--all`. */
|
|
33
33
|
projectScope: 'repo' | 'all';
|
|
34
34
|
/** time window (undefined = all time) — the `W` key / `--since`. */
|
|
@@ -39,8 +39,8 @@ export interface BrowserFilter {
|
|
|
39
39
|
project?: string;
|
|
40
40
|
/** upper time bound from --until. */
|
|
41
41
|
until?: string;
|
|
42
|
-
/** retain only routine-origin sessions. */
|
|
43
|
-
routine: boolean;
|
|
42
|
+
/** retain only routine-origin sessions, optionally narrowed to one routine. */
|
|
43
|
+
routine: boolean | string;
|
|
44
44
|
/** indexed resource filters shared by the flag and focus surfaces. */
|
|
45
45
|
skill?: string;
|
|
46
46
|
plugin?: string;
|
|
@@ -97,7 +97,8 @@ export declare function activeBrowserSeed(opts: {
|
|
|
97
97
|
host?: string[];
|
|
98
98
|
since?: string;
|
|
99
99
|
all?: boolean;
|
|
100
|
-
|
|
100
|
+
bookmarks?: boolean;
|
|
101
|
+
routine?: boolean | string;
|
|
101
102
|
}): Partial<BrowserFilter>;
|
|
102
103
|
/**
|
|
103
104
|
* The initial filter for the bare interactive listing: current-repo subtree by
|
|
@@ -113,7 +114,8 @@ export declare function bareBrowserSeed(opts: {
|
|
|
113
114
|
since?: string;
|
|
114
115
|
host?: string[];
|
|
115
116
|
inTeam?: string;
|
|
116
|
-
|
|
117
|
+
bookmarks?: boolean;
|
|
118
|
+
routine?: boolean | string;
|
|
117
119
|
}): Partial<BrowserFilter>;
|
|
118
120
|
/** Build the static peer query. Keeping it pure pins the per-device alias
|
|
119
121
|
* contract: latest/oldest travel unresolved and are resolved by each peer. */
|
|
@@ -156,8 +158,8 @@ export declare function mergeLiveIntoPool(rows: SessionMeta[], live: Map<string,
|
|
|
156
158
|
* listing that has no live rows to explain it.
|
|
157
159
|
*/
|
|
158
160
|
export declare function shouldShowHostColumn(f: BrowserFilter, live: Map<string, ActiveSession> | null, rows: SessionMeta[]): boolean;
|
|
159
|
-
/** Apply the cheap in-memory filters (agent / device / project / running /
|
|
160
|
-
export declare function applyFilters(rows: SessionMeta[], live: Map<string, ActiveSession>, f: BrowserFilter, self: string,
|
|
161
|
+
/** Apply the cheap in-memory filters (agent / device / project / running / bookmarks). */
|
|
162
|
+
export declare function applyFilters(rows: SessionMeta[], live: Map<string, ActiveSession>, f: BrowserFilter, self: string, bookmarks: Set<string>): SessionMeta[];
|
|
161
163
|
/** One-shot form of the browser's canonical candidate pipeline. Focus uses this
|
|
162
164
|
* instead of maintaining a second discovery/filter implementation. */
|
|
163
165
|
export declare function collectSessionCandidates(initial: Partial<BrowserFilter>, opts?: {
|