@phnx-labs/agents-cli 1.20.39 → 1.20.41
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 +10 -0
- package/dist/commands/computer-actions.d.ts +2 -0
- package/dist/commands/computer-actions.js +59 -0
- package/dist/commands/focus.d.ts +31 -0
- package/dist/commands/focus.js +150 -0
- package/dist/commands/go.d.ts +34 -11
- package/dist/commands/go.js +44 -64
- package/dist/commands/secrets.js +23 -10
- package/dist/commands/sessions.d.ts +9 -0
- package/dist/commands/sessions.js +47 -8
- package/dist/lib/hooks/cache.d.ts +1 -1
- package/dist/lib/hooks/cache.js +4 -2
- package/dist/lib/hosts/option.js +1 -0
- package/dist/lib/hosts/passthrough.d.ts +3 -3
- package/dist/lib/hosts/passthrough.js +14 -4
- package/dist/lib/hosts/remote-cmd.d.ts +7 -1
- package/dist/lib/hosts/remote-cmd.js +8 -1
- package/dist/lib/secrets/agent.d.ts +18 -7
- package/dist/lib/secrets/agent.js +32 -15
- package/dist/lib/secrets/bundles.d.ts +8 -6
- package/dist/lib/secrets/bundles.js +9 -5
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +32 -10
- package/dist/lib/types.d.ts +3 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 1.20.41
|
|
6
|
+
|
|
7
|
+
- **NEW: `agents sessions focus [id]`** — one command to get back to a session, however it's reachable. It **attaches** a live session in place (tmux `switch-client`/`attach-session`, a remote tmux over `ssh -tt`, or a Ghostty tab — joining the live process without forking); where there's **no live terminal to attach**, it **opens a new tab and resumes** the session — locally, or on the remote peer over SSH (`runOnPeer`, so the peer resolves the version-pinned binary). No id opens the rich live-session picker (this-machine first). Reuses the live-session detection and the terminal launch engine (`openSurfaces`), and folds `go`'s attach paths in. Source: `src/commands/focus.ts`, `src/commands/go.ts`.
|
|
8
|
+
- **`--device` is now a first-class alias of `--host`** on every host-routable command (`sessions`, `run`, …), registered centrally on `addHostOption` so a local fall-through no longer errors. Source: `src/lib/hosts/`.
|
|
9
|
+
- **`agents computer` steers Electron/webview targets over CDP** instead of reporting a fake success when the native-automation path can't reach them (#716).
|
|
10
|
+
- **Secrets: the "remember" policy hold now lasts 7 days and survives screen-lock**, instead of re-prompting after every lock/sleep; stale copies are evicted when a policy is tightened. Source: `src/lib/secrets/`.
|
|
11
|
+
- **Fixes:** shim `machine_id()` normalizes to match `normalizeHost()`, and shim resolution honors the per-device default pin (not just the central `agents.yaml`).
|
|
12
|
+
- **`agents sessions go` is retired as a deprecated alias for `agents sessions focus --attach-only`.** `go` was already a strict subset of `focus` — its only unique behavior was "attach the live terminal or refuse, never fork/resume." That behavior is now a first-class `--attach-only` flag on `focus` (`focus.ts`: `selectFallback()` picks `refuseFallback` under `--attach-only`, else the resume-in-a-new-tab fallback). `go` now prints a one-line deprecation notice and delegates to `focusAction(id, { attachOnly: true })`; the shared reach engine (`jumpTo`/`gatherLiveTargets`/`pickLiveTarget`/`refuseFallback`) still lives in `go.ts` and is imported by `focus.ts`. Source: `src/commands/go.ts`, `src/commands/focus.ts`.
|
|
13
|
+
- **`agents sessions --json --host <h>` now emits a clean JSON array** of recent (non-active) sessions instead of the legacy per-host raw banner stream, so a UI can fetch a remote device's recent sessions when it has no live agents. `serializeSessionsJson()` is shared by the local and remote `--json` paths; `runRemoteSessionsJson()` reuses the existing `gatherRemoteList` SSH fan-out. The non-JSON banner path and `--active` are unchanged (#711).
|
|
14
|
+
|
|
5
15
|
## 1.20.36
|
|
6
16
|
|
|
7
17
|
**[windows] `agents sessions --active` detects sessions on Windows, and shim launches carry cwd + session identity everywhere**
|
|
@@ -80,4 +80,6 @@ export declare function shouldRaise(opts: {
|
|
|
80
80
|
id?: string;
|
|
81
81
|
raise?: boolean;
|
|
82
82
|
}): boolean;
|
|
83
|
+
export declare function appPathIsElectron(appPath: string | null, exists?: (p: string) => boolean): boolean;
|
|
84
|
+
export declare function electronWebviewTip(appLabel: string): string;
|
|
83
85
|
export declare function registerActionCommands(program: Command): void;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// The daemon already implements every method; this file is the thin, typed
|
|
7
7
|
// CLI skin over it plus a shared target resolver so callers stay in bundle-id
|
|
8
8
|
// space and never hand-manage pids.
|
|
9
|
+
import { execFileSync } from 'child_process';
|
|
9
10
|
import * as fs from 'fs';
|
|
10
11
|
import * as path from 'path';
|
|
11
12
|
import { openComputerClient, describeTransport, resolvePolicyPath, } from '../lib/computer-rpc.js';
|
|
@@ -288,6 +289,59 @@ async function applyFocusPolicy(client, pid, opts) {
|
|
|
288
289
|
if (shouldRaise(opts))
|
|
289
290
|
unwrap(await client.call('focus_window', { pid }));
|
|
290
291
|
}
|
|
292
|
+
// Electron/webview steering. macOS accepts an AX action (AXPress / set-AXValue)
|
|
293
|
+
// on an Electron/Chromium window, but it does NOT run the web app's real DOM
|
|
294
|
+
// handlers — React ignores it — so a reported `clicked`/`typed` on a webview can
|
|
295
|
+
// be a silent no-op. Detect Electron targets and steer the caller to CDP
|
|
296
|
+
// (`agents browser --electron`), which drives the webview for real. We warn, not
|
|
297
|
+
// block: the caller may still want the raw action (e.g. to focus + coordinate).
|
|
298
|
+
const electronCache = new Map();
|
|
299
|
+
// Resolve a bundle id to its .app path via Spotlight. Best-effort; null on miss.
|
|
300
|
+
function appPathForBundle(bundleId) {
|
|
301
|
+
try {
|
|
302
|
+
const out = execFileSync('mdfind', [`kMDItemCFBundleIdentifier == '${bundleId}'`], {
|
|
303
|
+
encoding: 'utf-8',
|
|
304
|
+
timeout: 3000,
|
|
305
|
+
});
|
|
306
|
+
return out.split('\n').map((s) => s.trim()).find((s) => s.endsWith('.app')) ?? null;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// Pure + unit-tested: does the .app at this path bundle the Electron framework?
|
|
313
|
+
export function appPathIsElectron(appPath, exists = fs.existsSync) {
|
|
314
|
+
if (!appPath)
|
|
315
|
+
return false;
|
|
316
|
+
return exists(path.join(appPath, 'Contents', 'Frameworks', 'Electron Framework.framework'));
|
|
317
|
+
}
|
|
318
|
+
// Is the app for this bundle id an Electron/webview app? macOS-only; memoized so
|
|
319
|
+
// the mdfind lookup runs at most once per bundle id per process.
|
|
320
|
+
function isElectronApp(bundleId) {
|
|
321
|
+
if (!bundleId || process.platform !== 'darwin')
|
|
322
|
+
return false;
|
|
323
|
+
const cached = electronCache.get(bundleId);
|
|
324
|
+
if (cached !== undefined)
|
|
325
|
+
return cached;
|
|
326
|
+
const result = appPathIsElectron(appPathForBundle(bundleId));
|
|
327
|
+
electronCache.set(bundleId, result);
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
// Pure + unit-tested: the CDP-steer note printed for a webview target.
|
|
331
|
+
export function electronWebviewTip(appLabel) {
|
|
332
|
+
return `note: ${appLabel} is an Electron/web UI — an AX click/type may not reach the webview `
|
|
333
|
+
+ `(a reported success can be a no-op). To drive it reliably, relaunch it with `
|
|
334
|
+
+ '`--remote-debugging-port=9222` and use `agents browser --electron` (CDP).';
|
|
335
|
+
}
|
|
336
|
+
// Print the CDP steer when the target is a known Electron app. Keyed off --bundle
|
|
337
|
+
// (the recommended way to target); a frontmost-resolved target without --bundle is
|
|
338
|
+
// left alone to avoid a second RPC on the hot path. Skipped for remote --host.
|
|
339
|
+
function warnIfElectronWebview(opts) {
|
|
340
|
+
if (opts.host)
|
|
341
|
+
return;
|
|
342
|
+
if (opts.bundle && isElectronApp(opts.bundle))
|
|
343
|
+
console.error(electronWebviewTip(opts.bundle));
|
|
344
|
+
}
|
|
291
345
|
function emit(result, json, human) {
|
|
292
346
|
if (json) {
|
|
293
347
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -355,6 +409,7 @@ export function registerActionCommands(program) {
|
|
|
355
409
|
.option('--json', 'Emit JSON'))).action(async (opts) => {
|
|
356
410
|
await withClient(async (client) => {
|
|
357
411
|
const pid = await resolveTargetPid(client, opts, { verb: 'click' });
|
|
412
|
+
warnIfElectronWebview(opts);
|
|
358
413
|
const spec = buildElementOrCoords(opts);
|
|
359
414
|
if (!spec.ok) {
|
|
360
415
|
console.error(spec.error);
|
|
@@ -377,6 +432,7 @@ export function registerActionCommands(program) {
|
|
|
377
432
|
.option('--json', 'Emit JSON'))).action(async (opts) => {
|
|
378
433
|
await withClient(async (client) => {
|
|
379
434
|
const pid = await resolveTargetPid(client, opts, { verb: 'right-click' });
|
|
435
|
+
warnIfElectronWebview(opts);
|
|
380
436
|
const spec = buildElementOrCoords(opts);
|
|
381
437
|
if (!spec.ok) {
|
|
382
438
|
console.error(spec.error);
|
|
@@ -397,6 +453,7 @@ export function registerActionCommands(program) {
|
|
|
397
453
|
.option('--json', 'Emit JSON'))).action(async (opts) => {
|
|
398
454
|
await withClient(async (client) => {
|
|
399
455
|
const pid = await resolveTargetPid(client, opts, { verb: 'type' });
|
|
456
|
+
warnIfElectronWebview(opts);
|
|
400
457
|
const spec = buildElementOrCoords(opts);
|
|
401
458
|
if (!spec.ok) {
|
|
402
459
|
console.error(spec.error);
|
|
@@ -424,6 +481,7 @@ export function registerActionCommands(program) {
|
|
|
424
481
|
.option('--json', 'Emit JSON')).action(async (opts) => {
|
|
425
482
|
await withClient(async (client) => {
|
|
426
483
|
const pid = await resolveTargetPid(client, opts, { verb: 'type-text' });
|
|
484
|
+
warnIfElectronWebview(opts);
|
|
427
485
|
await applyFocusPolicy(client, pid, opts);
|
|
428
486
|
const params = { pid, text: opts.text };
|
|
429
487
|
if (opts.commit)
|
|
@@ -448,6 +506,7 @@ export function registerActionCommands(program) {
|
|
|
448
506
|
.option('--json', 'Emit JSON')).action(async (opts) => {
|
|
449
507
|
await withClient(async (client) => {
|
|
450
508
|
const pid = await resolveTargetPid(client, opts, { verb: 'key' });
|
|
509
|
+
warnIfElectronWebview(opts);
|
|
451
510
|
await applyFocusPolicy(client, pid, opts);
|
|
452
511
|
const params = { pid, keys: opts.keys };
|
|
453
512
|
if (opts.requireFrontmost)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents sessions focus [id]` — take me to a live session, however it's reachable.
|
|
3
|
+
*
|
|
4
|
+
* Same detection as `go`, but where `go` *refuses* an un-attachable session,
|
|
5
|
+
* `focus` **opens a new tab and resumes it** — locally, or on the remote over SSH
|
|
6
|
+
* (via the terminal launch engine's `openSurfaces`, `host` = the peer). So:
|
|
7
|
+
* - in tmux (local/remote) -> attach the live pane (join it, no fork)
|
|
8
|
+
* - in Ghostty -> focus its tab
|
|
9
|
+
* - headless / plain / etc. -> new tab + `resume` (a copy if it's mid-run — the
|
|
10
|
+
* original keeps going; a clean continue if it's idle)
|
|
11
|
+
*
|
|
12
|
+
* NOTE: joining a live process without forking is only possible via tmux — that's
|
|
13
|
+
* why `--tmux`-wrapped launches are worth it for sessions you'll want back live.
|
|
14
|
+
*/
|
|
15
|
+
import type { Command } from 'commander';
|
|
16
|
+
import { type UnreachableFallback } from './go.js';
|
|
17
|
+
import type { ActiveSession } from '../lib/session/active.js';
|
|
18
|
+
import type { SessionMeta } from '../lib/session/types.js';
|
|
19
|
+
export declare function registerFocusCommand(program: Command): void;
|
|
20
|
+
/**
|
|
21
|
+
* Which fallback fires when a session has no attach rail. `--attach-only` (the old
|
|
22
|
+
* `go`) refuses; the default opens a new tab and resumes a copy. Pure so it's testable
|
|
23
|
+
* without touching `jumpTo`'s side effects.
|
|
24
|
+
*/
|
|
25
|
+
export declare function selectFallback(attachOnly: boolean | undefined): UnreachableFallback;
|
|
26
|
+
export declare function focusAction(id: string | undefined, opts: {
|
|
27
|
+
local?: boolean;
|
|
28
|
+
attachOnly?: boolean;
|
|
29
|
+
}): Promise<void>;
|
|
30
|
+
/** Minimal SessionMeta for a live session, enough for `buildResumeCommand` + placement. */
|
|
31
|
+
export declare function metaFromActive(s: ActiveSession): SessionMeta;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents sessions focus [id]` — take me to a live session, however it's reachable.
|
|
3
|
+
*
|
|
4
|
+
* Same detection as `go`, but where `go` *refuses* an un-attachable session,
|
|
5
|
+
* `focus` **opens a new tab and resumes it** — locally, or on the remote over SSH
|
|
6
|
+
* (via the terminal launch engine's `openSurfaces`, `host` = the peer). So:
|
|
7
|
+
* - in tmux (local/remote) -> attach the live pane (join it, no fork)
|
|
8
|
+
* - in Ghostty -> focus its tab
|
|
9
|
+
* - headless / plain / etc. -> new tab + `resume` (a copy if it's mid-run — the
|
|
10
|
+
* original keeps going; a clean continue if it's idle)
|
|
11
|
+
*
|
|
12
|
+
* NOTE: joining a live process without forking is only possible via tmux — that's
|
|
13
|
+
* why `--tmux`-wrapped launches are worth it for sessions you'll want back live.
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
import { gatherLiveTargets, pickLiveTarget, jumpTo, refuseFallback } from './go.js';
|
|
18
|
+
import { buildResumeCommand, resumeSessionInPlace } from './sessions.js';
|
|
19
|
+
import { runOnPeer } from '../lib/session/remote-list.js';
|
|
20
|
+
import { discoverSessions } from '../lib/session/discover.js';
|
|
21
|
+
import { openSurfaces, currentContext, availableBackends, detectCurrentBackend, } from '../lib/terminal/index.js';
|
|
22
|
+
import { isInteractiveTerminal } from './utils.js';
|
|
23
|
+
export function registerFocusCommand(program) {
|
|
24
|
+
program
|
|
25
|
+
.command('focus')
|
|
26
|
+
.argument('[id]', 'Short/full session id to focus; omit for an interactive picker')
|
|
27
|
+
.option('--local', 'Only this machine (skip the cross-host sweep)')
|
|
28
|
+
.option('--attach-only', 'Attach only — never open a new tab / resume a copy (the old `go` behavior)')
|
|
29
|
+
.description('Focus a live session — attach its terminal, or open a new tab and resume it')
|
|
30
|
+
.action(async (id, opts) => {
|
|
31
|
+
await focusAction(id, opts);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Which fallback fires when a session has no attach rail. `--attach-only` (the old
|
|
36
|
+
* `go`) refuses; the default opens a new tab and resumes a copy. Pure so it's testable
|
|
37
|
+
* without touching `jumpTo`'s side effects.
|
|
38
|
+
*/
|
|
39
|
+
export function selectFallback(attachOnly) {
|
|
40
|
+
return attachOnly ? refuseFallback : resumeInNewTab;
|
|
41
|
+
}
|
|
42
|
+
export async function focusAction(id, opts) {
|
|
43
|
+
const { self, activeById } = await gatherLiveTargets(!!opts.local);
|
|
44
|
+
const fallback = selectFallback(opts.attachOnly);
|
|
45
|
+
if (id) {
|
|
46
|
+
const q = id.toLowerCase();
|
|
47
|
+
const matches = [...activeById.values()].filter((s) => s.sessionId.toLowerCase().startsWith(q));
|
|
48
|
+
if (matches.length === 1) {
|
|
49
|
+
await jumpTo(matches[0], self, fallback);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (matches.length > 1) {
|
|
53
|
+
console.error(chalk.red(`"${id}" is ambiguous (${matches.length} live matches). Use more of the id.`));
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Not live — it's a past session; resume is the right tool (multi-select + placement).
|
|
58
|
+
console.log(chalk.yellow(`No live session matching "${id}".`) +
|
|
59
|
+
chalk.gray(`\nTo resume a past session: agents sessions resume ${id}`));
|
|
60
|
+
process.exitCode = 1;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (!isInteractiveTerminal()) {
|
|
64
|
+
console.error(chalk.red('focus needs an interactive terminal, or pass a session id.'));
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (activeById.size === 0) {
|
|
69
|
+
console.log(chalk.gray('No live sessions to focus. To resume a past one: agents sessions resume'));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const target = await pickLiveTarget(activeById, self, 'Focus a live session:', 'focus');
|
|
73
|
+
if (!target)
|
|
74
|
+
return;
|
|
75
|
+
await jumpTo(target, self, fallback);
|
|
76
|
+
}
|
|
77
|
+
function shortId(s) {
|
|
78
|
+
return (s.sessionId ?? '').slice(0, 8) || '-';
|
|
79
|
+
}
|
|
80
|
+
/** Minimal SessionMeta for a live session, enough for `buildResumeCommand` + placement. */
|
|
81
|
+
export function metaFromActive(s) {
|
|
82
|
+
return {
|
|
83
|
+
id: s.sessionId ?? '',
|
|
84
|
+
shortId: shortId(s),
|
|
85
|
+
agent: s.kind,
|
|
86
|
+
timestamp: new Date(s.startedAtMs ?? Date.now()).toISOString(),
|
|
87
|
+
filePath: '',
|
|
88
|
+
cwd: s.cwd,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** Look up the rich indexed SessionMeta by id so `version` survives (version-pinned resume). */
|
|
92
|
+
async function richMetaById(id) {
|
|
93
|
+
try {
|
|
94
|
+
const metas = await discoverSessions({ all: true, since: '90d', limit: 2000 });
|
|
95
|
+
return metas.find((m) => m.id === id) ?? metas.find((m) => m.id.startsWith(id));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* `focus`'s fallback for a session with no attach rail: reopen it and hand you to it.
|
|
103
|
+
* - remote → resume ON the peer over SSH (foreground) — the peer resolves the pinned
|
|
104
|
+
* version and holds the transcript, and `-tt` delivers you there.
|
|
105
|
+
* - local → resume in a new tab in your terminal, version-pinned via the indexed meta.
|
|
106
|
+
* Note: for a session that's still mid-run, this opens a COPY (the original keeps going);
|
|
107
|
+
* only tmux can *join* a live one without forking (see the header).
|
|
108
|
+
*/
|
|
109
|
+
const resumeInNewTab = async (s, remote) => {
|
|
110
|
+
const id = s.sessionId ?? '';
|
|
111
|
+
if (!id) {
|
|
112
|
+
console.log(chalk.yellow('This session has no id to resume.'));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Remote: the transcript + pinned version live on the peer, so resume THERE over SSH.
|
|
116
|
+
// runOnPeer runs `agents sessions resume <id>` with a real TTY (`-tt`) in the foreground —
|
|
117
|
+
// it actually delivers you to the session (the peer picks the right version + HOME).
|
|
118
|
+
if (remote) {
|
|
119
|
+
console.log(chalk.gray(`${shortId(s)} has no live terminal on ${remote} — resuming it there over SSH…`));
|
|
120
|
+
const rc = await runOnPeer(['sessions', 'resume', id], remote, { tty: true });
|
|
121
|
+
if (rc === 'no-target') {
|
|
122
|
+
console.log(chalk.red(`${remote} isn't reachable as a device. Try: agents devices sync`));
|
|
123
|
+
console.log(chalk.gray(` or run it yourself: ssh ${remote} 'agents sessions resume ${shortId(s)}'`));
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Local: resume in a new tab. Use the indexed meta so the version-pinned binary
|
|
128
|
+
// resumes in the same isolated HOME the transcript was written in.
|
|
129
|
+
const meta = (await richMetaById(id)) ?? metaFromActive(s);
|
|
130
|
+
const command = buildResumeCommand(meta);
|
|
131
|
+
if (!command) {
|
|
132
|
+
console.log(chalk.yellow(`${meta.shortId} — ${meta.agent} sessions aren't resumable, so there's no way to reopen it.`));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const cwd = meta.cwd && fs.existsSync(meta.cwd) ? meta.cwd : process.cwd();
|
|
136
|
+
const ctx = currentContext();
|
|
137
|
+
const backend = detectCurrentBackend(ctx) ?? availableBackends(ctx)[0]?.id;
|
|
138
|
+
if (!backend) {
|
|
139
|
+
// No tab-capable surface (off-macOS, not in tmux) — resume in this process.
|
|
140
|
+
await resumeSessionInPlace(meta);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
console.log(chalk.gray(`${shortId(s)} has no live terminal to attach — opening a new ${backend} tab and resuming a copy.`));
|
|
144
|
+
const results = await openSurfaces([{ cwd, command }], { backend, packing: 'tabs' });
|
|
145
|
+
const r = results[0];
|
|
146
|
+
if (!r || !r.ok) {
|
|
147
|
+
console.log(chalk.red(` failed to open — ${r?.error ?? 'unknown error'}`));
|
|
148
|
+
console.log(chalk.gray(` try: agents sessions resume ${meta.shortId}`));
|
|
149
|
+
}
|
|
150
|
+
};
|
package/dist/commands/go.d.ts
CHANGED
|
@@ -1,21 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `agents sessions go [id]` —
|
|
2
|
+
* `agents sessions go [id]` — DEPRECATED alias for `agents sessions focus --attach-only`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* filtered to sessions that are running right now.
|
|
7
|
-
* With id -> jump directly.
|
|
4
|
+
* `go` was "attach or refuse" (never fork/resume). `focus --attach-only` is exactly
|
|
5
|
+
* that behavior, so `go` now prints a deprecation notice and delegates to `focusAction`.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
7
|
+
* This file still owns the shared reach engine that `focus` imports:
|
|
8
|
+
* - `gatherLiveTargets` / `pickLiveTarget` / `buildLivePool` — live-session discovery + picker
|
|
9
|
+
* - `jumpTo` — the side-effecting jump: attach the already-running terminal
|
|
10
|
+
* local tmux -> attach (switch-client when already inside tmux)
|
|
11
|
+
* local Ghostty -> focus its tab (Cmd+<n> via System Events; tab # from ghostty-tabs)
|
|
12
|
+
* remote tmux -> ssh -tt + tmux attach (pane->session resolved on the remote)
|
|
13
|
+
* otherwise -> hand off to the `UnreachableFallback` (attach-only refuses; focus resumes)
|
|
14
|
+
* - `refuseFallback` — the attach-only fallback (remote -> login shell; local -> refuse)
|
|
15
15
|
*/
|
|
16
16
|
import type { Command } from 'commander';
|
|
17
17
|
import { type ActiveSession } from '../lib/session/active.js';
|
|
18
|
+
import type { SessionMeta } from '../lib/session/types.js';
|
|
18
19
|
export declare function registerGoCommand(program: Command): void;
|
|
20
|
+
/** Live jump targets (local + remote), keyed by session id. Cloud excluded (no pid). */
|
|
21
|
+
export declare function gatherLiveTargets(local: boolean): Promise<{
|
|
22
|
+
self: string;
|
|
23
|
+
activeById: Map<string, ActiveSession>;
|
|
24
|
+
}>;
|
|
25
|
+
/** Interactive pick over the live sessions' rich SessionMeta; returns the chosen live session. */
|
|
26
|
+
export declare function pickLiveTarget(activeById: Map<string, ActiveSession>, self: string, message: string, enterHint: string): Promise<ActiveSession | null>;
|
|
27
|
+
/**
|
|
28
|
+
* Map each live session to its rich SessionMeta (worktree/PR/changes/tools/tests
|
|
29
|
+
* via the shared picker), reusing `discoverSessions`. Remote or unindexed live
|
|
30
|
+
* sessions get a minimal synthesized meta so they still appear and jump.
|
|
31
|
+
*/
|
|
32
|
+
export declare function buildLivePool(activeById: Map<string, ActiveSession>, self: string): Promise<SessionMeta[]>;
|
|
19
33
|
export interface Where {
|
|
20
34
|
label: string;
|
|
21
35
|
action: string;
|
|
@@ -26,3 +40,12 @@ export interface Where {
|
|
|
26
40
|
* `jumpTo` below: remote-tmux, then local-tmux, then ghostty, then refuse.
|
|
27
41
|
*/
|
|
28
42
|
export declare function describeWhere(s: ActiveSession, self: string): Where;
|
|
43
|
+
/**
|
|
44
|
+
* What to do when a session can't be *attached* (no tmux/Ghostty rail). `go`
|
|
45
|
+
* refuses; `focus` opens a new tab and resumes. `remote` is the peer name when
|
|
46
|
+
* the session lives on another machine, else undefined.
|
|
47
|
+
*/
|
|
48
|
+
export type UnreachableFallback = (s: ActiveSession, remote: string | undefined) => void | Promise<void>;
|
|
49
|
+
/** Default (attach-only): open a login shell on the remote, or refuse locally. */
|
|
50
|
+
export declare function refuseFallback(s: ActiveSession, remote: string | undefined): Promise<void>;
|
|
51
|
+
export declare function jumpTo(s: ActiveSession, self: string, fallback?: UnreachableFallback): Promise<void>;
|
package/dist/commands/go.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `agents sessions go [id]` —
|
|
2
|
+
* `agents sessions go [id]` — DEPRECATED alias for `agents sessions focus --attach-only`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* filtered to sessions that are running right now.
|
|
7
|
-
* With id -> jump directly.
|
|
4
|
+
* `go` was "attach or refuse" (never fork/resume). `focus --attach-only` is exactly
|
|
5
|
+
* that behavior, so `go` now prints a deprecation notice and delegates to `focusAction`.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
7
|
+
* This file still owns the shared reach engine that `focus` imports:
|
|
8
|
+
* - `gatherLiveTargets` / `pickLiveTarget` / `buildLivePool` — live-session discovery + picker
|
|
9
|
+
* - `jumpTo` — the side-effecting jump: attach the already-running terminal
|
|
10
|
+
* local tmux -> attach (switch-client when already inside tmux)
|
|
11
|
+
* local Ghostty -> focus its tab (Cmd+<n> via System Events; tab # from ghostty-tabs)
|
|
12
|
+
* remote tmux -> ssh -tt + tmux attach (pane->session resolved on the remote)
|
|
13
|
+
* otherwise -> hand off to the `UnreachableFallback` (attach-only refuses; focus resumes)
|
|
14
|
+
* - `refuseFallback` — the attach-only fallback (remote -> login shell; local -> refuse)
|
|
15
15
|
*/
|
|
16
16
|
import chalk from 'chalk';
|
|
17
17
|
import path from 'path';
|
|
@@ -21,8 +21,8 @@ import { getActiveSessions, findSessionFileForKind } from '../lib/session/active
|
|
|
21
21
|
import { gatherRemoteActive } from '../lib/session/remote-active.js';
|
|
22
22
|
import { discoverSessions } from '../lib/session/discover.js';
|
|
23
23
|
import { dedupeByMachineSession, mergeLocalFirst, pickSessionInteractive } from './sessions.js';
|
|
24
|
+
import { focusAction } from './focus.js';
|
|
24
25
|
import { machineId } from '../lib/session/sync/config.js';
|
|
25
|
-
import { isInteractiveTerminal } from './utils.js';
|
|
26
26
|
import { attachTmux, runTmux } from '../lib/tmux/binary.js';
|
|
27
27
|
import { getDefaultSocketPath } from '../lib/tmux/paths.js';
|
|
28
28
|
import { sshStream, assertValidSshTarget, shellQuote } from '../lib/ssh-exec.js';
|
|
@@ -33,20 +33,21 @@ export function registerGoCommand(program) {
|
|
|
33
33
|
.command('go')
|
|
34
34
|
.argument('[id]', 'Short/full session id to jump to; omit for an interactive picker')
|
|
35
35
|
.option('--local', 'Only this machine (skip the cross-host sweep)')
|
|
36
|
-
.description('
|
|
36
|
+
.description('Deprecated alias for `sessions focus --attach-only`')
|
|
37
37
|
.action(async (id, opts) => {
|
|
38
|
-
|
|
38
|
+
console.error(chalk.yellow('`sessions go` is deprecated — use `sessions focus --attach-only`'));
|
|
39
|
+
await focusAction(id, { local: opts.local, attachOnly: true });
|
|
39
40
|
});
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
+
/** Live jump targets (local + remote), keyed by session id. Cloud excluded (no pid). */
|
|
43
|
+
export async function gatherLiveTargets(local) {
|
|
42
44
|
const self = machineId();
|
|
43
|
-
// Live jump targets (local + remote), keyed by session id.
|
|
44
45
|
const localActive = await getActiveSessions();
|
|
45
46
|
for (const s of localActive)
|
|
46
47
|
if (!s.machine)
|
|
47
48
|
s.machine = self;
|
|
48
49
|
let active = localActive;
|
|
49
|
-
if (!
|
|
50
|
+
if (!local) {
|
|
50
51
|
try {
|
|
51
52
|
const remote = await gatherRemoteActive();
|
|
52
53
|
active = dedupeByMachineSession([...localActive, ...remote.sessions]);
|
|
@@ -57,54 +58,24 @@ async function goAction(id, opts) {
|
|
|
57
58
|
for (const s of active)
|
|
58
59
|
if (s.context !== 'cloud' && s.sessionId)
|
|
59
60
|
activeById.set(s.sessionId, s);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// Direct jump by id — no picker.
|
|
65
|
-
if (id) {
|
|
66
|
-
const q = id.toLowerCase();
|
|
67
|
-
const matches = [...activeById.values()].filter((s) => s.sessionId.toLowerCase().startsWith(q));
|
|
68
|
-
if (matches.length === 0) {
|
|
69
|
-
console.error(chalk.red(`No live session matching "${id}".`));
|
|
70
|
-
process.exitCode = 1;
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
if (matches.length > 1) {
|
|
74
|
-
console.error(chalk.red(`"${id}" is ambiguous (${matches.length} matches). Use more of the id.`));
|
|
75
|
-
process.exitCode = 1;
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
await jumpTo(matches[0], self);
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
if (!isInteractiveTerminal()) {
|
|
82
|
-
console.error(chalk.red('go needs an interactive terminal, or pass a session id.'));
|
|
83
|
-
process.exitCode = 1;
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
// Reuse the rich `sessions` picker over the live sessions' full SessionMeta.
|
|
61
|
+
return { self, activeById };
|
|
62
|
+
}
|
|
63
|
+
/** Interactive pick over the live sessions' rich SessionMeta; returns the chosen live session. */
|
|
64
|
+
export async function pickLiveTarget(activeById, self, message, enterHint) {
|
|
87
65
|
const pool = await buildLivePool(activeById, self);
|
|
88
|
-
if (pool.length === 0)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
const picked = await pickSessionInteractive(pool, 'Jump to a live session:', undefined, 0, 'jump');
|
|
66
|
+
if (pool.length === 0)
|
|
67
|
+
return null;
|
|
68
|
+
const picked = await pickSessionInteractive(pool, message, undefined, 0, enterHint);
|
|
93
69
|
if (!picked)
|
|
94
|
-
return;
|
|
95
|
-
|
|
96
|
-
if (!target) {
|
|
97
|
-
console.log(chalk.yellow(`${picked.session.shortId} is no longer live — try: `) + chalk.gray(`agents sessions resume ${picked.session.shortId}`));
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
await jumpTo(target, self);
|
|
70
|
+
return null;
|
|
71
|
+
return activeById.get(picked.session.id) ?? null;
|
|
101
72
|
}
|
|
102
73
|
/**
|
|
103
74
|
* Map each live session to its rich SessionMeta (worktree/PR/changes/tools/tests
|
|
104
75
|
* via the shared picker), reusing `discoverSessions`. Remote or unindexed live
|
|
105
76
|
* sessions get a minimal synthesized meta so they still appear and jump.
|
|
106
77
|
*/
|
|
107
|
-
async function buildLivePool(activeById, self) {
|
|
78
|
+
export async function buildLivePool(activeById, self) {
|
|
108
79
|
let metas = [];
|
|
109
80
|
try {
|
|
110
81
|
metas = await discoverSessions({ all: true, since: '30d', limit: 1000 });
|
|
@@ -161,7 +132,17 @@ export function describeWhere(s, self) {
|
|
|
161
132
|
return { label: `${s.host ?? 'shell'} on ${remote}`, action: `open a shell on ${remote}` };
|
|
162
133
|
return { label: s.host ?? 'unknown terminal', action: 'resume it (no live attach rail)' };
|
|
163
134
|
}
|
|
164
|
-
|
|
135
|
+
/** Default (attach-only): open a login shell on the remote, or refuse locally. */
|
|
136
|
+
export async function refuseFallback(s, remote) {
|
|
137
|
+
if (remote) {
|
|
138
|
+
console.log(chalk.yellow(`${shortId(s)} on ${remote} isn't inside tmux — opening a shell on ${remote} instead.`));
|
|
139
|
+
assertValidSshTarget(remote);
|
|
140
|
+
process.exit(sshStream(remote, 'exec "${SHELL:-/bin/sh}" -l', { tty: true }));
|
|
141
|
+
}
|
|
142
|
+
console.log(chalk.yellow(`Can't jump to ${shortId(s)} — it's in ${s.host ?? 'an unknown terminal'} with no attach rail (not tmux/Ghostty).`) +
|
|
143
|
+
chalk.gray(`\nTry: agents sessions resume ${shortId(s)}`));
|
|
144
|
+
}
|
|
145
|
+
export async function jumpTo(s, self, fallback = refuseFallback) {
|
|
165
146
|
const remote = s.machine && s.machine !== self ? s.machine : undefined;
|
|
166
147
|
const mux = s.provenance?.mux;
|
|
167
148
|
// Path C: remote tmux — ssh in and attach, resolving the pane's session on the remote.
|
|
@@ -177,9 +158,9 @@ async function jumpTo(s, self) {
|
|
|
177
158
|
console.log(chalk.gray(`Attaching ${shortId(s)} on ${remote} over SSH — Ctrl-b d to detach.`));
|
|
178
159
|
process.exit(sshStream(remote, remoteCmd, { tty: true }));
|
|
179
160
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
161
|
+
// Remote, not in tmux → hand off to the fallback (go: shell; focus: resume in a tab).
|
|
162
|
+
await fallback(s, remote);
|
|
163
|
+
return;
|
|
183
164
|
}
|
|
184
165
|
// Path B: local tmux — attach (or switch-client if we're already inside tmux).
|
|
185
166
|
if (mux?.kind === 'tmux' && mux.pane) {
|
|
@@ -218,9 +199,8 @@ async function jumpTo(s, self) {
|
|
|
218
199
|
chalk.gray(tab != null ? ` — switch to tab ${tab} (Cmd+${tab}).` : " — couldn't pinpoint its tab (same-repo forks are ambiguous); switch tabs manually."));
|
|
219
200
|
return;
|
|
220
201
|
}
|
|
221
|
-
// Path D:
|
|
222
|
-
|
|
223
|
-
chalk.gray(`\nTry: agents sessions resume ${shortId(s)}`));
|
|
202
|
+
// Path D: no attach rail (headless / plain terminal) → hand off to the fallback.
|
|
203
|
+
await fallback(s, undefined);
|
|
224
204
|
}
|
|
225
205
|
/** Resolve a local tmux pane id to its session name + window index. */
|
|
226
206
|
async function resolveLocalPane(socket, pane) {
|
package/dist/commands/secrets.js
CHANGED
|
@@ -490,7 +490,7 @@ export function registerSecretsCommands(program) {
|
|
|
490
490
|
# See what's in the bundle (values masked); shows its prompt policy
|
|
491
491
|
agents secrets view prod
|
|
492
492
|
|
|
493
|
-
# Stop a noisy automation bundle from prompting every run: ask once a
|
|
493
|
+
# Stop a noisy automation bundle from prompting every run: ask once a week
|
|
494
494
|
agents secrets policy prod daily
|
|
495
495
|
|
|
496
496
|
# Eval the bundle into your current shell
|
|
@@ -510,15 +510,16 @@ export function registerSecretsCommands(program) {
|
|
|
510
510
|
Touch ID noise: macOS pops a prompt per bundle per process. Each bundle has
|
|
511
511
|
a prompt policy, shown in the POLICY column of 'agents secrets list':
|
|
512
512
|
daily (default) ask once, then hold it silently in the local agent up
|
|
513
|
-
to ~
|
|
513
|
+
to ~7 days, until sleep / logout or 'lock' (a bare
|
|
514
|
+
screen-lock does NOT drop it). Name is historical.
|
|
514
515
|
always ask for Touch ID every time — never auto-held.
|
|
515
|
-
The default is 'daily' (one Touch ID per ~
|
|
516
|
+
The default is 'daily' (one Touch ID per ~7 days); change it globally with
|
|
516
517
|
'secrets.policy' in agents.yaml, or per bundle with 'agents secrets policy
|
|
517
518
|
<bundle> always'. 'agents secrets unlock <bundle>' holds any bundle after one
|
|
518
519
|
prompt regardless of policy. Nothing on disk.
|
|
519
520
|
|
|
520
521
|
See also:
|
|
521
|
-
agents secrets policy <bundle> daily ask once a
|
|
522
|
+
agents secrets policy <bundle> daily ask once a week, not every run
|
|
522
523
|
agents secrets unlock <bundle> hold a bundle after one Touch ID
|
|
523
524
|
agents secrets lock wipe held bundles (re-prompt next read)
|
|
524
525
|
agents secrets status show held bundles + when they lock
|
|
@@ -623,7 +624,7 @@ export function registerSecretsCommands(program) {
|
|
|
623
624
|
}
|
|
624
625
|
else {
|
|
625
626
|
console.log(bundlePolicy(bundle) === 'daily'
|
|
626
|
-
? chalk.gray('policy: daily (ask once, then held ~
|
|
627
|
+
? chalk.gray('policy: daily (ask once, then held ~7 days until sleep / logout — screen-lock does not drop it)')
|
|
627
628
|
: chalk.gray('policy: always (asks for Touch ID every time — never auto-held)'));
|
|
628
629
|
}
|
|
629
630
|
if (bundle.created_at)
|
|
@@ -747,7 +748,7 @@ export function registerSecretsCommands(program) {
|
|
|
747
748
|
.description('Create an empty bundle')
|
|
748
749
|
.option('--description <text>', 'Free-form description')
|
|
749
750
|
.option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
|
|
750
|
-
.option('--policy <policy>', 'prompt policy: daily (default, ask once a
|
|
751
|
+
.option('--policy <policy>', 'prompt policy: daily (default, ask once a week), always (ask every time), or never (silent, NO biometry ACL — needs --i-understand)')
|
|
751
752
|
.addOption(new Option('--tier <policy>', 'deprecated alias for --policy').hideHelp())
|
|
752
753
|
.option('--i-understand', 'Confirm creating a "never"-policy bundle (no biometry ACL) without an interactive prompt')
|
|
753
754
|
.option('--backend <backend>', 'storage backend: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
|
|
@@ -1528,7 +1529,7 @@ Examples:
|
|
|
1528
1529
|
cmd
|
|
1529
1530
|
.command('unlock [names...]')
|
|
1530
1531
|
.description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS).')
|
|
1531
|
-
.option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h). Default
|
|
1532
|
+
.option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
|
|
1532
1533
|
.option('--all', 'Unlock every configured bundle')
|
|
1533
1534
|
.action(async (names, opts) => {
|
|
1534
1535
|
if (process.platform !== 'darwin') {
|
|
@@ -1544,7 +1545,7 @@ Examples:
|
|
|
1544
1545
|
if (opts.ttl) {
|
|
1545
1546
|
const secs = parseDuration(opts.ttl);
|
|
1546
1547
|
if (!secs) {
|
|
1547
|
-
console.error(chalk.red(`Invalid --ttl '${opts.ttl}'. Use e.g. 30m, 2h, 8h.`));
|
|
1548
|
+
console.error(chalk.red(`Invalid --ttl '${opts.ttl}'. Use e.g. 30m, 2h, 8h, 3d.`));
|
|
1548
1549
|
process.exit(1);
|
|
1549
1550
|
}
|
|
1550
1551
|
ttlMs = secs * 1000;
|
|
@@ -1628,7 +1629,7 @@ Examples:
|
|
|
1628
1629
|
cmd
|
|
1629
1630
|
.command('policy <bundle> [policy]')
|
|
1630
1631
|
.alias('tier')
|
|
1631
|
-
.description("Show or set a bundle's prompt policy: daily (default, ask once a
|
|
1632
|
+
.description("Show or set a bundle's prompt policy: daily (default, ask once a week), always (ask every time), or never (silent, NO biometry ACL).")
|
|
1632
1633
|
.option('--i-understand', 'Confirm switching to the "never" policy (no biometry ACL) without an interactive prompt')
|
|
1633
1634
|
.action(async (bundleName, policyArg, opts) => {
|
|
1634
1635
|
try {
|
|
@@ -1644,11 +1645,23 @@ Examples:
|
|
|
1644
1645
|
console.error(chalk.yellow('Aborted.'));
|
|
1645
1646
|
return;
|
|
1646
1647
|
}
|
|
1648
|
+
const wasDaily = bundlePolicy(bundle) === 'daily';
|
|
1647
1649
|
bundle.policy = next;
|
|
1648
1650
|
writeBundle(bundle);
|
|
1651
|
+
// Tightening daily -> always/never must take effect NOW, not up to the
|
|
1652
|
+
// ~7d hold later. If the broker is already serving this bundle silently
|
|
1653
|
+
// (auto-cached under the old `daily` policy), evict it so the next read
|
|
1654
|
+
// re-prompts (`always`) or reads its no-ACL item directly (`never`).
|
|
1655
|
+
// macOS-only + best-effort; agentLock no-ops off darwin / with no broker.
|
|
1656
|
+
if (wasDaily && next !== 'daily') {
|
|
1657
|
+
try {
|
|
1658
|
+
await agentLock(bundle.name);
|
|
1659
|
+
}
|
|
1660
|
+
catch { /* broker down — nothing held */ }
|
|
1661
|
+
}
|
|
1649
1662
|
console.log(chalk.green(`${bundle.name} policy set to ${next}.`));
|
|
1650
1663
|
if (next === 'daily') {
|
|
1651
|
-
console.log(chalk.gray('Held by the secrets-agent for ~
|
|
1664
|
+
console.log(chalk.gray('Held by the secrets-agent for ~7 days after one unlock (auto-cache is on by default; disable with `secrets.agent.auto: false` in agents.yaml).'));
|
|
1652
1665
|
}
|
|
1653
1666
|
else if (next === 'always') {
|
|
1654
1667
|
console.log(chalk.gray('Asks for Touch ID every time — never auto-held.'));
|
|
@@ -98,6 +98,15 @@ export declare function dedupeByMachineSession(sessions: ActiveSession[]): Activ
|
|
|
98
98
|
* `localMachine` is injected so the ordering is testable without os.hostname().
|
|
99
99
|
*/
|
|
100
100
|
export declare function mergeLocalFirst(sessions: SessionMeta[], localMachine: string): SessionMeta[];
|
|
101
|
+
/**
|
|
102
|
+
* Serialize a `SessionMeta[]` to the clean JSON shape the `--json` listing
|
|
103
|
+
* emits: strip the internal-only scoring/provenance fields (`_matchedTerms`,
|
|
104
|
+
* `_bm25Score`, `_remote`) that are search/fan-out bookkeeping, never part of
|
|
105
|
+
* the public record, then pretty-print as a 2-space array with a trailing
|
|
106
|
+
* newline. The single seam shared by the local `--json` path and the
|
|
107
|
+
* `--json --host` remote fan-out so both emit byte-identical row shapes.
|
|
108
|
+
*/
|
|
109
|
+
export declare function serializeSessionsJson(sessions: SessionMeta[]): string;
|
|
101
110
|
/**
|
|
102
111
|
* Whether the local machine's sessions belong in an `--active` view. Local is
|
|
103
112
|
* included by default; an explicit `--host`/`--device` list scopes the view to
|
|
@@ -40,6 +40,7 @@ import { registerSessionsTailCommand } from './sessions-tail.js';
|
|
|
40
40
|
import { registerSessionsSyncCommand } from './sessions-sync.js';
|
|
41
41
|
import { registerSessionsResumeCommand } from './sessions-resume.js';
|
|
42
42
|
import { registerGoCommand } from './go.js';
|
|
43
|
+
import { registerFocusCommand } from './focus.js';
|
|
43
44
|
import { registerSessionsInjectCommand } from './sessions-inject.js';
|
|
44
45
|
const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
|
|
45
46
|
/**
|
|
@@ -507,6 +508,40 @@ export function mergeLocalFirst(sessions, localMachine) {
|
|
|
507
508
|
});
|
|
508
509
|
return keys.flatMap((k) => byMachine.get(k));
|
|
509
510
|
}
|
|
511
|
+
/**
|
|
512
|
+
* Serialize a `SessionMeta[]` to the clean JSON shape the `--json` listing
|
|
513
|
+
* emits: strip the internal-only scoring/provenance fields (`_matchedTerms`,
|
|
514
|
+
* `_bm25Score`, `_remote`) that are search/fan-out bookkeeping, never part of
|
|
515
|
+
* the public record, then pretty-print as a 2-space array with a trailing
|
|
516
|
+
* newline. The single seam shared by the local `--json` path and the
|
|
517
|
+
* `--json --host` remote fan-out so both emit byte-identical row shapes.
|
|
518
|
+
*/
|
|
519
|
+
export function serializeSessionsJson(sessions) {
|
|
520
|
+
const serializable = sessions.map((s) => {
|
|
521
|
+
const { _matchedTerms, _bm25Score, _remote, ...rest } = s;
|
|
522
|
+
return rest;
|
|
523
|
+
});
|
|
524
|
+
return JSON.stringify(serializable, null, 2) + '\n';
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* `agents sessions --json --host <h>` — fan the RECENT (non-active) listing out
|
|
528
|
+
* to the named host(s) and emit ONE clean merged `SessionMeta[]` JSON array,
|
|
529
|
+
* the same shape the local `--json` path emits. Reuses `gatherRemoteList` (the
|
|
530
|
+
* exact SSH fan-out the interactive cross-machine listing already uses) and
|
|
531
|
+
* serializes the merged, machine-tagged rows — instead of `runRemoteSessions`,
|
|
532
|
+
* which streams each remote's raw stdout under a per-host banner and so can
|
|
533
|
+
* never be JSON.parsed. A dead host contributes `[]` (with a stderr note from
|
|
534
|
+
* the fan-out), so stdout is always a valid array and the exit stays 0.
|
|
535
|
+
*/
|
|
536
|
+
async function runRemoteSessionsJson(hosts) {
|
|
537
|
+
// Forward the caller's own filters (query, --limit, --since, …) minus --host,
|
|
538
|
+
// and guarantee --json so each peer answers with a parseable array.
|
|
539
|
+
const forwarded = buildForwardedArgs(process.argv, new Set(hosts));
|
|
540
|
+
if (!forwarded.includes('--json'))
|
|
541
|
+
forwarded.push('--json');
|
|
542
|
+
const { sessions } = await gatherRemoteList(forwarded, hosts);
|
|
543
|
+
process.stdout.write(serializeSessionsJson(sessions));
|
|
544
|
+
}
|
|
510
545
|
/**
|
|
511
546
|
* `running N · idle N · waiting N · queued N` for a bucket of sessions (zero
|
|
512
547
|
* buckets omitted). Same bucketing as the grand-total summary so per-group
|
|
@@ -715,10 +750,17 @@ async function sessionsAction(query, options) {
|
|
|
715
750
|
if (options.device && options.device.length > 0) {
|
|
716
751
|
options.host = [...(options.host ?? []), ...options.device];
|
|
717
752
|
}
|
|
718
|
-
// --host WITHOUT --active
|
|
719
|
-
//
|
|
720
|
-
//
|
|
753
|
+
// --host WITHOUT --active. `--json` fans the recent listing out and emits ONE
|
|
754
|
+
// clean merged SessionMeta[] array (same shape as the local --json path), for
|
|
755
|
+
// scripts/extensions that JSON.parse a remote's history. Without --json it
|
|
756
|
+
// keeps the legacy per-host stream (each remote's raw stdout under a
|
|
757
|
+
// `── host ──` banner). With --active, the hosts are folded into the merged
|
|
758
|
+
// machine-grouped view instead (handled below).
|
|
721
759
|
if (options.host && options.host.length > 0 && !options.active) {
|
|
760
|
+
if (options.json) {
|
|
761
|
+
await runRemoteSessionsJson(options.host);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
722
764
|
try {
|
|
723
765
|
runRemoteSessions(options.host);
|
|
724
766
|
}
|
|
@@ -858,11 +900,7 @@ async function sessionsAction(query, options) {
|
|
|
858
900
|
}
|
|
859
901
|
if (options.json) {
|
|
860
902
|
const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
|
|
861
|
-
|
|
862
|
-
const { _matchedTerms, _bm25Score, _remote, ...rest } = s;
|
|
863
|
-
return rest;
|
|
864
|
-
});
|
|
865
|
-
process.stdout.write(JSON.stringify(serializable, null, 2) + '\n');
|
|
903
|
+
process.stdout.write(serializeSessionsJson(filtered));
|
|
866
904
|
return;
|
|
867
905
|
}
|
|
868
906
|
// Cross-machine fan-out: unless --local (or we ARE a peer answering a
|
|
@@ -2014,6 +2052,7 @@ export function registerSessionsCommands(program) {
|
|
|
2014
2052
|
registerSessionsSyncCommand(sessionsCmd);
|
|
2015
2053
|
registerSessionsResumeCommand(sessionsCmd);
|
|
2016
2054
|
registerGoCommand(sessionsCmd);
|
|
2055
|
+
registerFocusCommand(sessionsCmd);
|
|
2017
2056
|
registerSessionsInjectCommand(sessionsCmd);
|
|
2018
2057
|
}
|
|
2019
2058
|
function formatNoSessionsMessage(showAll, project) {
|
|
@@ -5,7 +5,7 @@ import type { HookCache, HookCacheConfig } from '../types.js';
|
|
|
5
5
|
* Returns null if the value is missing or unparseable.
|
|
6
6
|
*/
|
|
7
7
|
export declare function parseCacheConfig(raw: HookCache | undefined): HookCacheConfig | null;
|
|
8
|
-
/** Parse "30s" | "5m" | "1h" | plain seconds. Returns seconds, or null on failure. */
|
|
8
|
+
/** Parse "30s" | "5m" | "1h" | "7d" | plain seconds. Returns seconds, or null on failure. */
|
|
9
9
|
export declare function parseDuration(d: number | string | undefined): number | null;
|
|
10
10
|
/**
|
|
11
11
|
* Reject hook names that could escape the shims directory when interpolated
|
package/dist/lib/hooks/cache.js
CHANGED
|
@@ -51,19 +51,21 @@ function parseShorthand(s) {
|
|
|
51
51
|
return null;
|
|
52
52
|
return { ttl: ttlSec, key: 'global', prefetch };
|
|
53
53
|
}
|
|
54
|
-
/** Parse "30s" | "5m" | "1h" | plain seconds. Returns seconds, or null on failure. */
|
|
54
|
+
/** Parse "30s" | "5m" | "1h" | "7d" | plain seconds. Returns seconds, or null on failure. */
|
|
55
55
|
export function parseDuration(d) {
|
|
56
56
|
if (d == null)
|
|
57
57
|
return null;
|
|
58
58
|
if (typeof d === 'number')
|
|
59
59
|
return Number.isFinite(d) && d > 0 ? Math.floor(d) : null;
|
|
60
|
-
const m = d.trim().match(/^(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs)?$/i);
|
|
60
|
+
const m = d.trim().match(/^(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)?$/i);
|
|
61
61
|
if (!m)
|
|
62
62
|
return null;
|
|
63
63
|
const value = parseInt(m[1], 10);
|
|
64
64
|
if (!Number.isFinite(value) || value <= 0)
|
|
65
65
|
return null;
|
|
66
66
|
const unit = (m[2] || 's').toLowerCase();
|
|
67
|
+
if (unit.startsWith('d'))
|
|
68
|
+
return value * 86400;
|
|
67
69
|
if (unit.startsWith('h'))
|
|
68
70
|
return value * 3600;
|
|
69
71
|
if (unit.startsWith('m'))
|
package/dist/lib/hosts/option.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
export function addHostOption(cmd) {
|
|
14
14
|
return cmd
|
|
15
15
|
.option('-H, --host <name>', 'Run this command on another machine over SSH instead of locally — a device, a registered host, or user@host. See `agents devices` / `agents hosts`.')
|
|
16
|
+
.option('--device <name>', 'Alias of --host: run this command on a registered device (from `agents devices`).')
|
|
16
17
|
.option('--remote-cwd <dir>', 'Working directory on the host for --host runs.')
|
|
17
18
|
.option('--no-tty', 'Force non-interactive output for --host runs even from a terminal.')
|
|
18
19
|
.option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.');
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
export declare function flagValue(args: string[], long: string, short?: string): string | undefined;
|
|
21
21
|
/**
|
|
22
22
|
* Route `agents <command> … --host <name>` to a remote if the command is
|
|
23
|
-
* host-routable and a `--host` was given. Returns
|
|
24
|
-
*
|
|
25
|
-
* very machine.
|
|
23
|
+
* host-routable and a `--host` (or its `--device` alias) was given. Returns
|
|
24
|
+
* `false` (run locally) when neither flag is present, the command isn't in the
|
|
25
|
+
* table, or the target is this very machine.
|
|
26
26
|
*
|
|
27
27
|
* @param command the resolved subcommand name (`process.argv`'s first non-flag).
|
|
28
28
|
* @param allArgs `process.argv.slice(2)` — the command name followed by its args.
|
|
@@ -80,9 +80,9 @@ async function resolveTargetHost(name, any) {
|
|
|
80
80
|
}
|
|
81
81
|
/**
|
|
82
82
|
* Route `agents <command> … --host <name>` to a remote if the command is
|
|
83
|
-
* host-routable and a `--host` was given. Returns
|
|
84
|
-
*
|
|
85
|
-
* very machine.
|
|
83
|
+
* host-routable and a `--host` (or its `--device` alias) was given. Returns
|
|
84
|
+
* `false` (run locally) when neither flag is present, the command isn't in the
|
|
85
|
+
* table, or the target is this very machine.
|
|
86
86
|
*
|
|
87
87
|
* @param command the resolved subcommand name (`process.argv`'s first non-flag).
|
|
88
88
|
* @param allArgs `process.argv.slice(2)` — the command name followed by its args.
|
|
@@ -91,7 +91,17 @@ export async function maybeRunOnHost(command, allArgs) {
|
|
|
91
91
|
const spec = REMOTE_PASSTHROUGH[command];
|
|
92
92
|
if (!spec)
|
|
93
93
|
return false;
|
|
94
|
-
|
|
94
|
+
// `--device` is a first-class alias of `--host` (mirrors `agents run`); the
|
|
95
|
+
// device registry is the source of truth for machine identity. Reject a
|
|
96
|
+
// conflicting pair rather than silently preferring one — same rule as run.
|
|
97
|
+
const hostFlag = flagValue(allArgs, 'host', 'H');
|
|
98
|
+
const deviceFlag = flagValue(allArgs, 'device');
|
|
99
|
+
if (hostFlag && deviceFlag && hostFlag !== deviceFlag) {
|
|
100
|
+
console.error(chalk.red('Conflicting --host/--device values — pass just one.'));
|
|
101
|
+
process.exitCode = 1;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
const hostName = hostFlag ?? deviceFlag;
|
|
95
105
|
if (!hostName)
|
|
96
106
|
return false;
|
|
97
107
|
// Running against your own machine is just a local run — skip the SSH round-trip.
|
|
@@ -25,7 +25,13 @@ export interface StripSpec {
|
|
|
25
25
|
* @param args the command's args (already past the command name).
|
|
26
26
|
*/
|
|
27
27
|
export declare function stripRoutingFlags(args: string[], specs: StripSpec[]): string[];
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* The routing flags every `--host`-capable command shares. `--device` is a
|
|
30
|
+
* first-class alias of `--host` (the device registry is the source of truth for
|
|
31
|
+
* machine identity — see `agents devices`), mirroring `agents run --device`.
|
|
32
|
+
* Both are stripped before forwarding so the alias never leaks to the remote
|
|
33
|
+
* binary (which would re-trigger routing).
|
|
34
|
+
*/
|
|
29
35
|
export declare const HOST_ROUTING_SPECS: StripSpec[];
|
|
30
36
|
/**
|
|
31
37
|
* Build the single command string for `ssh <target> <cmd>`. The forwarded args
|
|
@@ -38,9 +38,16 @@ export function stripRoutingFlags(args, specs) {
|
|
|
38
38
|
}
|
|
39
39
|
return out;
|
|
40
40
|
}
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* The routing flags every `--host`-capable command shares. `--device` is a
|
|
43
|
+
* first-class alias of `--host` (the device registry is the source of truth for
|
|
44
|
+
* machine identity — see `agents devices`), mirroring `agents run --device`.
|
|
45
|
+
* Both are stripped before forwarding so the alias never leaks to the remote
|
|
46
|
+
* binary (which would re-trigger routing).
|
|
47
|
+
*/
|
|
42
48
|
export const HOST_ROUTING_SPECS = [
|
|
43
49
|
{ long: 'host', short: 'H', takesValue: true },
|
|
50
|
+
{ long: 'device', takesValue: true },
|
|
44
51
|
{ long: 'remote-cwd', takesValue: true },
|
|
45
52
|
];
|
|
46
53
|
/**
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* trust boundary the keychain already concedes (docs/secrets.md: the ACL is
|
|
17
17
|
* user-presence, not code-identity — any same-user process can pop the prompt
|
|
18
18
|
* and read), minus the visible prompt. We bound it with: explicit per-bundle
|
|
19
|
-
* opt-in (nothing is held unless you `unlock` it), an absolute TTL,
|
|
20
|
-
* on
|
|
19
|
+
* opt-in (nothing is held unless you `unlock` it), an absolute TTL (~7d), an
|
|
20
|
+
* auto-wipe on sleep / logout, and `agents secrets lock`. A bare screen-lock is
|
|
21
|
+
* NOT a wipe (the login password already gates it). Nothing ever touches disk.
|
|
21
22
|
*
|
|
22
23
|
* macOS only: Linux libsecret has no biometry prompt, so there's nothing to
|
|
23
24
|
* deduplicate — every entry point here no-ops off darwin.
|
|
@@ -30,7 +31,7 @@ export declare const DEFAULT_TTL_MS: number;
|
|
|
30
31
|
* The broker holds the resolved bundle-metadata array (names/policy/timestamps,
|
|
31
32
|
* NO resolved secret values beyond the literals already in metadata) keyed by a
|
|
32
33
|
* hash of the current keychain bundle name-set, so the second and later
|
|
33
|
-
* `secrets list` within the
|
|
34
|
+
* `secrets list` within the hold window read metadata without a Touch ID
|
|
34
35
|
* prompt. Keyed by the name-set hash so adding/removing/renaming a bundle
|
|
35
36
|
* changes the key and misses the cache automatically — no active invalidation.
|
|
36
37
|
* The '!' sentinel can never collide with a real bundle name
|
|
@@ -43,7 +44,7 @@ export declare const META_CACHE_PREFIX = "!meta:";
|
|
|
43
44
|
* code (exit so launchd relaunches it). Only when the store is EMPTY: exiting
|
|
44
45
|
* with bundles still unlocked wipes them from memory, so the next reader falls
|
|
45
46
|
* back to a direct keychain read and re-prompts for Touch ID. Deferring the
|
|
46
|
-
* restart until the cache is idle (TTL-expired /
|
|
47
|
+
* restart until the cache is idle (TTL-expired / slept) means an
|
|
47
48
|
* in-place `npm i -g` never wipes a hot cache — the new code is adopted at the
|
|
48
49
|
* next quiet moment instead. See #435: rapid repeated upgrades wiped a hot
|
|
49
50
|
* cache on every bump and produced a recurring Touch ID storm.
|
|
@@ -141,10 +142,20 @@ export type Response = {
|
|
|
141
142
|
*/
|
|
142
143
|
export declare function realBundleCount(store: Map<string, StoredBundle>): number;
|
|
143
144
|
export declare function handleAgentRequest(store: Map<string, StoredBundle>, req: Request, now?: number): Response;
|
|
145
|
+
/**
|
|
146
|
+
* Decide whether a `watch-lock` helper line should wipe the in-memory store.
|
|
147
|
+
* The helper emits `LOCK` on screen-lock / screensaver and `SLEEP` on system
|
|
148
|
+
* sleep. We wipe on SLEEP only: a bare screen-lock is already gated by the login
|
|
149
|
+
* password, and with the ~7d hold, re-authing after every lock would defeat the
|
|
150
|
+
* point. Logout needs no line — it tears down the launchd session and kills the
|
|
151
|
+
* broker outright. Pure + exported so the LOCK-survives / SLEEP-wipes contract
|
|
152
|
+
* has direct regression coverage (the inline stdout handler isn't unit-testable).
|
|
153
|
+
*/
|
|
154
|
+
export declare function shouldWipeOnWatchEvent(chunk: string): boolean;
|
|
144
155
|
/**
|
|
145
156
|
* Run the broker in the foreground. Spawned detached by ensureAgentRunning via
|
|
146
157
|
* `agents secrets _agent-run`. Holds the store in memory, serves the socket,
|
|
147
|
-
* sweeps expired entries, wipes on
|
|
158
|
+
* sweeps expired entries, wipes on sleep, and self-exits when idle.
|
|
148
159
|
*/
|
|
149
160
|
export declare function runSecretsAgent(opts?: {
|
|
150
161
|
service?: boolean;
|
|
@@ -171,7 +182,7 @@ export declare function agentGetSync(name: string): {
|
|
|
171
182
|
export declare function agentGetMetaSync(nameSetHash: string): SecretsBundle[] | null;
|
|
172
183
|
/**
|
|
173
184
|
* Fire-and-forget: populate the broker with a freshly-read metadata snapshot so
|
|
174
|
-
* the next `secrets list` within the
|
|
185
|
+
* the next `secrets list` within the hold window renders without a prompt.
|
|
175
186
|
* Stored as an ordinary entry (placeholder bundle, snapshot in env) under the
|
|
176
187
|
* reserved META_CACHE_PREFIX key; the snapshot travels over stdin to the
|
|
177
188
|
* detached worker (never argv/disk), same as value caching. macOS only.
|
|
@@ -179,7 +190,7 @@ export declare function agentGetMetaSync(nameSetHash: string): SecretsBundle[] |
|
|
|
179
190
|
export declare function agentAutoLoadMetaSync(nameSetHash: string, bundles: SecretsBundle[], ttlMs: number): void;
|
|
180
191
|
/** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
|
|
181
192
|
* broker is the mechanism that delivers the `daily` default policy (one Touch ID
|
|
182
|
-
* per ~
|
|
193
|
+
* per ~7d), so auto-caching is ON by default; opt out with
|
|
183
194
|
* `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
|
|
184
195
|
export declare function secretsAgentAutoEnabled(): boolean;
|
|
185
196
|
/**
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* trust boundary the keychain already concedes (docs/secrets.md: the ACL is
|
|
17
17
|
* user-presence, not code-identity — any same-user process can pop the prompt
|
|
18
18
|
* and read), minus the visible prompt. We bound it with: explicit per-bundle
|
|
19
|
-
* opt-in (nothing is held unless you `unlock` it), an absolute TTL,
|
|
20
|
-
* on
|
|
19
|
+
* opt-in (nothing is held unless you `unlock` it), an absolute TTL (~7d), an
|
|
20
|
+
* auto-wipe on sleep / logout, and `agents secrets lock`. A bare screen-lock is
|
|
21
|
+
* NOT a wipe (the login password already gates it). Nothing ever touches disk.
|
|
21
22
|
*
|
|
22
23
|
* macOS only: Linux libsecret has no biometry prompt, so there's nothing to
|
|
23
24
|
* deduplicate — every entry point here no-ops off darwin.
|
|
@@ -35,13 +36,13 @@ import { getCliVersion, getCliVersionFresh } from '../version.js';
|
|
|
35
36
|
* server kills and respawns it rather than talking a stale dialect. */
|
|
36
37
|
const PROTOCOL_VERSION = 1;
|
|
37
38
|
/** Default lifetime of an unlocked bundle when `--ttl` is not given. */
|
|
38
|
-
export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; //
|
|
39
|
+
export const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d
|
|
39
40
|
/**
|
|
40
41
|
* Reserved store-key prefix for the `secrets list` metadata snapshot cache.
|
|
41
42
|
* The broker holds the resolved bundle-metadata array (names/policy/timestamps,
|
|
42
43
|
* NO resolved secret values beyond the literals already in metadata) keyed by a
|
|
43
44
|
* hash of the current keychain bundle name-set, so the second and later
|
|
44
|
-
* `secrets list` within the
|
|
45
|
+
* `secrets list` within the hold window read metadata without a Touch ID
|
|
45
46
|
* prompt. Keyed by the name-set hash so adding/removing/renaming a bundle
|
|
46
47
|
* changes the key and misses the cache automatically — no active invalidation.
|
|
47
48
|
* The '!' sentinel can never collide with a real bundle name
|
|
@@ -59,7 +60,7 @@ const SWEEP_INTERVAL_MS = 30 * 1000;
|
|
|
59
60
|
* code (exit so launchd relaunches it). Only when the store is EMPTY: exiting
|
|
60
61
|
* with bundles still unlocked wipes them from memory, so the next reader falls
|
|
61
62
|
* back to a direct keychain read and re-prompts for Touch ID. Deferring the
|
|
62
|
-
* restart until the cache is idle (TTL-expired /
|
|
63
|
+
* restart until the cache is idle (TTL-expired / slept) means an
|
|
63
64
|
* in-place `npm i -g` never wipes a hot cache — the new code is adopted at the
|
|
64
65
|
* next quiet moment instead. See #435: rapid repeated upgrades wiped a hot
|
|
65
66
|
* cache on every bump and produced a recurring Touch ID storm.
|
|
@@ -301,10 +302,22 @@ export function handleAgentRequest(store, req, now = Date.now()) {
|
|
|
301
302
|
}
|
|
302
303
|
}
|
|
303
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Decide whether a `watch-lock` helper line should wipe the in-memory store.
|
|
307
|
+
* The helper emits `LOCK` on screen-lock / screensaver and `SLEEP` on system
|
|
308
|
+
* sleep. We wipe on SLEEP only: a bare screen-lock is already gated by the login
|
|
309
|
+
* password, and with the ~7d hold, re-authing after every lock would defeat the
|
|
310
|
+
* point. Logout needs no line — it tears down the launchd session and kills the
|
|
311
|
+
* broker outright. Pure + exported so the LOCK-survives / SLEEP-wipes contract
|
|
312
|
+
* has direct regression coverage (the inline stdout handler isn't unit-testable).
|
|
313
|
+
*/
|
|
314
|
+
export function shouldWipeOnWatchEvent(chunk) {
|
|
315
|
+
return /\bSLEEP\b/.test(chunk);
|
|
316
|
+
}
|
|
304
317
|
/**
|
|
305
318
|
* Run the broker in the foreground. Spawned detached by ensureAgentRunning via
|
|
306
319
|
* `agents secrets _agent-run`. Holds the store in memory, serves the socket,
|
|
307
|
-
* sweeps expired entries, wipes on
|
|
320
|
+
* sweeps expired entries, wipes on sleep, and self-exits when idle.
|
|
308
321
|
*/
|
|
309
322
|
export async function runSecretsAgent(opts = {}) {
|
|
310
323
|
if (!onDarwin())
|
|
@@ -351,9 +364,9 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
351
364
|
// this value for the process lifetime; getCliVersionFresh re-reads on disk.
|
|
352
365
|
const runningVersion = getCliVersion();
|
|
353
366
|
// "Warmth" for self-heal / idle-exit counts only real unlocked bundles, NOT
|
|
354
|
-
// the internal `secrets list` metadata cache (#524). Otherwise a
|
|
367
|
+
// the internal `secrets list` metadata cache (#524). Otherwise a 7d-TTL list
|
|
355
368
|
// cache would keep the store non-empty and (a) block the persistent broker
|
|
356
|
-
// from self-healing onto a freshly-installed version for up to a
|
|
369
|
+
// from self-healing onto a freshly-installed version for up to a week (#435's
|
|
357
370
|
// gate is size===0), and (b) stop a one-off broker from ever idle-exiting. The
|
|
358
371
|
// metadata cache is a disposable list snapshot — wiping it on upgrade/idle
|
|
359
372
|
// costs at most one extra prompt on the next `secrets list`.
|
|
@@ -450,15 +463,19 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
450
463
|
});
|
|
451
464
|
});
|
|
452
465
|
sweepTimer = setInterval(sweep, SWEEP_INTERVAL_MS);
|
|
453
|
-
// Auto-lock on
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
466
|
+
// Auto-lock on sleep. The signed helper emits LOCK / SLEEP lines; we wipe
|
|
467
|
+
// everything on SLEEP (and, implicitly, logout — that tears down the launchd
|
|
468
|
+
// session and kills this in-memory broker). A bare screen-lock is deliberately
|
|
469
|
+
// NOT a wipe: with the ~7d hold, re-prompting after every lock would defeat the
|
|
470
|
+
// point, and a locked screen is already gated by the login password. If the
|
|
471
|
+
// installed helper predates watch-lock (exits non-zero immediately), we fall
|
|
472
|
+
// back to TTL-only and log nothing — the unlock already warned when
|
|
473
|
+
// lock_on_sleep couldn't be armed.
|
|
457
474
|
try {
|
|
458
475
|
watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
459
476
|
watcher.stdout?.setEncoding('utf-8');
|
|
460
477
|
watcher.stdout?.on('data', (chunk) => {
|
|
461
|
-
if (
|
|
478
|
+
if (shouldWipeOnWatchEvent(chunk)) {
|
|
462
479
|
store.clear();
|
|
463
480
|
emptySince = Date.now();
|
|
464
481
|
}
|
|
@@ -590,7 +607,7 @@ export function agentGetMetaSync(nameSetHash) {
|
|
|
590
607
|
}
|
|
591
608
|
/**
|
|
592
609
|
* Fire-and-forget: populate the broker with a freshly-read metadata snapshot so
|
|
593
|
-
* the next `secrets list` within the
|
|
610
|
+
* the next `secrets list` within the hold window renders without a prompt.
|
|
594
611
|
* Stored as an ordinary entry (placeholder bundle, snapshot in env) under the
|
|
595
612
|
* reserved META_CACHE_PREFIX key; the snapshot travels over stdin to the
|
|
596
613
|
* detached worker (never argv/disk), same as value caching. macOS only.
|
|
@@ -604,7 +621,7 @@ export function agentAutoLoadMetaSync(nameSetHash, bundles, ttlMs) {
|
|
|
604
621
|
}
|
|
605
622
|
/** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
|
|
606
623
|
* broker is the mechanism that delivers the `daily` default policy (one Touch ID
|
|
607
|
-
* per ~
|
|
624
|
+
* per ~7d), so auto-caching is ON by default; opt out with
|
|
608
625
|
* `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
|
|
609
626
|
export function secretsAgentAutoEnabled() {
|
|
610
627
|
try {
|
|
@@ -40,11 +40,13 @@ export interface VarMeta {
|
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
42
|
* A bundle's prompt policy — how often macOS asks for Touch ID to read it:
|
|
43
|
-
* - `daily` (default): ask once, then hold it silently for up to ~
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* - `daily` (default): ask once, then hold it silently for up to ~7 days.
|
|
44
|
+
* (Historical name — the window is now a rolling ~1 week, not one calendar day.)
|
|
45
|
+
* Eligible for the secrets-agent — the first real keychain read auto-loads it
|
|
46
|
+
* (auto-cache is on by default) so concurrent runs read it silently, or `unlock`
|
|
47
|
+
* it explicitly. Held from that unlock (not refreshed on use); re-asks sooner
|
|
48
|
+
* after sleep, logout, or `agents secrets lock`. A bare screen-lock does NOT
|
|
49
|
+
* drop it (the login password already gates a locked screen).
|
|
48
50
|
* - `always`: asks every time. Never auto-held — only an explicit `agents
|
|
49
51
|
* secrets unlock` ever holds it; every other read pops Touch ID. Opt a
|
|
50
52
|
* high-value bundle into this when you want to confirm every single read.
|
|
@@ -107,7 +109,7 @@ export declare function bundleExists(name: string): boolean;
|
|
|
107
109
|
export declare function readBundle(name: string): SecretsBundle;
|
|
108
110
|
/** The default prompt policy applied to bundles without an explicit per-bundle
|
|
109
111
|
* policy. Configurable via `secrets.policy` in agents.yaml; `daily` (one Touch
|
|
110
|
-
* ID per ~
|
|
112
|
+
* ID per ~7d) unless the user explicitly opts back into prompt-every-time with
|
|
111
113
|
* `always`. Best-effort: an unreadable config falls back to the `daily` default. */
|
|
112
114
|
export declare function secretsDefaultPolicy(): SecretsPolicy;
|
|
113
115
|
/** The effective prompt policy of a bundle (absent ⇒ the configured default). */
|
|
@@ -263,7 +263,7 @@ function parsePolicy(raw) {
|
|
|
263
263
|
}
|
|
264
264
|
/** The default prompt policy applied to bundles without an explicit per-bundle
|
|
265
265
|
* policy. Configurable via `secrets.policy` in agents.yaml; `daily` (one Touch
|
|
266
|
-
* ID per ~
|
|
266
|
+
* ID per ~7d) unless the user explicitly opts back into prompt-every-time with
|
|
267
267
|
* `always`. Best-effort: an unreadable config falls back to the `daily` default. */
|
|
268
268
|
export function secretsDefaultPolicy() {
|
|
269
269
|
try {
|
|
@@ -414,11 +414,15 @@ export function listBundles() {
|
|
|
414
414
|
// so the getKeychainTokens batch below pops Touch ID on every `secrets
|
|
415
415
|
// list` — the broker/`daily` mechanism only ever covered value reads, not
|
|
416
416
|
// this listing. Serve a broker-cached metadata snapshot when one is held,
|
|
417
|
-
// so only the first list per ~
|
|
417
|
+
// so only the first list per ~7d prompts. The cache key is a hash of the
|
|
418
418
|
// current keychain name-set (enumerated silently above): add / remove /
|
|
419
419
|
// rename a bundle and the key changes, so the stale snapshot is never
|
|
420
|
-
// served
|
|
421
|
-
//
|
|
420
|
+
// served. A same-name metadata edit (e.g. `secrets policy <b> always`)
|
|
421
|
+
// does NOT change the key, so the POLICY column in `secrets list` can lag
|
|
422
|
+
// by up to the hold window (~7d) until the next name-set change or `lock`.
|
|
423
|
+
// This is cosmetic only — enforcement always reads the bundle's live
|
|
424
|
+
// policy (readBundle), never this snapshot, and `secrets view <b>` shows
|
|
425
|
+
// the fresh value immediately. Values are never cached here; metadata only.
|
|
422
426
|
const useAgent = process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
|
|
423
427
|
!isKeychainBackendOverridden() &&
|
|
424
428
|
secretsAgentAutoEnabled();
|
|
@@ -444,7 +448,7 @@ export function listBundles() {
|
|
|
444
448
|
}
|
|
445
449
|
for (const bundle of keychainBundles)
|
|
446
450
|
out.push(bundle);
|
|
447
|
-
// Populate the broker for the rest of the
|
|
451
|
+
// Populate the broker for the rest of the hold window (fire-and-forget).
|
|
448
452
|
if (useAgent && keychainBundles.length > 0) {
|
|
449
453
|
agentAutoLoadMetaSync(nameSetHash, keychainBundles, DEFAULT_TTL_MS);
|
|
450
454
|
}
|
package/dist/lib/shims.d.ts
CHANGED
|
@@ -77,7 +77,7 @@ export interface ConflictInfo {
|
|
|
77
77
|
* top-level entry add/remove — deep edits to plugin contents won't
|
|
78
78
|
* trigger auto-resync, run `agents sync` for that.
|
|
79
79
|
*/
|
|
80
|
-
export declare const SHIM_SCHEMA_VERSION =
|
|
80
|
+
export declare const SHIM_SCHEMA_VERSION = 24;
|
|
81
81
|
/**
|
|
82
82
|
* Generate the full bash shim script for the given agent. The returned string
|
|
83
83
|
* is written to ~/.agents/shims/{cliCommand} and made executable.
|
package/dist/lib/shims.js
CHANGED
|
@@ -211,7 +211,7 @@ async function promptConflictStrategy(conflictInfos) {
|
|
|
211
211
|
// v22 — export DISABLE_AUTOUPDATER=1 for claude shims so a pinned per-version
|
|
212
212
|
// install can't self-mutate: Claude Code's background auto-updater would
|
|
213
213
|
// otherwise rewrite the pinned binary in place. Explicit user value wins.
|
|
214
|
-
export const SHIM_SCHEMA_VERSION =
|
|
214
|
+
export const SHIM_SCHEMA_VERSION = 24;
|
|
215
215
|
/** Internal marker string used to embed the schema version in shim scripts. */
|
|
216
216
|
const SHIM_VERSION_MARKER = 'agents-shim-version:';
|
|
217
217
|
function shellQuote(value) {
|
|
@@ -346,16 +346,38 @@ find_project_version() {
|
|
|
346
346
|
return 1
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
-
#
|
|
349
|
+
# Parse the agents: default map of one agents.yaml for this AGENT's version.
|
|
350
|
+
parse_agents_default() {
|
|
351
|
+
local meta="$1"
|
|
352
|
+
[ -f "$meta" ] || return 0
|
|
353
|
+
awk -v agent="$AGENT" '
|
|
354
|
+
/^agents:/ { in_agents=1; next }
|
|
355
|
+
in_agents && /^[^ ]/ { in_agents=0 }
|
|
356
|
+
in_agents && $0 ~ "^ " agent ":" { gsub(/.*:[[:space:]]*["'"'"']?|["'"'"']?[[:space:]]*$/, ""); print; exit }
|
|
357
|
+
' "$meta"
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
# This machine's device id — mirrors machineId()/normalizeHost() in
|
|
361
|
+
# src/lib/machine-id.ts: first hostname label, lowercased, non-[a-z0-9_-] -> '-'.
|
|
362
|
+
# MUST stay in sync or the shim reads the wrong device folder.
|
|
363
|
+
machine_id() {
|
|
364
|
+
local raw="\${AGENTS_SYNC_MACHINE_ID:-$(hostname 2>/dev/null)}"
|
|
365
|
+
# first label -> trim -> lowercase -> non-[a-z0-9_-] to '-' (matches normalizeHost order).
|
|
366
|
+
raw=$(printf '%s' "$raw" | cut -d. -f1 | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g')
|
|
367
|
+
[ -n "$raw" ] && printf '%s' "$raw" || printf 'unknown'
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
# Resolve the default version. The agents: version pins are stored PER-DEVICE at
|
|
371
|
+
# devices/<machine>/agents.yaml (moved there so multi-machine syncs never
|
|
372
|
+
# conflict); read that first, then fall back to the central agents.yaml for
|
|
373
|
+
# pre-split installs. Must match readMeta()'s central+device merge in state.ts --
|
|
374
|
+
# reading only the central file (the old behavior) missed every device pin and
|
|
375
|
+
# made the shim re-prompt "no default set" on every launch.
|
|
350
376
|
resolve_default_version() {
|
|
351
|
-
local
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
in_agents && /^[^ ]/ { in_agents=0 }
|
|
356
|
-
in_agents && $0 ~ "^ " agent ":" { gsub(/.*:[[:space:]]*["'"'"']?|["'"'"']?[[:space:]]*$/, ""); print; exit }
|
|
357
|
-
' "$meta"
|
|
358
|
-
fi
|
|
377
|
+
local v
|
|
378
|
+
v=$(parse_agents_default "$AGENTS_USER_DIR/devices/$(machine_id)/agents.yaml")
|
|
379
|
+
[ -n "$v" ] || v=$(parse_agents_default "$AGENTS_USER_DIR/agents.yaml")
|
|
380
|
+
printf '%s' "$v"
|
|
359
381
|
}
|
|
360
382
|
|
|
361
383
|
# Find the latest installed version by numeric component comparison.
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -599,9 +599,9 @@ export interface Meta {
|
|
|
599
599
|
run?: RunConfig;
|
|
600
600
|
/** macOS secrets-agent config. `policy` is the default prompt policy for
|
|
601
601
|
* bundles without an explicit per-bundle policy: `daily` (the default) asks
|
|
602
|
-
* once per ~
|
|
603
|
-
* real keychain read of a `daily` bundle populate the broker so
|
|
604
|
-
* runs read silently — set it `false` to force a prompt on every read. */
|
|
602
|
+
* once per ~7 days, `always` asks every time. `auto` (default on) lets the
|
|
603
|
+
* first real keychain read of a `daily` bundle populate the broker so
|
|
604
|
+
* concurrent runs read silently — set it `false` to force a prompt on every read. */
|
|
605
605
|
secrets?: {
|
|
606
606
|
policy?: 'always' | 'daily';
|
|
607
607
|
agent?: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.41",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|