@phnx-labs/agents-cli 1.20.76 → 1.20.78
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 +264 -0
- package/README.md +2 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.js +143 -9
- package/dist/commands/exec.js +87 -19
- package/dist/commands/repo.js +8 -0
- package/dist/commands/routines.js +275 -15
- package/dist/commands/secrets.js +65 -17
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +128 -12
- package/dist/commands/sessions-export.d.ts +3 -0
- package/dist/commands/sessions-export.js +13 -10
- package/dist/commands/sessions-picker.d.ts +28 -1
- package/dist/commands/sessions-picker.js +222 -15
- package/dist/commands/sessions.d.ts +117 -4
- package/dist/commands/sessions.js +350 -62
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +48 -3
- package/dist/index.js +5 -6
- package/dist/lib/activity.d.ts +5 -4
- package/dist/lib/activity.js +450 -36
- package/dist/lib/agents.d.ts +0 -21
- package/dist/lib/agents.js +0 -37
- package/dist/lib/auto-pull.d.ts +16 -8
- package/dist/lib/auto-pull.js +23 -28
- package/dist/lib/cloud/session-index.js +2 -2
- package/dist/lib/daemon.d.ts +9 -32
- package/dist/lib/daemon.js +70 -84
- package/dist/lib/devices/fleet-divergence.d.ts +101 -0
- package/dist/lib/devices/fleet-divergence.js +188 -0
- package/dist/lib/devices/fleet-inventory.d.ts +19 -0
- package/dist/lib/devices/fleet-inventory.js +57 -0
- package/dist/lib/devices/fleet.d.ts +16 -1
- package/dist/lib/devices/fleet.js +10 -2
- package/dist/lib/devices/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +27 -4
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/git.d.ts +13 -0
- package/dist/lib/git.js +102 -4
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hosts/dispatch.d.ts +9 -0
- package/dist/lib/hosts/dispatch.js +24 -4
- package/dist/lib/hosts/passthrough.js +7 -2
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +33 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +4 -0
- package/dist/lib/hosts/run-target.js +5 -1
- package/dist/lib/hosts/session-index.js +3 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/menubar/install-menubar.d.ts +9 -0
- package/dist/lib/menubar/install-menubar.js +14 -0
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/overdue.d.ts +4 -5
- package/dist/lib/overdue.js +8 -41
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +68 -0
- package/dist/lib/routines.js +74 -7
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +156 -28
- package/dist/lib/sandbox.js +0 -6
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/secrets/agent.d.ts +11 -4
- package/dist/lib/secrets/agent.js +46 -24
- package/dist/lib/secrets/bundles.d.ts +21 -7
- package/dist/lib/secrets/bundles.js +82 -38
- package/dist/lib/secrets/index.d.ts +11 -2
- package/dist/lib/secrets/index.js +21 -9
- package/dist/lib/secrets/scope.d.ts +26 -0
- package/dist/lib/secrets/scope.js +29 -0
- package/dist/lib/secrets/session-store.d.ts +16 -2
- package/dist/lib/secrets/session-store.js +118 -15
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/session/active.d.ts +17 -0
- package/dist/lib/session/active.js +105 -7
- package/dist/lib/session/db.d.ts +43 -0
- package/dist/lib/session/db.js +191 -6
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +28 -3
- package/dist/lib/session/discover.js +110 -14
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/parse.js +11 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.js +18 -16
- package/dist/lib/session/state.d.ts +5 -0
- package/dist/lib/session/state.js +94 -12
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/dist/lib/state.d.ts +7 -3
- package/dist/lib/state.js +20 -5
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
package/dist/commands/secrets.js
CHANGED
|
@@ -18,16 +18,18 @@ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
|
|
|
18
18
|
import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, } from '../lib/secrets/remote.js';
|
|
19
19
|
import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
|
|
20
20
|
import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
|
|
21
|
-
import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, readBundleIfDecryptable, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, } from '../lib/secrets/bundles.js';
|
|
21
|
+
import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, readBundleIfDecryptable, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, } from '../lib/secrets/bundles.js';
|
|
22
22
|
import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
|
|
23
23
|
import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
|
|
24
24
|
import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
|
|
25
|
+
import { GLOBAL_HARNESS } from '../lib/secrets/scope.js';
|
|
25
26
|
import { secretsHoldMs, secretsAgentDurable, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
|
|
26
|
-
import { saveSession,
|
|
27
|
+
import { saveSession, deleteBundleSessions, deleteAllSessions } from '../lib/secrets/session-store.js';
|
|
27
28
|
import { getCliVersionFresh } from '../lib/version.js';
|
|
28
29
|
import { readMeta } from '../lib/state.js';
|
|
29
30
|
import { parseDuration } from '../lib/hooks/cache.js';
|
|
30
|
-
import { emit } from '../lib/events.js';
|
|
31
|
+
import { emit, query } from '../lib/events.js';
|
|
32
|
+
import { frequentlyPromptedBundles } from '../lib/secrets/unlock-hints.js';
|
|
31
33
|
import { registerCommandGroups, setHelpSections } from '../lib/help.js';
|
|
32
34
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
33
35
|
import { discoverSyncedBundles, importSyncedBundle, } from '../lib/secrets/icloud-import.js';
|
|
@@ -1096,8 +1098,9 @@ export function registerSecretsCommands(program) {
|
|
|
1096
1098
|
process.exit(1);
|
|
1097
1099
|
}
|
|
1098
1100
|
// `secrets get` is the scriptable automation primitive ($(agents secrets
|
|
1099
|
-
// get bundle KEY)); when embedded in a headless routine/CI script
|
|
1100
|
-
//
|
|
1101
|
+
// get bundle KEY)); when embedded in a headless routine/CI script — or run
|
|
1102
|
+
// beneath any agent, which inherits AGENTS_RUNTIME — it must not pop an
|
|
1103
|
+
// unwatched Touch ID prompt. Typed in a plain shell it still prompts.
|
|
1101
1104
|
const { env } = readAndResolveBundleEnv(item, { caller: 'secrets get', keys: [key], keyMode: 'storage', agentOnly: isHeadlessSecretsContext() });
|
|
1102
1105
|
if (!(key in env)) {
|
|
1103
1106
|
console.error(chalk.red(`Key '${key}' not in bundle '${item}'.`));
|
|
@@ -1816,10 +1819,10 @@ Examples:
|
|
|
1816
1819
|
process.exit(1);
|
|
1817
1820
|
}
|
|
1818
1821
|
// `agents secrets export --plaintext` is what release/CI scripts eval.
|
|
1819
|
-
// When it runs detached (both stdio non-TTY) or
|
|
1820
|
-
// resolve broker-only so it can never pop a Touch
|
|
1821
|
-
// interactive user's screen. An
|
|
1822
|
-
//
|
|
1822
|
+
// When it runs detached (both stdio non-TTY) or beneath ANY agent — which
|
|
1823
|
+
// inherits AGENTS_RUNTIME — resolve broker-only so it can never pop a Touch
|
|
1824
|
+
// ID sheet on the interactive user's screen. An `eval "$(...)"` typed in a
|
|
1825
|
+
// plain shell carries no AGENTS_RUNTIME, so it is not headless and still prompts.
|
|
1823
1826
|
const { env } = readAndResolveBundleEnv(resolvedBundleName, {
|
|
1824
1827
|
caller: `export to shell`,
|
|
1825
1828
|
keyMode: 'process',
|
|
@@ -2055,6 +2058,7 @@ Examples:
|
|
|
2055
2058
|
.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.')
|
|
2056
2059
|
.option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
|
|
2057
2060
|
.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.')
|
|
2061
|
+
.option('--for <agent>', 'Narrow the unlock to ONE harness type (for example claude, codex, or kimi). Default: the grant is global — every harness and a plain shell can read it, so one Touch ID covers them all.')
|
|
2058
2062
|
.option('--all', 'Unlock every configured bundle')
|
|
2059
2063
|
.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>`.')
|
|
2060
2064
|
.action(async (names, opts) => {
|
|
@@ -2127,12 +2131,24 @@ Examples:
|
|
|
2127
2131
|
// (single-instance start lock, #414) and best-effort — never blocks unlock.
|
|
2128
2132
|
ensureDaemonStarted();
|
|
2129
2133
|
let loaded = 0;
|
|
2134
|
+
// An unlock is a deliberate act, so it grants GLOBALLY unless `--for`
|
|
2135
|
+
// narrows it to one harness. It must NOT inherit the ambient
|
|
2136
|
+
// AGENTS_AGENT_NAME: that silently scoped a terminal unlock to whichever
|
|
2137
|
+
// agent happened to launch the shell, leaving the grant unreadable to
|
|
2138
|
+
// every other reader for its whole TTL.
|
|
2139
|
+
const harness = opts.for || GLOBAL_HARNESS;
|
|
2130
2140
|
for (const name of targets) {
|
|
2131
2141
|
try {
|
|
2132
2142
|
// noAgent: read the real keychain (one Touch ID) rather than the
|
|
2133
2143
|
// agent we're about to populate.
|
|
2134
|
-
const { bundle, env } = readAndResolveBundleEnv(name, {
|
|
2135
|
-
|
|
2144
|
+
const { bundle, env } = readAndResolveBundleEnv(name, {
|
|
2145
|
+
noAgent: true,
|
|
2146
|
+
caller: 'unlock secrets',
|
|
2147
|
+
agent: harness,
|
|
2148
|
+
duration: humanRemaining(Date.now() + ttlMs),
|
|
2149
|
+
keyMode: 'storage',
|
|
2150
|
+
});
|
|
2151
|
+
if (await agentLoad(name, bundle, env, ttlMs, harness)) {
|
|
2136
2152
|
loaded++;
|
|
2137
2153
|
// Persist a durable session snapshot so the unlock survives a daemon
|
|
2138
2154
|
// restart / upgrade (and sleep too, with --durable). session-store.ts.
|
|
@@ -2141,6 +2157,7 @@ Examples:
|
|
|
2141
2157
|
env,
|
|
2142
2158
|
expiresAt: Date.now() + ttlMs,
|
|
2143
2159
|
sleepPersist: opts.durable ?? secretsAgentDurable(),
|
|
2160
|
+
harness,
|
|
2144
2161
|
});
|
|
2145
2162
|
console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(Date.now() + ttlMs)})`)}`);
|
|
2146
2163
|
}
|
|
@@ -2170,7 +2187,7 @@ Examples:
|
|
|
2170
2187
|
let total = 0;
|
|
2171
2188
|
for (const name of names) {
|
|
2172
2189
|
total += await agentLock(name);
|
|
2173
|
-
|
|
2190
|
+
deleteBundleSessions(name); // drop every harness scope
|
|
2174
2191
|
}
|
|
2175
2192
|
console.log(total > 0 ? chalk.green(`Locked ${total} bundle(s).`) : chalk.gray('Nothing to lock.'));
|
|
2176
2193
|
}
|
|
@@ -2216,16 +2233,47 @@ Examples:
|
|
|
2216
2233
|
} })();
|
|
2217
2234
|
console.log(chalk.gray(`hold: ${holdStr}${configured ? ' (secrets.agent.holdMs)' : ' (default)'} — a daily bundle prompts once, then stays silent for this long or until sleep/logout.`));
|
|
2218
2235
|
const entries = await agentStatus();
|
|
2236
|
+
const held = new Set(entries.map((e) => e.name));
|
|
2219
2237
|
if (entries.length === 0) {
|
|
2220
2238
|
console.log(chalk.gray('No bundles held. The next read of each daily bundle will prompt once, then hold.'));
|
|
2221
2239
|
console.log(chalk.gray('Pre-warm now with: agents secrets unlock <bundle> (or --all)'));
|
|
2222
|
-
return;
|
|
2223
2240
|
}
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2241
|
+
else {
|
|
2242
|
+
console.log(chalk.bold(`${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(5)} LOCKS IN`));
|
|
2243
|
+
for (const e of entries) {
|
|
2244
|
+
console.log(`${chalk.cyan(e.name.padEnd(24))} ${String(e.keyCount).padEnd(5)} ${humanRemaining(e.expiresAt)}`);
|
|
2245
|
+
}
|
|
2246
|
+
console.log(chalk.gray('Reads of held bundles are silent; any bundle not listed prompts once on its next read.'));
|
|
2247
|
+
}
|
|
2248
|
+
// Usage hint: bundles you keep getting a Touch ID prompt for — a keychain
|
|
2249
|
+
// read that fell through to biometry (not served by the broker/session)
|
|
2250
|
+
// >= a few times in the last week, and not currently held. Unlocking each
|
|
2251
|
+
// once silences it for the hold window. This runs even when NO bundle is
|
|
2252
|
+
// held — that user re-prompts on every read and most needs the nudge.
|
|
2253
|
+
// Excludes `never`/no-ACL bundles (never prompt) and file/vault bundles
|
|
2254
|
+
// (resolve by passphrase, not the keychain broker — `unlock` is a no-op).
|
|
2255
|
+
try {
|
|
2256
|
+
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
2257
|
+
const reads = query({ eventTypes: ['secrets.get'], startDate: weekAgo });
|
|
2258
|
+
const hot = frequentlyPromptedBundles(reads, held, { minReads: 3 }).filter((h) => {
|
|
2259
|
+
try {
|
|
2260
|
+
return bundleBackend(h.name) === 'keychain' && bundlePolicy(readBundle(h.name)) !== 'never';
|
|
2261
|
+
}
|
|
2262
|
+
catch {
|
|
2263
|
+
return false; // bundle gone / unreadable metadata — nothing to suggest
|
|
2264
|
+
}
|
|
2265
|
+
});
|
|
2266
|
+
if (hot.length > 0) {
|
|
2267
|
+
console.log();
|
|
2268
|
+
console.log(chalk.bold('Prompted often — unlock once to silence:'));
|
|
2269
|
+
for (const h of hot) {
|
|
2270
|
+
console.log(`${chalk.cyan(h.name.padEnd(24))} ${chalk.gray(`${h.count}× in the last 7d`)} → ${chalk.green(`agents secrets unlock ${h.name}`)}`);
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
catch {
|
|
2275
|
+
// Best-effort hint — never let usage analysis break `secrets status`.
|
|
2227
2276
|
}
|
|
2228
|
-
console.log(chalk.gray('Reads of held bundles are silent; any bundle not listed prompts once on its next read.'));
|
|
2229
2277
|
});
|
|
2230
2278
|
cmd
|
|
2231
2279
|
.command('policy <bundle> [policy]')
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* Built on {@link dynamicPicker}; every data source (discover, fleet, live index,
|
|
10
10
|
* preview, resume dispatch) is reused from the existing sessions plumbing.
|
|
11
11
|
*/
|
|
12
|
-
import type
|
|
12
|
+
import { type SessionMeta } from '../lib/session/types.js';
|
|
13
|
+
import type { ActiveSession } from '../lib/session/active.js';
|
|
13
14
|
/**
|
|
14
15
|
* The single canonical filter state. Every field has a flag equivalent, so the
|
|
15
16
|
* same view is reachable interactively (hotkeys) or from the command line (flags).
|
|
@@ -59,11 +60,14 @@ export declare function activeBrowserSeed(opts: {
|
|
|
59
60
|
agent?: string;
|
|
60
61
|
host?: string[];
|
|
61
62
|
since?: string;
|
|
63
|
+
all?: boolean;
|
|
62
64
|
}): Partial<BrowserFilter>;
|
|
63
65
|
/**
|
|
64
66
|
* The initial filter for the bare interactive listing: current-repo subtree by
|
|
65
|
-
* default (matches the static overview's cwd scoping)
|
|
66
|
-
* directory
|
|
67
|
+
* default (matches the static overview's cwd scoping). `--all` sets every
|
|
68
|
+
* non-status filter to its "all" value — every directory (project) AND all-time
|
|
69
|
+
* (window) — so one flag maxes the view; `--since` still overrides the window and
|
|
70
|
+
* `-a`/`--device` still narrow their axis.
|
|
67
71
|
*/
|
|
68
72
|
export declare function bareBrowserSeed(opts: {
|
|
69
73
|
teams?: boolean;
|
|
@@ -71,6 +75,44 @@ export declare function bareBrowserSeed(opts: {
|
|
|
71
75
|
all?: boolean;
|
|
72
76
|
since?: string;
|
|
73
77
|
}): Partial<BrowserFilter>;
|
|
78
|
+
/**
|
|
79
|
+
* A live session's stable row key: its session id when the agent reported one,
|
|
80
|
+
* else a per-machine handle so an id-less live session (a just-booted harness, a
|
|
81
|
+
* cloud task) still gets exactly one row instead of being dropped. Cloud rows key
|
|
82
|
+
* on the task id because they have no pid — keying them all on the machine alone
|
|
83
|
+
* would collapse two cloud tasks into one row, the same silent-drop this whole
|
|
84
|
+
* change exists to remove.
|
|
85
|
+
*/
|
|
86
|
+
export declare function liveRowKey(a: ActiveSession, self: string): string;
|
|
87
|
+
/** Index live sessions by {@link liveRowKey} — the join key between the live scan
|
|
88
|
+
* and the transcript pool. Id-carrying rows key on the session id, so a live row
|
|
89
|
+
* and its transcript row collapse to one. */
|
|
90
|
+
export declare function indexLiveRows(rows: ActiveSession[], self: string): Map<string, ActiveSession>;
|
|
91
|
+
/**
|
|
92
|
+
* Project a live session onto the picker's row shape, for a session the transcript
|
|
93
|
+
* pool doesn't carry — a peer's session, a transcript outside the current window,
|
|
94
|
+
* or an agent that has not written one yet. `filePath` is the live transcript path
|
|
95
|
+
* when the scan resolved one and empty otherwise, which is what
|
|
96
|
+
* {@link handlePickedSession} keys "there is nothing to open yet" off.
|
|
97
|
+
*/
|
|
98
|
+
export declare function liveSessionToMeta(a: ActiveSession, self: string): SessionMeta;
|
|
99
|
+
/**
|
|
100
|
+
* Fold the live scan INTO the transcript pool. The running filter used to be a
|
|
101
|
+
* pure intersection (`pool ∩ live`), which meant a session had to already be in
|
|
102
|
+
* the pool to be shown as running — so every session on another machine, and
|
|
103
|
+
* every local one outside the pool's window, was invisible in the browser while
|
|
104
|
+
* `--active --json` listed it. Live sessions the pool lacks are appended as their
|
|
105
|
+
* own rows, then the whole set is grouped local-machine-first.
|
|
106
|
+
*/
|
|
107
|
+
export declare function mergeLiveIntoPool(rows: SessionMeta[], live: Map<string, ActiveSession>, self: string): SessionMeta[];
|
|
108
|
+
/**
|
|
109
|
+
* Whether to render the host-program column. It belongs to the running view
|
|
110
|
+
* only, so this gates on the FILTER — not on the live index being populated.
|
|
111
|
+
* `liveCache` outlives a toggle of the `r` hotkey, so testing `live` alone would
|
|
112
|
+
* keep the column after running is turned back off, widening a plain transcript
|
|
113
|
+
* listing that has no live rows to explain it.
|
|
114
|
+
*/
|
|
115
|
+
export declare function shouldShowHostColumn(f: BrowserFilter, live: Map<string, ActiveSession> | null, rows: SessionMeta[]): boolean;
|
|
74
116
|
/**
|
|
75
117
|
* Launch the interactive session browser. `initial` seeds the filter (e.g.
|
|
76
118
|
* `{ running: true }` for `--active`). Resolves after the user resumes a session
|
|
@@ -9,14 +9,15 @@
|
|
|
9
9
|
* Built on {@link dynamicPicker}; every data source (discover, fleet, live index,
|
|
10
10
|
* preview, resume dispatch) is reused from the existing sessions plumbing.
|
|
11
11
|
*/
|
|
12
|
+
import path from 'path';
|
|
12
13
|
import { spawnSync } from 'child_process';
|
|
13
14
|
import { dynamicPicker } from '../lib/picker.js';
|
|
14
|
-
import {
|
|
15
|
+
import { isSessionTrackedAgent } from '../lib/session/types.js';
|
|
15
16
|
import { discoverSessions } from '../lib/session/discover.js';
|
|
16
17
|
import { gatherRemoteList } from '../lib/session/remote-list.js';
|
|
17
18
|
import { machineId, normalizeHost } from '../lib/session/sync/config.js';
|
|
18
19
|
import { buildPreview } from './sessions-picker.js';
|
|
19
|
-
import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst,
|
|
20
|
+
import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, } from './sessions.js';
|
|
20
21
|
/** Ordered window cycle for the `W` key. `undefined` = all time. */
|
|
21
22
|
const WINDOW_CYCLE = [undefined, '1d', '7d', '30d'];
|
|
22
23
|
/** Return the next value in `[undefined, ...options]`, wrapping. */
|
|
@@ -102,20 +103,25 @@ export function activeBrowserSeed(opts) {
|
|
|
102
103
|
agent: opts.agent,
|
|
103
104
|
projectScope: 'all',
|
|
104
105
|
device: normalizeDeviceSeed(opts.host?.[0]),
|
|
105
|
-
window
|
|
106
|
+
// --all widens the window to all-time (project is already 'all' here);
|
|
107
|
+
// --since still overrides.
|
|
108
|
+
window: opts.since ?? (opts.all ? undefined : '30d'),
|
|
106
109
|
};
|
|
107
110
|
}
|
|
108
111
|
/**
|
|
109
112
|
* The initial filter for the bare interactive listing: current-repo subtree by
|
|
110
|
-
* default (matches the static overview's cwd scoping)
|
|
111
|
-
* directory
|
|
113
|
+
* default (matches the static overview's cwd scoping). `--all` sets every
|
|
114
|
+
* non-status filter to its "all" value — every directory (project) AND all-time
|
|
115
|
+
* (window) — so one flag maxes the view; `--since` still overrides the window and
|
|
116
|
+
* `-a`/`--device` still narrow their axis.
|
|
112
117
|
*/
|
|
113
118
|
export function bareBrowserSeed(opts) {
|
|
114
119
|
return {
|
|
115
120
|
teams: !!opts.teams,
|
|
116
121
|
agent: opts.agent,
|
|
122
|
+
// --all maxes every non-status filter: all dirs AND all-time. --since wins.
|
|
117
123
|
projectScope: opts.all ? 'all' : 'repo',
|
|
118
|
-
window: opts.since ?? '30d',
|
|
124
|
+
window: opts.since ?? (opts.all ? undefined : '30d'),
|
|
119
125
|
};
|
|
120
126
|
}
|
|
121
127
|
/** Copy text to the OS clipboard (best-effort; silently no-ops if unavailable). */
|
|
@@ -182,6 +188,99 @@ async function fetchRawPool(f, self, local, hosts) {
|
|
|
182
188
|
}
|
|
183
189
|
return { key: `${since ?? 'all'}|${f.teams}`, rows };
|
|
184
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* A live session's stable row key: its session id when the agent reported one,
|
|
193
|
+
* else a per-machine handle so an id-less live session (a just-booted harness, a
|
|
194
|
+
* cloud task) still gets exactly one row instead of being dropped. Cloud rows key
|
|
195
|
+
* on the task id because they have no pid — keying them all on the machine alone
|
|
196
|
+
* would collapse two cloud tasks into one row, the same silent-drop this whole
|
|
197
|
+
* change exists to remove.
|
|
198
|
+
*/
|
|
199
|
+
export function liveRowKey(a, self) {
|
|
200
|
+
if (a.sessionId)
|
|
201
|
+
return a.sessionId;
|
|
202
|
+
const handle = a.cloudTaskId ?? (a.pid != null ? String(a.pid) : 'unknown');
|
|
203
|
+
return `${LIVE_ROW_PREFIX}${a.machine ?? self}:${handle}`;
|
|
204
|
+
}
|
|
205
|
+
/** Index live sessions by {@link liveRowKey} — the join key between the live scan
|
|
206
|
+
* and the transcript pool. Id-carrying rows key on the session id, so a live row
|
|
207
|
+
* and its transcript row collapse to one. */
|
|
208
|
+
export function indexLiveRows(rows, self) {
|
|
209
|
+
const byKey = new Map();
|
|
210
|
+
for (const a of rows)
|
|
211
|
+
byKey.set(liveRowKey(a, self), a);
|
|
212
|
+
return byKey;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Project a live session onto the picker's row shape, for a session the transcript
|
|
216
|
+
* pool doesn't carry — a peer's session, a transcript outside the current window,
|
|
217
|
+
* or an agent that has not written one yet. `filePath` is the live transcript path
|
|
218
|
+
* when the scan resolved one and empty otherwise, which is what
|
|
219
|
+
* {@link handlePickedSession} keys "there is nothing to open yet" off.
|
|
220
|
+
*/
|
|
221
|
+
export function liveSessionToMeta(a, self) {
|
|
222
|
+
const machine = a.machine ?? self;
|
|
223
|
+
const started = a.startedAtMs ?? a.lastActivityMs;
|
|
224
|
+
const topic = a.topic ?? a.preview;
|
|
225
|
+
return {
|
|
226
|
+
id: liveRowKey(a, self),
|
|
227
|
+
// No session id to short — name the row by what it IS (a cloud task, a pid),
|
|
228
|
+
// so the id column still identifies the process you'd go looking for.
|
|
229
|
+
shortId: a.sessionId
|
|
230
|
+
? a.sessionId.slice(0, 8)
|
|
231
|
+
: a.cloudTaskId
|
|
232
|
+
? a.cloudTaskId.slice(0, 8)
|
|
233
|
+
: `p:${a.pid ?? '?'}`,
|
|
234
|
+
agent: isSessionTrackedAgent(a.kind) ? a.kind : 'claude',
|
|
235
|
+
timestamp: new Date(started ?? Date.now()).toISOString(),
|
|
236
|
+
lastActivity: a.lastActivityMs ? new Date(a.lastActivityMs).toISOString() : undefined,
|
|
237
|
+
project: a.cwd ? path.basename(a.cwd) : undefined,
|
|
238
|
+
cwd: a.cwd,
|
|
239
|
+
filePath: a.sessionFile ?? '',
|
|
240
|
+
// A live topic is raw transcript text: a newline in it would break the row
|
|
241
|
+
// into two and misalign every column after it. Indexed rows are already
|
|
242
|
+
// cleaned at scan time, so this puts projected rows on the same footing.
|
|
243
|
+
topic: topic ? cleanPreview(topic) : undefined,
|
|
244
|
+
label: a.label ? cleanPreview(a.label) : undefined,
|
|
245
|
+
machine,
|
|
246
|
+
// Reading/resuming a peer's session hops back over SSH — same contract the
|
|
247
|
+
// cross-machine listing sets, so handlePickedSession routes it correctly.
|
|
248
|
+
_remote: machine !== self,
|
|
249
|
+
prUrl: a.pr?.url,
|
|
250
|
+
prNumber: a.pr?.number,
|
|
251
|
+
ticketId: a.ticket?.id,
|
|
252
|
+
worktreeSlug: a.worktree?.slug,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Fold the live scan INTO the transcript pool. The running filter used to be a
|
|
257
|
+
* pure intersection (`pool ∩ live`), which meant a session had to already be in
|
|
258
|
+
* the pool to be shown as running — so every session on another machine, and
|
|
259
|
+
* every local one outside the pool's window, was invisible in the browser while
|
|
260
|
+
* `--active --json` listed it. Live sessions the pool lacks are appended as their
|
|
261
|
+
* own rows, then the whole set is grouped local-machine-first.
|
|
262
|
+
*/
|
|
263
|
+
export function mergeLiveIntoPool(rows, live, self) {
|
|
264
|
+
const known = new Set(rows.map((r) => r.id));
|
|
265
|
+
const extra = [];
|
|
266
|
+
for (const [key, a] of live) {
|
|
267
|
+
if (!known.has(key))
|
|
268
|
+
extra.push(liveSessionToMeta(a, self));
|
|
269
|
+
}
|
|
270
|
+
return extra.length === 0 ? rows : mergeLocalFirst([...rows, ...extra], self);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Whether to render the host-program column. It belongs to the running view
|
|
274
|
+
* only, so this gates on the FILTER — not on the live index being populated.
|
|
275
|
+
* `liveCache` outlives a toggle of the `r` hotkey, so testing `live` alone would
|
|
276
|
+
* keep the column after running is turned back off, widening a plain transcript
|
|
277
|
+
* listing that has no live rows to explain it.
|
|
278
|
+
*/
|
|
279
|
+
export function shouldShowHostColumn(f, live, rows) {
|
|
280
|
+
if (!f.running || !live)
|
|
281
|
+
return false;
|
|
282
|
+
return rows.some((r) => liveHostLabel(live.get(r.id)) !== '');
|
|
283
|
+
}
|
|
185
284
|
/** Apply the cheap in-memory filters (agent / device / project / running). */
|
|
186
285
|
function applyFilters(rows, live, f, self) {
|
|
187
286
|
let out = rows;
|
|
@@ -197,6 +296,16 @@ function applyFilters(rows, live, f, self) {
|
|
|
197
296
|
out = out.filter((r) => live.has(r.id));
|
|
198
297
|
return out;
|
|
199
298
|
}
|
|
299
|
+
/** Derive the SSH-launch origin tag for a picker row from the live index. Set
|
|
300
|
+
* only when the live session's provenance is ssh transport; `device` is the
|
|
301
|
+
* resolved origin device (absent → the row shows a bare `ssh`). Rows without a
|
|
302
|
+
* live entry (the running filter off) get no tag — provenance is live-only. */
|
|
303
|
+
function sshOriginTagFor(live, id) {
|
|
304
|
+
const p = live?.get(id)?.provenance;
|
|
305
|
+
if (p?.transport !== 'ssh')
|
|
306
|
+
return undefined;
|
|
307
|
+
return p.origin?.device ? { device: p.origin.device } : {};
|
|
308
|
+
}
|
|
200
309
|
function headerFor(f) {
|
|
201
310
|
const bits = [
|
|
202
311
|
`device:${f.device ?? 'all'}`,
|
|
@@ -259,11 +368,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
259
368
|
return []; // superseded — don't touch shared state
|
|
260
369
|
pool = fetched;
|
|
261
370
|
}
|
|
262
|
-
// Only pay for the live scan when the running filter needs it.
|
|
371
|
+
// Only pay for the live scan when the running filter needs it. Same fleet
|
|
372
|
+
// sweep the static `--active` view uses, so the two never disagree about
|
|
373
|
+
// what is running.
|
|
263
374
|
let live = liveCache;
|
|
264
375
|
if (f.running && !live) {
|
|
265
376
|
try {
|
|
266
|
-
|
|
377
|
+
const { sessions } = await gatherActiveSessions({ local, hosts });
|
|
378
|
+
live = indexLiveRows(sessions, self);
|
|
267
379
|
}
|
|
268
380
|
catch {
|
|
269
381
|
live = new Map();
|
|
@@ -276,10 +388,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
276
388
|
rawCache = pool;
|
|
277
389
|
if (live)
|
|
278
390
|
liveCache = live;
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
391
|
+
// Live sessions the transcript pool lacks become rows of their own, so the
|
|
392
|
+
// running view lists every active session, not just the ones already indexed.
|
|
393
|
+
const rows = f.running && live ? mergeLiveIntoPool(pool.rows, live, self) : pool.rows;
|
|
394
|
+
agentsInPool = distinct(rows.map((r) => r.agent));
|
|
395
|
+
devicesInPool = distinct(rows.map((r) => r.machine ?? self));
|
|
396
|
+
const filtered = applyFilters(rows, live ?? new Map(), f, self);
|
|
282
397
|
cols = pickerColumnsFor(filtered);
|
|
398
|
+
cols.showHost = shouldShowHostColumn(f, live, filtered);
|
|
283
399
|
return filtered;
|
|
284
400
|
};
|
|
285
401
|
const picked = await dynamicPicker({
|
|
@@ -287,7 +403,7 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
287
403
|
initialFilter,
|
|
288
404
|
load,
|
|
289
405
|
keyFor: (s) => s.id,
|
|
290
|
-
labelFor: (s, q) => formatPickerLabel(s, q, cols),
|
|
406
|
+
labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id))),
|
|
291
407
|
matches: sessionMatchesQuery,
|
|
292
408
|
buildPreview,
|
|
293
409
|
headerFor,
|
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
|
+
import type { SessionMeta } from '../lib/session/types.js';
|
|
2
3
|
export declare function registerSessionsExportCommand(sessionsCmd: Command): void;
|
|
4
|
+
/** ids > query > everything-in-scope. Exported for the id-resolution test. */
|
|
5
|
+
export declare function selectSessions(metas: SessionMeta[], selectors: string[]): SessionMeta[];
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import * as fs from 'fs';
|
|
21
21
|
import * as path from 'path';
|
|
22
22
|
import chalk from 'chalk';
|
|
23
|
-
import { discoverSessions, resolveSessionById,
|
|
23
|
+
import { discoverSessions, resolveSessionById, looksLikeSessionId } from '../lib/session/discover.js';
|
|
24
24
|
import { findSessionsById } from '../lib/session/db.js';
|
|
25
25
|
import { filterSessionsByQuery, parseAgentFilter } from './sessions.js';
|
|
26
26
|
import { listLocalTranscripts, SYNC_AGENTS } from '../lib/session/sync/agents.js';
|
|
@@ -222,8 +222,8 @@ function resolveAgentShorthand(g) {
|
|
|
222
222
|
return 'antigravity';
|
|
223
223
|
return undefined;
|
|
224
224
|
}
|
|
225
|
-
/** ids > query > everything-in-scope. */
|
|
226
|
-
function selectSessions(metas, selectors) {
|
|
225
|
+
/** ids > query > everything-in-scope. Exported for the id-resolution test. */
|
|
226
|
+
export function selectSessions(metas, selectors) {
|
|
227
227
|
if (selectors.length === 0)
|
|
228
228
|
return metas;
|
|
229
229
|
const byId = [];
|
|
@@ -231,9 +231,11 @@ function selectSessions(metas, selectors) {
|
|
|
231
231
|
for (const sel of selectors) {
|
|
232
232
|
const trimmed = sel.trim();
|
|
233
233
|
// Same reason as resolveSessionQuery: the discovered pool is a minority of
|
|
234
|
-
// the index, so
|
|
234
|
+
// the index, so an id-shaped selector absent from it may still be indexed
|
|
235
|
+
// here. Gate on looksLikeSessionId, not isCompleteSessionId, so a SHORT id
|
|
236
|
+
// ("d3470b57") also resolves through the index instead of falling to content.
|
|
235
237
|
const hits = resolveSessionById(metas, trimmed);
|
|
236
|
-
const resolved = hits.length > 0 || !
|
|
238
|
+
const resolved = hits.length > 0 || !looksLikeSessionId(trimmed) ? hits : findSessionsById(trimmed);
|
|
237
239
|
if (resolved.length > 0)
|
|
238
240
|
byId.push(...resolved);
|
|
239
241
|
else
|
|
@@ -243,11 +245,12 @@ function selectSessions(metas, selectors) {
|
|
|
243
245
|
const seen = new Set();
|
|
244
246
|
return byId.filter(s => (seen.has(s.id) ? false : (seen.add(s.id), true)));
|
|
245
247
|
}
|
|
246
|
-
// A selector that is a
|
|
247
|
-
// the id is unique, so the text query below could only bundle
|
|
248
|
-
// merely mention it. Bundling those would ship unrelated
|
|
249
|
-
// whoever receives the export, so select nothing and let the
|
|
250
|
-
|
|
248
|
+
// A selector that is an id (complete OR a short hex id/prefix) and still missed
|
|
249
|
+
// cannot be widened: the id is unique, so the text query below could only bundle
|
|
250
|
+
// sessions that merely mention it. Bundling those would ship unrelated
|
|
251
|
+
// transcripts to whoever receives the export, so select nothing and let the
|
|
252
|
+
// caller report it — a short id like "d3470b57" must never content-search.
|
|
253
|
+
const missingIds = unmatched.filter(looksLikeSessionId);
|
|
251
254
|
if (missingIds.length > 0) {
|
|
252
255
|
process.stderr.write(chalk.red(`No session with id ${missingIds.join(', ')} on this machine.\n`));
|
|
253
256
|
return [];
|
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
import type { SessionMeta } from '../lib/session/types.js';
|
|
1
|
+
import type { SessionEvent, SessionMeta, TodoProgress } from '../lib/session/types.js';
|
|
2
|
+
import { classifyFileChanges } from '../lib/session/digest.js';
|
|
3
|
+
/**
|
|
4
|
+
* Compact checklist tally for list rows and previews (RUSH-2045).
|
|
5
|
+
* Example: `✓6/8 · A5 wiring runner`. Empty string when there is no list.
|
|
6
|
+
* Consumes `SessionMeta.todos` / `ActiveSession.todos` as populated by the
|
|
7
|
+
* state engine — does not re-parse transcripts.
|
|
8
|
+
*/
|
|
9
|
+
export declare function formatTodoCompact(todos?: Pick<TodoProgress, 'done' | 'total' | 'activeForm'> | null): string;
|
|
10
|
+
/**
|
|
11
|
+
* Best-effort GitHub repo URL from a checkout path shaped like
|
|
12
|
+
* `…/github.com/<owner>/<repo>/…`. Used to make the project name clickable
|
|
13
|
+
* when no Linear project URL is available.
|
|
14
|
+
*/
|
|
15
|
+
export declare function githubRepoUrlFromCwd(cwd?: string): string | undefined;
|
|
2
16
|
export interface PickedSession {
|
|
3
17
|
session: SessionMeta;
|
|
4
18
|
action: 'resume' | 'view';
|
|
@@ -17,5 +31,18 @@ export interface SessionPickerConfig {
|
|
|
17
31
|
}
|
|
18
32
|
/** Build a cached multi-line preview string for display in the session picker. */
|
|
19
33
|
export declare function buildPreview(session: SessionMeta): string;
|
|
34
|
+
/** Optional dirs-touched field the parser teammate may attach; we prefer it. */
|
|
35
|
+
type SessionMetaWithDirs = SessionMeta & {
|
|
36
|
+
dirsTouched?: string[];
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Unique directories the session touched, compact and human-readable.
|
|
40
|
+
* Prefer `session.dirsTouched` when the parser teammate has populated it;
|
|
41
|
+
* otherwise derive from file-change + tool paths already available here.
|
|
42
|
+
*/
|
|
43
|
+
export declare function directoriesTouched(session: SessionMetaWithDirs, events: SessionEvent[], changes: ReturnType<typeof classifyFileChanges>): string[];
|
|
44
|
+
/** Relativize a file path to its parent dir, short enough for one preview line. */
|
|
45
|
+
export declare function relativizeDir(filePath: string, cwd?: string): string | undefined;
|
|
20
46
|
/** Show an interactive session picker and return the selected session with its action (resume or view). */
|
|
21
47
|
export declare function sessionPicker(config: SessionPickerConfig): Promise<PickedSession | null>;
|
|
48
|
+
export {};
|