@phnx-labs/agents-cli 1.20.43 → 1.20.45
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 +18 -0
- package/README.md +4 -3
- package/dist/commands/exec.js +84 -13
- package/dist/commands/hosts.js +5 -4
- package/dist/commands/logs.d.ts +4 -0
- package/dist/commands/logs.js +19 -13
- package/dist/commands/routines.d.ts +6 -0
- package/dist/commands/routines.js +70 -12
- package/dist/commands/secrets.d.ts +18 -0
- package/dist/commands/secrets.js +105 -30
- package/dist/commands/sessions.d.ts +6 -5
- package/dist/commands/sessions.js +50 -22
- package/dist/commands/teams.js +104 -8
- package/dist/lib/daemon.js +34 -9
- package/dist/lib/exec.d.ts +8 -0
- package/dist/lib/exec.js +70 -6
- package/dist/lib/hosts/dispatch.d.ts +29 -0
- package/dist/lib/hosts/dispatch.js +46 -1
- package/dist/lib/hosts/logs.d.ts +14 -5
- package/dist/lib/hosts/logs.js +39 -13
- package/dist/lib/hosts/remote-cmd.d.ts +17 -0
- package/dist/lib/hosts/remote-cmd.js +27 -0
- package/dist/lib/hosts/session-index.d.ts +15 -0
- package/dist/lib/hosts/session-index.js +28 -2
- package/dist/lib/redact.js +1 -0
- package/dist/lib/rotate.d.ts +33 -0
- package/dist/lib/rotate.js +37 -0
- package/dist/lib/secrets/remote.d.ts +14 -0
- package/dist/lib/secrets/remote.js +18 -1
- package/dist/lib/session/active.d.ts +10 -0
- package/dist/lib/session/active.js +12 -1
- package/dist/lib/session/db.d.ts +16 -9
- package/dist/lib/session/db.js +66 -44
- package/dist/lib/session/discover.d.ts +4 -0
- package/dist/lib/session/discover.js +84 -13
- package/dist/lib/session/run-names.d.ts +9 -7
- package/dist/lib/session/run-names.js +9 -7
- package/dist/lib/session/state.d.ts +29 -3
- package/dist/lib/session/state.js +84 -5
- package/dist/lib/session/types.d.ts +19 -8
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +17 -3
- package/dist/lib/teams/agents.js +25 -7
- package/dist/lib/tmux/session.d.ts +40 -0
- package/dist/lib/tmux/session.js +92 -0
- package/dist/lib/versions.d.ts +54 -1
- package/dist/lib/versions.js +138 -1
- package/package.json +1 -1
package/dist/commands/secrets.js
CHANGED
|
@@ -12,7 +12,9 @@ import * as fs from 'fs';
|
|
|
12
12
|
import { SSH_TARGET_RE, assertValidSshTarget, sshExec } from '../lib/ssh-exec.js';
|
|
13
13
|
import { quoteWin32ExecArg, composeWin32CommandLine } from '../lib/platform/index.js';
|
|
14
14
|
import { ensureDaemonStarted } from '../lib/daemon.js';
|
|
15
|
-
import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, resolveSshTarget, } from '../lib/secrets/remote.js';
|
|
15
|
+
import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveSshTarget, } from '../lib/secrets/remote.js';
|
|
16
|
+
import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
|
|
17
|
+
import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
|
|
16
18
|
import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
|
|
17
19
|
import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
|
|
18
20
|
import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
|
|
@@ -139,6 +141,29 @@ function readStdinSync() {
|
|
|
139
141
|
}
|
|
140
142
|
return Buffer.concat(chunks).toString('utf-8').trim();
|
|
141
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Read the raw `.env` text for `import --from <path|->`. A `-` reads the .env
|
|
146
|
+
* from stdin (the SSH push path: `export --host` pipes the resolved dotenv over
|
|
147
|
+
* ssh stdin, which has no `/dev/stdin` on a Windows remote); any other value is
|
|
148
|
+
* a filesystem path.
|
|
149
|
+
*/
|
|
150
|
+
export function readImportDotenv(from) {
|
|
151
|
+
return from === '-' ? readStdinSync() : fs.readFileSync(from, 'utf-8');
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Build the remote `agents secrets unlock` argv for `unlock --host`. `--all`
|
|
155
|
+
* forwards verbatim; otherwise the explicit bundle names. A `--ttl` is passed
|
|
156
|
+
* through as-is so the REMOTE parses its own duration (its platform rules, its
|
|
157
|
+
* defaults). Shared with the command action so the wiring is unit-testable
|
|
158
|
+
* without a live SSH session.
|
|
159
|
+
*/
|
|
160
|
+
export function buildRemoteUnlockArgs(names, opts) {
|
|
161
|
+
return [
|
|
162
|
+
'unlock',
|
|
163
|
+
...(opts.all ? ['--all'] : names),
|
|
164
|
+
...(opts.ttl ? ['--ttl', opts.ttl] : []),
|
|
165
|
+
];
|
|
166
|
+
}
|
|
142
167
|
// SSH target validation is defined canonically in src/lib/ssh-exec.ts and
|
|
143
168
|
// re-exported here for back-compat with existing importers of these symbols.
|
|
144
169
|
export { SSH_TARGET_RE, assertValidSshTarget };
|
|
@@ -1162,7 +1187,7 @@ Examples:
|
|
|
1162
1187
|
cmd
|
|
1163
1188
|
.command('import [bundle]')
|
|
1164
1189
|
.description('Import keys from a .env file or a 1Password vault into a bundle. The bundle is created if it does not exist. Values are stored in the bundle\'s backend (keychain by default).')
|
|
1165
|
-
.option('--from <path>', 'Path to a .env file')
|
|
1190
|
+
.option('--from <path>', 'Path to a .env file (use - to read the .env from stdin)')
|
|
1166
1191
|
.option('--from-1password', 'Import secrets from a 1Password vault (requires the op CLI)')
|
|
1167
1192
|
.option('--vault <name>', '1Password vault name (used with --from-1password)')
|
|
1168
1193
|
.option('--all-plaintext', 'Store every imported value as a literal in the bundle metadata (skip keychain item creation)')
|
|
@@ -1227,7 +1252,7 @@ Examples:
|
|
|
1227
1252
|
console.log(chalk.green(`Imported ${added} key(s) from 1Password vault '${vault}'${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
|
|
1228
1253
|
}
|
|
1229
1254
|
else {
|
|
1230
|
-
const raw =
|
|
1255
|
+
const raw = readImportDotenv(opts.from);
|
|
1231
1256
|
const pairs = parseDotenv(raw);
|
|
1232
1257
|
for (const [key, value] of Object.entries(pairs)) {
|
|
1233
1258
|
if (!opts.force && key in bundle.vars) {
|
|
@@ -1294,34 +1319,47 @@ Examples:
|
|
|
1294
1319
|
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export` });
|
|
1295
1320
|
const dotenv = bundleEnvToDotenv(env);
|
|
1296
1321
|
const keyCount = Object.keys(env).length;
|
|
1297
|
-
// Drive the remote's own `agents secrets
|
|
1298
|
-
// chosen backend
|
|
1299
|
-
//
|
|
1300
|
-
//
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
if (remoteBackend === 'file') {
|
|
1306
|
-
// import --backend file auto-creates the file-backed bundle; no
|
|
1307
|
-
// separate `create` needed.
|
|
1308
|
-
remoteAgents =
|
|
1309
|
-
`IFS= read -r AGENTS_SECRETS_PASSPHRASE; export AGENTS_SECRETS_PASSPHRASE; ` +
|
|
1310
|
-
`agents secrets import ${shellQuote(resolvedBundleName)} --from /dev/stdin${backendFlag}${force}`;
|
|
1311
|
-
input = `${remotePassphrase}\n${dotenv}`;
|
|
1312
|
-
}
|
|
1313
|
-
else {
|
|
1314
|
-
remoteAgents =
|
|
1315
|
-
`agents secrets create ${shellQuote(resolvedBundleName)} >/dev/null 2>&1 || true; ` +
|
|
1316
|
-
`agents secrets import ${shellQuote(resolvedBundleName)} --from /dev/stdin${force}`;
|
|
1317
|
-
input = dotenv;
|
|
1318
|
-
}
|
|
1319
|
-
const remoteCmd = `bash -lc ${shellQuote(remoteAgents)}`;
|
|
1322
|
+
// Drive the remote's own `agents secrets import --from -` so the values
|
|
1323
|
+
// land in its chosen backend, reading the .env off ssh stdin (never
|
|
1324
|
+
// parsed by a remote shell — `--from -` replaces the POSIX-only
|
|
1325
|
+
// `/dev/stdin`). The keychain path is built OS-aware via
|
|
1326
|
+
// `remoteSecretsRaw` (bash -lc on POSIX, PowerShell on Windows), so it
|
|
1327
|
+
// works on macOS, Linux AND Windows targets. `import` auto-creates the
|
|
1328
|
+
// bundle, so no separate `create` (the old `|| true` was a POSIXism
|
|
1329
|
+
// that broke on PowerShell: `'true' is not recognized`).
|
|
1320
1330
|
let failures = 0;
|
|
1321
1331
|
for (const host of hosts) {
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1332
|
+
let res;
|
|
1333
|
+
if (remoteBackend === 'file') {
|
|
1334
|
+
// File backend forwards AGENTS_SECRETS_PASSPHRASE as the FIRST stdin
|
|
1335
|
+
// line (consumed by `read`, so it never lands in argv / `ps` /
|
|
1336
|
+
// remote history), then the .env. That `read`/`export` prologue is
|
|
1337
|
+
// POSIX shell — refuse a Windows target cleanly rather than emit
|
|
1338
|
+
// broken PowerShell.
|
|
1339
|
+
if (remoteShellFor(resolveRemoteOsSync(host.split('@').pop() ?? host)) === 'powershell') {
|
|
1340
|
+
failures++;
|
|
1341
|
+
console.error(chalk.red(`${host}: file backend export to a Windows target is not yet supported.`));
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
const remoteAgents = `IFS= read -r AGENTS_SECRETS_PASSPHRASE; export AGENTS_SECRETS_PASSPHRASE; ` +
|
|
1345
|
+
`agents secrets import ${shellQuote(resolvedBundleName)} --from - --backend file${opts.force ? ' --force' : ''}`;
|
|
1346
|
+
res = sshExec(host, `bash -lc ${shellQuote(remoteAgents)}`, { input: `${remotePassphrase}\n${dotenv}` });
|
|
1347
|
+
}
|
|
1348
|
+
else if (remoteShellFor(resolveRemoteOsSync(host.split('@').pop() ?? host)) === 'powershell') {
|
|
1349
|
+
// Keychain on a Windows target: the `agents.ps1` shim doesn't
|
|
1350
|
+
// forward ssh-piped stdin to node, so `--from -` would hang.
|
|
1351
|
+
// Bridge the piped .env through PowerShell into a temp file and
|
|
1352
|
+
// import `--from <file>` (deleted afterwards). Same hardened ssh
|
|
1353
|
+
// engine, .env still only ever crosses the wire over ssh stdin.
|
|
1354
|
+
res = sshExec(host, buildWindowsStdinImportCommand(resolvedBundleName, { force: opts.force }), { input: dotenv });
|
|
1355
|
+
}
|
|
1356
|
+
else {
|
|
1357
|
+
// Keychain on a POSIX target: OS-aware wrapping + hardened ssh
|
|
1358
|
+
// engine (BatchMode, ConnectTimeout, keepalive, control-socket
|
|
1359
|
+
// reuse) via the same path the READ inverse (`remoteResolveEnv`)
|
|
1360
|
+
// uses. `--from -` reads the .env off ssh stdin.
|
|
1361
|
+
res = remoteSecretsRaw(host, ['import', resolvedBundleName, '--from', '-', ...(opts.force ? ['--force'] : [])], { input: dotenv });
|
|
1362
|
+
}
|
|
1325
1363
|
if (res.code === null) {
|
|
1326
1364
|
failures++;
|
|
1327
1365
|
console.error(chalk.red(`${host}: ${res.stderr.trim() || (res.timedOut ? 'ssh timed out' : 'ssh failed')}`));
|
|
@@ -1554,10 +1592,47 @@ Examples:
|
|
|
1554
1592
|
});
|
|
1555
1593
|
cmd
|
|
1556
1594
|
.command('unlock [names...]')
|
|
1557
|
-
.description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS).')
|
|
1595
|
+
.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.')
|
|
1558
1596
|
.option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
|
|
1559
1597
|
.option('--all', 'Unlock every configured bundle')
|
|
1598
|
+
.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>`.')
|
|
1560
1599
|
.action(async (names, opts) => {
|
|
1600
|
+
// Single-valued (not variadic): a variadic --host greedily consumes the
|
|
1601
|
+
// positional bundle name (`unlock --host mac wztest` -> host=[mac,wztest],
|
|
1602
|
+
// names=[]). Unlock targets one remote at a time anyway.
|
|
1603
|
+
const hosts = opts.host ? [opts.host] : [];
|
|
1604
|
+
if (hosts.length > 0) {
|
|
1605
|
+
// Remote unlock: the REMOTE enforces its own platform rules, so the
|
|
1606
|
+
// local darwin-only guard below does NOT apply. Only file-backed
|
|
1607
|
+
// bundles are remote-unlockable — their passphrase prompt surfaces over
|
|
1608
|
+
// the -tt SSH TTY; a keychain/biometry bundle would trigger a local GUI
|
|
1609
|
+
// Touch-ID sheet that can't cross SSH.
|
|
1610
|
+
if (!opts.all && (!names || names.length === 0)) {
|
|
1611
|
+
console.error(chalk.red('Specify one or more bundle names, or --all.'));
|
|
1612
|
+
process.exit(1);
|
|
1613
|
+
}
|
|
1614
|
+
const unlockArgs = buildRemoteUnlockArgs(names, opts);
|
|
1615
|
+
let failures = 0;
|
|
1616
|
+
for (const h of hosts) {
|
|
1617
|
+
const target = await resolveSshTarget(h);
|
|
1618
|
+
// FOREGROUND stream (stdio inherited), NOT the piped remoteSecretsRaw:
|
|
1619
|
+
// the remote's passphrase prompt only surfaces if the remote process
|
|
1620
|
+
// sees a real TTY, which requires our local terminal to pass straight
|
|
1621
|
+
// through. The remote's prompt + output stream to this terminal; we get
|
|
1622
|
+
// back only the exit code.
|
|
1623
|
+
const code = remoteSecretsStream(target, unlockArgs);
|
|
1624
|
+
if (code === 0) {
|
|
1625
|
+
console.log(chalk.green(`${h}: unlocked`));
|
|
1626
|
+
}
|
|
1627
|
+
else {
|
|
1628
|
+
failures++;
|
|
1629
|
+
console.error(chalk.red(`${h}: unlock failed (exit ${code})`));
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
if (failures > 0)
|
|
1633
|
+
process.exit(1);
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1561
1636
|
if (process.platform !== 'darwin') {
|
|
1562
1637
|
console.error(chalk.red('secrets-agent is macOS-only (no biometry prompt to deduplicate elsewhere).'));
|
|
1563
1638
|
process.exit(1);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
|
-
import type { SessionAgentId, SessionMeta } from '../lib/session/types.js';
|
|
2
|
+
import type { SessionAgentId, SessionMeta, ViewMode } from '../lib/session/types.js';
|
|
3
3
|
import { type ActiveSession } from '../lib/session/active.js';
|
|
4
4
|
import { type PickedSession } from './sessions-picker.js';
|
|
5
5
|
/**
|
|
@@ -149,11 +149,12 @@ export declare function buildOverviewGroups(pool: SessionMeta[], perProjectCap:
|
|
|
149
149
|
projectCount: number;
|
|
150
150
|
};
|
|
151
151
|
/**
|
|
152
|
-
* Render a session
|
|
153
|
-
* `agents logs <sessionId>`.
|
|
154
|
-
* `agents sessions <id
|
|
152
|
+
* Render a resolved session to stdout — the non-follow view behind
|
|
153
|
+
* `agents logs <sessionId>`. Defaults to the concise `summary` digest (same as
|
|
154
|
+
* `agents sessions <id>`); pass `'markdown'` for the full transcript
|
|
155
|
+
* (`agents logs <id> --full`). Reuses the shared `renderSession` renderer.
|
|
155
156
|
*/
|
|
156
|
-
export declare function renderSessionLog(session: SessionMeta): Promise<void>;
|
|
157
|
+
export declare function renderSessionLog(session: SessionMeta, mode?: ViewMode): Promise<void>;
|
|
157
158
|
/** Column-visibility flags for the picker row, computed once over the whole pool. */
|
|
158
159
|
export interface PickerColumns {
|
|
159
160
|
/** Render the machine column (only when the pool spans more than one machine). */
|
|
@@ -425,7 +425,14 @@ export function groupActiveSessions(sessions) {
|
|
|
425
425
|
* to the same id form); else the local machine. Never keys off `ActiveSession.host`
|
|
426
426
|
* — that is the terminal *app* (code/tmux), not the computer.
|
|
427
427
|
*/
|
|
428
|
+
/** Synthetic top-level group key for provider-sandboxed cloud tasks. */
|
|
429
|
+
const CLOUD_MACHINE_KEY = 'cloud';
|
|
428
430
|
function machineKeyFor(s, localMachine) {
|
|
431
|
+
// Cloud tasks run in a provider sandbox, not on the machine they're attributed
|
|
432
|
+
// to for reply routing (s.machine = the querier). Surface them as their own
|
|
433
|
+
// top-level "cloud" group instead of nested under the local device.
|
|
434
|
+
if (s.context === 'cloud')
|
|
435
|
+
return CLOUD_MACHINE_KEY;
|
|
429
436
|
if (s.machine)
|
|
430
437
|
return s.machine;
|
|
431
438
|
if (s.provenance?.host)
|
|
@@ -449,6 +456,11 @@ export function groupSessionsByMachine(sessions, localMachine) {
|
|
|
449
456
|
return -1;
|
|
450
457
|
if (b === localMachine)
|
|
451
458
|
return 1;
|
|
459
|
+
// The synthetic "cloud" category sorts after all real machines.
|
|
460
|
+
if (a === CLOUD_MACHINE_KEY)
|
|
461
|
+
return 1;
|
|
462
|
+
if (b === CLOUD_MACHINE_KEY)
|
|
463
|
+
return -1;
|
|
452
464
|
const ac = byMachine.get(a).length, bc = byMachine.get(b).length;
|
|
453
465
|
if (ac !== bc)
|
|
454
466
|
return bc - ac;
|
|
@@ -575,38 +587,48 @@ function groupTally(sessions) {
|
|
|
575
587
|
return parts.join(' · ');
|
|
576
588
|
}
|
|
577
589
|
/** Print one machine's workspace tree, indented under its machine header. */
|
|
578
|
-
function renderWorkspaceLayout(layout, base) {
|
|
590
|
+
function renderWorkspaceLayout(layout, base, machineKey) {
|
|
579
591
|
let first = true;
|
|
580
592
|
for (const ws of layout.workspaces) {
|
|
581
593
|
if (!first)
|
|
582
594
|
console.log();
|
|
583
595
|
first = false;
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
596
|
+
// Under the top-level "cloud" machine group the __cloud__ workspace header is
|
|
597
|
+
// redundant ("▸ cloud" then "cloud") — render its rows flat under the machine
|
|
598
|
+
// header instead. Row indent collapses by one level to match.
|
|
599
|
+
const redundantCloud = ws.key === '__cloud__' && machineKey === CLOUD_MACHINE_KEY;
|
|
600
|
+
const rowBase = redundantCloud ? base : base + ' ';
|
|
601
|
+
if (!redundantCloud) {
|
|
602
|
+
const header = ws.key === '__cloud__'
|
|
603
|
+
? chalk.magenta.bold('cloud')
|
|
604
|
+
: ws.key === '__unknown__'
|
|
605
|
+
? chalk.gray.bold('unknown')
|
|
606
|
+
: chalk.cyan.bold(shortCwd(ws.key));
|
|
607
|
+
const wsSessions = [...ws.windows.flatMap(w => w.sessions), ...ws.flat];
|
|
608
|
+
const tally = groupTally(wsSessions);
|
|
609
|
+
console.log(`${base}${header} ${chalk.gray(`(${ws.total})`)}${tally ? chalk.gray(` ${tally}`) : ''}`);
|
|
610
|
+
}
|
|
592
611
|
for (const win of ws.windows) {
|
|
593
612
|
// Host is per-process, but every terminal in the same IDE window shares
|
|
594
613
|
// an ancestor — take the first non-empty host as the window's label.
|
|
595
614
|
const host = win.sessions.find((s) => s.host)?.host ?? 'terminal';
|
|
596
615
|
const winHeader = `${chalk.gray(host)} ${chalk.gray('·')} ${chalk.gray(shortWindowLabel(win.windowId))} ${chalk.gray(`(${win.sessions.length})`)}`;
|
|
597
|
-
console.log(
|
|
616
|
+
console.log(rowBase + winHeader);
|
|
598
617
|
for (const s of win.sessions)
|
|
599
|
-
printActiveRow(s,
|
|
618
|
+
printActiveRow(s, rowBase + ' ');
|
|
600
619
|
}
|
|
601
620
|
for (const s of ws.flat)
|
|
602
|
-
printActiveRow(s,
|
|
621
|
+
printActiveRow(s, rowBase);
|
|
603
622
|
}
|
|
604
623
|
}
|
|
605
624
|
/** Machine header: `▸ <name> ← this machine` for the local box (cyan), matching
|
|
606
625
|
* the `ag devices list` treatment; a plain `▸ <name>` for remotes. */
|
|
607
626
|
function printMachineHeader(mg) {
|
|
608
|
-
|
|
609
|
-
|
|
627
|
+
// The synthetic "cloud" group isn't a device — tint it magenta (matching the
|
|
628
|
+
// cloud row/label styling) so it reads as a category, not a machine.
|
|
629
|
+
const isCloud = mg.machine === CLOUD_MACHINE_KEY;
|
|
630
|
+
const marker = mg.isLocal ? chalk.cyan('▸ ') : isCloud ? chalk.magenta('▸ ') : chalk.gray('▸ ');
|
|
631
|
+
const name = mg.isLocal ? chalk.bold.cyan(mg.machine) : isCloud ? chalk.bold.magenta(mg.machine) : chalk.bold(mg.machine);
|
|
610
632
|
const here = mg.isLocal ? chalk.cyan(' ← this machine') : '';
|
|
611
633
|
console.log(`${marker}${name} ${chalk.gray(`(${mg.total})`)}${here}`);
|
|
612
634
|
}
|
|
@@ -740,11 +762,16 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
|
740
762
|
console.log();
|
|
741
763
|
firstMachine = false;
|
|
742
764
|
printMachineHeader(mg);
|
|
743
|
-
renderWorkspaceLayout(mg.layout, ' ');
|
|
765
|
+
renderWorkspaceLayout(mg.layout, ' ', mg.machine);
|
|
744
766
|
}
|
|
745
767
|
const parts = groupTally(sessions).split(' · ').filter(Boolean);
|
|
746
|
-
|
|
747
|
-
|
|
768
|
+
// The synthetic "cloud" group is a category, not a machine — exclude it from the
|
|
769
|
+
// machine count and note it separately so the tally stays truthful.
|
|
770
|
+
const realMachines = grouped.machines.filter((m) => m.machine !== CLOUD_MACHINE_KEY).length;
|
|
771
|
+
const hasCloud = grouped.machines.some((m) => m.machine === CLOUD_MACHINE_KEY);
|
|
772
|
+
const machineWord = realMachines === 1 ? 'machine' : 'machines';
|
|
773
|
+
const cloudNote = hasCloud ? ' + cloud' : '';
|
|
774
|
+
console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}) across ${realMachines} ${machineWord}${cloudNote}.`));
|
|
748
775
|
// Tip only when nothing else could be included and the user didn't opt out.
|
|
749
776
|
if (!opts.local && !opts.hosts?.length && remoteDeviceCount === 0)
|
|
750
777
|
printCrossMachineTip();
|
|
@@ -1258,12 +1285,13 @@ function resolveViewMode(options, filters) {
|
|
|
1258
1285
|
return 'summary';
|
|
1259
1286
|
}
|
|
1260
1287
|
/**
|
|
1261
|
-
* Render a session
|
|
1262
|
-
* `agents logs <sessionId>`.
|
|
1263
|
-
* `agents sessions <id
|
|
1288
|
+
* Render a resolved session to stdout — the non-follow view behind
|
|
1289
|
+
* `agents logs <sessionId>`. Defaults to the concise `summary` digest (same as
|
|
1290
|
+
* `agents sessions <id>`); pass `'markdown'` for the full transcript
|
|
1291
|
+
* (`agents logs <id> --full`). Reuses the shared `renderSession` renderer.
|
|
1264
1292
|
*/
|
|
1265
|
-
export async function renderSessionLog(session) {
|
|
1266
|
-
await renderSession(session,
|
|
1293
|
+
export async function renderSessionLog(session, mode = 'summary') {
|
|
1294
|
+
await renderSession(session, mode, {});
|
|
1267
1295
|
}
|
|
1268
1296
|
async function renderSession(session, mode, filters, options = {}) {
|
|
1269
1297
|
// OpenCode stores sessions in SQLite; filePath is "db_path#session_id"
|
package/dist/commands/teams.js
CHANGED
|
@@ -11,11 +11,13 @@ import { handleSpawn, handleStatus, handleStop, handleTasks, toTaskStatusSummary
|
|
|
11
11
|
import { createTeam, ensureTeam, getTeam, loadTeams, removeTeam, teamExists, } from '../lib/teams/registry.js';
|
|
12
12
|
import { setHelpSections } from '../lib/help.js';
|
|
13
13
|
import { createWorktree, isGitRepo, hasUncommittedChanges, removeWorktree, } from '../lib/teams/worktree.js';
|
|
14
|
-
import { isVersionInstalled, resolveVersionAlias, resolveVersionAliasLoose } from '../lib/versions.js';
|
|
14
|
+
import { isVersionInstalled, resolveVersion, resolveVersionAlias, resolveVersionAliasLoose, verifyInstalledBinaryLaunches } from '../lib/versions.js';
|
|
15
15
|
import { AGENTS, warnAgentDeprecated } from '../lib/agents.js';
|
|
16
16
|
import { discoverSessions, parseTimeFilter, resolveSessionById } from '../lib/session/discover.js';
|
|
17
|
+
import { renderSessionLog } from './sessions.js';
|
|
17
18
|
import { buildPreview as buildSessionPreview } from './sessions-picker.js';
|
|
18
19
|
import { parseExecEnv } from '../lib/exec.js';
|
|
20
|
+
import { checkRunAccountReadiness } from '../lib/rotate.js';
|
|
19
21
|
import { teamPicker, printTeamTable } from './teams-picker.js';
|
|
20
22
|
import { itemPicker } from '../lib/picker.js';
|
|
21
23
|
import { profileExists, readProfile } from '../lib/profiles.js';
|
|
@@ -191,6 +193,51 @@ export function wireCloudDispatcher(mgr) {
|
|
|
191
193
|
* teammate whose CLI may not be signed in. Warn-only — never blocks `start`.
|
|
192
194
|
* Local teammates only; cloud teammates authenticate through their provider.
|
|
193
195
|
*/
|
|
196
|
+
/**
|
|
197
|
+
* Advisory line for a version-pinned teammate whose account can't serve a run
|
|
198
|
+
* right now. A pinned target (`agents run <agent>@<version>`) bypasses account
|
|
199
|
+
* rotation — the pin IS the target — so unlike a bare teammate it can't route
|
|
200
|
+
* around a throttled/expired account; it will launch and likely 429 at once.
|
|
201
|
+
*/
|
|
202
|
+
function throttleWarningLine(agent, version, r) {
|
|
203
|
+
const who = `${AGENT_NAMES[agent]} ${version}`;
|
|
204
|
+
const acct = r.email ? ` (${r.email})` : '';
|
|
205
|
+
const reason = r.reason === 'out_of_credits' ? 'is out of credits'
|
|
206
|
+
: r.reason === 'signed_out' ? 'is not signed in'
|
|
207
|
+
: 'is rate-limited right now';
|
|
208
|
+
return (chalk.yellow(`⚠ ${who}${acct} ${reason}.`) +
|
|
209
|
+
chalk.gray(`\n A pinned version skips account rotation, so it will launch on this account and may immediately hit its limit.` +
|
|
210
|
+
`\n Use a bare \`${agent}\` teammate to let the team pick a healthy account, or pass --force to silence this.`));
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Advisory: for each staged VERSION-PINNED teammate, warn if its account is
|
|
214
|
+
* rate-limited / out of credits / signed out right now — reusing the router's
|
|
215
|
+
* own eligibility signal (`checkRunAccountReadiness`) so the warning matches
|
|
216
|
+
* what the spawn would actually do. Bare teammates (rotation handles them) and
|
|
217
|
+
* profile/cloud teammates (account not locally checkable) are skipped. Warns,
|
|
218
|
+
* never blocks. Deduped by agent@version so N teammates on one account warn once.
|
|
219
|
+
*/
|
|
220
|
+
async function warnThrottledTeammates(mgr, team) {
|
|
221
|
+
let pending;
|
|
222
|
+
try {
|
|
223
|
+
pending = (await mgr.listByTask(team)).filter((a) => a.status === 'pending' && !a.cloudProvider && !a.profileName && a.version);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return; // team not loadable yet — nothing to warn about
|
|
227
|
+
}
|
|
228
|
+
const seen = new Set();
|
|
229
|
+
for (const a of pending) {
|
|
230
|
+
const agent = a.agentType;
|
|
231
|
+
const version = a.version;
|
|
232
|
+
const key = `${agent}@${version}`;
|
|
233
|
+
if (seen.has(key) || !AGENT_NAMES[agent])
|
|
234
|
+
continue;
|
|
235
|
+
seen.add(key);
|
|
236
|
+
const readiness = await checkRunAccountReadiness(agent, version);
|
|
237
|
+
if (!readiness.ready)
|
|
238
|
+
console.error(throttleWarningLine(agent, version, readiness));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
194
241
|
async function warnUnsignedTeammates(mgr, team) {
|
|
195
242
|
let pending;
|
|
196
243
|
try {
|
|
@@ -966,7 +1013,7 @@ export function registerTeamsCommands(program) {
|
|
|
966
1013
|
.option('--cloud <provider>', `Dispatch to cloud backend instead of local CLI: ${VALID_CLOUD_PROVIDERS.join('|')}`)
|
|
967
1014
|
.option('--repo <owner/repo>', 'GitHub repository (required for --cloud rush)')
|
|
968
1015
|
.option('--branch <name>', 'Target git branch for cloud dispatch')
|
|
969
|
-
.option('--force', "Skip the advisory 'may not be signed in'
|
|
1016
|
+
.option('--force', "Skip the advisory 'may not be signed in' / 'account throttled' warnings")
|
|
970
1017
|
.option('--json', 'Output machine-readable JSON')
|
|
971
1018
|
.action(async (team, teammate, task, opts) => {
|
|
972
1019
|
if (!VALID_MODES.includes(opts.mode)) {
|
|
@@ -1006,6 +1053,16 @@ export function registerTeamsCommands(program) {
|
|
|
1006
1053
|
console.error(chalk.yellow(`⚠ ${AGENT_NAMES[agent]} may not be signed in (detection is unreliable). Adding anyway.`) +
|
|
1007
1054
|
chalk.gray(`\n If it fails to start, run \`${AGENTS[agent].cliCommand}\` to log in, or pass --force to silence this.`));
|
|
1008
1055
|
}
|
|
1056
|
+
// Advisory throttle check — only for a version-pinned teammate, which
|
|
1057
|
+
// bypasses account rotation and so can't route around a rate-limited /
|
|
1058
|
+
// out-of-credits / signed-out account (see throttleWarningLine). Skip bare
|
|
1059
|
+
// targets (rotation handles them), profiles (auth-injected account isn't
|
|
1060
|
+
// the version-home one we can read), and cloud dispatch. Warn, never block.
|
|
1061
|
+
if (!opts.force && !cloudProviderId && !profileName && version) {
|
|
1062
|
+
const readiness = await checkRunAccountReadiness(agent, version);
|
|
1063
|
+
if (!readiness.ready)
|
|
1064
|
+
console.error(throttleWarningLine(agent, version, readiness));
|
|
1065
|
+
}
|
|
1009
1066
|
if (opts.name !== undefined) {
|
|
1010
1067
|
if (!opts.name || !/^[A-Za-z0-9_-]+$/.test(opts.name)) {
|
|
1011
1068
|
die(`Invalid teammate name '${opts.name}'. Use letters, numbers, '-', or '_'.`);
|
|
@@ -1275,7 +1332,7 @@ export function registerTeamsCommands(program) {
|
|
|
1275
1332
|
.option('--watch', 'Keep running: poll every --interval seconds, fire new waves, exit when the DAG drains.')
|
|
1276
1333
|
.option('--interval <seconds>', 'Seconds between waves in --watch mode (default 8)', '8')
|
|
1277
1334
|
.option('--max-waves <n>', 'Safety cap on waves in --watch mode (default 1000)', '1000')
|
|
1278
|
-
.option('--force', "Skip the advisory 'may not be signed in'
|
|
1335
|
+
.option('--force', "Skip the advisory 'may not be signed in' / 'account throttled' warnings for staged teammates")
|
|
1279
1336
|
.action(async (team, opts) => {
|
|
1280
1337
|
const mgr = mkManager();
|
|
1281
1338
|
wireCloudDispatcher(mgr);
|
|
@@ -1285,8 +1342,10 @@ export function registerTeamsCommands(program) {
|
|
|
1285
1342
|
return;
|
|
1286
1343
|
team = picked;
|
|
1287
1344
|
}
|
|
1288
|
-
if (!opts.force && !isJsonMode(opts))
|
|
1345
|
+
if (!opts.force && !isJsonMode(opts)) {
|
|
1289
1346
|
await warnUnsignedTeammates(mgr, team);
|
|
1347
|
+
await warnThrottledTeammates(mgr, team);
|
|
1348
|
+
}
|
|
1290
1349
|
if (!opts.watch) {
|
|
1291
1350
|
await runOneWave(mgr, team, Boolean(opts.json));
|
|
1292
1351
|
return;
|
|
@@ -1651,8 +1710,9 @@ export function registerTeamsCommands(program) {
|
|
|
1651
1710
|
teams
|
|
1652
1711
|
.command('logs [teammate]')
|
|
1653
1712
|
.alias('log')
|
|
1654
|
-
.description("
|
|
1655
|
-
.option('-n, --tail <n>', 'Show
|
|
1713
|
+
.description("Show a teammate's concise session summary. --full (or -n <lines>) for the raw stdout. Accepts positional name, --teammate <name>, UUID, or UUID prefix.")
|
|
1714
|
+
.option('-n, --tail <n>', 'Show the last N lines of raw stdout instead of the concise summary')
|
|
1715
|
+
.option('-m, --full', 'Show the full raw stdout log instead of the concise summary')
|
|
1656
1716
|
.option('--team <team>', 'Disambiguate when the same name appears in multiple teams')
|
|
1657
1717
|
.option('--teammate <name>', 'Teammate name (alias for the positional arg; useful for scripts)')
|
|
1658
1718
|
.action(async (ref, opts) => {
|
|
@@ -1682,14 +1742,28 @@ export function registerTeamsCommands(program) {
|
|
|
1682
1742
|
}
|
|
1683
1743
|
agentId = resolved.agentId;
|
|
1684
1744
|
}
|
|
1745
|
+
// Concise by default: a teammate's agentId IS its agent session id (passed
|
|
1746
|
+
// as --session-id at launch), so render the same summary digest as
|
|
1747
|
+
// `agents sessions <id>`. --full / -n <lines> opt into the raw stdout.log.
|
|
1748
|
+
if (!opts.full && !opts.tail) {
|
|
1749
|
+
const all = await discoverSessions({ all: true, limit: 5000 });
|
|
1750
|
+
const matches = resolveSessionById(all, agentId);
|
|
1751
|
+
if (matches.length > 0) {
|
|
1752
|
+
await renderSessionLog(matches[0], 'summary');
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
// No resolvable session (e.g. a non-Claude teammate) — fall through to a
|
|
1756
|
+
// bounded tail of raw stdout rather than dumping the whole file.
|
|
1757
|
+
}
|
|
1685
1758
|
const logPath = path.join(base, agentId, 'stdout.log');
|
|
1686
1759
|
try {
|
|
1687
1760
|
const content = await fs.readFile(logPath, 'utf-8');
|
|
1688
|
-
if (
|
|
1761
|
+
if (opts.full) {
|
|
1689
1762
|
process.stdout.write(content);
|
|
1690
1763
|
return;
|
|
1691
1764
|
}
|
|
1692
|
-
|
|
1765
|
+
// Default tail size keeps an un-resolvable teammate's glance bounded too.
|
|
1766
|
+
const n = opts.tail ? Math.max(1, parseInt(opts.tail, 10) || 50) : 40;
|
|
1693
1767
|
const lines = content.split('\n');
|
|
1694
1768
|
process.stdout.write(lines.slice(-n).join('\n'));
|
|
1695
1769
|
}
|
|
@@ -1705,6 +1779,28 @@ export function registerTeamsCommands(program) {
|
|
|
1705
1779
|
.option('--json', 'Output machine-readable JSON')
|
|
1706
1780
|
.action(async (opts) => {
|
|
1707
1781
|
const info = checkAllClis();
|
|
1782
|
+
// Deep integrity probe. `checkAllClis` reports presence (shim + stub guard),
|
|
1783
|
+
// but a GUTTED native binary (JS wrapper present, platform binary missing —
|
|
1784
|
+
// the codex/kimi optional-dep partial-extract failure) still passes that. So
|
|
1785
|
+
// actually launch the resolved default version and, if it won't run, flip the
|
|
1786
|
+
// agent to not-installed with a repair hint — otherwise doctor says "ready"
|
|
1787
|
+
// and the teammate ENOENTs at spawn. Parallel; win32 is treated as healthy by
|
|
1788
|
+
// verifyInstalledBinaryLaunches.
|
|
1789
|
+
await Promise.all(Object.entries(info).map(async ([name, entry]) => {
|
|
1790
|
+
if (!entry.installed)
|
|
1791
|
+
return;
|
|
1792
|
+
const agent = name;
|
|
1793
|
+
const version = resolveVersion(agent);
|
|
1794
|
+
if (!version)
|
|
1795
|
+
return;
|
|
1796
|
+
const health = await verifyInstalledBinaryLaunches(agent, version);
|
|
1797
|
+
if (!health.ok) {
|
|
1798
|
+
entry.installed = false;
|
|
1799
|
+
entry.path = null;
|
|
1800
|
+
entry.error = `${AGENTS[agent]?.cliCommand ?? name}@${version} is installed but its binary won't launch`
|
|
1801
|
+
+ `${health.detail ? ` (${health.detail})` : ''}. Repair: agents add ${agent}@${version}`;
|
|
1802
|
+
}
|
|
1803
|
+
}));
|
|
1708
1804
|
// Advisory enrichment only. Sign-in detection is UNRELIABLE, so it never
|
|
1709
1805
|
// changes the authoritative installed/ready column — it annotates. And an
|
|
1710
1806
|
// agent that is actually running in a team is treated as signed in
|
package/dist/lib/daemon.js
CHANGED
|
@@ -19,6 +19,7 @@ import { detectOverdueJobs, notifyOverdue } from './overdue.js';
|
|
|
19
19
|
import { BrowserService } from './browser/service.js';
|
|
20
20
|
import { BrowserIPCServer } from './browser/ipc.js';
|
|
21
21
|
import { readAndResolveBundleEnv } from './secrets/bundles.js';
|
|
22
|
+
import { redactSecrets } from './redact.js';
|
|
22
23
|
const PID_FILE = 'daemon.pid';
|
|
23
24
|
const LOCK_FILE = 'daemon.lock';
|
|
24
25
|
const LOG_FILE = 'logs.jsonl';
|
|
@@ -207,15 +208,6 @@ export function reapStrayDaemons(keepPid = process.pid) {
|
|
|
207
208
|
}
|
|
208
209
|
return { reaped, details };
|
|
209
210
|
}
|
|
210
|
-
/** Redact values that look like tokens or credentials in a log message. */
|
|
211
|
-
function redactSecrets(message) {
|
|
212
|
-
let safe = message;
|
|
213
|
-
safe = safe.replace(/eyJ[A-Za-z0-9_-]{20,}/g, '[REDACTED_TOKEN]');
|
|
214
|
-
safe = safe.replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]');
|
|
215
|
-
safe = safe.replace(/(sk-[a-zA-Z0-9]{20,})/g, '[REDACTED_KEY]');
|
|
216
|
-
safe = safe.replace(/(ANTHROPIC_API_KEY|OPENAI_API_KEY|API_KEY|SECRET|TOKEN|PASSWORD)=\S+/gi, '$1=[REDACTED]');
|
|
217
|
-
return safe;
|
|
218
|
-
}
|
|
219
211
|
function rotateLogsIfNeeded(logPath) {
|
|
220
212
|
try {
|
|
221
213
|
const stat = fs.statSync(logPath);
|
|
@@ -428,6 +420,37 @@ export async function runDaemon() {
|
|
|
428
420
|
};
|
|
429
421
|
const deviceProbeInterval = setInterval(() => { void runDeviceProbe(); }, 3 * 60_000);
|
|
430
422
|
const deviceProbeKickoff = setTimeout(() => { void runDeviceProbe(); }, 15_000);
|
|
423
|
+
// tmux hook reconcile: retrofit the guarded `pane-died` hook onto managed
|
|
424
|
+
// `agents run` sessions a pre-fix binary left with the old unconditional hook
|
|
425
|
+
// (which detached the whole client — kicking the user out of the view — when
|
|
426
|
+
// they exited a split they'd opened). Non-destructive: set-hook only, never a
|
|
427
|
+
// kill or detach. A per-session schema marker makes steady-state a no-op, so
|
|
428
|
+
// this stays cheap at ~every 5 min, plus once ~20s after startup so a
|
|
429
|
+
// just-upgraded daemon heals still-running sessions without waiting for them to
|
|
430
|
+
// cycle or the shared server to be recycled.
|
|
431
|
+
let reconcilingTmux = false;
|
|
432
|
+
const runTmuxReconcile = async () => {
|
|
433
|
+
if (reconcilingTmux)
|
|
434
|
+
return;
|
|
435
|
+
reconcilingTmux = true;
|
|
436
|
+
try {
|
|
437
|
+
const { isTmuxInstalled } = await import('./tmux/binary.js');
|
|
438
|
+
if (!isTmuxInstalled())
|
|
439
|
+
return;
|
|
440
|
+
const { reconcileSessionHooks } = await import('./tmux/session.js');
|
|
441
|
+
const r = await reconcileSessionHooks();
|
|
442
|
+
if (r.reconciled > 0)
|
|
443
|
+
log('INFO', `tmux: retrofitted pane-died hook on ${r.reconciled} session(s)`);
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
log('ERROR', `tmux reconcile failed: ${err.message}`);
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
reconcilingTmux = false;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
const tmuxReconcileInterval = setInterval(() => { void runTmuxReconcile(); }, 5 * 60_000);
|
|
453
|
+
const tmuxReconcileKickoff = setTimeout(() => { void runTmuxReconcile(); }, 20_000);
|
|
431
454
|
const handleReload = () => {
|
|
432
455
|
log('INFO', 'Reloading jobs (SIGHUP)');
|
|
433
456
|
scheduler.reloadAll();
|
|
@@ -447,6 +470,8 @@ export async function runDaemon() {
|
|
|
447
470
|
clearTimeout(healKickoff);
|
|
448
471
|
clearInterval(deviceProbeInterval);
|
|
449
472
|
clearTimeout(deviceProbeKickoff);
|
|
473
|
+
clearInterval(tmuxReconcileInterval);
|
|
474
|
+
clearTimeout(tmuxReconcileKickoff);
|
|
450
475
|
removeDaemonPid();
|
|
451
476
|
process.exit(0);
|
|
452
477
|
};
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -272,6 +272,14 @@ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
|
|
|
272
272
|
* (`BASH_FUNC_*%%`) can't make `env` choke.
|
|
273
273
|
*/
|
|
274
274
|
export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv): string;
|
|
275
|
+
/**
|
|
276
|
+
* Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
|
|
277
|
+
* (right-stripping each). Used by runInTmux to recap a fast-failed agent's
|
|
278
|
+
* output into the caller's shell so a launch crash (e.g. a gutted install that
|
|
279
|
+
* dies with ENOENT the instant it spawns) isn't swallowed by the bare
|
|
280
|
+
* `[detached]` the pane-died hook otherwise leaves behind.
|
|
281
|
+
*/
|
|
282
|
+
export declare function formatPaneTail(raw: string, maxLines?: number): string;
|
|
275
283
|
/** Exit code spawnAgent resolves with when a run is killed for crossing a budget cap. */
|
|
276
284
|
export declare const BUDGET_KILL_EXIT_CODE = 7;
|
|
277
285
|
/**
|