@phnx-labs/agents-cli 1.22.8 → 1.22.10
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 +64 -4
- package/README.md +5 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +2 -2
- package/dist/commands/exec.js +41 -9
- package/dist/commands/feed.js +38 -1
- package/dist/commands/harness.d.ts +40 -1
- package/dist/commands/harness.js +316 -76
- package/dist/commands/profiles.d.ts +42 -0
- package/dist/commands/profiles.js +91 -3
- package/dist/commands/secrets.js +38 -39
- package/dist/commands/sessions-picker.js +3 -3
- package/dist/commands/sessions-render.d.ts +12 -0
- package/dist/commands/sessions-render.js +124 -0
- package/dist/commands/sessions.js +28 -1
- package/dist/commands/ssh.js +30 -8
- package/dist/commands/teams.js +12 -1
- package/dist/commands/workflows.js +1 -1
- package/dist/index.js +9 -2
- package/dist/lib/crabbox/cli.d.ts +35 -0
- package/dist/lib/crabbox/cli.js +46 -0
- package/dist/lib/crabbox/config.d.ts +21 -0
- package/dist/lib/crabbox/config.js +43 -0
- package/dist/lib/crabbox/lease.d.ts +15 -5
- package/dist/lib/crabbox/lease.js +57 -17
- package/dist/lib/daemon.js +12 -11
- package/dist/lib/devices/resolve-target.d.ts +4 -3
- package/dist/lib/devices/resolve-target.js +4 -3
- package/dist/lib/hosts/passthrough.js +18 -1
- package/dist/lib/hosts/registry.d.ts +4 -0
- package/dist/lib/hosts/registry.js +16 -0
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/observe-aliases.d.ts +30 -0
- package/dist/lib/observe-aliases.js +56 -0
- package/dist/lib/plugins.d.ts +14 -5
- package/dist/lib/plugins.js +29 -5
- package/dist/lib/redact.js +4 -0
- package/dist/lib/resources/types.d.ts +2 -1
- package/dist/lib/resources/workflows.d.ts +1 -1
- package/dist/lib/resources/workflows.js +22 -20
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.d.ts +9 -0
- package/dist/lib/secrets/bundles.js +9 -23
- package/dist/lib/secrets/headless.d.ts +3 -15
- package/dist/lib/secrets/headless.js +5 -33
- package/dist/lib/secrets/index.d.ts +8 -0
- package/dist/lib/secrets/index.js +16 -36
- package/dist/lib/session/parse.d.ts +5 -1
- package/dist/lib/session/parse.js +23 -13
- package/dist/lib/session/prompt.js +5 -0
- package/dist/lib/session/render.d.ts +3 -0
- package/dist/lib/session/render.js +33 -10
- package/dist/lib/share/config.js +5 -7
- package/dist/lib/startup/command-registry.js +15 -1
- package/dist/lib/types.d.ts +6 -0
- package/dist/lib/usage-refresh.d.ts +19 -11
- package/dist/lib/usage-refresh.js +75 -43
- package/dist/lib/usage.d.ts +12 -0
- package/dist/lib/usage.js +37 -6
- package/dist/lib/workflows.d.ts +19 -4
- package/dist/lib/workflows.js +81 -7
- package/package.json +1 -1
package/dist/commands/secrets.js
CHANGED
|
@@ -18,10 +18,10 @@ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
|
|
|
18
18
|
import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, verifyRemoteKeychainPush, keychainWriteFailureMessage, } 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 { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv,
|
|
21
|
+
import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
|
|
22
22
|
import { parseListFilters, bundleMatchesFilter, bundleExpiry, filterIsActive, describeFilter, parseSortField, sortBundles, SORT_FIELDS, REF_KINDS, DEFAULT_EXPIRING_DAYS, } from '../lib/secrets/list-filter.js';
|
|
23
23
|
import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
|
|
24
|
-
import { getKeychainToken,
|
|
24
|
+
import { getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, maybeAutoRekey, } from '../lib/secrets/index.js';
|
|
25
25
|
import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
|
|
26
26
|
import { GLOBAL_HARNESS } from '../lib/secrets/scope.js';
|
|
27
27
|
import { secretsHoldMs, secretsAgentDurable, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
|
|
@@ -1251,15 +1251,15 @@ export function registerSecretsCommands(program) {
|
|
|
1251
1251
|
}
|
|
1252
1252
|
const revealed = new Map();
|
|
1253
1253
|
if (reveal) {
|
|
1254
|
-
const
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1254
|
+
const { env } = readAndResolveBundleEnv(bundle.name, {
|
|
1255
|
+
caller: 'view --reveal --json',
|
|
1256
|
+
keyMode: 'storage',
|
|
1257
|
+
agentOnly: true,
|
|
1258
|
+
});
|
|
1259
|
+
for (const entry of entries) {
|
|
1260
|
+
if (entry.kind === 'keychain' && env[entry.key] !== undefined) {
|
|
1261
|
+
revealed.set(secretsKeychainItem(bundle.name, entry.detail), env[entry.key]);
|
|
1262
|
+
}
|
|
1263
1263
|
}
|
|
1264
1264
|
const exposed = revealed.size + entries.filter((e) => e.kind === 'literal').length;
|
|
1265
1265
|
if (exposed > 0) {
|
|
@@ -1369,23 +1369,20 @@ export function registerSecretsCommands(program) {
|
|
|
1369
1369
|
console.error(chalk.red('--reveal in a non-TTY requires --plaintext.'));
|
|
1370
1370
|
process.exit(1);
|
|
1371
1371
|
}
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
//
|
|
1372
|
+
// Resolve through the broker / durable-session path. A locked keychain
|
|
1373
|
+
// bundle errors with the explicit unlock hint; viewing never raises a
|
|
1374
|
+
// Touch ID sheet itself.
|
|
1375
1375
|
const revealedValues = new Map();
|
|
1376
1376
|
if (reveal) {
|
|
1377
|
-
const
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
}
|
|
1387
|
-
catch {
|
|
1388
|
-
// Fall through to masked output on cancellation / batch failure.
|
|
1377
|
+
const { env } = readAndResolveBundleEnv(bundle.name, {
|
|
1378
|
+
caller: 'view --reveal',
|
|
1379
|
+
keyMode: 'storage',
|
|
1380
|
+
agentOnly: true,
|
|
1381
|
+
});
|
|
1382
|
+
for (const entry of entries) {
|
|
1383
|
+
if (entry.kind === 'keychain' && env[entry.key] !== undefined) {
|
|
1384
|
+
revealedValues.set(secretsKeychainItem(bundle.name, entry.detail), env[entry.key]);
|
|
1385
|
+
}
|
|
1389
1386
|
}
|
|
1390
1387
|
// Revealing plaintext bypasses readAndResolveBundleEnv (the usual
|
|
1391
1388
|
// audit chokepoint), so emit here — a `--reveal` exposes real values
|
|
@@ -1475,10 +1472,9 @@ export function registerSecretsCommands(program) {
|
|
|
1475
1472
|
process.exit(1);
|
|
1476
1473
|
}
|
|
1477
1474
|
// `secrets get` is the scriptable automation primitive ($(agents secrets
|
|
1478
|
-
// get bundle KEY))
|
|
1479
|
-
//
|
|
1480
|
-
|
|
1481
|
-
const { env } = readAndResolveBundleEnv(item, { caller: 'secrets get', keys: [key], keyMode: 'storage', agentOnly: isHeadlessSecretsContext() });
|
|
1475
|
+
// get bundle KEY)). Every read is broker-only; a locked bundle points at
|
|
1476
|
+
// the explicit unlock command instead of raising Touch ID.
|
|
1477
|
+
const { env } = readAndResolveBundleEnv(item, { caller: 'secrets get', keys: [key], keyMode: 'storage', agentOnly: true });
|
|
1482
1478
|
if (!(key in env)) {
|
|
1483
1479
|
console.error(chalk.red(`Key '${key}' not in bundle '${item}'.`));
|
|
1484
1480
|
process.exit(1);
|
|
@@ -2083,7 +2079,7 @@ Examples:
|
|
|
2083
2079
|
throw new Error('--to-file needs AGENTS_SECRETS_PASSPHRASE set to encrypt the bundle. ' +
|
|
2084
2080
|
'Set it for this command, then supply the same value when importing.');
|
|
2085
2081
|
}
|
|
2086
|
-
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: 'export --to-file', keyMode: 'storage', agentOnly:
|
|
2082
|
+
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: 'export --to-file', keyMode: 'storage', agentOnly: true });
|
|
2087
2083
|
exportBundleToFile(env, opts.toFile, passphrase);
|
|
2088
2084
|
emitSecretAudit({ event: 'secrets.export', bundle: resolvedBundleName, operation: 'export --to-file', source: 'file', status: 'success', keyCount: Object.keys(env).length });
|
|
2089
2085
|
console.log(chalk.green(`Exported ${Object.keys(env).length} key(s) to ${opts.toFile}`));
|
|
@@ -2111,7 +2107,7 @@ Examples:
|
|
|
2111
2107
|
'bundle at rest on the remote. Set it for this command, then unlock it the same way per run.');
|
|
2112
2108
|
}
|
|
2113
2109
|
}
|
|
2114
|
-
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export`, keyMode: 'storage', agentOnly:
|
|
2110
|
+
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export`, keyMode: 'storage', agentOnly: true });
|
|
2115
2111
|
const dotenv = bundleEnvToDotenv(env);
|
|
2116
2112
|
const keyCount = Object.keys(env).length;
|
|
2117
2113
|
// Drive the remote's own `agents secrets import --from -` so the values
|
|
@@ -2200,7 +2196,7 @@ Examples:
|
|
|
2200
2196
|
if (opts.to1password) {
|
|
2201
2197
|
assertOpAvailable();
|
|
2202
2198
|
const vault = await resolveVault(opts.vault);
|
|
2203
|
-
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `1Password vault ${vault}`, keyMode: 'storage', agentOnly:
|
|
2199
|
+
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `1Password vault ${vault}`, keyMode: 'storage', agentOnly: true });
|
|
2204
2200
|
let created = 0;
|
|
2205
2201
|
let overwritten = 0;
|
|
2206
2202
|
let skipped = 0;
|
|
@@ -2240,14 +2236,12 @@ Examples:
|
|
|
2240
2236
|
process.exit(1);
|
|
2241
2237
|
}
|
|
2242
2238
|
// `agents secrets export --plaintext` is what release/CI scripts eval.
|
|
2243
|
-
//
|
|
2244
|
-
//
|
|
2245
|
-
// ID sheet on the interactive user's screen. An `eval "$(...)"` typed in a
|
|
2246
|
-
// plain shell carries no AGENTS_RUNTIME, so it is not headless and still prompts.
|
|
2239
|
+
// Every caller is broker-only, including a plain shell, so export never
|
|
2240
|
+
// raises Touch ID on the interactive user's screen.
|
|
2247
2241
|
const { env } = readAndResolveBundleEnv(resolvedBundleName, {
|
|
2248
2242
|
caller: `export to shell`,
|
|
2249
2243
|
keyMode: 'process',
|
|
2250
|
-
agentOnly:
|
|
2244
|
+
agentOnly: true,
|
|
2251
2245
|
});
|
|
2252
2246
|
if (opts.format === 'json') {
|
|
2253
2247
|
// Machine-readable form consumed by `remoteResolveEnv` over SSH.
|
|
@@ -2309,7 +2303,7 @@ Examples:
|
|
|
2309
2303
|
caller: `command ${cmd}`,
|
|
2310
2304
|
keys: keysSubset,
|
|
2311
2305
|
allowExpired: execOpts.allowExpired,
|
|
2312
|
-
agentOnly:
|
|
2306
|
+
agentOnly: true,
|
|
2313
2307
|
}).env;
|
|
2314
2308
|
}
|
|
2315
2309
|
const { spawn } = await import('child_process');
|
|
@@ -2575,6 +2569,11 @@ Examples:
|
|
|
2575
2569
|
duration: humanRemaining(Date.now() + ttlMs),
|
|
2576
2570
|
keyMode: 'storage',
|
|
2577
2571
|
});
|
|
2572
|
+
// Migrations are authorized only by this explicit unlock. The bundle
|
|
2573
|
+
// metadata was included in the successful authenticated batch, so the
|
|
2574
|
+
// ACL heal can rewrite that already-read value without another read.
|
|
2575
|
+
maybeAutoRekey();
|
|
2576
|
+
healKeychainBundleMetadataAclOnce(new Map([[bundle.name, JSON.stringify(bundle)]]));
|
|
2578
2577
|
if (await agentLoad(name, bundle, env, ttlMs, harness)) {
|
|
2579
2578
|
loaded++;
|
|
2580
2579
|
// Persist a durable session snapshot so the unlock survives a daemon
|
|
@@ -11,7 +11,7 @@ import chalk from 'chalk';
|
|
|
11
11
|
import { truncate, humanDuration } from '../lib/format.js';
|
|
12
12
|
import { parseSession, sanitizeForTerminal, SNAPSHOT_TODO_TOOLS } from '../lib/session/parse.js';
|
|
13
13
|
import { safeTeamText } from '../lib/session/team-filter.js';
|
|
14
|
-
import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.js';
|
|
14
|
+
import { cleanSessionPrompt, extractSessionTopic, isSyntheticUserMessage } from '../lib/session/prompt.js';
|
|
15
15
|
import { linkPath, linkUrl, relativeToCwd, shortenModel } from '../lib/session/render.js';
|
|
16
16
|
import { linearIssueUrl } from '../lib/session/linear.js';
|
|
17
17
|
import { extractTodoProgress, WORKTREE_RE } from '../lib/session/state.js';
|
|
@@ -383,7 +383,7 @@ function formatCompactPreview(events, session) {
|
|
|
383
383
|
const knownToolUsage = session.usedBrowser !== undefined;
|
|
384
384
|
for (const event of events) {
|
|
385
385
|
if (event.type === 'message') {
|
|
386
|
-
if (event.role === 'user' && !firstUser && event.content) {
|
|
386
|
+
if (event.role === 'user' && !event._synthetic && !firstUser && event.content) {
|
|
387
387
|
if (!SYSTEM_MESSAGE_PATTERNS.some(p => p.test(event.content))) {
|
|
388
388
|
firstUser = event.content;
|
|
389
389
|
}
|
|
@@ -443,7 +443,7 @@ function formatCompactPreview(events, session) {
|
|
|
443
443
|
lines.push(chalk.cyan('Prompt: ') + chalk.white(truncate(first.trim(), termWidth - 12)));
|
|
444
444
|
}
|
|
445
445
|
}
|
|
446
|
-
else if (session.topic) {
|
|
446
|
+
else if (session.topic && !isSyntheticUserMessage(session.topic)) {
|
|
447
447
|
lines.push(chalk.cyan('Prompt: ') + chalk.white(truncate(session.topic.trim(), termWidth - 12)));
|
|
448
448
|
}
|
|
449
449
|
// Compact checklist: ✓done/total · current step (RUSH-2045).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import type { SessionMeta } from '../lib/session/types.js';
|
|
3
|
+
export declare const MARKDOWN_RENDER_AGENTS: readonly ["claude", "codex", "gemini", "antigravity", "opencode", "grok", "rush", "hermes", "kimi", "droid", "cursor"];
|
|
4
|
+
export type ReasoningMode = 'omit' | 'fold' | 'include';
|
|
5
|
+
/** Build one shareable Markdown document from the canonical preview and event model. */
|
|
6
|
+
export declare function renderSessionMarkdownDocument(session: SessionMeta, options?: {
|
|
7
|
+
redact?: boolean;
|
|
8
|
+
reasoning?: ReasoningMode;
|
|
9
|
+
knownSecrets?: readonly string[];
|
|
10
|
+
maxToolOutputChars?: number;
|
|
11
|
+
}): string;
|
|
12
|
+
export declare function registerSessionsRenderCommand(sessionsCmd: Command): void;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** Render selected normalized session transcripts as redacted Markdown documents. */
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { setHelpSections } from '../lib/help.js';
|
|
6
|
+
import { knownSecretValuesFromEnv, redactSecrets } from '../lib/redact.js';
|
|
7
|
+
import { discoverSessions } from '../lib/session/discover.js';
|
|
8
|
+
import { parseSession } from '../lib/session/parse.js';
|
|
9
|
+
import { extractSessionTopic, isSyntheticUserMessage } from '../lib/session/prompt.js';
|
|
10
|
+
import { renderConversationMarkdown } from '../lib/session/render.js';
|
|
11
|
+
import { buildPreview } from './sessions-picker.js';
|
|
12
|
+
import { parseAgentFilter } from './sessions.js';
|
|
13
|
+
import { selectSessions } from './sessions-export.js';
|
|
14
|
+
export const MARKDOWN_RENDER_AGENTS = [
|
|
15
|
+
'claude',
|
|
16
|
+
'codex',
|
|
17
|
+
'gemini',
|
|
18
|
+
'antigravity',
|
|
19
|
+
'opencode',
|
|
20
|
+
'grok',
|
|
21
|
+
'rush',
|
|
22
|
+
'hermes',
|
|
23
|
+
'kimi',
|
|
24
|
+
'droid',
|
|
25
|
+
'cursor',
|
|
26
|
+
];
|
|
27
|
+
function parseReasoning(value) {
|
|
28
|
+
if (value === 'omit' || value === 'fold' || value === 'include')
|
|
29
|
+
return value;
|
|
30
|
+
throw new Error(`Unknown reasoning mode "${value}". Expected omit, fold, or include.`);
|
|
31
|
+
}
|
|
32
|
+
function quotePreview(preview) {
|
|
33
|
+
return preview.split('\n').map((line) => `> ${line}`).join('\n');
|
|
34
|
+
}
|
|
35
|
+
/** Build one shareable Markdown document from the canonical preview and event model. */
|
|
36
|
+
export function renderSessionMarkdownDocument(session, options = {}) {
|
|
37
|
+
if (!MARKDOWN_RENDER_AGENTS.includes(session.agent)) {
|
|
38
|
+
throw new Error(`Cannot render ${session.agent} session ${session.shortId || session.id}: Markdown rendering supports ${MARKDOWN_RENDER_AGENTS.join(', ')}.`);
|
|
39
|
+
}
|
|
40
|
+
// Preserve normalized tool output here so the Markdown renderer owns the
|
|
41
|
+
// visible cap and can report exactly how much it omitted.
|
|
42
|
+
const events = parseSession(session.filePath, session.agent, { maxToolOutputChars: Infinity });
|
|
43
|
+
if (events.length === 0) {
|
|
44
|
+
throw new Error(`Cannot render ${session.agent} session ${session.shortId || session.id}: transcript produced no normalized events.`);
|
|
45
|
+
}
|
|
46
|
+
const shouldRedact = options.redact !== false;
|
|
47
|
+
const sanitize = (text) => shouldRedact ? redactSecrets(text, options.knownSecrets) : text;
|
|
48
|
+
const preview = sanitize(stripVTControlCharacters(buildPreview(session)));
|
|
49
|
+
const shareEvents = events.filter((event) => !(event.type === 'message' && event._synthetic));
|
|
50
|
+
const firstPrompt = shareEvents.find((event) => event.type === 'message' && event.role === 'user')?.content;
|
|
51
|
+
const title = sanitize(session.label || (firstPrompt ? extractSessionTopic(firstPrompt) : undefined) ||
|
|
52
|
+
(session.topic && !isSyntheticUserMessage(session.topic) ? session.topic : undefined) ||
|
|
53
|
+
`${session.agent} session ${session.shortId || session.id}`);
|
|
54
|
+
const conversation = renderConversationMarkdown(shareEvents, {
|
|
55
|
+
redact: shouldRedact,
|
|
56
|
+
knownSecrets: options.knownSecrets,
|
|
57
|
+
reasoning: options.reasoning ?? 'omit',
|
|
58
|
+
maxToolOutputChars: options.maxToolOutputChars,
|
|
59
|
+
});
|
|
60
|
+
return `# ${title}\n\n## Session preview\n\n${quotePreview(preview)}\n\n## Conversation\n\n${conversation}\n`;
|
|
61
|
+
}
|
|
62
|
+
export function registerSessionsRenderCommand(sessionsCmd) {
|
|
63
|
+
const cmd = sessionsCmd
|
|
64
|
+
.command('render <selectors...>')
|
|
65
|
+
.description('Render one or more sessions as readable, redacted Markdown for review or sharing.')
|
|
66
|
+
.option('--format <format>', 'Output format (md or markdown)', 'md')
|
|
67
|
+
.option('-o, --output <path>', 'Write the rendered document to a file instead of stdout')
|
|
68
|
+
.option('--reasoning <mode>', 'Reasoning visibility: omit, fold, or include', 'omit');
|
|
69
|
+
setHelpSections(cmd, {
|
|
70
|
+
examples: `# Render one redacted session for a confidential gist
|
|
71
|
+
agents sessions render a1b2c3d4 -o session.md
|
|
72
|
+
|
|
73
|
+
# Combine several sessions into one Markdown document
|
|
74
|
+
agents sessions render a1b2c3d4 d4c3b2a1 -o delivery-sessions.md
|
|
75
|
+
|
|
76
|
+
# Keep reasoning locally in collapsible sections and opt out of redaction
|
|
77
|
+
agents sessions render a1b2c3d4 --reasoning fold --no-redact`,
|
|
78
|
+
notes: `Markdown is redacted by default, including credential-shaped values and local home paths.
|
|
79
|
+
The preview at the top is the same preview shown by 'agents sessions'. Tool output is truncated
|
|
80
|
+
with an explicit note. Use --no-redact only for local output that will not be shared.`,
|
|
81
|
+
});
|
|
82
|
+
cmd.action(async (selectors, options, command) => {
|
|
83
|
+
const globals = command.optsWithGlobals();
|
|
84
|
+
if (!['md', 'markdown'].includes(options.format.toLowerCase())) {
|
|
85
|
+
throw new Error(`Unknown format "${options.format}". Expected md or markdown.`);
|
|
86
|
+
}
|
|
87
|
+
const reasoning = parseReasoning(options.reasoning);
|
|
88
|
+
const limit = Math.max(1, Number.parseInt(globals.limit || '100', 10) || 100);
|
|
89
|
+
const agent = parseAgentFilter(globals.agent).agent;
|
|
90
|
+
const sessions = selectSessions(await discoverSessions({
|
|
91
|
+
all: globals.all !== false,
|
|
92
|
+
since: globals.since,
|
|
93
|
+
limit,
|
|
94
|
+
agent: agent ?? undefined,
|
|
95
|
+
}), selectors);
|
|
96
|
+
if (sessions.length === 0) {
|
|
97
|
+
process.stderr.write(chalk.yellow('No sessions matched the selection.\n'));
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const knownSecrets = globals.redact === false ? undefined : knownSecretValuesFromEnv();
|
|
102
|
+
const rendered = sessions.map((session) => ({
|
|
103
|
+
id: session.id,
|
|
104
|
+
agent: session.agent,
|
|
105
|
+
markdown: renderSessionMarkdownDocument(session, {
|
|
106
|
+
redact: globals.redact !== false,
|
|
107
|
+
reasoning,
|
|
108
|
+
knownSecrets,
|
|
109
|
+
}),
|
|
110
|
+
}));
|
|
111
|
+
const markdown = rendered.map((item) => item.markdown.trimEnd()).join('\n\n---\n\n') + '\n';
|
|
112
|
+
if (options.output)
|
|
113
|
+
fs.writeFileSync(options.output, markdown, { mode: 0o600 });
|
|
114
|
+
if (globals.json) {
|
|
115
|
+
process.stdout.write(JSON.stringify({ redacted: globals.redact !== false, reasoning, sessions: rendered }, null, 2) + '\n');
|
|
116
|
+
}
|
|
117
|
+
else if (!options.output) {
|
|
118
|
+
process.stdout.write(markdown);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
process.stderr.write(chalk.green(`Rendered ${rendered.length} session${rendered.length === 1 ? '' : 's'} to ${options.output}\n`));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -56,6 +56,7 @@ import { registerDetachCommand } from './detach.js';
|
|
|
56
56
|
import { registerAttachCommand } from './attach.js';
|
|
57
57
|
import { registerSessionsInjectCommand } from './sessions-inject.js';
|
|
58
58
|
import { registerSessionsExportCommand } from './sessions-export.js';
|
|
59
|
+
import { registerSessionsRenderCommand } from './sessions-render.js';
|
|
59
60
|
import { registerSessionsImportCommand } from './sessions-import.js';
|
|
60
61
|
import { registerSessionsMigrateCommand, registerSessionsMigrationsCommand } from './sessions-migrate.js';
|
|
61
62
|
import { registerSessionsBackfillCommand } from './sessions-backfill.js';
|
|
@@ -3584,7 +3585,7 @@ export function registerSessionsCommands(program) {
|
|
|
3584
3585
|
.option('--query <clause>', 'Search text; repeat with --include tools to require distinct matching calls', collectQueryClause, [])
|
|
3585
3586
|
.option('--resolve <selector>', 'Resolve one full ID, unique prefix, or keyword query to safe session metadata (requires --json; searches the fleet unless --local)')
|
|
3586
3587
|
.addOption(new Option('--resolve-safe-v1 <selector>').hideHelp())
|
|
3587
|
-
.description('Find, browse, and read agent conversation transcripts
|
|
3588
|
+
.description('Find, browse, and read agent conversation transcripts. Live roster: `agents sessions --active` (alias: `agents roster`).')
|
|
3588
3589
|
.option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
|
|
3589
3590
|
.option('--claude', 'Shorthand for --agent claude')
|
|
3590
3591
|
.option('--codex', 'Shorthand for --agent codex')
|
|
@@ -3735,11 +3736,37 @@ export function registerSessionsCommands(program) {
|
|
|
3735
3736
|
registerAttachCommand(sessionsCmd);
|
|
3736
3737
|
registerSessionsInjectCommand(sessionsCmd);
|
|
3737
3738
|
registerSessionsExportCommand(sessionsCmd);
|
|
3739
|
+
registerSessionsRenderCommand(sessionsCmd);
|
|
3738
3740
|
registerSessionsImportCommand(sessionsCmd);
|
|
3739
3741
|
registerSessionsMigrateCommand(sessionsCmd);
|
|
3740
3742
|
registerSessionsMigrationsCommand(sessionsCmd);
|
|
3741
3743
|
registerSessionsBackfillCommand(sessionsCmd);
|
|
3742
3744
|
registerSessionsStatsCommand(sessionsCmd);
|
|
3745
|
+
// Observe-umbrella alias (Phase 3): roster → sessions --active.
|
|
3746
|
+
registerSessionsObserveAliases(program);
|
|
3747
|
+
}
|
|
3748
|
+
/**
|
|
3749
|
+
* `roster` → sessions --active. Registered with the sessions module so the
|
|
3750
|
+
* lazy loader for `roster` also registers the real `sessions` command for re-parse.
|
|
3751
|
+
*/
|
|
3752
|
+
function registerSessionsObserveAliases(program) {
|
|
3753
|
+
program
|
|
3754
|
+
.command('roster')
|
|
3755
|
+
.description('Live agent roster (alias of `agents sessions --active`). Who is running right now.')
|
|
3756
|
+
.allowUnknownOption()
|
|
3757
|
+
.allowExcessArguments()
|
|
3758
|
+
.action(async () => {
|
|
3759
|
+
const { expandObserveAlias } = await import('../lib/observe-aliases.js');
|
|
3760
|
+
const rest = process.argv.slice(3);
|
|
3761
|
+
const expanded = expandObserveAlias('roster', rest);
|
|
3762
|
+
if (!expanded) {
|
|
3763
|
+
console.error(chalk.red('Unknown observe alias: roster'));
|
|
3764
|
+
process.exit(1);
|
|
3765
|
+
}
|
|
3766
|
+
if (process.stderr.isTTY)
|
|
3767
|
+
process.stderr.write(chalk.gray(`${expanded.note}\n`));
|
|
3768
|
+
await program.parseAsync(['node', 'agents', ...expanded.argv]);
|
|
3769
|
+
});
|
|
3743
3770
|
}
|
|
3744
3771
|
function formatNoSessionsMessage(showAll, project) {
|
|
3745
3772
|
const projectQuery = project?.trim();
|
package/dist/commands/ssh.js
CHANGED
|
@@ -16,8 +16,9 @@ import * as path from 'path';
|
|
|
16
16
|
import chalk from 'chalk';
|
|
17
17
|
import ora from 'ora';
|
|
18
18
|
import { getCliVersion } from '../lib/version.js';
|
|
19
|
-
import { readAndResolveBundleEnv
|
|
19
|
+
import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
|
|
20
20
|
import { machineId } from '../lib/session/sync/config.js';
|
|
21
|
+
import { isDeviceAuto, resolveDeviceAffinity } from '../lib/smart-launch.js';
|
|
21
22
|
import { registerFleetCaptureCommand } from './fleet-capture.js';
|
|
22
23
|
import { registerFleetApplyAlias } from './apply.js';
|
|
23
24
|
import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, setAutoLaunchEnabled, setAutoLaunchPreferred, upsertDevice, writeReachability, } from '../lib/devices/registry.js';
|
|
@@ -30,7 +31,7 @@ import { resolveDeviceTarget, splitUserHost } from '../lib/devices/resolve-targe
|
|
|
30
31
|
import { clearPendingSentinel } from '../lib/devices/pending.js';
|
|
31
32
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
32
33
|
import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
|
|
33
|
-
import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV,
|
|
34
|
+
import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, fleetDialTarget, writeAskpassShim, } from '../lib/devices/connect.js';
|
|
34
35
|
import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
|
|
35
36
|
import { shouldSyncTerminfo, syncTerminfoToDevice, terminfoHostKey } from '../lib/devices/terminfo.js';
|
|
36
37
|
import { fanOutDevices, fleetHealthSkip, planFleetTargets, remoteFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
|
|
@@ -1413,9 +1414,12 @@ Examples:
|
|
|
1413
1414
|
agents ssh win-mini # interactive login
|
|
1414
1415
|
agents ssh win-mini hostname # run a command (PowerShell on Windows)
|
|
1415
1416
|
agents ssh yosemite-s0 uptime # run a command (POSIX)
|
|
1417
|
+
agents ssh auto # affinity-pick a device (same engine as 'agents run --device auto')
|
|
1416
1418
|
|
|
1417
1419
|
Devices come from 'agents devices'. Password auth pulls the secret from a
|
|
1418
1420
|
secrets bundle via an askpass shim — the password never touches argv.
|
|
1421
|
+
'auto' picks a remote device by 14-day usage; a pick landing on this machine
|
|
1422
|
+
is refused with a clear message instead of self-dialing.
|
|
1419
1423
|
`)
|
|
1420
1424
|
.action(async (name, cmd) => {
|
|
1421
1425
|
// Hidden askpass bridge: ssh execs the shim, which re-invokes us here.
|
|
@@ -1423,16 +1427,35 @@ secrets bundle via an askpass shim — the password never touches argv.
|
|
|
1423
1427
|
await runAskpass();
|
|
1424
1428
|
return;
|
|
1425
1429
|
}
|
|
1430
|
+
// `auto` is the same affinity sentinel `agents run --device auto` resolves
|
|
1431
|
+
// (RUSH-2185) — pick the concrete device up front via the SAME engine
|
|
1432
|
+
// (resolveDeviceAffinity), rather than leaning on matchHost's generic
|
|
1433
|
+
// self-resolution (../lib/hosts/registry.js): `agents ssh` connects OUT to
|
|
1434
|
+
// a remote device, so a pick that lands on THIS machine is refused with a
|
|
1435
|
+
// clear message instead of self-SSHing — which also holds when this
|
|
1436
|
+
// machine was never itself enrolled as a device (matchHost would have
|
|
1437
|
+
// nothing to resolve "self" to, and mis-report the pick as "Unknown
|
|
1438
|
+
// device").
|
|
1439
|
+
let target = name;
|
|
1440
|
+
if (isDeviceAuto(name)) {
|
|
1441
|
+
const plan = resolveDeviceAffinity({});
|
|
1442
|
+
if (!plan.host) {
|
|
1443
|
+
console.error(chalk.red(`'auto' picked this machine — 'agents ssh' connects to a remote device. Pass a device name; see 'agents devices list'.`));
|
|
1444
|
+
process.exit(1);
|
|
1445
|
+
}
|
|
1446
|
+
process.stderr.write(chalk.gray(`[agents] device=auto → ${plan.host}\n`));
|
|
1447
|
+
target = plan.host;
|
|
1448
|
+
}
|
|
1426
1449
|
// Accept the full fleet target grammar: a registered `name`, a
|
|
1427
1450
|
// `user@device` (same device, login user overridden — dialed via its
|
|
1428
1451
|
// Tailscale route, not LAN DNS), or an ad-hoc `user@host`/`host` literal.
|
|
1429
1452
|
// A bare unregistered alias still errors as "Unknown device".
|
|
1430
|
-
const device = await resolveDeviceTarget(
|
|
1453
|
+
const device = await resolveDeviceTarget(target);
|
|
1431
1454
|
if (!device) {
|
|
1432
1455
|
// Not a registered device — it may be a leased crabbox box slug. ssh into
|
|
1433
1456
|
// it directly (crabbox@<tailnet|ip>:2222) before giving up.
|
|
1434
|
-
trySshLeasedBox(
|
|
1435
|
-
console.error(chalk.red(`Unknown device '${
|
|
1457
|
+
trySshLeasedBox(target, cmd); // exits the process on a match
|
|
1458
|
+
console.error(chalk.red(`Unknown device '${target}'. See 'agents devices list'.`));
|
|
1436
1459
|
process.exit(1);
|
|
1437
1460
|
}
|
|
1438
1461
|
// Preflight: a device Tailscale last saw offline would otherwise hang
|
|
@@ -1491,9 +1514,8 @@ async function runAskpass() {
|
|
|
1491
1514
|
}
|
|
1492
1515
|
// A read-only stats probe sets ASKPASS_AGENT_ONLY_ENV to force a broker-only
|
|
1493
1516
|
// resolve even under a TTY — so `agents devices` never pops Touch ID just to
|
|
1494
|
-
// render load/mem for an uncached password-auth device (RUSH-1970).
|
|
1495
|
-
|
|
1496
|
-
const agentOnly = process.env[ASKPASS_AGENT_ONLY_ENV] === '1' || isHeadlessSecretsContext();
|
|
1517
|
+
// render load/mem for an uncached password-auth device (RUSH-1970).
|
|
1518
|
+
const agentOnly = true;
|
|
1497
1519
|
try {
|
|
1498
1520
|
const { env } = readAndResolveBundleEnv(bundle, { caller: 'agents ssh', keys: [key], keyMode: 'storage', agentOnly });
|
|
1499
1521
|
const value = env[key];
|
package/dist/commands/teams.js
CHANGED
|
@@ -17,6 +17,7 @@ import { createTeam, ensureTeam, getTeam, loadTeams, removeTeam, teamExists, } f
|
|
|
17
17
|
import { setHelpSections } from '../lib/help.js';
|
|
18
18
|
import { createWorktree, isGitRepo, hasUncommittedChanges, removeWorktree, } from '../lib/teams/worktree.js';
|
|
19
19
|
import { resolveHost } from '../lib/hosts/registry.js';
|
|
20
|
+
import { isDeviceAuto, resolveDeviceAffinity } from '../lib/smart-launch.js';
|
|
20
21
|
import { sshTargetFor } from '../lib/hosts/types.js';
|
|
21
22
|
import { ensureHostReady } from '../lib/hosts/ready.js';
|
|
22
23
|
import { remoteShellFor } from '../lib/hosts/remote-cmd.js';
|
|
@@ -1330,7 +1331,7 @@ export function registerTeamsCommands(program) {
|
|
|
1330
1331
|
// `--device`/`--host` are aliases (addHostOption registers both). For `teams
|
|
1331
1332
|
// add` the passthrough special-cases them as PLACEMENT, not routing, so the
|
|
1332
1333
|
// local action reads them here. Reject a conflicting pair.
|
|
1333
|
-
|
|
1334
|
+
let explicitDevice = (() => {
|
|
1334
1335
|
const h = opts.host;
|
|
1335
1336
|
const d = opts.device;
|
|
1336
1337
|
if (h && d && h !== d) {
|
|
@@ -1338,6 +1339,16 @@ export function registerTeamsCommands(program) {
|
|
|
1338
1339
|
}
|
|
1339
1340
|
return h ?? d ?? null;
|
|
1340
1341
|
})();
|
|
1342
|
+
// `auto` is the same affinity sentinel `agents run --device auto` resolves
|
|
1343
|
+
// (RUSH-2185) — pick the concrete device name up front so the local-machine
|
|
1344
|
+
// check right below (and every dieFriction message further down) sees the
|
|
1345
|
+
// real target instead of the literal string "auto".
|
|
1346
|
+
if (explicitDevice && isDeviceAuto(explicitDevice)) {
|
|
1347
|
+
const plan = resolveDeviceAffinity({});
|
|
1348
|
+
const picked = plan.host ?? machineId();
|
|
1349
|
+
process.stderr.write(chalk.gray(`[teams] device=auto → ${picked === machineId() ? 'local' : picked}\n`));
|
|
1350
|
+
explicitDevice = picked;
|
|
1351
|
+
}
|
|
1341
1352
|
// Distributed teams: --device <name> PINS this teammate to a machine over
|
|
1342
1353
|
// SSH. Resolve + validate the placement here so a bad target fails at `add`
|
|
1343
1354
|
// time, not silently at launch. Persisted (hostName/hostTarget/repoPath) so
|
|
@@ -29,7 +29,7 @@ Structure:
|
|
|
29
29
|
skills/ optional: knowledge packs scoped to this workflow
|
|
30
30
|
plugins/ optional: plugin bundles scoped to this workflow
|
|
31
31
|
|
|
32
|
-
Resolution: project
|
|
32
|
+
Resolution: project > user > plugin (plugins/*/workflows/) > extra > system.
|
|
33
33
|
|
|
34
34
|
Note: agents run defaults to --mode plan (read-only). For workflows that
|
|
35
35
|
write files, post comments, or otherwise mutate state, pass --mode edit or
|
package/dist/index.js
CHANGED
|
@@ -310,6 +310,15 @@ Run and dispatch:
|
|
|
310
310
|
browser Automate a browser — navigate, click, screenshot, console, network
|
|
311
311
|
pty Drive interactive terminal programs (REPLs, TUIs) via a persistent PTY session
|
|
312
312
|
|
|
313
|
+
Observe (read the fleet — no store merge; aliases point at the real readers):
|
|
314
|
+
feed / inbox Needs-you inbox (open blocks waiting on you)
|
|
315
|
+
timeline Agent progress stream (= feed --filter updates)
|
|
316
|
+
roster Live agents (= sessions --active)
|
|
317
|
+
events Unified ops + activity event trail
|
|
318
|
+
audit Tamper-evident run-dispatch log (not events)
|
|
319
|
+
snapshot One-process inventory + active sessions poll
|
|
320
|
+
status Sync/drift only (not the live fleet snapshot)
|
|
321
|
+
|
|
313
322
|
Credentials and profiles:
|
|
314
323
|
profile Activate resource profiles across skills, MCP, permissions, and secrets
|
|
315
324
|
profiles Bundles of (host CLI, endpoint, model, auth)
|
|
@@ -317,8 +326,6 @@ Credentials and profiles:
|
|
|
317
326
|
|
|
318
327
|
Diagnostics:
|
|
319
328
|
doctor [agent[@version]] Diagnose CLI availability, sync status, and resource divergence; --check for the CI drift gate
|
|
320
|
-
status Unified sync status (drift/missing) — not the live fleet snapshot
|
|
321
|
-
snapshot One-process poll: inventory + active sessions (+ optional feed/sync)
|
|
322
329
|
usage [agent] Show rate-limit and quota usage per agent
|
|
323
330
|
perf Latency rollups (hooks, commands, runs) from the disposable perf warehouse
|
|
324
331
|
|
|
@@ -114,6 +114,41 @@ export declare function crabboxEnv(opts: CrabboxOptions): NodeJS.ProcessEnv;
|
|
|
114
114
|
export declare function crabboxList(opts?: CrabboxOptions): CrabboxBox[];
|
|
115
115
|
/** Find one box by slug, or null. */
|
|
116
116
|
export declare function crabboxFind(slug: string, opts?: CrabboxOptions): CrabboxBox | null;
|
|
117
|
+
/**
|
|
118
|
+
* Whether `crabbox status` reports the box SSH-ready (`ready=true`). A box whose
|
|
119
|
+
* cloud-init bootstrap failed still LISTS as `running` but never becomes ready —
|
|
120
|
+
* selecting it burns the full SSH wait before hard-failing. `crabbox status`
|
|
121
|
+
* flips ready=true only once sshd answers, so warm-pool reuse gates on it
|
|
122
|
+
* (mirrors scripts/sandbox.sh's `box_ready`).
|
|
123
|
+
*/
|
|
124
|
+
export declare function crabboxStatusReady(slug: string, opts?: CrabboxOptions): boolean;
|
|
125
|
+
export interface PoolMatchOptions {
|
|
126
|
+
/**
|
|
127
|
+
* Profile label this run would warm a box with (from the repo's
|
|
128
|
+
* `.crabbox.yaml`; see config.ts). Both sides normalize an unset profile to
|
|
129
|
+
* DEFAULT_CRABBOX_PROFILE, so profile-less runs match unlabeled boxes.
|
|
130
|
+
*/
|
|
131
|
+
profile?: string;
|
|
132
|
+
/**
|
|
133
|
+
* Network mode of the run (default 'public'). A tailnet-joined box is never
|
|
134
|
+
* handed to a public run, nor a public box to a tailnet run — reachability and
|
|
135
|
+
* exposure differ, so the pool is partitioned by it.
|
|
136
|
+
*/
|
|
137
|
+
netMode?: 'public' | 'tailscale';
|
|
138
|
+
/** Injectable clock (unix seconds) for the expiry check. */
|
|
139
|
+
nowSecs?: number;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Warm boxes in the profile pool this run could reuse: `running`, same profile
|
|
143
|
+
* label, same network mode, lease unexpired — most-recently-touched first.
|
|
144
|
+
*
|
|
145
|
+
* Readiness is NOT required here (deliberately mirrors sandbox.sh's
|
|
146
|
+
* `running_slugs_for_profile`, which filters on `status` only): the list `state`
|
|
147
|
+
* label can lag, so the caller gates each candidate on `crabboxStatusReady`
|
|
148
|
+
* before committing. A not-ready box is skipped, never stopped — a concurrent
|
|
149
|
+
* run may be mid-boot on it, and crabbox's idle timeout reaps genuine duds.
|
|
150
|
+
*/
|
|
151
|
+
export declare function poolReusableBoxes(boxes: CrabboxBox[], opts?: PoolMatchOptions): CrabboxBox[];
|
|
117
152
|
export interface WarmupOptions extends CrabboxOptions {
|
|
118
153
|
class?: string;
|
|
119
154
|
profile?: string;
|
package/dist/lib/crabbox/cli.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { spawn, spawnSync } from 'child_process';
|
|
15
15
|
import { readAndResolveBundleEnv, listBundles, bundleExists } from '../secrets/bundles.js';
|
|
16
16
|
import { readMeta, writeMeta } from '../state.js';
|
|
17
|
+
import { DEFAULT_CRABBOX_PROFILE } from './config.js';
|
|
17
18
|
/** Locate the crabbox binary, or throw an actionable error. */
|
|
18
19
|
export function findCrabbox() {
|
|
19
20
|
const r = spawnSync('crabbox', ['--help'], { encoding: 'utf-8' });
|
|
@@ -301,6 +302,51 @@ export function crabboxList(opts = {}) {
|
|
|
301
302
|
export function crabboxFind(slug, opts = {}) {
|
|
302
303
|
return crabboxList(opts).find((b) => b.slug === slug) ?? null;
|
|
303
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Whether `crabbox status` reports the box SSH-ready (`ready=true`). A box whose
|
|
307
|
+
* cloud-init bootstrap failed still LISTS as `running` but never becomes ready —
|
|
308
|
+
* selecting it burns the full SSH wait before hard-failing. `crabbox status`
|
|
309
|
+
* flips ready=true only once sshd answers, so warm-pool reuse gates on it
|
|
310
|
+
* (mirrors scripts/sandbox.sh's `box_ready`).
|
|
311
|
+
*/
|
|
312
|
+
export function crabboxStatusReady(slug, opts = {}) {
|
|
313
|
+
findCrabbox();
|
|
314
|
+
const r = spawnSync('crabbox', ['status', '--id', slug], {
|
|
315
|
+
encoding: 'utf-8',
|
|
316
|
+
env: crabboxEnv(opts),
|
|
317
|
+
timeout: opts.timeoutMs ?? 15000,
|
|
318
|
+
});
|
|
319
|
+
if (r.status !== 0 || !r.stdout)
|
|
320
|
+
return false;
|
|
321
|
+
return /(^|\s)ready=true(\s|$)/m.test(r.stdout);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Warm boxes in the profile pool this run could reuse: `running`, same profile
|
|
325
|
+
* label, same network mode, lease unexpired — most-recently-touched first.
|
|
326
|
+
*
|
|
327
|
+
* Readiness is NOT required here (deliberately mirrors sandbox.sh's
|
|
328
|
+
* `running_slugs_for_profile`, which filters on `status` only): the list `state`
|
|
329
|
+
* label can lag, so the caller gates each candidate on `crabboxStatusReady`
|
|
330
|
+
* before committing. A not-ready box is skipped, never stopped — a concurrent
|
|
331
|
+
* run may be mid-boot on it, and crabbox's idle timeout reaps genuine duds.
|
|
332
|
+
*/
|
|
333
|
+
export function poolReusableBoxes(boxes, opts = {}) {
|
|
334
|
+
const profile = opts.profile ?? DEFAULT_CRABBOX_PROFILE;
|
|
335
|
+
const netMode = opts.netMode ?? 'public';
|
|
336
|
+
const nowSecs = opts.nowSecs ?? Math.floor(Date.now() / 1000);
|
|
337
|
+
return boxes
|
|
338
|
+
.filter((b) => {
|
|
339
|
+
if (b.status !== 'running')
|
|
340
|
+
return false;
|
|
341
|
+
if ((b.profile ?? DEFAULT_CRABBOX_PROFILE) !== profile)
|
|
342
|
+
return false;
|
|
343
|
+
const boxNet = b.tailscaleIPv4 || b.tailscaleFQDN ? 'tailscale' : 'public';
|
|
344
|
+
if (boxNet !== netMode)
|
|
345
|
+
return false;
|
|
346
|
+
return b.expiresAt === null || b.expiresAt > nowSecs;
|
|
347
|
+
})
|
|
348
|
+
.sort((a, b) => (b.lastTouchedAt ?? 0) - (a.lastTouchedAt ?? 0));
|
|
349
|
+
}
|
|
304
350
|
/**
|
|
305
351
|
* Lease a box and block until it is ready. Returns the leased box.
|
|
306
352
|
*
|